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

View File

@@ -72,6 +72,10 @@ import {
inspectBranchConflict,
reportBranchAttribution,
} from "./branch-conflicts.js";
import {
classifyOrphanOurAdvance,
rehomeOrphanOntoIntegration,
} from "./merger-orphan-rehome.js";
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { AgentLogger } from "./agent-logger.js";
@@ -4884,7 +4888,7 @@ export class TaskExecutor {
});
const misrouted: Array<{ commit: (typeof classified.unique)[number]; foreignTaskId: string; paths: string[] }> = [];
const genuinelyUnique: typeof classified.unique = [];
const preOrphanUnique: typeof classified.unique = [];
for (const commit of classified.unique) {
const misroutedResult = await classifyMisroutedForeignCommit({
repoDir: this.rootDir,
@@ -4896,16 +4900,79 @@ export class TaskExecutor {
if (misroutedResult.misrouted && misroutedResult.foreignTaskId) {
misrouted.push({ commit, foreignTaskId: misroutedResult.foreignTaskId, paths: misroutedResult.paths ?? [] });
} else {
preOrphanUnique.push(commit);
}
}
// Orphan-our-advance: a "unique" foreign commit attributed to a
// task that's already `done` is a stranded merge from the pre-FF
// ref-advance bug. FF-rehomeable orphans are advanced onto the
// integration branch and then dropped from this task's branch
// alongside already-upstream commits. Non-FF orphans (diverged
// from current integration tip) are logged with a cherry-pick
// hint and left as `genuinelyUnique` for human adjudication.
const rehomedOrphans: typeof classified.unique = [];
const genuinelyUnique: typeof classified.unique = [];
const integrationBranchForOrphan = task.mergeDetails?.mergeTargetBranch
?? task.baseBranch
?? "main";
for (const commit of preOrphanUnique) {
const orphanBody = await execAsync(`git log -1 --format=%b ${commit.sha}`, { cwd: this.rootDir, encoding: "utf-8" })
.then((r) => r.stdout)
.catch(() => "");
const orphanClass = await classifyOrphanOurAdvance({
repoDir: this.rootDir,
taskStore: this.store,
integrationBranch: integrationBranchForOrphan,
currentTaskId: task.id,
commitSha: commit.sha,
commitSubject: commit.subject,
commitBody: orphanBody,
});
if (!orphanClass.orphan) {
genuinelyUnique.push(commit);
continue;
}
const rehome = await rehomeOrphanOntoIntegration({
rootDir: this.rootDir,
projectRootDir: this.rootDir,
integrationBranch: integrationBranchForOrphan,
orphanSha: commit.sha,
taskId: task.id,
audit,
}).catch((rehomeError: unknown): { rehomed: false; reason: string } => ({
rehomed: false,
reason: rehomeError instanceof Error ? rehomeError.message : String(rehomeError),
}));
if (rehome.rehomed) {
rehomedOrphans.push(commit);
await this.store.logEntry(
task.id,
`[recovery] rehomed orphan-our-advance commit ${commit.sha.slice(0, 12)} (source ${orphanClass.sourceTaskId}) onto ${integrationBranchForOrphan} via fast-forward; dropping from branch`,
undefined,
this.getRunContextFor(task.id),
);
} else {
const hint = "cherryPickHint" in rehome && rehome.cherryPickHint
? ` — manual rehome: \`${rehome.cherryPickHint}\``
: "";
await this.store.logEntry(
task.id,
`[recovery] orphan-our-advance commit ${commit.sha.slice(0, 12)} (source ${orphanClass.sourceTaskId}) refused auto-rehome: ${rehome.reason}${hint}`,
undefined,
this.getRunContextFor(task.id),
);
genuinelyUnique.push(commit);
}
}
const alreadyShas = classified.alreadyUpstream.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
const misroutedShas = misrouted.map(({ commit }) => commit.sha.slice(0, 12)).join(", ") || "none";
const rehomedShas = rehomedOrphans.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
const uniqueShas = genuinelyUnique.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
await this.store.logEntry(
task.id,
`[recovery] contamination classification: already-upstream=[${alreadyShas}] misrouted=[${misroutedShas}] unique=[${uniqueShas}]`,
`[recovery] contamination classification: already-upstream=[${alreadyShas}] misrouted=[${misroutedShas}] rehomed-orphan=[${rehomedShas}] unique=[${uniqueShas}]`,
undefined,
this.getRunContextFor(task.id),
);
@@ -4928,6 +4995,7 @@ export class TaskExecutor {
shasToDrop: [
...classified.alreadyUpstream.map((commit) => commit.sha),
...misrouted.map(({ commit }) => commit.sha),
...rehomedOrphans.map((commit) => commit.sha),
],
});

View File

@@ -0,0 +1,198 @@
/**
* Orphan-our-advance classification + fast-forward rehome.
*
* Catches the post-fix tail of the non-FF ref-advance bug fixed in
* `merger-ref-update-advance.ts`: a squash commit produced by the merger
* that *was* the integration tip at one point but is now reachable only
* from a downstream feature branch (because a sibling merger advanced the
* ref off a stale base before the FF-only invariant was enforced).
*
* Recovery scope is intentionally narrow:
* - FF rehome only — advance `refs/heads/<integrationBranch>` to the
* orphan when the integration tip is an ancestor of the orphan.
* - Non-FF (sibling) orphans are *detected and logged* with an
* actionable hint, but not auto-rehomed: a cherry-pick mutates the
* integration branch with content that may conflict, and that's a
* blast radius we don't want inside automated recovery.
*
* Strictness gates:
* - The commit's Fusion-Task-Id trailer must resolve to a task whose
* column is `done` (the merger logged success).
* - The commit must NOT already be reachable from the integration ref
* (otherwise it's `already-upstream` and the existing classifier
* handles it).
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { TaskStore } from "@fusion/core";
import type { RunAuditor } from "./run-audit.js";
import {
advanceIntegrationBranchRef,
} from "./merger-ref-update-advance.js";
const execFileAsync = promisify(execFile);
async function runGit(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
encoding: "utf-8",
timeout: 30_000,
});
return stdout;
}
async function isAncestor(repoDir: string, ancestor: string, descendant: string): Promise<boolean> {
try {
await execFileAsync(
"git",
["merge-base", "--is-ancestor", ancestor, descendant],
{ cwd: repoDir, encoding: "utf-8", timeout: 30_000 },
);
return true;
} catch {
return false;
}
}
export type OrphanClassification =
| { orphan: true; sourceTaskId: string; orphanSha: string }
| {
orphan: false;
reason:
| "no-trailer"
| "source-task-not-found"
| "source-task-not-done"
| "reachable-from-integration"
| "self-attributed";
};
export interface ClassifyOrphanOurAdvanceInput {
repoDir: string;
taskStore: TaskStore;
integrationBranch: string;
currentTaskId: string;
commitSha: string;
commitSubject: string;
commitBody: string;
}
export async function classifyOrphanOurAdvance(
input: ClassifyOrphanOurAdvanceInput,
): Promise<OrphanClassification> {
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
const subjectMatch = input.commitSubject.match(subjectPattern);
const trailerMatch = input.commitBody.match(trailerPattern);
const sourceTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
if (!sourceTaskId) return { orphan: false, reason: "no-trailer" };
if (sourceTaskId === input.currentTaskId.toUpperCase()) {
return { orphan: false, reason: "self-attributed" };
}
const sourceTask = await input.taskStore.getTask(sourceTaskId);
if (!sourceTask) return { orphan: false, reason: "source-task-not-found" };
if (sourceTask.column !== "done") return { orphan: false, reason: "source-task-not-done" };
const integrationRef = `refs/heads/${input.integrationBranch}`;
if (await isAncestor(input.repoDir, input.commitSha, integrationRef)) {
return { orphan: false, reason: "reachable-from-integration" };
}
return { orphan: true, sourceTaskId, orphanSha: input.commitSha };
}
export type RehomeOutcome =
| { rehomed: true; mode: "fast-forward"; previousTipSha: string; newTipSha: string }
| {
rehomed: false;
mode: "refused-non-fast-forward" | "advance-refused";
reason: string;
integrationTipSha: string;
cherryPickHint?: string;
};
export interface RehomeOrphanOntoIntegrationInput {
rootDir: string;
projectRootDir: string;
integrationBranch: string;
orphanSha: string;
taskId: string;
audit: RunAuditor;
}
/**
* Attempt to rehome an orphan-our-advance commit onto the integration
* branch via fast-forward only. Non-FF orphans return
* `mode: "refused-non-fast-forward"` with a `cherryPickHint` the caller
* should log so an operator can rehome manually.
*/
export async function rehomeOrphanOntoIntegration(
input: RehomeOrphanOntoIntegrationInput,
): Promise<RehomeOutcome> {
const integrationRef = `refs/heads/${input.integrationBranch}`;
const integrationTipSha = (await runGit(["rev-parse", "--verify", integrationRef], input.rootDir)).trim();
// Fast-forward is possible iff the integration tip is an ancestor of the
// orphan (i.e., the orphan extends the integration branch by one or
// more commits).
if (!(await isAncestor(input.rootDir, integrationTipSha, input.orphanSha))) {
const hint = `git -C ${input.projectRootDir} cherry-pick ${input.orphanSha}`;
await input.audit.git({
type: "merger:orphan-rehome-refused",
target: input.integrationBranch,
metadata: {
taskId: input.taskId,
integrationBranch: input.integrationBranch,
orphanSha: input.orphanSha,
integrationTipSha,
reason: "non-fast-forward",
cherryPickHint: hint,
},
});
return {
rehomed: false,
mode: "refused-non-fast-forward",
reason: `orphan ${input.orphanSha} diverges from integration tip ${integrationTipSha}; manual cherry-pick required`,
integrationTipSha,
cherryPickHint: hint,
};
}
const advanceResult = await advanceIntegrationBranchRef({
rootDir: input.rootDir,
projectRootDir: input.projectRootDir,
integrationBranch: input.integrationBranch,
newSha: input.orphanSha,
expectedCurrentSha: integrationTipSha,
taskId: input.taskId,
audit: input.audit,
});
if (!advanceResult.advanced) {
return {
rehomed: false,
mode: "advance-refused",
reason: `${advanceResult.reason}: ${advanceResult.diagnostic}`,
integrationTipSha,
};
}
await input.audit.git({
type: "merger:orphan-rehome-ff",
target: input.integrationBranch,
metadata: {
taskId: input.taskId,
integrationBranch: input.integrationBranch,
orphanSha: input.orphanSha,
previousTipSha: advanceResult.previousSha,
newTipSha: advanceResult.newSha,
},
});
return {
rehomed: true,
mode: "fast-forward",
previousTipSha: advanceResult.previousSha,
newTipSha: advanceResult.newSha,
};
}

View File

@@ -220,6 +220,30 @@ export type GitMutationType =
* ```
*/
| "merge:integration-ref-advance"
/**
* Emitted when contamination recovery detects a foreign commit attributable
* to a `done` task that is not reachable from the integration branch — an
* orphan produced by a pre-fix non-FF ref advance. `merger:orphan-rehome-ff`
* fires after a successful fast-forward rehome; `merger:orphan-rehome-refused`
* fires when the orphan diverges from the integration tip and would require
* a cherry-pick (refused as too high-blast-radius for automated recovery).
*
* Metadata shape:
* ```ts
* {
* taskId: string;
* integrationBranch: string;
* orphanSha: string;
* integrationTipSha?: string;
* previousTipSha?: string;
* newTipSha?: string;
* reason?: "non-fast-forward";
* cherryPickHint?: string;
* }
* ```
*/
| "merger:orphan-rehome-ff"
| "merger:orphan-rehome-refused"
| "merge:audit-failure"
| "branch:auto-reclaim"
| "branch:auto-canonicalize-case"