diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 8d93cf3d8..cc925871b 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1310,8 +1310,76 @@ async function dispatchNextUnit( unitDispatchCount.clear(); unitRecoveryCount.clear(); unitLifetimeDispatches.clear(); - // Capture integration branch for the new milestone and update git service - captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + // Clear completed-units.json for the finished milestone + try { + const file = completedKeysPath(basePath); + if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8"); + completedKeySet.clear(); + } catch { /* non-fatal */ } + + // ── Worktree lifecycle on milestone transition (#616) ────────────── + // When transitioning from M_old to M_new inside a worktree, we must: + // 1. Merge the completed milestone's worktree back to main + // 2. Re-derive state from the project root + // 3. Create a new worktree for the incoming milestone + // Without this, M_new runs inside M_old's worktree on the wrong branch, + // and artifact paths resolve against the wrong .gsd/ directory. + if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) { + try { + const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent); + ctx.ui.notify( + `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, + "info", + ); + } else { + // No roadmap found — teardown worktree without merge + teardownAutoWorktree(originalBasePath, currentMilestoneId); + ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info"); + } + } catch (err) { + ctx.ui.notify( + `Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + // Force cwd back to project root even if merge failed + if (originalBasePath) { + try { process.chdir(originalBasePath); } catch { /* best-effort */ } + } + } + + // Update basePath to project root (mergeMilestoneToMain already chdir'd) + basePath = originalBasePath; + gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + invalidateAllCaches(); + + // Re-derive state from project root before creating new worktree + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + + // Create new worktree for the incoming milestone + if (mid) { + captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + try { + const wtPath = createAutoWorktree(basePath, mid); + basePath = wtPath; + gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info"); + } catch (err) { + ctx.ui.notify( + `Auto-worktree creation for ${mid} failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`, + "warning", + ); + } + } + } else { + // Not in worktree — just capture integration branch for the new milestone + captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + } + // Prune completed milestone from queue order file const pendingIds = state.registry .filter(m => m.status !== "complete") diff --git a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts new file mode 100644 index 000000000..514a0dc0c --- /dev/null +++ b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts @@ -0,0 +1,144 @@ +/** + * milestone-transition-worktree.test.ts — Tests for #616 fix. + * + * Verifies that when auto-mode transitions between milestones, the + * worktree lifecycle is handled: old worktree merged, new worktree created. + * + * Uses source-level checks since the full auto-mode dispatch loop + * requires the @gsd/pi-coding-agent runtime. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + createAutoWorktree, + teardownAutoWorktree, + isInAutoWorktree, + getAutoWorktreeOriginalBase, + mergeMilestoneToMain, +} from "../auto-worktree.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +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(), "gsd-mt-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; +} + +function createMilestoneArtifacts(dir: string, mid: string): void { + const msDir = join(dir, ".gsd", "milestones", mid); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "CONTEXT.md"), `# ${mid} Context\n`); + const roadmap = [ + `# ${mid}: Test Milestone`, + "**Vision**: testing", + "## Success Criteria", + "- It works", + "## Slices", + "- [x] S01 — First slice", + ].join("\n"); + writeFileSync(join(msDir, `${mid}-ROADMAP.md`), roadmap); +} + +// ─── Milestone transition: worktree swap ───────────────────────────────────── + +test("worktree swap on milestone transition: merge old, create new", () => { + const savedCwd = process.cwd(); + let tempDir = ""; + + try { + tempDir = createTempRepo(); + + // Set up M001 and M002 milestone artifacts + createMilestoneArtifacts(tempDir, "M001"); + createMilestoneArtifacts(tempDir, "M002"); + run("git add .", tempDir); + run("git commit -m \"add milestones\"", tempDir); + + // Phase 1: Create worktree for M001 (simulates auto-mode start) + const wt1 = createAutoWorktree(tempDir, "M001"); + assert.equal(process.cwd(), wt1, "cwd should be in M001 worktree"); + assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree"); + assert.equal(getAutoWorktreeOriginalBase(), tempDir, "original base preserved"); + + // Add a commit in M001 worktree to simulate work + writeFileSync(join(wt1, "feature-m001.txt"), "M001 work\n"); + run("git add .", wt1); + run("git commit -m \"feat(M001): add feature\"", wt1); + + // Phase 2: Simulate milestone transition — merge M001, exit worktree + const roadmapPath = join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + mergeMilestoneToMain(tempDir, "M001", roadmapContent); + + // After merge: cwd should be back at project root + assert.equal(process.cwd(), tempDir, "cwd restored to project root after merge"); + assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree after merge"); + + // Verify M001 work was merged to main + const mainLog = run("git log --oneline -3", tempDir); + assert.ok(mainLog.includes("M001"), "M001 squash commit should be on main"); + + // Phase 3: Create new worktree for M002 (simulates new milestone) + const wt2 = createAutoWorktree(tempDir, "M002"); + assert.equal(process.cwd(), wt2, "cwd should be in M002 worktree"); + assert.ok(isInAutoWorktree(tempDir), "should be in M002 auto-worktree"); + + // The new worktree should have the M001 feature file (merged to main) + assert.ok(existsSync(join(wt2, "feature-m001.txt")), "M002 worktree inherits M001 merged work"); + + // Verify branch is correct + const branch = run("git branch --show-current", wt2); + assert.equal(branch, "milestone/M002", "M002 worktree on correct branch"); + + // Cleanup + teardownAutoWorktree(tempDir, "M002"); + } finally { + process.chdir(savedCwd); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } +}); + +// ─── Verify the transition code path exists in auto.ts ────────────────────── + +test("auto.ts milestone transition block contains worktree lifecycle", () => { + const autoSrc = readFileSync( + join(__dirname, "..", "auto.ts"), + "utf-8", + ); + + // The fix adds worktree merge + create inside the milestone transition block + assert.ok( + autoSrc.includes("Worktree lifecycle on milestone transition"), + "auto.ts should contain the worktree lifecycle comment marker", + ); + assert.ok( + autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== currentMilestoneId"), + "auto.ts should call mergeMilestoneToMain during milestone transition", + ); + assert.ok( + autoSrc.includes("createAutoWorktree") && autoSrc.includes("Created auto-worktree for"), + "auto.ts should create new worktree for incoming milestone", + ); +});