diff --git a/packages/server/review-workspace.test.ts b/packages/server/review-workspace.test.ts index bf6acef53..6a2a3da0f 100644 --- a/packages/server/review-workspace.test.ts +++ b/packages/server/review-workspace.test.ts @@ -24,6 +24,7 @@ import { spawnSync } from "node:child_process"; import { aggregateWorkspacePatch, buildLocalWorkspaceReview, + isRepoRelative, prefixPatchPaths, resolveWorkspaceFilePath, discoverWorkspaceRepoPaths, @@ -1127,6 +1128,16 @@ describe("review-workspace", () => { expect(workspace.normalizeAnnotationPath(join(api, "src/file.ts"))).toBe("api/src/file.ts"); }); + it("treats cross-drive relative() results as escaping the repo", () => { + expect(isRepoRelative("src/file.ts")).toBe(true); + expect(isRepoRelative("nested/deep/file.ts")).toBe(true); + expect(isRepoRelative("../outside.ts")).toBe(false); + expect(isRepoRelative("")).toBe(false); + // On Windows, path.relative returns the target's absolute path when the + // base is on a different drive; after normalization that is "L:/...". + expect(isRepoRelative("L:/repos/project/src/file.ts")).toBe(false); + }); + it("keeps requested Git-only workspace modes available when another child repo fails detection", async () => { const root = makeTempDir("plannotator-workspace-partial-failure-"); const api = join(root, "api"); diff --git a/packages/server/review-workspace.ts b/packages/server/review-workspace.ts index 4dc8803ed..ff8fa45f0 100644 --- a/packages/server/review-workspace.ts +++ b/packages/server/review-workspace.ts @@ -15,6 +15,7 @@ import { export { WorkspaceReviewSession, + isRepoRelative, mapRepoDiffTypeToWorkspaceMode, mapWorkspaceModeToRepoDiffType, resolveWorkspaceInitialDiffType, diff --git a/packages/shared/review-workspace.ts b/packages/shared/review-workspace.ts index 21689d369..23e22aff3 100644 --- a/packages/shared/review-workspace.ts +++ b/packages/shared/review-workspace.ts @@ -224,6 +224,14 @@ function aggregateRepos(repos: WorkspaceRepoRuntimeState[]): WorkspaceDiffSnapsh }; } +// Guards a normalizeWorkspacePath(relative(...)) result: on Windows, +// path.relative cannot relativize across drives and returns the target's +// absolute path instead (after backslash normalization, e.g. "L:/other/..."), +// which must be treated as escaping the repo the same as "..". +export function isRepoRelative(rel: string): boolean { + return Boolean(rel) && !rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:/.test(rel); +} + function normalizeAgentPath(root: string, repos: WorkspaceRepoRuntimeState[], filePath: string): string { const normalized = normalizeWorkspacePath(filePath); if (resolveWorkspaceFilePath(repos, normalized)) return normalized; @@ -231,13 +239,13 @@ function normalizeAgentPath(root: string, repos: WorkspaceRepoRuntimeState[], fi const sorted = [...repos].sort((a, b) => b.cwd.length - a.cwd.length); for (const repo of sorted) { const rel = normalizeWorkspacePath(relative(repo.cwd, filePath)); - if (rel && !rel.startsWith("..") && !rel.startsWith("/")) { + if (isRepoRelative(rel)) { return `${normalizeWorkspacePath(repo.label)}/${rel}`; } } const rootRel = normalizeWorkspacePath(relative(root, filePath)); - if (rootRel && !rootRel.startsWith("..") && !rootRel.startsWith("/")) { + if (isRepoRelative(rootRel)) { if (resolveWorkspaceFilePath(repos, rootRel)) return rootRel; } @@ -246,7 +254,7 @@ function normalizeAgentPath(root: string, repos: WorkspaceRepoRuntimeState[], fi return `${normalizeWorkspacePath(changedRepos[0].label)}/${normalized}`; } - if (rootRel && !rootRel.startsWith("..") && !rootRel.startsWith("/")) return rootRel; + if (isRepoRelative(rootRel)) return rootRel; return normalized; }