diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 5e9094950..417a291c6 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -2241,7 +2241,54 @@ async function dispatchNextUnit( const incomplete = state.registry.filter(m => m.status !== "complete"); if (incomplete.length === 0) { - // Genuinely all complete + // Genuinely all complete — merge milestone branch to main before stopping (#962) + if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) { + try { + const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent); + s.basePath = s.originalBasePath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + ctx.ui.notify( + `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`, + "info", + ); + } + } catch (err) { + ctx.ui.notify( + `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + if (s.originalBasePath) { + s.basePath = s.originalBasePath; + try { process.chdir(s.basePath); } catch { /* best-effort */ } + } + } + } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") { + // Branch isolation mode: squash-merge milestone branch back before stopping + try { + const currentBranch = getCurrentBranch(s.basePath); + const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId); + if (currentBranch === milestoneBranch) { + const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent); + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + ctx.ui.notify( + `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`, + "info", + ); + } + } + } catch (err) { + ctx.ui.notify( + `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + } sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone"); await stopAuto(ctx, pi, "All milestones complete"); } else if (state.phase === "blocked") { diff --git a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts new file mode 100644 index 000000000..59114c912 --- /dev/null +++ b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts @@ -0,0 +1,194 @@ +/** + * all-milestones-complete-merge.test.ts — Tests for #962 fix. + * + * Verifies that when the final milestone completes and there are no queued + * follow-up milestones, the worktree is squash-merged to main before + * stopAuto() tears it down. Without this fix, all work stays on the + * milestone branch, unmerged to main. + * + * Uses both source-level checks (verifying the code path exists in auto.ts) + * and real git integration tests (verifying merge behavior). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { + createAutoWorktree, + 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-all-complete-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); +} + +// ─── Source-level: verify the merge code exists in the "all complete" path ──── + +test("auto.ts 'all milestones complete' path merges before stopping (#962)", () => { + const autoSrc = readFileSync(join(__dirname, "..", "auto.ts"), "utf-8"); + + // Find the "incomplete.length === 0" block + const incompleteIdx = autoSrc.indexOf("incomplete.length === 0"); + assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check"); + + // The merge call must appear BETWEEN the incomplete check and the stopAuto call + // in that same block + const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000); + + assert.ok( + blockAfterIncomplete.includes("mergeMilestoneToMain"), + "auto.ts should call mergeMilestoneToMain in the 'all milestones complete' path", + ); + + // The merge should come before stopAuto in this block + const mergePos = blockAfterIncomplete.indexOf("mergeMilestoneToMain"); + const stopPos = blockAfterIncomplete.indexOf("stopAuto"); + assert.ok( + mergePos < stopPos, + "mergeMilestoneToMain should be called before stopAuto in the 'all complete' path", + ); + + // Should handle both worktree and branch isolation modes + assert.ok( + blockAfterIncomplete.includes("isInAutoWorktree"), + "should check isInAutoWorktree for worktree mode", + ); + assert.ok( + blockAfterIncomplete.includes("getIsolationMode"), + "should check getIsolationMode for branch isolation mode", + ); +}); + +// ─── Integration: single milestone completes → merged to main ──────────────── + +test("single milestone worktree is merged to main when all complete (#962)", () => { + const savedCwd = process.cwd(); + let tempDir = ""; + + try { + tempDir = createTempRepo(); + + // Set up a single milestone + createMilestoneArtifacts(tempDir, "M001"); + run("git add .", tempDir); + run('git commit -m "add milestone"', tempDir); + + // Create worktree and simulate work + const wt = createAutoWorktree(tempDir, "M001"); + assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree"); + + writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n"); + run("git add .", wt); + run('git commit -m "feat(M001): add feature"', wt); + + // Simulate the fix: merge before stopping (what the "all complete" path now does) + const roadmapPath = join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent); + + // Verify work is on main + assert.ok(existsSync(join(tempDir, "feature.ts")), "feature.ts should be on main after merge"); + assert.equal(process.cwd(), tempDir, "cwd restored to project root"); + assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree"); + assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared"); + + // Verify milestone branch was cleaned up + const branches = run("git branch", tempDir); + assert.ok(!branches.includes("milestone/M001"), "milestone branch should be deleted"); + + // Verify squash commit on main + const log = run("git log --oneline -3", tempDir); + assert.ok(log.includes("M001"), "squash commit on main should reference M001"); + + assert.ok(mergeResult.commitMessage.length > 0, "commit message returned"); + } finally { + process.chdir(savedCwd); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } +}); + +// ─── Integration: last of multiple milestones completes → merged ───────────── + +test("last milestone worktree is merged when it's the final one (#962)", () => { + const savedCwd = process.cwd(); + let tempDir = ""; + + try { + tempDir = createTempRepo(); + + // Set up two milestones + createMilestoneArtifacts(tempDir, "M001"); + createMilestoneArtifacts(tempDir, "M002"); + run("git add .", tempDir); + run('git commit -m "add milestones"', tempDir); + + // Complete M001 first (merge it) + const wt1 = createAutoWorktree(tempDir, "M001"); + writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n"); + run("git add .", wt1); + run('git commit -m "feat(M001): m001 work"', wt1); + const roadmap1 = readFileSync(join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf-8"); + mergeMilestoneToMain(tempDir, "M001", roadmap1); + + // Now complete M002 (the LAST milestone — this is the #962 scenario) + const wt2 = createAutoWorktree(tempDir, "M002"); + writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n"); + run("git add .", wt2); + run('git commit -m "feat(M002): m002 work"', wt2); + const roadmap2 = readFileSync(join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "utf-8"); + mergeMilestoneToMain(tempDir, "M002", roadmap2); + + // Both features should now be on main + assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main"); + assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main"); + assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge"); + + // Both milestone branches should be cleaned up + const branches = run("git branch", tempDir); + assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted"); + assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted"); + } finally { + process.chdir(savedCwd); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + } +});