diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b75856369..16b74f5e8 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -1430,14 +1430,18 @@ export async function detachGitDir(worktreeRoot: string, sourceCommonDir: string if (isEnoent(err)) return "no-git"; throw err; } - const parentCommon = path.resolve(sourceCommonDir); - const isoCommon = path.resolve( - ( - await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], { - readOnly: true, - }) - ).trim(), - ); + // Canonicalize both sides before comparing: `rev-parse` resolves symlinks + // (macOS `/tmp` → `/private/tmp`) while callers derive `sourceCommonDir` + // lexically from the session cwd. A lexical mismatch here would silently + // classify a shared linked-worktree copy as "independent" and skip the + // detach entirely — leaving the parent-mutation leak in place. + const parentCommon = await fs.promises.realpath(sourceCommonDir).catch(() => path.resolve(sourceCommonDir)); + const isoCommonRaw = ( + await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], { + readOnly: true, + }) + ).trim(); + const isoCommon = await fs.promises.realpath(isoCommonRaw).catch(() => path.resolve(isoCommonRaw)); // A full-copy `.git` already resolves to its own object DB — leave it alone. if (isoCommon !== parentCommon) return "independent"; diff --git a/packages/coding-agent/test/task/worktree.test.ts b/packages/coding-agent/test/task/worktree.test.ts index 0d0dcf8e0..b75fd35ba 100644 --- a/packages/coding-agent/test/task/worktree.test.ts +++ b/packages/coding-agent/test/task/worktree.test.ts @@ -753,6 +753,31 @@ describe("detachGitDir", () => { expect((await runGit(iso, ["rev-list", "HEAD"])).split("\n")).toHaveLength(1); }); + it("detaches when sourceCommonDir is reached through a symlinked path", async () => { + const { wt, commonDir, baseSha } = await makeLinkedWorktree(); + // Alias the main checkout through a symlink and hand detachGitDir the + // lexical (un-canonicalized) common dir — the shape ensureIsolation + // produces when the session cwd traverses a symlink (macOS /tmp, + // symlinked project dirs). The shared-common-dir gate must still match, + // or the detach silently no-ops and the parent leak survives. + const aliasBase = await fs.mkdtemp(path.join(os.tmpdir(), "omp-detach-alias-")); + tempDirs.push(aliasBase); + const aliasMain = path.join(aliasBase, "main-link"); + await fs.symlink(path.dirname(commonDir), aliasMain); + const aliasCommonDir = path.join(aliasMain, ".git"); + + const iso = await copyTree(wt); + expect(await git.detachGitDir(iso, aliasCommonDir)).toBe("detached"); + + // Isolation is fully functional: task branch + commit stay private. + await runGit(iso, ["checkout", "-q", "-b", "feature/a", baseSha]); + await fs.writeFile(path.join(iso, "a.txt"), "task a\n"); + await runGit(iso, ["add", "a.txt"]); + await runGit(iso, ["commit", "-q", "-m", "task a"]); + expect(await runGit(wt, ["rev-parse", "--abbrev-ref", "HEAD"])).toBe("feature/parent"); + expect(await runGit(wt, ["branch", "--format=%(refname:short)"])).not.toContain("feature/a"); + }); + it("keeps ensureIsolation from mutating a linked-worktree parent (rcopy backend)", async () => { const { wt, baseSha } = await makeLinkedWorktree(); vi.spyOn(natives, "isoResolve").mockReturnValue({