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:
parent
e5ae9fd249
commit
7054e5dde6
2 changed files with 171 additions and 0 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue