feat(FN-4958): complete Step 3 — wire auto-prerebase into merger

Fusion-Task-Id: FN-4958
Fusion-Task-Lineage: 2f1bc742-14e0-4fdd-a980-00051fc3323c
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 06:00:40 -07:00
committed by gsxdsm
parent 760690c588
commit e93863ca42
3 changed files with 183 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "../merger-auto-prerebase.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
describeIfGit("merger auto-prerebase real-git scenarios", () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function repoFixture() {
const repo = mkdtempSync(join(tmpdir(), "fusion-prerebase-real-"));
dirs.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
writeFileSync(join(repo, "README.md"), "init\n");
writeFileSync(join(repo, "AGENTS.md"), "base\n");
git(repo, "git add README.md AGENTS.md && git commit -m 'init'");
return repo;
}
it("A: fires on hot-file divergence and rebase applies", async () => {
const repo = repoFixture();
const base = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "AGENTS.md"), "main-change\n");
git(repo, "git add AGENTS.md && git commit -m 'main hot change'");
git(repo, `git checkout -b fusion/fn-4958-test ${base}`);
writeFileSync(join(repo, "feature.txt"), "feature\n");
git(repo, "git add feature.txt && git commit -m 'feature'");
const mainHead = git(repo, "git rev-parse main");
const divergence = await probeDivergence({ rootDir: repo, baseCommitSha: base, mainRef: mainHead });
const decision = decideAutoPrerebase({
settings: { prerebaseAutoEnabled: true, prerebaseHotFiles: ["AGENTS.md"], prerebaseDivergenceThreshold: 50 } as any,
baseCommitSha: base,
commitsBehind: divergence.commitsBehind,
changedFiles: divergence.changedFiles,
worktrunkEnabled: false,
});
expect(decision.reason).toBe("hot-file");
const result = await runAutoPrerebase({ rootDir: repo, worktreePath: repo, branch: "fusion/fn-4958-test", taskId: "FN-4958", mainHead, logger: { log: vi.fn(), warn: vi.fn() } });
expect(result.ok).toBe(true);
});
it("B/C/E: threshold + no-divergence + worktrunk-deferred decisions", async () => {
const decisionThreshold = decideAutoPrerebase({
settings: { prerebaseAutoEnabled: true, prerebaseHotFiles: [], prerebaseDivergenceThreshold: 2 } as any,
baseCommitSha: "abc",
commitsBehind: 3,
changedFiles: ["x.ts"],
worktrunkEnabled: false,
});
expect(decisionThreshold.reason).toBe("divergence-threshold");
const decisionNoDiv = decideAutoPrerebase({
settings: { prerebaseAutoEnabled: true, prerebaseHotFiles: [], prerebaseDivergenceThreshold: 10 } as any,
baseCommitSha: "abc",
commitsBehind: 0,
changedFiles: [],
worktrunkEnabled: false,
});
expect(decisionNoDiv.reason).toBe("no-divergence");
const decisionWorktrunk = decideAutoPrerebase({
settings: { prerebaseAutoEnabled: true, prerebaseHotFiles: ["AGENTS.md"], prerebaseDivergenceThreshold: 1 } as any,
baseCommitSha: "abc",
commitsBehind: 99,
changedFiles: ["AGENTS.md"],
worktrunkEnabled: true,
});
expect(decisionWorktrunk.reason).toBe("worktrunk-deferred");
});
});

View File

@@ -91,6 +91,7 @@ import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-gua
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js";
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
@@ -6530,6 +6531,99 @@ export async function aiMergeTask(
let rebaseHappened = false;
let preferMainRebaseFailureMessage: string | undefined;
if (worktreePath && task.baseCommitSha) {
try {
throwIfAborted(options.signal, taskId);
const { stdout: mainHeadOut } = await execAsync("git rev-parse HEAD", {
cwd: rootDir,
encoding: "utf-8",
});
const mainHead = mainHeadOut.trim();
if (mainHead) {
const divergence = await probeDivergence({
rootDir,
baseCommitSha: task.baseCommitSha,
mainRef: mainHead,
});
const prerebaseDecision = decideAutoPrerebase({
settings,
baseCommitSha: task.baseCommitSha,
commitsBehind: divergence.commitsBehind,
changedFiles: divergence.changedFiles,
worktrunkEnabled: settings.worktrunk?.enabled === true,
});
const prerebaseMetadata = {
reason: prerebaseDecision.reason,
commitsBehind: prerebaseDecision.commitsBehind,
hotMatches: prerebaseDecision.hotMatches,
baseCommitSha: task.baseCommitSha,
mainHead,
taskId,
};
if (!prerebaseDecision.fire) {
await audit.git({
type: "merge:auto-prerebase:skipped",
target: taskId,
metadata: prerebaseMetadata,
});
} else {
throwIfAborted(options.signal, taskId);
const prerebaseResult = await runAutoPrerebase({
rootDir,
worktreePath,
branch,
taskId,
mainHead,
logger: mergerLog,
});
if (prerebaseResult.ok) {
rebaseHappened = true;
await store.appendAgentLog(
taskId,
`Pre-merge auto-prerebase: ${branch} → local HEAD ${mainHead.slice(0, 8)} (${prerebaseDecision.reason})`,
"text",
undefined,
"merger",
);
await audit.git({
type: "merge:auto-prerebase:applied",
target: taskId,
metadata: prerebaseMetadata,
});
} else {
mergerLog.warn(`${taskId}: auto-prerebase failed (${prerebaseResult.error ?? "unknown"}) — proceeding to existing rebase cascade`);
await audit.git({
type: "merge:auto-prerebase:failed",
target: taskId,
metadata: {
...prerebaseMetadata,
error: prerebaseResult.error ?? "unknown",
},
});
}
}
}
} catch (err: unknown) {
rethrowIfMergeAborted(err);
mergerLog.warn(`${taskId}: auto-prerebase probe failed (${getCommandErrorMessage(err)}) — proceeding to existing rebase cascade`);
await audit.git({
type: "merge:auto-prerebase:failed",
target: taskId,
metadata: {
reason: "no-divergence",
commitsBehind: 0,
hotMatches: [],
baseCommitSha: task.baseCommitSha,
mainHead: "",
taskId,
error: getCommandErrorMessage(err),
},
});
}
}
// Semantic guards: prefer-main with no rebase available is incoherent —
// the strategy depends on rebase to honor main's deletions. Fail fast
// before we waste work attempting a merge that can't deliver its promise.

View File

@@ -135,6 +135,9 @@ export type GitMutationType =
| "merge:start"
| "merge:resolve"
| "merge:file-scope-violation"
| "merge:auto-prerebase:applied"
| "merge:auto-prerebase:skipped"
| "merge:auto-prerebase:failed"
| "merge:layer3:foreign-file-skipped"
| "merge:layer3:scope-override-bypass"
| "merge:audit-failure"