diff --git a/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts b/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts new file mode 100644 index 000000000..f92f719e0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts @@ -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//). +// 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 { + 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); +}); diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 6c54b90b9..23ba831a6 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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