fix(projects): preserve exact placement identity

This commit is contained in:
Mohamed Boudra
2026-07-29 02:48:30 +00:00
parent 3e66464b2e
commit f054e403a6
8 changed files with 71 additions and 39 deletions

View File

@@ -13,28 +13,13 @@ function gitWorkspace(
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
it("selects the exact active git workspace project root", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectId: "project-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectId: "project-1",
projectKey: "project-1",
@@ -65,10 +50,7 @@ describe("selectActiveGitWorkspaceProject", () => {
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectRootPath: " " })),
).toBe(null);
});
});

View File

@@ -9,11 +9,6 @@ export interface WorktreeSetupWorkspaceInput {
projectGroupKey?: string | null;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
@@ -53,7 +48,7 @@ export function selectActiveGitWorkspaceProject(
projectId,
projectGroupKey: workspace.projectGroupKey,
});
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
const repoRoot = workspace.projectRootPath.trim();
if (!projectId || !repoRoot) {
return null;
}

View File

@@ -7,7 +7,9 @@ export function resolveProjectGroupKey(input: {
// COMPAT(projectGroupKey): added in v0.2.4 on 2026-07-28; remove after 2027-01-28.
// Older daemons used remote/path-shaped project IDs as their grouping key. New opaque IDs
// must remain host-local when the new field is absent.
return input.projectId.startsWith("prj_")
? `host:${input.serverId}:project:${input.projectId}`
: input.projectId;
return input.projectId.startsWith("prj_") ? frameHostProjectKey(input) : input.projectId;
}
export function frameHostProjectKey(input: { serverId: string; projectId: string }): string {
return `host:${encodeURIComponent(input.serverId)}:project:${encodeURIComponent(input.projectId)}`;
}

View File

@@ -1,3 +1,5 @@
import { frameHostProjectKey } from "./project-group-key";
interface ProjectSettingsTarget {
projectKey: string;
hosts: ReadonlyArray<{ serverId: string; projectId?: string; isOnline?: boolean }>;
@@ -9,7 +11,7 @@ export function resolveHostProjectSettingsRouteKey(host: {
}): string | null {
const projectId = host.projectId?.trim();
if (!projectId) return null;
return `host:${encodeURIComponent(host.serverId)}:project:${encodeURIComponent(projectId)}`;
return frameHostProjectKey({ serverId: host.serverId, projectId });
}
export function resolveProjectSettingsRouteKey(project: ProjectSettingsTarget): string {

View File

@@ -6,10 +6,11 @@ function workspace(input: {
id: string;
projectName: string;
projectCustomName: string | null;
projectId?: string;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: `project-${input.id}`,
projectId: input.projectId ?? `project-${input.id}`,
projectGroupKey: "remote:github.com/acme/app",
projectDisplayName: input.projectName,
projectCustomName: input.projectCustomName,
@@ -50,4 +51,42 @@ describe("buildWorkspaceStructureProjects", () => {
}),
]);
});
it("frames ambiguous host placements without alias collisions", () => {
const projects = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "a",
workspaces: [
workspace({
id: "a-1",
projectId: "remote:x/foo:project:/c",
projectName: "first",
projectCustomName: null,
}),
workspace({
id: "a-2",
projectId: "other",
projectName: "other",
projectCustomName: null,
}),
],
},
{
serverId: "a:project:remote:x/foo",
workspaces: [
workspace({
id: "b",
projectId: "/c",
projectName: "second",
projectCustomName: null,
}),
],
},
],
});
expect(projects).toHaveLength(3);
expect(new Set(projects.map((project) => project.projectKey)).size).toBe(3);
});
});

View File

@@ -1,6 +1,6 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectGroupKey } from "@/projects/project-group-key";
import { frameHostProjectKey, resolveProjectGroupKey } from "@/projects/project-group-key";
export interface WorkspaceStructureHostPlacement {
serverId: string;
@@ -104,9 +104,7 @@ function resolveUnambiguousProjectGroupKey(input: {
ambiguousGroupKeys: ReadonlySet<string>;
}): string {
const groupKey = resolveProjectGroupKey(input);
return input.ambiguousGroupKeys.has(groupKey)
? `host:${input.serverId}:project:${input.projectId}`
: groupKey;
return input.ambiguousGroupKeys.has(groupKey) ? frameHostProjectKey(input) : groupKey;
}
export function buildWorkspaceStructureProjects(input: {

View File

@@ -159,6 +159,21 @@ describe("deriveProjectGroupKey", () => {
);
});
test("preserves leading whitespace in SCP repository paths", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectGroupKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("git@example.com: acme/repo.git")).not.toBe(
derive("git@example.com:acme/repo.git"),
);
});
test("does not parse drive-relative Windows paths as SCP remotes", () => {
const rootPath = path.resolve("repo");

View File

@@ -50,9 +50,8 @@ function encodeSelectedPath(selectedPath: string): string {
}
function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) return null;
const remote = parseRemoteLocation(trimmed);
if (!remoteUrl?.trim()) return null;
const remote = parseRemoteLocation(remoteUrl);
if (!remote) return null;
const cleanedPath = normalizeRemotePath(
remote.path,
@@ -143,7 +142,7 @@ function normalizeRemotePath(
decodePercentEncoding: boolean,
stripDotGitSuffix: boolean,
): string {
const trimmedPath = remotePath.trim().replace(/\/+$/, "");
const trimmedPath = remotePath.replace(/\/+$/, "");
const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, "");
const segments = pathForEncoding.split("/");
const decodedSegments = segments.map((segment) => {