diff --git a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts index 74514725f..5616c74ef 100644 --- a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts @@ -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", + ); +}); diff --git a/src/resources/extensions/gsd/tests/worktree-resolver.test.ts b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts index df0170228..beff8be62 100644 --- a/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts @@ -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", () => { diff --git a/src/resources/extensions/gsd/worktree-resolver.ts b/src/resources/extensions/gsd/worktree-resolver.ts index 5d8cc52a8..c8ca8c409 100644 --- a/src/resources/extensions/gsd/worktree-resolver.ts +++ b/src/resources/extensions/gsd/worktree-resolver.ts @@ -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) {