diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index c46194105..cfee0a7ff 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -57,6 +57,8 @@ import { nativeBranchDelete, nativeBranchExists, nativeDiffNumstat, + nativeUpdateRef, + nativeIsAncestor, } from "./native-git-bridge.js"; // ─── Module State ────────────────────────────────────────────────────────── @@ -1020,6 +1022,62 @@ export function mergeMilestoneToMain( } const commitMessage = subject + body; + // 6b. Reconcile worktree HEAD with milestone branch ref (#1846). + // When the worktree HEAD detaches and advances past the named branch, + // the branch ref becomes stale. Squash-merging the stale ref silently + // orphans all commits between the branch ref and the actual worktree HEAD. + // Fix: fast-forward the branch ref to the worktree HEAD before merging. + // Only applies when merging from an actual worktree (worktreeCwd differs + // from originalBasePath_). + if (worktreeCwd !== originalBasePath_) { + try { + const worktreeHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: worktreeCwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + const branchHead = execFileSync("git", ["rev-parse", milestoneBranch], { + cwd: originalBasePath_, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + + if (worktreeHead && branchHead && worktreeHead !== branchHead) { + if (nativeIsAncestor(originalBasePath_, branchHead, worktreeHead)) { + // Worktree HEAD is strictly ahead — fast-forward the branch ref + nativeUpdateRef( + originalBasePath_, + `refs/heads/${milestoneBranch}`, + worktreeHead, + ); + debugLog("mergeMilestoneToMain", { + action: "fast-forward-branch-ref", + milestoneBranch, + oldRef: branchHead.slice(0, 8), + newRef: worktreeHead.slice(0, 8), + }); + } else { + // Diverged — fail loudly rather than silently losing commits + process.chdir(previousCwd); + throw new GSDError( + GSD_GIT_ERROR, + `Worktree HEAD (${worktreeHead.slice(0, 8)}) diverged from ` + + `${milestoneBranch} (${branchHead.slice(0, 8)}). ` + + `Manual reconciliation required before merge.`, + ); + } + } + } catch (err) { + // Re-throw GSDError (divergence); swallow rev-parse failures + // (e.g. worktree dir already removed by external cleanup) + if (err instanceof GSDError) throw err; + debugLog("mergeMilestoneToMain", { + action: "reconcile-skipped", + reason: String(err), + }); + } + } + // 7. Squash merge — auto-resolve .gsd/ state file conflicts (#530) const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 2af1d8697..d5dd4039b 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -569,6 +569,119 @@ async function main(): Promise { assertTrue(existsSync(join(repo, "landed.ts")), "landed.ts present on main"); } + // ─── Test 14: Stale branch ref — worktree HEAD ahead of branch (#1846) ─ + console.log("\n=== stale branch ref — fast-forward before squash merge (#1846) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M140"); + + // Add a first slice normally — this advances both the branch ref and HEAD + addSliceToMilestone(repo, wtPath, "M140", "S01", "Initial work", [ + { file: "initial.ts", content: "export const initial = true;\n", message: "add initial" }, + ]); + + // Now simulate the bug: detach HEAD in the worktree, then make commits + // that advance HEAD but leave the milestone/M140 branch ref behind. + const branchRefBefore = run("git rev-parse milestone/M140", wtPath); + run("git checkout --detach HEAD", wtPath); + + // Add multiple commits on the detached HEAD (simulates agent work) + writeFileSync(join(wtPath, "feature-a.ts"), "export const featureA = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-a"', wtPath); + + writeFileSync(join(wtPath, "feature-b.ts"), "export const featureB = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-b"', wtPath); + + writeFileSync(join(wtPath, "feature-c.ts"), "export const featureC = true;\n"); + run("git add .", wtPath); + run('git commit -m "add feature-c"', wtPath); + + // Verify: branch ref is stale, HEAD is ahead + const branchRefAfter = run("git rev-parse milestone/M140", wtPath); + const worktreeHead = run("git rev-parse HEAD", wtPath); + assertEq(branchRefBefore, branchRefAfter, "branch ref unchanged (stale)"); + assertTrue(worktreeHead !== branchRefAfter, "worktree HEAD ahead of branch ref"); + + const roadmap = makeRoadmap("M140", "Stale ref milestone", [ + { id: "S01", title: "Initial work" }, + ]); + + // The fix should fast-forward the branch ref to worktree HEAD before + // squash-merging, so ALL commits are captured. + let threw = false; + let errMsg = ""; + try { + const result = mergeMilestoneToMain(repo, "M140", roadmap); + assertTrue(result.commitMessage.includes("feat(M140)"), "merge commit created"); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(!threw, `should not throw with stale branch ref (got: ${errMsg})`); + + // ALL files from detached HEAD commits must be on main — not just + // the ones from the stale branch ref + assertTrue(existsSync(join(repo, "initial.ts")), "initial.ts on main"); + assertTrue(existsSync(join(repo, "feature-a.ts")), "feature-a.ts on main (#1846)"); + assertTrue(existsSync(join(repo, "feature-b.ts")), "feature-b.ts on main (#1846)"); + assertTrue(existsSync(join(repo, "feature-c.ts")), "feature-c.ts on main (#1846)"); + } + + // ─── Test 15: Diverged worktree HEAD — throws instead of losing data (#1846) ─ + console.log("\n=== diverged worktree HEAD — throws on divergence (#1846) ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M150"); + + addSliceToMilestone(repo, wtPath, "M150", "S01", "Base work", [ + { file: "base.ts", content: "export const base = true;\n", message: "add base" }, + ]); + + // Detach HEAD, then reset branch ref forward independently to create + // divergence (branch ref is NOT an ancestor of worktree HEAD). + run("git checkout --detach HEAD", wtPath); + writeFileSync(join(wtPath, "detached-work.ts"), "export const detached = true;\n"); + run("git add .", wtPath); + run('git commit -m "detached work"', wtPath); + + // Now advance the branch ref on a different path (via the main repo) + run("git checkout milestone/M150", repo); + writeFileSync(join(repo, "diverged-work.ts"), "export const diverged = true;\n"); + run("git add .", repo); + run('git commit -m "diverged work on branch"', repo); + run("git checkout main", repo); + + // Move back to worktree cwd + process.chdir(wtPath); + + const roadmap = makeRoadmap("M150", "Diverged milestone", [ + { id: "S01", title: "Base work" }, + ]); + + let threw = false; + let errMsg = ""; + try { + mergeMilestoneToMain(repo, "M150", roadmap); + } catch (err) { + threw = true; + errMsg = err instanceof Error ? err.message : String(err); + } + assertTrue(threw, "throws when worktree HEAD diverged from branch ref (#1846)"); + assertTrue( + errMsg.includes("diverged"), + "error message mentions divergence (#1846)", + ); + + // Branch must be preserved — no data loss + const branches = run("git branch", repo); + assertTrue( + branches.includes("milestone/M150"), + "milestone branch preserved on divergence (#1846)", + ); + } + } finally { process.chdir(savedCwd); for (const d of tempDirs) {