From 7e25e6d427134c8d11ec6209ec7e8cc5c6c11acc Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Mon, 16 Mar 2026 07:58:02 -0500 Subject: [PATCH] fix: prevent stale worktree cwd after milestone completion (#608) (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After milestone completion and merge, the process cwd could remain inside .gsd/worktrees//, causing new milestone writes to land in the wrong directory. Three-layer fix: 1. escapeStaleWorktree() at startAuto entry — detects if base path is inside .gsd/worktrees/ and chdir back to project root 2. stopAuto() unconditionally restores cwd to originalBasePath, not just when isInAutoWorktree returns true (module state may have been cleared by mergeMilestoneToMain already) 3. Milestone merge error handler restores cwd on partial failure where mergeMilestoneToMain chdir'd but then threw Closes #608 --- src/resources/extensions/gsd/auto.ts | 53 ++++++- .../gsd/tests/stale-worktree-cwd.test.ts | 139 ++++++++++++++++++ 2 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 07575ce81..a4c6f498b 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -197,6 +197,33 @@ function shouldUseWorktreeIsolation(): boolean { return true; // default: worktree } +/** + * Detect and escape a stale worktree cwd (#608). + * + * After milestone completion + merge, the worktree directory is removed but + * the process cwd may still point inside `.gsd/worktrees//`. + * When a new session starts, `process.cwd()` is passed as `base` to startAuto + * and all subsequent writes land in the wrong directory. This function detects + * that scenario and chdir back to the project root. + * + * Returns the corrected base path. + */ +function escapeStaleWorktree(base: string): string { + const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; + const idx = base.indexOf(marker); + if (idx === -1) return base; + + // base is inside .gsd/worktrees/ — extract the project root + const projectRoot = base.slice(0, idx); + try { + process.chdir(projectRoot); + } catch { + // If chdir fails, return the original — caller will handle errors downstream + return base; + } + return projectRoot; +} + /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */ let pendingCrashRecovery: string | null = null; @@ -447,14 +474,18 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi `Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`, "warning", ); - // Force basePath back to original even if teardown failed - if (originalBasePath) { - basePath = originalBasePath; - try { process.chdir(basePath); } catch { /* best-effort */ } - } } } + // Always restore cwd to project root on stop (#608). + // Even if isInAutoWorktree returned false (e.g., module state was already + // cleared by mergeMilestoneToMain), the process cwd may still be inside + // the worktree directory. Force it back to originalBasePath. + if (originalBasePath) { + basePath = originalBasePath; + try { process.chdir(basePath); } catch { /* best-effort */ } + } + const ledger = getLedger(); if (ledger && ledger.units.length > 0) { const totals = getProjectTotals(ledger.units); @@ -543,6 +574,11 @@ export async function startAuto( ): Promise { const requestedStepMode = options?.step ?? false; + // Escape stale worktree cwd from a previous milestone (#608). + // After milestone merge + worktree removal, the process cwd may still point + // inside .gsd/worktrees// — detect and chdir back to project root. + base = escapeStaleWorktree(base); + // If resuming from paused state, just re-activate and dispatch next unit. // The conversation is still intact — no need to reinitialize everything. if (paused) { @@ -1360,6 +1396,13 @@ async function dispatchNextUnit( `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`, "warning", ); + // Ensure cwd is restored even if merge failed partway through (#608). + // mergeMilestoneToMain may have chdir'd but then thrown, leaving us + // in an indeterminate location. + if (originalBasePath) { + basePath = originalBasePath; + try { process.chdir(basePath); } catch { /* best-effort */ } + } } } sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone"); diff --git a/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts new file mode 100644 index 000000000..163b0a804 --- /dev/null +++ b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts @@ -0,0 +1,139 @@ +/** + * stale-worktree-cwd.test.ts — Tests for #608 fix. + * + * Verifies that when process.cwd() is inside a stale .gsd/worktrees/ path, + * startAuto escapes back to the project root before proceeding. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { + createAutoWorktree, + teardownAutoWorktree, + mergeMilestoneToMain, +} from "../auto-worktree.ts"; + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function createTempRepo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "stale-wt-test-"))); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + writeFileSync(join(dir, "README.md"), "# test\n"); + run("git add .", dir); + run("git commit -m init", dir); + run("git branch -M main", dir); + return dir; +} + +// ─── escapeStaleWorktree is called by startAuto, test the detection logic ──── + +test("detects stale worktree path and extracts project root", () => { + // Simulate the path pattern: /project/.gsd/worktrees/M004/... + const projectRoot = "/Users/test/myproject"; + const stalePath = `${projectRoot}${sep}.gsd${sep}worktrees${sep}M004`; + + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = stalePath.indexOf(marker); + + assert.ok(idx !== -1, "marker found in stale path"); + assert.equal(stalePath.slice(0, idx), projectRoot, "project root extracted correctly"); +}); + +test("does not trigger on normal project path", () => { + const normalPath = "/Users/test/myproject"; + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = normalPath.indexOf(marker); + + assert.equal(idx, -1, "marker not found in normal path"); +}); + +// ─── Integration: mergeMilestoneToMain restores cwd ───────────────────────── + +test("mergeMilestoneToMain restores cwd to project root", () => { + const savedCwd = process.cwd(); + let tempDir = ""; + + try { + tempDir = createTempRepo(); + + // Create milestone planning artifacts + const msDir = join(tempDir, ".gsd", "milestones", "M050"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "CONTEXT.md"), "# M050 Context\n"); + const roadmap = [ + "# M050: Test Milestone", + "**Vision**: testing", + "## Success Criteria", + "- It works", + "## Slices", + "- [x] S01 — First slice", + ].join("\n"); + writeFileSync(join(msDir, "ROADMAP.md"), roadmap); + run("git add .", tempDir); + run("git commit -m \"add milestone\"", tempDir); + + // Create auto-worktree (enters the worktree dir) + const wtPath = createAutoWorktree(tempDir, "M050"); + assert.equal(process.cwd(), wtPath, "cwd is in worktree after create"); + + // Add a change in the worktree + writeFileSync(join(wtPath, "feature.txt"), "new feature\n"); + run("git add .", wtPath); + run("git commit -m \"feat: add feature\"", wtPath); + + // Merge back — should restore cwd to tempDir + mergeMilestoneToMain(tempDir, "M050", roadmap); + + assert.equal(process.cwd(), tempDir, "cwd restored to project root after merge"); + assert.ok(!existsSync(wtPath), "worktree directory removed after merge"); + } finally { + process.chdir(savedCwd); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } +}); + +// ─── Integration: stale worktree directory is detectable ──────────────────── + +test("process.cwd() inside removed worktree is recoverable", () => { + const savedCwd = process.cwd(); + let tempDir = ""; + + try { + tempDir = createTempRepo(); + + // Create a .gsd/worktrees/M099 directory to simulate stale state + const staleWtDir = join(tempDir, ".gsd", "worktrees", "M099"); + mkdirSync(staleWtDir, { recursive: true }); + + // Enter the stale directory + process.chdir(staleWtDir); + const cwdBefore = process.cwd(); + assert.ok(cwdBefore.includes(`${sep}.gsd${sep}worktrees${sep}`), "cwd is inside worktree dir"); + + // Simulate escapeStaleWorktree logic + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = cwdBefore.indexOf(marker); + assert.ok(idx !== -1, "marker found"); + + const projectRoot = cwdBefore.slice(0, idx); + process.chdir(projectRoot); + + assert.equal(process.cwd(), tempDir, "successfully escaped to project root"); + } finally { + process.chdir(savedCwd); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } +});