fix: reconcile worktree HEAD with milestone branch ref before squash merge (#1846) (#1859)

When the worktree HEAD detaches and advances past the named milestone
branch, the branch ref becomes stale. The squash merge only captured
the stale ref, silently orphaning all commits between the branch ref
and the actual worktree HEAD.

Before the squash merge, compare the milestone branch ref with the
worktree's actual HEAD. If the branch ref is an ancestor of the
worktree HEAD, fast-forward the branch ref. If they have diverged,
throw a clear error instead of silently losing commits.

Guarded by worktreeCwd !== originalBasePath_ so non-worktree merge
paths (e.g. parallel-merge) are unaffected.

Fixes #1846
This commit is contained in:
Tom Boucher 2026-03-21 16:58:16 -04:00 committed by GitHub
parent e5ae9fd249
commit 7054e5dde6
2 changed files with 171 additions and 0 deletions

View file

@ -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);

View file

@ -569,6 +569,119 @@ async function main(): Promise<void> {
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) {