diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index ffa8e2a2b..a54c64b61 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -70,6 +70,49 @@ describe("deriveProjectGroupKey", () => { expect(derive("example.com:srv/repo.git")).not.toBe(derive("example.com:/srv/repo.git")); }); + test("distinguishes SSH users for home-relative SCP paths", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("alice@git.example.com:repo.git")).not.toBe( + derive("bob@git.example.com:repo.git"), + ); + }); + + test("keeps percent sequences literal in SCP paths", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git.example.com:acme/repo%41.git")).not.toBe( + derive("git.example.com:acme/repoA.git"), + ); + }); + + test("does not parse drive-relative Windows paths as SCP remotes", () => { + const rootPath = path.resolve("repo"); + + expect( + deriveProjectGroupKey({ + rootPath, + remoteUrl: "C:repo", + worktreeRoot: rootPath, + mainRepoRoot: null, + }), + ).toBe(rootPath); + }); + test.each(["git+ssh:", "ssh+git:"])("normalizes SSH alias default ports for %s", (scheme) => { 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 9143dd7f4..ef4b3bfda 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -34,39 +34,78 @@ function encodeSelectedPath(selectedPath: string): string { function deriveRemoteProjectGroupKey(remoteUrl: string | null): string | null { const trimmed = remoteUrl?.trim(); if (!trimmed) return null; - - let host: string | null = null; - let remotePath: string | null = null; - let preserveLeadingSlash = false; - const scpLike = - !trimmed.includes("://") && !/^[A-Za-z]:[\\/]/.test(trimmed) - ? trimmed.match(/^(?:[^@/:]+@)?(\[[^\]]+\]|[^/:]+):(.+)$/) - : null; - if (scpLike) { - host = scpLike[1] ?? null; - remotePath = scpLike[2] ?? null; - preserveLeadingSlash = remotePath?.startsWith("/") ?? false; - } else if (trimmed.includes("://")) { - try { - const parsed = new URL(trimmed); - host = deriveRemoteHost(parsed); - remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; - } catch { - return null; - } - } - - if (!host || !remotePath) return null; - const cleanedPath = normalizeRemotePath(remotePath, preserveLeadingSlash); + const remote = parseRemoteLocation(trimmed); + if (!remote) return null; + const cleanedPath = normalizeRemotePath( + remote.path, + remote.preserveLeadingSlash, + remote.decodePercentEncoding, + ); if (!cleanedPath) return null; - return `remote:${host.toLowerCase()}/${cleanedPath}`; + const userPrefix = remote.relativePathUser + ? `${encodeURIComponent(remote.relativePathUser)}@` + : ""; + return `remote:${userPrefix}${remote.host.toLowerCase()}/${cleanedPath}`; } -function normalizeRemotePath(remotePath: string, preserveLeadingSlash: boolean): string { +interface RemoteLocation { + host: string; + path: string; + relativePathUser: string | null; + preserveLeadingSlash: boolean; + decodePercentEncoding: boolean; +} + +function parseRemoteLocation(remoteUrl: string): RemoteLocation | null { + return remoteUrl.includes("://") ? parseUrlRemote(remoteUrl) : parseScpRemote(remoteUrl); +} + +function parseScpRemote(remoteUrl: string): RemoteLocation | null { + if (/^[A-Za-z]:/.test(remoteUrl)) return null; + const match = remoteUrl.match(/^(?:(?[^@/:]+)@)?(?\[[^\]]+\]|[^/:]+):(?.+)$/); + const host = match?.groups?.host; + const remotePath = match?.groups?.path; + if (!host || !remotePath) return null; + + const preserveLeadingSlash = remotePath.startsWith("/"); + const user = match.groups?.user; + return { + host, + path: remotePath, + relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null, + preserveLeadingSlash, + decodePercentEncoding: false, + }; +} + +function parseUrlRemote(remoteUrl: string): RemoteLocation | null { + try { + const parsed = new URL(remoteUrl); + const host = deriveRemoteHost(parsed); + const remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; + if (!host || !remotePath) return null; + return { + host, + path: remotePath, + relativePathUser: null, + preserveLeadingSlash: false, + decodePercentEncoding: true, + }; + } catch { + return null; + } +} + +function normalizeRemotePath( + remotePath: string, + preserveLeadingSlash: boolean, + decodePercentEncoding: boolean, +): string { const trimmedPath = remotePath.trim().replace(/\/+$/, ""); const pathForEncoding = preserveLeadingSlash ? trimmedPath : trimmedPath.replace(/^\/+/, ""); const segments = pathForEncoding.split("/"); const decodedSegments = segments.map((segment) => { + if (!decodePercentEncoding) return segment; try { return decodeURIComponent(segment); } catch {