fix: resolve worktree path from git registry when .gsd/ symlink is shadowed (#1866)

When .gsd/ is a symlink to an external state directory, git registers
worktrees at the resolved (real) path. If syncStateToProjectRoot later
creates a real .gsd/ directory that shadows the symlink, worktreePath()
computes a local path that diverges from git's registered path. The
stale local directory passes existsSync but is not a git worktree, so
nativeWorktreeRemove fails silently.

removeWorktree now queries nativeWorktreeList to find the actual
git-registered path by matching on branch name before attempting
removal, falling back to the computed path if the lookup fails.

Fixes #1852

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 17:24:21 -04:00 committed by GitHub
parent 2e04253c0b
commit f79de8a583
2 changed files with 161 additions and 6 deletions

View file

@ -0,0 +1,140 @@
/**
* Regression test for #1852: removeWorktree targets wrong path when .gsd/ is a symlink.
*
* When .gsd/ is a symlink to an external state directory, git registers
* the worktree at the resolved (real) path. But removeWorktree recomputes
* the path via worktreePath() which uses the unresolved symlink, causing
* a mismatch the removal silently fails.
*
* Fix: removeWorktree should query `git worktree list` to find the actual
* registered path when the computed path doesn't match.
*/
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, existsSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import {
createWorktree,
removeWorktree,
listWorktrees,
worktreePath,
} from "../worktree-manager.ts";
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
// Set up a test repo with .gsd/ as a symlink to an external directory,
// mimicking the external state directory layout (~/.gsd/projects/<hash>/).
// Resolve tmpdir to handle macOS /tmp -> /private/var/... symlink.
const realTmp = realpathSync(tmpdir());
const base = mkdtempSync(join(realTmp, "gsd-wt-symlink-test-"));
const externalState = mkdtempSync(join(realTmp, "gsd-wt-symlink-ext-"));
run("git init -b main", base);
run('git config user.name "Test"', base);
run('git config user.email "test@example.com"', base);
// Create external state directory structure
mkdirSync(join(externalState, "worktrees"), { recursive: true });
// Create .gsd as a symlink to the external state directory
symlinkSync(externalState, join(base, ".gsd"));
// Verify the symlink is in place
assertTrue(existsSync(join(base, ".gsd")), ".gsd symlink exists");
assertTrue(
realpathSync(join(base, ".gsd")) === externalState,
".gsd resolves to external state dir",
);
// Create initial commit so we have a valid repo
writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
run("git add .", base);
run('git commit -m "init"', base);
async function main(): Promise<void> {
console.log("\n=== #1852: removeWorktree with symlinked .gsd/ ===");
// Create a worktree — git will resolve the symlink and register
// the worktree at the external path
const info = createWorktree(base, "M002", { branch: "milestone/M002" });
assertTrue(info.exists, "worktree created");
// Verify worktree was created at the resolved (external) path
const realWtPath = realpathSync(info.path);
assertTrue(
realWtPath.startsWith(externalState),
`worktree real path (${realWtPath}) is under external state dir`,
);
// Verify git registered the worktree
const gitList = run("git worktree list", base);
assertTrue(gitList.includes("M002"), "git worktree list shows M002");
// The computed path via worktreePath uses the symlink path
const computedPath = worktreePath(base, "M002");
assertTrue(existsSync(computedPath), "computed path exists (via symlink)");
// Simulate what syncStateToProjectRoot does: replace the .gsd symlink with
// a real directory containing stale worktree data. This causes worktreePath()
// to compute a LOCAL path that differs from git's REGISTERED path (the
// resolved external path). The stale local dir passes existsSync but is not
// a real git worktree, so nativeWorktreeRemove fails silently.
unlinkSync(join(base, ".gsd")); // remove the symlink
mkdirSync(join(base, ".gsd", "worktrees", "M002"), { recursive: true });
// Write a dummy file so the stale directory is non-empty
writeFileSync(join(base, ".gsd", "worktrees", "M002", "stale.txt"), "stale sync artifact", "utf-8");
// Now worktreePath(base, "M002") points to the LOCAL stale dir, not the
// external path where git actually registered the worktree.
const stalePath = worktreePath(base, "M002");
assertTrue(existsSync(stalePath), "stale local worktree dir exists");
assertTrue(
stalePath !== realWtPath,
`computed path (${stalePath}) differs from git-registered path (${realWtPath})`,
);
// THE ACTUAL TEST: removeWorktree must find the git-registered path and
// remove the real worktree, not just operate on the stale local directory.
removeWorktree(base, "M002", { branch: "milestone/M002", deleteBranch: true });
// After removal, the worktree should be gone from git's list
const gitListAfter = run("git worktree list", base);
assertTrue(
!gitListAfter.includes("M002"),
"worktree removed from git worktree list after removeWorktree",
);
// The branch should be deleted
const branches = run("git branch", base);
assertTrue(
!branches.includes("milestone/M002"),
"milestone/M002 branch deleted after removeWorktree",
);
// The worktree directory should be gone
assertTrue(
!existsSync(realWtPath),
"worktree directory removed from disk",
);
// List should be empty
const listed = listWorktrees(base);
assertEq(listed.length, 0, "no worktrees listed after removal");
// Cleanup
rmSync(base, { recursive: true, force: true });
rmSync(externalState, { recursive: true, force: true });
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -286,11 +286,26 @@ export function removeWorktree(
name: string,
opts: { deleteBranch?: boolean; force?: boolean; branch?: string } = {},
): void {
const wtPath = worktreePath(basePath, name);
const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath;
let wtPath = worktreePath(basePath, name);
const branch = opts.branch ?? worktreeBranchName(name);
const { deleteBranch = true, force = true } = opts;
// Resolve the ACTUAL worktree path from git's worktree list.
// The computed path may differ when .gsd/ is (or was) a symlink to an
// external state directory — git resolves symlinks at worktree creation
// time, so its registered path points to the resolved external location.
// If syncStateToProjectRoot later creates a real .gsd/ directory that
// shadows the symlink, the computed path diverges from git's record.
try {
const entries = nativeWorktreeList(basePath);
const entry = entries.find(e => e.branch === branch);
if (entry?.path) {
wtPath = entry.path;
}
} catch { /* fall back to computed path */ }
const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath;
// If we're inside the worktree, move out first — git can't remove an in-use directory
const cwd = process.cwd();
const resolvedCwd = existsSync(cwd) ? realpathSync(cwd) : cwd;
@ -306,12 +321,12 @@ export function removeWorktree(
return;
}
// Remove worktree (force if requested, to handle dirty worktrees)
try { nativeWorktreeRemove(basePath, wtPath, force); } catch { /* may fail */ }
// Remove worktree using the resolved path (force if requested, to handle dirty worktrees)
try { nativeWorktreeRemove(basePath, resolvedWtPath, force); } catch { /* may fail */ }
// If the directory is still there (e.g. locked), try harder with force
if (existsSync(wtPath)) {
try { nativeWorktreeRemove(basePath, wtPath, true); } catch { /* may fail */ }
if (existsSync(resolvedWtPath)) {
try { nativeWorktreeRemove(basePath, resolvedWtPath, true); } catch { /* may fail */ }
}
// Prune stale entries so git knows the worktree is gone