mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): preserve conservative identity boundaries
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user