feat(merger): auto-rehome FF-recoverable orphan commits in contamination recovery

Follow-up to bf4428c00 (FF-only ref advance). After the prevention fix
new orphans can't form, but pre-fix orphans like f6358ce4 on
fusion/fn-5419 still need a path back onto the integration branch.

Adds an `orphan-our-advance` classification to contamination recovery:
a "unique" foreign commit whose Fusion-Task-Id trailer points at a
`done` task AND that is unreachable from refs/heads/<integrationBranch>
is treated as a stranded merger output.

For these, the executor attempts a fast-forward rehome onto the
integration branch via advanceIntegrationBranchRef (which still enforces
the FF-only invariant). When successful, the orphan sha is added to the
existing `shasToDrop` set so the same recovery pass that drops
already-upstream/misrouted commits also drops the now-upstream orphan.

Non-FF orphans (diverged from current integration tip) are refused.
Doing a cherry-pick onto the integration branch from inside automated
recovery would introduce conflict-resolution surface that's too high
blast radius for a never-event recovery path. The refusal log line
includes the exact `git cherry-pick <sha>` command an operator can run
manually.

Two new GitMutationType audit events:
  - merger:orphan-rehome-ff (successful FF rehome)
  - merger:orphan-rehome-refused (non-FF, manual cherry-pick required)

Tests in merger-orphan-rehome.test.ts cover classification (orphan,
not-done, already-reachable, no-trailer) and the rehome operation
(FF success advances the ref + emits the audit event; non-FF refusal
emits the hint and leaves the ref untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 09:59:50 -07:00
parent bf4428c00c
commit ec1269fd84
5 changed files with 564 additions and 2 deletions

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, afterAll } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import {
classifyOrphanOurAdvance,
rehomeOrphanOntoIntegration,
} from "../merger-orphan-rehome.js";
const TMP_DIR_RM_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
const trackedTmpDirs = new Set<string>();
function removeTmpDirSync(dir: string): void {
try {
rmSync(dir, TMP_DIR_RM_OPTIONS);
} catch {
// best-effort
} finally {
trackedTmpDirs.delete(dir);
}
}
afterAll(() => {
for (const dir of Array.from(trackedTmpDirs)) removeTmpDirSync(dir);
});
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
}
function setupRepo() {
const dir = mkdtempSync(join(tmpdir(), "fusion-test-orphan-rehome-"));
trackedTmpDirs.add(dir);
git(dir, "git init -b main");
git(dir, "git config user.name tester");
git(dir, "git config user.email tester@example.com");
writeFileSync(join(dir, "tracked.txt"), "one\n");
git(dir, "git add tracked.txt");
git(dir, "git commit -m init");
return dir;
}
function makeFakeStore(tasks: Record<string, { column: string }>) {
return {
getTask: async (id: string) => tasks[id.toUpperCase()] ?? null,
} as any;
}
function makeFakeAuditor(events: Array<{ type: string; metadata?: any }>) {
return {
git: async (event: any) => { events.push(event); },
database: async () => undefined,
filesystem: async () => undefined,
sandbox: async () => undefined,
} as any;
}
describe("classifyOrphanOurAdvance", () => {
it("classifies a done-task foreign commit unreachable from integration as orphan", async () => {
const dir = setupRepo();
try {
// Sibling commit attributed to FN-5551, never landed on main.
git(dir, "git checkout -b sibling-orphan");
writeFileSync(join(dir, "orphan.txt"), "orphan\n");
git(dir, "git add orphan.txt");
git(dir, `git commit -m "feat(FN-5551): orphaned squash" -m "Fusion-Task-Id: FN-5551"`);
const orphanSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
const result = await classifyOrphanOurAdvance({
repoDir: dir,
taskStore: makeFakeStore({ "FN-5551": { column: "done" } }),
integrationBranch: "main",
currentTaskId: "FN-5419",
commitSha: orphanSha,
commitSubject: "feat(FN-5551): orphaned squash",
commitBody: "Fusion-Task-Id: FN-5551\n",
});
expect(result.orphan).toBe(true);
if (result.orphan) {
expect(result.sourceTaskId).toBe("FN-5551");
expect(result.orphanSha).toBe(orphanSha);
}
} finally {
removeTmpDirSync(dir);
}
});
it("refuses when source task is not done", async () => {
const dir = setupRepo();
try {
git(dir, "git checkout -b sibling");
writeFileSync(join(dir, "x.txt"), "x\n");
git(dir, "git add x.txt");
git(dir, `git commit -m "feat(FN-5551): wip" -m "Fusion-Task-Id: FN-5551"`);
const sha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
const result = await classifyOrphanOurAdvance({
repoDir: dir,
taskStore: makeFakeStore({ "FN-5551": { column: "in-progress" } }),
integrationBranch: "main",
currentTaskId: "FN-5419",
commitSha: sha,
commitSubject: "feat(FN-5551): wip",
commitBody: "Fusion-Task-Id: FN-5551\n",
});
expect(result.orphan).toBe(false);
if (!result.orphan) expect(result.reason).toBe("source-task-not-done");
} finally {
removeTmpDirSync(dir);
}
});
it("refuses when the commit is already reachable from integration", async () => {
const dir = setupRepo();
try {
writeFileSync(join(dir, "y.txt"), "y\n");
git(dir, "git add y.txt");
git(dir, `git commit -m "feat(FN-5551): landed" -m "Fusion-Task-Id: FN-5551"`);
const sha = git(dir, "git rev-parse HEAD");
const result = await classifyOrphanOurAdvance({
repoDir: dir,
taskStore: makeFakeStore({ "FN-5551": { column: "done" } }),
integrationBranch: "main",
currentTaskId: "FN-5419",
commitSha: sha,
commitSubject: "feat(FN-5551): landed",
commitBody: "Fusion-Task-Id: FN-5551\n",
});
expect(result.orphan).toBe(false);
if (!result.orphan) expect(result.reason).toBe("reachable-from-integration");
} finally {
removeTmpDirSync(dir);
}
});
it("refuses when no Fusion trailer or subject prefix is present", async () => {
const dir = setupRepo();
try {
git(dir, "git checkout -b stray");
writeFileSync(join(dir, "z.txt"), "z\n");
git(dir, "git add z.txt");
git(dir, `git commit -m "untagged commit"`);
const sha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
const result = await classifyOrphanOurAdvance({
repoDir: dir,
taskStore: makeFakeStore({}),
integrationBranch: "main",
currentTaskId: "FN-5419",
commitSha: sha,
commitSubject: "untagged commit",
commitBody: "",
});
expect(result.orphan).toBe(false);
if (!result.orphan) expect(result.reason).toBe("no-trailer");
} finally {
removeTmpDirSync(dir);
}
});
});
describe("rehomeOrphanOntoIntegration", () => {
it("fast-forwards integration when its tip is an ancestor of the orphan", async () => {
const dir = setupRepo();
const events: Array<{ type: string; metadata?: any }> = [];
try {
// Orphan extends main by one commit (integration tip IS an ancestor of orphan).
const integrationTipBefore = git(dir, "git rev-parse refs/heads/main");
git(dir, "git checkout -b feature");
writeFileSync(join(dir, "ff.txt"), "ff\n");
git(dir, "git add ff.txt");
git(dir, `git commit -m "feat(FN-5551): ff orphan" -m "Fusion-Task-Id: FN-5551"`);
const orphanSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
const result = await rehomeOrphanOntoIntegration({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
orphanSha,
taskId: "FN-5419",
audit: makeFakeAuditor(events),
});
expect(result.rehomed).toBe(true);
if (result.rehomed) {
expect(result.mode).toBe("fast-forward");
expect(result.previousTipSha).toBe(integrationTipBefore);
expect(result.newTipSha).toBe(orphanSha);
}
expect(git(dir, "git rev-parse refs/heads/main")).toBe(orphanSha);
expect(events.some((e) => e.type === "merger:orphan-rehome-ff")).toBe(true);
} finally {
removeTmpDirSync(dir);
}
});
it("refuses non-FF rehome and emits an actionable cherry-pick hint", async () => {
const dir = setupRepo();
const events: Array<{ type: string; metadata?: any }> = [];
try {
const baseSha = git(dir, "git rev-parse refs/heads/main");
// Orphan branch (FN-5551) parented at base.
git(dir, "git checkout -b orphan-branch");
writeFileSync(join(dir, "orphan.txt"), "orphan\n");
git(dir, "git add orphan.txt");
git(dir, `git commit -m "feat(FN-5551): orphaned squash" -m "Fusion-Task-Id: FN-5551"`);
const orphanSha = git(dir, "git rev-parse HEAD");
// Advance main to a divergent sibling (FN-5552) — main and orphan
// now share `baseSha` but neither is an ancestor of the other.
git(dir, `git checkout ${baseSha}`);
git(dir, "git checkout -b advancer");
writeFileSync(join(dir, "advancer.txt"), "advancer\n");
git(dir, "git add advancer.txt");
git(dir, `git commit -m "feat(FN-5552): divergent"`);
const advancerSha = git(dir, "git rev-parse HEAD");
git(dir, `git update-ref refs/heads/main ${advancerSha} ${baseSha}`);
git(dir, "git checkout main");
const result = await rehomeOrphanOntoIntegration({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
orphanSha,
taskId: "FN-5419",
audit: makeFakeAuditor(events),
});
expect(result.rehomed).toBe(false);
if (!result.rehomed) {
expect(result.mode).toBe("refused-non-fast-forward");
expect(result.cherryPickHint).toContain(`cherry-pick ${orphanSha}`);
}
// Integration ref must NOT have moved.
expect(git(dir, "git rev-parse refs/heads/main")).toBe(advancerSha);
const refused = events.find((e) => e.type === "merger:orphan-rehome-refused");
expect(refused).toBeTruthy();
expect(refused?.metadata?.reason).toBe("non-fast-forward");
} finally {
removeTmpDirSync(dir);
}
});
});