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:
parent
2e04253c0b
commit
f79de8a583
2 changed files with 161 additions and 6 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue