fix(projects): preserve conservative identity boundaries

This commit is contained in:
Mohamed Boudra
2026-07-29 11:13:07 +00:00
parent 7b9787a75d
commit ad0939dfbb
4 changed files with 86 additions and 12 deletions

View File

@@ -263,6 +263,41 @@ describe("normalizeWorkspaceDescriptor", () => {
mainRepoRoot: null, 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();
}); });
}); });

View File

@@ -170,8 +170,7 @@ export function normalizeWorkspaceDescriptor(
return { return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId, projectId: payload.projectId,
// COMPAT(projectKey): added in v0.2.4 on 2026-07-29; remove after 2027-01-29. projectKey: normalizeWorkspaceProjectKey(payload),
projectKey: payload.projectKey ?? payload.project?.projectKey ?? null,
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null, projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath, 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 { export interface EmptyProjectDescriptor {
projectId: string; projectId: string;
projectKey?: string | null; projectKey?: string | null;

View File

@@ -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", () => { test("normalizes equivalent absolute SSH remote forms", () => {
const rootPath = path.resolve("repo"); const rootPath = path.resolve("repo");
const derive = (remoteUrl: string) => const derive = (remoteUrl: string) =>
@@ -317,7 +331,7 @@ describe("deriveProjectKey", () => {
worktreeRoot: rootPath, worktreeRoot: rootPath,
mainRepoRoot: null, 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", () => { test("includes the selected path within a repository", () => {
@@ -422,6 +436,19 @@ describe("deriveProjectKey", () => {
).toBe("remote:github.com/getpaseo/paseo#subdir:Packages/App"); ).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")( test.skipIf(process.platform === "win32")(
"preserves a selected subproject reached through a symlink", "preserves a selected subproject reached through a symlink",
() => { () => {

View File

@@ -1,4 +1,4 @@
import { resolve } from "node:path"; import { resolve, win32 } from "node:path";
import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest"; import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest";
import { getRealpathAwareRelativePath } from "../utils/path.js"; import { getRealpathAwareRelativePath } from "../utils/path.js";
@@ -33,10 +33,11 @@ export function deriveProjectKey(input: {
const remoteKey = deriveRemoteProjectKey(input.remoteUrl); const remoteKey = deriveRemoteProjectKey(input.remoteUrl);
const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot); const selectedPath = deriveSelectedPath(input.rootPath, input.worktreeRoot);
if (!remoteKey) { if (!remoteKey) {
const localPath = const localPathParts =
selectedPath && input.mainRepoRoot selectedPath && input.mainRepoRoot
? resolve(input.mainRepoRoot, selectedPath) ? [input.mainRepoRoot, selectedPath]
: resolve(input.mainRepoRoot ?? input.rootPath); : [input.mainRepoRoot ?? input.rootPath];
const localPath = resolveLocalProjectPath(...localPathParts);
return input.serverId return input.serverId
? `host:${input.serverId.length}:${input.serverId}:path:${localPath}` ? `host:${input.serverId.length}:${input.serverId}:path:${localPath}`
: localPath; : localPath;
@@ -47,6 +48,12 @@ export function deriveProjectKey(input: {
: remoteKey; : 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 { function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null {
if (!worktreeRoot) return null; if (!worktreeRoot) return null;
return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null; return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null;
@@ -105,7 +112,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null {
host: normalizedHost, host: normalizedHost,
path: remotePath, path: remotePath,
transport: null, transport: null,
user: user && user !== "git" ? user : null, user: user && (user !== "git" || !CLOUD_FORGE_HOSTS.has(host.toLowerCase())) ? user : null,
preserveLeadingSlash, preserveLeadingSlash,
decodePercentEncoding: false, decodePercentEncoding: false,
stripDotGitSuffix: CLOUD_FORGE_HOSTS.has(host.toLowerCase()), stripDotGitSuffix: CLOUD_FORGE_HOSTS.has(host.toLowerCase()),
@@ -128,10 +135,7 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null {
host, host,
path: remotePath, path: remotePath,
transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(), transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(),
user: user: isSsh && !forgeHost && parsed.username ? decodeUrlComponent(parsed.username) : null,
isSsh && !forgeHost && parsed.username && parsed.username !== "git"
? decodeUrlComponent(parsed.username)
: null,
preserveLeadingSlash, preserveLeadingSlash,
decodePercentEncoding: true, decodePercentEncoding: true,
stripDotGitSuffix: forgeHost !== null, stripDotGitSuffix: forgeHost !== null,