fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.
This commit is contained in:
Mohamed Boudra
2026-07-29 09:16:01 +00:00
parent 649592fe73
commit 471a7431f2
4 changed files with 78 additions and 8 deletions

View File

@@ -15,7 +15,22 @@ describe("deriveProjectKey", () => {
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:git.example.com:8443/acme/app.git");
).toBe("remote:https://git.example.com:8443/acme/app.git");
});
test("distinguishes transports for generic URL remotes", () => {
const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) =>
deriveProjectKey({
rootPath,
remoteUrl,
worktreeRoot: rootPath,
mainRepoRoot: null,
});
expect(derive("http://git.example.com:80/acme/app.git")).not.toBe(
derive("https://git.example.com:443/acme/app.git"),
);
});
test("normalizes the default SSH port", () => {
@@ -43,7 +58,7 @@ describe("deriveProjectKey", () => {
worktreeRoot: rootPath,
mainRepoRoot: null,
}),
).toBe("remote:git.example.com/repo.git");
).toBe("remote:https://git.example.com/repo.git");
});
test("preserves meaningful dot-git suffixes on unknown Git servers", () => {
@@ -244,6 +259,23 @@ describe("deriveProjectKey", () => {
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
});
test.skipIf(process.platform === "win32")(
"preserves backslashes in POSIX selected path segments",
() => {
const worktreeRoot = "/repo";
const derive = (rootPath: string) =>
deriveProjectKey({
rootPath,
remoteUrl: "git@github.com:getpaseo/paseo.git",
worktreeRoot,
mainRepoRoot: null,
});
expect(derive("/repo/foo\\bar")).toBe("remote:github.com/getpaseo/paseo#subdir:foo%5Cbar");
expect(derive("/repo/foo\\bar")).not.toBe(derive("/repo/foo/bar"));
},
);
test("keeps a selected path distinct from remote path syntax", () => {
const worktreeRoot = path.resolve("repo");
const selectedKey = deriveProjectKey({

View File

@@ -42,7 +42,9 @@ export function deriveProjectKey(input: {
: localPath;
}
return selectedPath ? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath)}` : remoteKey;
return selectedPath
? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath, input.worktreeRoot ?? input.rootPath)}`
: remoteKey;
}
function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null {
@@ -50,8 +52,11 @@ function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): stri
return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null;
}
function encodeSelectedPath(selectedPath: string): string {
return selectedPath.split(/[\\/]/u).map(encodeURIComponent).join("/");
function encodeSelectedPath(selectedPath: string, sourcePath: string): string {
const segments = looksLikeWindowsPath(sourcePath)
? selectedPath.split(/[\\/]/u)
: selectedPath.split("/");
return segments.map(encodeURIComponent).join("/");
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
@@ -70,12 +75,14 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
: "";
const normalizedHost = remote.host.toLowerCase();
const normalizedPath = normalizedHost === "github.com" ? cleanedPath.toLowerCase() : cleanedPath;
return `remote:${userPrefix}${normalizedHost}/${normalizedPath}`;
const transportPrefix = remote.transport ? `${remote.transport}//` : "";
return `remote:${transportPrefix}${userPrefix}${normalizedHost}/${normalizedPath}`;
}
interface RemoteLocation {
host: string;
path: string;
transport: string | null;
relativePathUser: string | null;
preserveLeadingSlash: boolean;
decodePercentEncoding: boolean;
@@ -99,6 +106,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null {
return {
host: normalizedHost,
path: remotePath,
transport: null,
relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null,
preserveLeadingSlash,
decodePercentEncoding: false,
@@ -119,6 +127,7 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
return {
host,
path: remotePath,
transport: forgeHost ? null : parsed.protocol.toLowerCase(),
relativePathUser: null,
preserveLeadingSlash,
decodePercentEncoding: true,
@@ -212,3 +221,7 @@ function deriveRemoteHost(remoteUrl: URL): string | null {
return remoteUrl.hostname || null;
return remoteUrl.host || null;
}
function looksLikeWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/u.test(value) || /^\\{2}[^\\/]+[\\/][^\\/]+/u.test(value);
}

View File

@@ -3,7 +3,7 @@ import { resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { getRealpathAwareRelativePath } from "../utils/path.js";
import { createRealpathAwarePathMatcher, getRealpathAwareRelativePath } from "../utils/path.js";
import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js";
import {
deriveProjectKind,
@@ -62,7 +62,9 @@ export function classifyDirectoryForProjectMembership(input: {
function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string {
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
const selectedRoot = resolve(cwd);
return worktreeRoot && resolve(worktreeRoot) === selectedRoot ? worktreeRoot : selectedRoot;
return worktreeRoot && createRealpathAwarePathMatcher(worktreeRoot)(selectedRoot)
? worktreeRoot
: selectedRoot;
}
function deriveProjectRootPath(input: {

View File

@@ -10,6 +10,7 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js";
let NON_GIT_PROJECT: string;
let ARCHIVED_PROJECT: string;
@@ -511,6 +512,28 @@ describe("bootstrapWorkspaceRegistries", () => {
},
);
test.skipIf(process.platform === "win32")(
"uses one directory key for symlink-equivalent checkout roots",
() => {
const linkedProject = path.join(tmpDir, "legacy-project-link");
symlinkSync(GIT_PROJECT, linkedProject, "dir");
const checkout = {
cwd: linkedProject,
isGit: true as const,
currentBranch: "main",
remoteUrl: "git@github.com:acme/legacy-project.git",
worktreeRoot: GIT_PROJECT,
isPaseoOwnedWorktree: false,
mainRepoRoot: GIT_PROJECT,
};
expect(
classifyDirectoryForProjectMembership({ cwd: linkedProject, checkout })
.workspaceDirectoryKey,
).toBe(GIT_PROJECT);
},
);
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
await projectRegistry.initialize();
await workspaceRegistry.initialize();