fix: prevent stale worktree cwd after milestone completion (#608) (#610)

After milestone completion and merge, the process cwd could remain
inside .gsd/worktrees/<MID>/, 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
This commit is contained in:
Flux Labs 2026-03-16 07:58:02 -05:00 committed by GitHub
parent b0f880689b
commit 7e25e6d427
2 changed files with 187 additions and 5 deletions

View file

@ -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/<MID>/`.
* 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/<something> — 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<void> {
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/<MID>/ — 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");

View file

@ -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 });
}
}
});