fix(merger): require fast-forward ref advances and read integration tip from refs/heads/<branch>
Closes a "non-fast-forward ref overwrite" path where a subsequent merger could orphan a previously-merged squash by advancing the integration branch to a sibling commit. Symptom (observed on fusion/fn-5419): main reflog shows385b6e93-> f6358ce4 (FN-5551 squash) ->63ec7098(FN-5552 squash) with f6358ce4 and63ec7098both parented at385b6e93. The FN-5551 squash was correctly committed to main, then the FN-5552 merger built its own squash off the stale385b6e93base and the CAS update-ref blindly moved main sideways, orphaning f6358ce4 onto whichever feature branch had already branched from it. Two coupled fixes uphold the missing invariant — local <integrationBranch> only advances via fast-forward, and the merger never builds a squash off a stale base sha: 1. advanceIntegrationBranchRef: add a `merge-base --is-ancestor` check before update-ref. Non-FF attempts now return reason: "non-fast-forward-advance" instead of overwriting the ref. The existing concurrent-advance CAS guard is retained. 2. runMerge: resolve the integration-branch tip via `git rev-parse --verify refs/heads/<integrationBranch>` instead of `git rev-parse HEAD` in rootDir. In reuse-task-worktree mode rootDir's HEAD can lag behind the shared ref after a sibling merger advanced it via update-ref without re-checking-out. Adds regression coverage in merger-ref-update-advance.test.ts: a sibling-commit advance with a matching expectedCurrentSha is now refused with the new reason, and multi-commit fast-forwards still succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,89 @@ describe("advanceIntegrationBranchRef", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses non-fast-forward advance even when expectedCurrentSha matches (sibling-commit orphan guard)", async () => {
|
||||
const dir = setupRepo("main");
|
||||
const events: Array<{ type: string; metadata?: Record<string, unknown> }> = [];
|
||||
try {
|
||||
const baseSha = git(dir, "git rev-parse refs/heads/main");
|
||||
|
||||
// First sibling — legitimate prior merger output that advanced main.
|
||||
git(dir, "git checkout -b sibling-a");
|
||||
writeFileSync(join(dir, "a.txt"), "a\n");
|
||||
git(dir, "git add a.txt");
|
||||
git(dir, "git commit -m a");
|
||||
const siblingASha = git(dir, "git rev-parse HEAD");
|
||||
git(dir, "git checkout main");
|
||||
git(dir, `git update-ref refs/heads/main ${siblingASha} ${baseSha}`);
|
||||
|
||||
// Second sibling — built off the stale base (the bug shape). Both
|
||||
// shas have the same parent, so siblingA is NOT an ancestor of
|
||||
// siblingB. CAS alone would happily move main from siblingA to
|
||||
// siblingB and orphan siblingA.
|
||||
git(dir, `git checkout ${baseSha}`);
|
||||
git(dir, "git checkout -b sibling-b");
|
||||
writeFileSync(join(dir, "b.txt"), "b\n");
|
||||
git(dir, "git add b.txt");
|
||||
git(dir, "git commit -m b");
|
||||
const siblingBSha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await advanceIntegrationBranchRef({
|
||||
rootDir: dir,
|
||||
projectRootDir: dir,
|
||||
integrationBranch: "main",
|
||||
newSha: siblingBSha,
|
||||
expectedCurrentSha: siblingASha,
|
||||
taskId: "FN-5419",
|
||||
audit: {
|
||||
git: async (event: any) => events.push(event),
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.advanced).toBe(false);
|
||||
if (result.advanced) throw new Error("expected refusal");
|
||||
expect(result.reason).toBe("non-fast-forward-advance");
|
||||
// Ref must NOT have moved — siblingA is still reachable from main.
|
||||
expect(git(dir, "git rev-parse refs/heads/main")).toBe(siblingASha);
|
||||
expect(events[0]?.type).toBe("merge:integration-ref-advance");
|
||||
expect(events[0]?.metadata?.succeeded).toBe(false);
|
||||
expect(String(events[0]?.metadata?.error ?? "")).toContain(
|
||||
"non-fast-forward-advance",
|
||||
);
|
||||
} finally {
|
||||
removeTmpDirSync(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows multi-commit fast-forward advance", async () => {
|
||||
const dir = setupRepo("main");
|
||||
try {
|
||||
const baseSha = git(dir, "git rev-parse refs/heads/main");
|
||||
git(dir, "git checkout -b feat");
|
||||
writeFileSync(join(dir, "f1.txt"), "f1\n");
|
||||
git(dir, "git add f1.txt");
|
||||
git(dir, "git commit -m f1");
|
||||
writeFileSync(join(dir, "f2.txt"), "f2\n");
|
||||
git(dir, "git add f2.txt");
|
||||
git(dir, "git commit -m f2");
|
||||
const newSha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await advanceIntegrationBranchRef({
|
||||
rootDir: dir,
|
||||
projectRootDir: dir,
|
||||
integrationBranch: "main",
|
||||
newSha,
|
||||
expectedCurrentSha: baseSha,
|
||||
taskId: "FN-5419",
|
||||
audit: { git: async () => undefined } as any,
|
||||
});
|
||||
|
||||
expect(result.advanced).toBe(true);
|
||||
expect(git(dir, "git rev-parse refs/heads/main")).toBe(newSha);
|
||||
} finally {
|
||||
removeTmpDirSync(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on missing precondition shas", async () => {
|
||||
const dir = setupRepo("main");
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user