fix: preserve milestone branch on merge-back during transitions (#1573) (#1758)

When mergeAndExit cannot find the roadmap at the project root, it now
tries the worktree path as a fallback. If neither location has a roadmap,
the teardown preserves the branch (preserveBranch: true) so commits are
not orphaned when the worktree is pruned.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 09:04:52 -06:00 committed by GitHub
parent 0483363a33
commit 4367ea36c4
3 changed files with 85 additions and 8 deletions

View file

@ -142,3 +142,25 @@ test("auto/phases.ts milestone transition block contains worktree lifecycle", ()
"auto/phases.ts should call resolver.enterMilestone for incoming milestone",
);
});
// ─── Verify worktree-resolver mergeAndExit preserves branch on missing roadmap (#1573) ──
test("worktree-resolver mergeAndExit preserves branch when roadmap is missing (#1573)", () => {
const resolverSrc = readFileSync(
join(__dirname, "..", "worktree-resolver.ts"),
"utf-8",
);
// The fallback teardown must pass preserveBranch: true to prevent orphaning commits
assert.ok(
resolverSrc.includes("preserveBranch: true"),
"worktree-resolver.ts should pass preserveBranch: true in the no-roadmap fallback",
);
// The worktree path should be tried as a fallback for roadmap resolution
assert.ok(
resolverSrc.includes("this.s.basePath !== originalBase") ||
resolverSrc.includes("roadmap-fallback"),
"worktree-resolver.ts should try resolving roadmap from worktree path as fallback",
);
});

View file

@ -434,7 +434,7 @@ test("mergeAndExit in worktree mode shows pushed status", () => {
assert.ok(ctx.messages.some((m) => m.msg.includes("Pushed to remote")));
});
test("mergeAndExit falls back to teardown when roadmap is missing", () => {
test("mergeAndExit falls back to teardown with preserveBranch when roadmap is missing (#1573)", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
@ -449,10 +449,42 @@ test("mergeAndExit falls back to teardown when roadmap is missing", () => {
resolver.mergeAndExit("M001", ctx);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1);
const teardownCalls = findCalls(deps.calls, "teardownAutoWorktree");
assert.equal(teardownCalls.length, 1);
// Branch must be preserved so commits are not orphaned (#1573)
assert.deepEqual(teardownCalls[0].args[2], { preserveBranch: true });
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
assert.equal(s.basePath, "/project"); // restored
assert.ok(ctx.messages.some((m) => m.msg.includes("no roadmap for merge")));
assert.ok(ctx.messages.some((m) => m.msg.includes("branch preserved")));
});
test("mergeAndExit resolves roadmap from worktree when missing at project root (#1573)", () => {
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
// resolveMilestoneFile returns null for project root, returns path for worktree
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
resolveMilestoneFile: (basePath: string) => {
if (basePath === "/project") return null; // missing at project root
if (basePath === "/project/.gsd/worktrees/M001") {
return "/project/.gsd/worktrees/M001/.gsd/milestones/M001/M001-ROADMAP.md";
}
return null;
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
// Should have called mergeMilestoneToMain, not bare teardown
assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
assert.equal(s.basePath, "/project"); // restored
assert.ok(ctx.messages.some((m) => m.msg.includes("merged to main")));
});
test("mergeAndExit in worktree mode restores to project root on merge failure", () => {

View file

@ -338,11 +338,31 @@ export class WorktreeResolver {
});
}
const roadmapPath = this.deps.resolveMilestoneFile(
// Resolve roadmap — try project root first, then worktree path as fallback.
// The worktree may hold the only copy when syncWorktreeStateBack fails
// silently or .gsd/ is not symlinked. Without the fallback, a missing
// roadmap triggers bare teardown which deletes the branch and orphans all
// milestone commits (#1573).
let roadmapPath = this.deps.resolveMilestoneFile(
originalBase,
milestoneId,
"ROADMAP",
);
if (!roadmapPath && this.s.basePath !== originalBase) {
roadmapPath = this.deps.resolveMilestoneFile(
this.s.basePath,
milestoneId,
"ROADMAP",
);
if (roadmapPath) {
debugLog("WorktreeResolver", {
action: "mergeAndExit",
milestoneId,
phase: "roadmap-fallback",
note: "resolved from worktree path",
});
}
}
if (roadmapPath) {
const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
@ -356,11 +376,14 @@ export class WorktreeResolver {
"info",
);
} else {
// No roadmap — fall back to bare teardown
this.deps.teardownAutoWorktree(originalBase, milestoneId);
// No roadmap at either location — teardown but PRESERVE the branch so
// commits are not orphaned. The user can merge manually later (#1573).
this.deps.teardownAutoWorktree(originalBase, milestoneId, {
preserveBranch: true,
});
ctx.notify(
`Exited worktree for ${milestoneId} (no roadmap for merge).`,
"info",
`Exited worktree for ${milestoneId} (no roadmap found — branch preserved for manual merge).`,
"warning",
);
}
} catch (err) {