mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user