This commit is contained in:
parent
9ffb927856
commit
c8f8795e73
2 changed files with 214 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue