fix(task): canonicalize the shared-common-dir gate in detachGitDir

rev-parse --git-common-dir resolves symlinks while ensureIsolation derives
sourceCommonDir lexically from the session cwd (resolveRepository walks
path.resolve'd components). On any symlinked repo path (macOS /tmp,
symlinked project dirs) the lexical comparison missed, detachGitDir
returned "independent", and the parent-mutation leak silently survived.
Realpath both sides before comparing; regression test drives the gate
through a symlink alias.
This commit is contained in:
can1357
2026-07-18 19:40:04 +02:00
parent 53437aff3d
commit df9b04a0eb
2 changed files with 37 additions and 8 deletions
@@ -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({