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:
254
packages/engine/src/__tests__/merger-orphan-rehome.test.ts
Normal file
254
packages/engine/src/__tests__/merger-orphan-rehome.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
198
packages/engine/src/merger-orphan-rehome.ts
Normal file
198
packages/engine/src/merger-orphan-rehome.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user