From b672f44014dacc189c098f3e19cc1b5d22e1c4fe Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 23:42:03 -0400 Subject: [PATCH] fix(doctor): chdir out of orphaned worktree before removal (#1946) The orphaned_auto_worktree fix skipped removal when process.cwd() was inside the worktree, creating a deadlock where the doctor repeatedly detected the orphan but never cleaned it up. Now chdir to basePath first, matching the existing pattern in removeWorktree(). Fixes #1946 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/doctor-checks.ts | 19 ++++--- .../extensions/gsd/tests/doctor-git.test.ts | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index 64eb0a921..c06e878bb 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -70,18 +70,25 @@ export async function checkGitHealth( }); if (shouldFix("orphaned_auto_worktree")) { - // Never remove a worktree matching current working directory + // If cwd is inside the worktree, chdir out first — matching the + // pattern in removeWorktree() (#1946). Without this, git cannot + // remove the worktree and the doctor enters a deadlock where it + // detects the orphan every run but never cleans it up. const cwd = process.cwd(); if (wt.path === cwd || cwd.startsWith(wt.path + sep)) { - fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`); - } else { try { - nativeWorktreeRemove(basePath, wt.path, true); - fixesApplied.push(`removed orphaned worktree ${wt.path}`); + process.chdir(basePath); } catch { - fixesApplied.push(`failed to remove worktree ${wt.path}`); + fixesApplied.push(`skipped removing worktree at ${wt.path} (cannot chdir to basePath)`); + continue; } } + try { + nativeWorktreeRemove(basePath, wt.path, true); + fixesApplied.push(`removed orphaned worktree ${wt.path}`); + } catch { + fixesApplied.push(`failed to remove worktree ${wt.path}`); + } } } } diff --git a/src/resources/extensions/gsd/tests/doctor-git.test.ts b/src/resources/extensions/gsd/tests/doctor-git.test.ts index 10e12e4d9..0fc8eae96 100644 --- a/src/resources/extensions/gsd/tests/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-git.test.ts @@ -149,6 +149,56 @@ async function main(): Promise { console.log("\n=== orphaned_auto_worktree (skipped on Windows) ==="); } + // ─── Test 1b: Orphaned worktree fix when cwd is inside worktree (#1946) ── + // Reproduces the deadlock: if process.cwd() is inside the orphaned worktree, + // the doctor must chdir out before removing it — not skip the removal. + if (process.platform !== "win32") { + console.log("\n=== orphaned_auto_worktree (cwd inside worktree) ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Create worktree with milestone/M001 branch under .gsd/worktrees/ + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); + + const wtPath = realpathSync(join(dir, ".gsd", "worktrees", "M001")); + + // Simulate the deadlock: set cwd inside the orphaned worktree + const previousCwd = process.cwd(); + process.chdir(wtPath); + try { + const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" }); + + // The fix must NOT skip removal — it should chdir out and remove + assertTrue( + !fixed.fixesApplied.some(f => f.includes("skipped removing worktree")), + "does NOT skip removal when cwd is inside worktree", + ); + assertTrue( + fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), + "removes orphaned worktree even when cwd was inside it", + ); + + // Verify worktree is gone + const wtList = run("git worktree list", dir); + assertTrue(!wtList.includes("milestone/M001"), "worktree removed after fix with cwd inside"); + + // Verify cwd was moved out (should be basePath, not still inside worktree) + const newCwd = process.cwd(); + assertTrue( + !newCwd.startsWith(wtPath), + "cwd moved out of worktree after fix", + ); + } finally { + // Restore cwd — the worktree dir may be gone, so chdir to previousCwd + try { process.chdir(previousCwd); } catch { process.chdir(dir); } + } + } + } else { + console.log("\n=== orphaned_auto_worktree (cwd inside worktree — skipped on Windows) ==="); + } + // ─── Test 2: Stale milestone branch detection & fix ──────────────── // Skip on Windows: git branch glob matching and path resolution // behave differently in Windows temp dirs.