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:
committed by
gsxdsm
parent
760690c588
commit
e93863ca42
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user