From 818d77d5a29904a3f91a37fffad108eb880ce84a Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 19:03:35 -0500 Subject: [PATCH] fix: merge worktree to main when all milestones complete (#962) (#1007) When the final milestone completed with no queued follow-up, stopAuto() tore down the worktree with preserveBranch: true but never called mergeMilestoneToMain(). All work stayed on the milestone branch, unmerged to main. Add merge logic to the "all milestones complete" path in dispatchNextUnit(), mirroring the existing merge handling in the single-milestone-complete path. Handles both worktree isolation and branch isolation modes. --- src/resources/extensions/gsd/auto.ts | 49 ++++- .../all-milestones-complete-merge.test.ts | 194 ++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts 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 }); + } + } +});