diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 1588fd114..2743e5d08 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -263,6 +263,41 @@ describe("normalizeWorkspaceDescriptor", () => { mainRepoRoot: null, }, }); + expect(workspace.projectKey).toBe("remote:github.com/acme/app"); + }); + + it("leaves legacy path-shaped placement keys host-local", () => { + const workspace = normalizeWorkspaceDescriptor({ + id: "1", + projectId: "/repo/app", + projectDisplayName: "app", + projectRootPath: "/repo/app", + workspaceDirectory: "/repo/app", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + project: { + projectKey: "/repo/app", + projectName: "app", + checkout: { + cwd: "/repo/app", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }); + + expect(workspace.projectKey).toBeNull(); }); }); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c838ee662..482830284 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -170,8 +170,7 @@ export function normalizeWorkspaceDescriptor( return { id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, projectId: payload.projectId, - // COMPAT(projectKey): added in v0.2.4 on 2026-07-29; remove after 2027-01-29. - projectKey: payload.projectKey ?? payload.project?.projectKey ?? null, + projectKey: normalizeWorkspaceProjectKey(payload), projectDisplayName: payload.projectDisplayName, projectCustomName: payload.projectCustomName ?? null, projectRootPath: payload.projectRootPath, @@ -196,6 +195,15 @@ export function normalizeWorkspaceDescriptor( }; } +function normalizeWorkspaceProjectKey(payload: WorkspaceDescriptorPayload): string | null { + if (payload.projectKey !== undefined) return payload.projectKey; + // COMPAT(projectKey): added in v0.2.4 on 2026-07-29; remove after 2027-01-29. + // Older daemons used remote-shaped placement keys for cross-host identity. Their path-shaped + // placement keys remain absent here so resolveProjectKey can scope them to the host. + const legacyProjectKey = payload.project?.projectKey; + return legacyProjectKey?.startsWith("remote:") ? legacyProjectKey : null; +} + export interface EmptyProjectDescriptor { projectId: string; projectKey?: string | null; diff --git a/packages/server/src/server/project-key.test.ts b/packages/server/src/server/project-key.test.ts index 84717ac6f..d44f702a8 100644 --- a/packages/server/src/server/project-key.test.ts +++ b/packages/server/src/server/project-key.test.ts @@ -219,6 +219,20 @@ describe("deriveProjectKey", () => { ); }); + test("preserves an explicit git user on generic SSH hosts", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git@example.com:repo.git")).not.toBe(derive("example.com:repo.git")); + expect(derive("ssh://git@example.com/repo.git")).not.toBe(derive("ssh://example.com/repo.git")); + }); + test("normalizes equivalent absolute SSH remote forms", () => { const rootPath = path.resolve("repo"); const derive = (remoteUrl: string) => @@ -317,7 +331,7 @@ describe("deriveProjectKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:[2001:db8::1]/getpaseo/paseo.git"); + ).toBe("remote:git@[2001:db8::1]/getpaseo/paseo.git"); }); test("includes the selected path within a repository", () => { @@ -422,6 +436,19 @@ describe("deriveProjectKey", () => { ).toBe("remote:github.com/getpaseo/paseo#subdir:Packages/App"); }); + test("stabilizes host-local Windows paths across equivalent spellings", () => { + const derive = (rootPath: string) => + deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId: "host-a", + }); + + expect(derive("C:\\Users\\Paseo\\Repo")).toBe(derive("c:/users/paseo/repo/.")); + }); + test.skipIf(process.platform === "win32")( "preserves a selected subproject reached through a symlink", () => { diff --git a/packages/server/src/server/project-key.ts b/packages/server/src/server/project-key.ts index bd04c5c68..00f7c07c1 100644 --- a/packages/server/src/server/project-key.ts +++ b/packages/server/src/server/project-key.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { resolve, win32 } from "node:path"; import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest"; import { getRealpathAwareRelativePath } from "../utils/path.js"; @@ -33,10 +33,11 @@ export function deriveProjectKey(input: { const remoteKey = deriveRemoteProjectKey(input.remoteUrl); const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot); if (!remoteKey) { - const localPath = + const localPathParts = selectedPath && input.mainRepoRoot - ? resolve(input.mainRepoRoot, selectedPath) - : resolve(input.mainRepoRoot ?? input.rootPath); + ? [input.mainRepoRoot, selectedPath] + : [input.mainRepoRoot ?? input.rootPath]; + const localPath = resolveLocalProjectPath(...localPathParts); return input.serverId ? `host:${input.serverId.length}:${input.serverId}:path:${localPath}` : localPath; @@ -47,6 +48,12 @@ export function deriveProjectKey(input: { : remoteKey; } +function resolveLocalProjectPath(...parts: string[]): string { + return parts.some(looksLikeWindowsPath) + ? win32.resolve(...parts).toLowerCase() + : resolve(...parts); +} + function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null { if (!worktreeRoot) return null; return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null; @@ -105,7 +112,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null { host: normalizedHost, path: remotePath, transport: null, - user: user && user !== "git" ? user : null, + user: user && (user !== "git" || !CLOUD_FORGE_HOSTS.has(host.toLowerCase())) ? user : null, preserveLeadingSlash, decodePercentEncoding: false, stripDotGitSuffix: CLOUD_FORGE_HOSTS.has(host.toLowerCase()), @@ -128,10 +135,7 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { host, path: remotePath, transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(), - user: - isSsh && !forgeHost && parsed.username && parsed.username !== "git" - ? decodeUrlComponent(parsed.username) - : null, + user: isSsh && !forgeHost && parsed.username ? decodeUrlComponent(parsed.username) : null, preserveLeadingSlash, decodePercentEncoding: true, stripDotGitSuffix: forgeHost !== null,