diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index d78e76179..45d0d807c 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -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); }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 718f9ae51..925d75414 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -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; } diff --git a/packages/app/src/projects/project-group-key.ts b/packages/app/src/projects/project-group-key.ts index 0d06a2e14..fd0aec778 100644 --- a/packages/app/src/projects/project-group-key.ts +++ b/packages/app/src/projects/project-group-key.ts @@ -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)}`; } diff --git a/packages/app/src/projects/project-settings-target.ts b/packages/app/src/projects/project-settings-target.ts index d0c09805e..c580a472a 100644 --- a/packages/app/src/projects/project-settings-target.ts +++ b/packages/app/src/projects/project-settings-target.ts @@ -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 { diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 3f32c2201..80a41192f 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -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); + }); }); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 8d6feba43..0023c6c5a 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -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 { 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: { diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index 56c80dc5d..8a9b6faee 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -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"); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index c78357d26..0698919ce 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -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) => {