feat(FN-4072): add diff volume gate to merger

Added a diff volume gate to the merger that blocks or warns on large diffs before merge completes. The feature includes a new `merger-diff-volume-gate` module wired into the merger, configurable via project settings, with tests covering all gate paths. Also updated docs and the changeset for the `@r

Fusion-Task-Id: FN-4072
This commit is contained in:
Fusion
2026-05-12 18:23:13 -07:00
committed by gsxdsm
parent e7fc3a9d17
commit 8fa3e8bfd1
8 changed files with 660 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Add a pre-commit diff-volume gate for auto-resolved squash merges. Fusion now compares each file's staged squash delta against the branch's net delta and blocks the merge in `in-review` when a non-allowlisted file silently loses too much branch content.
Add three new project settings for tuning the gate: `mergeDiffVolumeMinLines` (default `20`), `mergeDiffVolumeThreshold` (default `0.2`), and `mergeDiffVolumeAllowlist` (default `[]`).

View File

@@ -221,6 +221,8 @@ Two rules, learned the hard way (FN-2370 silently reverted three commits' work):
After any squash that auto-resolved conflicts, the merger now runs the post-squash audit as a blocking gate before auto-completing the task. Flagged merges stay in `in-review` for inspection, and only a clean audit proceeds to `done`. After any squash that auto-resolved conflicts, the merger now runs the post-squash audit as a blocking gate before auto-completing the task. Flagged merges stay in `in-review` for inspection, and only a clean audit proceeds to `done`.
Before those auto-resolved squash commits are written, the merger also runs a per-file diff-volume gate: it compares each file's staged squash delta against the branch's net delta vs its merge-base, and blocks the merge in `in-review` when a non-allowlisted file loses too much branch volume. This is the pre-commit guard against FN-3936-style silent drops where fallback resolution kept a branch's commit message but discarded the branch's main file edits.
When `mergeConflictStrategy="smart-prefer-main"`, the merger also runs an overlap guard before the Attempt 3 `-X ours` fallback. If recent `main` commits (30-commit lookback) touched files the task branch also changed, the default `mergeStrategyOverlapBehavior="flip-to-prefer-branch"` makes those overlapping files prefer the task branch instead of silently discarding branch hardening; `warn-only` preserves the legacy fallback while logging the risk, and `ignore` disables the guard. When `mergeConflictStrategy="smart-prefer-main"`, the merger also runs an overlap guard before the Attempt 3 `-X ours` fallback. If recent `main` commits (30-commit lookback) touched files the task branch also changed, the default `mergeStrategyOverlapBehavior="flip-to-prefer-branch"` makes those overlapping files prefer the task branch instead of silently discarding branch hardening; `warn-only` preserves the legacy fallback while logging the risk, and `ignore` disables the guard.
For manual follow-up, standalone auditing, or post-incident inspection, the script remains available: For manual follow-up, standalone auditing, or post-incident inspection, the script remains available:

View File

@@ -184,6 +184,9 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"auto"` | Direct-merge commit routing mode. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-squash` forces the legacy squash path; `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"auto"` | Direct-merge commit routing mode. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-squash` forces the legacy squash path; `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
| `mergeConflictStrategy` | `"smart-prefer-main" \| "smart-prefer-branch" \| "ai-only" \| "abort"` | `"smart-prefer-main"` | Controls the merger's conflict-resolution cascade. `smart-prefer-main` fast-forwards local main from `origin` when possible, then tries AI resolution, then auto-resolve heuristics, then a final `-X ours` fallback that prefers main unless the overlap guard below says otherwise. `smart-prefer-branch` uses the same cascade but ends with `-X theirs` so the task branch wins. `ai-only` never silently picks a side, and `abort` stops after the first AI attempt. Legacy `smart` / `prefer-main` values are normalized automatically. | | `mergeConflictStrategy` | `"smart-prefer-main" \| "smart-prefer-branch" \| "ai-only" \| "abort"` | `"smart-prefer-main"` | Controls the merger's conflict-resolution cascade. `smart-prefer-main` fast-forwards local main from `origin` when possible, then tries AI resolution, then auto-resolve heuristics, then a final `-X ours` fallback that prefers main unless the overlap guard below says otherwise. `smart-prefer-branch` uses the same cascade but ends with `-X theirs` so the task branch wins. `ai-only` never silently picks a side, and `abort` stops after the first AI attempt. Legacy `smart` / `prefer-main` values are normalized automatically. |
| `mergeDiffVolumeMinLines` | `number` | `20` | Minimum branch-net line volume before Fusion compares a file's staged squash delta against the branch's net delta. Applied at merge time and clamped to `>= 1`. |
| `mergeDiffVolumeThreshold` | `number` | `0.2` | Minimum staged-to-branch-net ratio allowed for a non-allowlisted file during auto-resolved squash finalization. Applied at merge time and clamped to `0..1`. |
| `mergeDiffVolumeAllowlist` | `string[]` | `[]` | Additional glob patterns skipped by the pre-commit diff-volume gate, beyond the built-in generated-file and lockfile allowlists. |
| `mergeStrategyOverlapBehavior` | `"flip-to-prefer-branch" \| "warn-only" \| "ignore"` | `"flip-to-prefer-branch"` | Safety control for `mergeConflictStrategy="smart-prefer-main"`. Before the Attempt 3 `-X ours` fallback, Fusion checks whether the task branch and recent `main` history overlap on the same files (30-commit lookback, matching the squash audit heuristics). `flip-to-prefer-branch` makes overlapping files prefer the task branch so hardening is not silently discarded (the FN-3936 class of regression). `warn-only` logs the overlap but keeps the legacy main-wins fallback. `ignore` disables the overlap guard and preserves legacy behavior exactly. | | `mergeStrategyOverlapBehavior` | `"flip-to-prefer-branch" \| "warn-only" \| "ignore"` | `"flip-to-prefer-branch"` | Safety control for `mergeConflictStrategy="smart-prefer-main"`. Before the Attempt 3 `-X ours` fallback, Fusion checks whether the task branch and recent `main` history overlap on the same files (30-commit lookback, matching the squash audit heuristics). `flip-to-prefer-branch` makes overlapping files prefer the task branch so hardening is not silently discarded (the FN-3936 class of regression). `warn-only` logs the overlap but keeps the legacy main-wins fallback. `ignore` disables the overlap guard and preserves legacy behavior exactly. |
### Per-task direct-merge override ### Per-task direct-merge override

View File

@@ -211,6 +211,9 @@ export const DEFAULT_PROJECT_SETTINGS = {
worktreeRebaseRemote: "", worktreeRebaseRemote: "",
worktreeRebaseLocalBase: true, worktreeRebaseLocalBase: true,
mergeConflictStrategy: "smart-prefer-main", mergeConflictStrategy: "smart-prefer-main",
mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined,
mergeDiffVolumeAllowlist: undefined,
mergeStrategyOverlapBehavior: "flip-to-prefer-branch", mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
workflowStepTimeoutMs: 360_000, workflowStepTimeoutMs: 360_000,
workflowRevisionForkOnScopeMismatch: true, workflowRevisionForkOnScopeMismatch: true,

View File

@@ -2203,6 +2203,12 @@ export interface ProjectSettings {
/** Strategy used when a merge conflict can't be resolved by AI. See /** Strategy used when a merge conflict can't be resolved by AI. See
* {@link MergeConflictStrategy}. Default: "smart". */ * {@link MergeConflictStrategy}. Default: "smart". */
mergeConflictStrategy?: MergeConflictStrategy; mergeConflictStrategy?: MergeConflictStrategy;
/** Minimum branch net line volume before the pre-commit diff-volume gate evaluates a file. Default applied at read site: 20. */
mergeDiffVolumeMinLines?: number;
/** Minimum staged/branch-net ratio required by the pre-commit diff-volume gate. Default applied at read site: 0.2. */
mergeDiffVolumeThreshold?: number;
/** Additional file globs allowlisted by the pre-commit diff-volume gate on top of generated/lockfile patterns. Default applied at read site: []. */
mergeDiffVolumeAllowlist?: string[];
/** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"` /** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"`
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */ * reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior; mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;

View File

@@ -0,0 +1,402 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { DEFAULT_SETTINGS } from "@fusion/core";
import { checkDiffVolume, DiffVolumeRegressionError } from "../merger-diff-volume-gate.js";
import { attemptWithSideStrategy, commitOrAmendMergeWithFixes, executeMergeAttempt } from "../merger.js";
function git(dir: string, command: string): string {
return execSync(command, { cwd: dir, stdio: "pipe" }).toString().trim();
}
function testTempParent(): string {
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
}
function assertIsolatedWorkspace(dir: string): void {
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
if (!repoRoot) return;
expect(resolve(dir).startsWith(resolve(repoRoot))).toBe(false);
}
function initRepo(dir: string): void {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
git(dir, 'git config commit.gpgsign false');
writeFileSync(join(dir, "README.md"), "# repo\n");
git(dir, "git add README.md");
git(dir, 'git commit -m "chore: initial commit"');
}
function writeRepeatedLines(dir: string, file: string, count: number, prefix = "line"): void {
mkdirSync(join(dir, file, ".."), { recursive: true });
writeFileSync(join(dir, file), Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`).join("\n") + "\n");
}
function discardStagedFile(dir: string, file: string): void {
git(dir, `git reset HEAD -- ${file}`);
const absolute = join(dir, file);
if (existsSync(absolute)) {
rmSync(absolute, { force: true });
}
}
function createBranchCommit(dir: string, branch: string, file: string, lineCount: number, prefix?: string): { preAttemptHeadSha: string } {
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, `git checkout -b ${branch}`);
writeRepeatedLines(dir, file, lineCount, prefix ?? branch);
git(dir, `git add ${file}`);
git(dir, `git commit -m "feat: update ${file}"`);
git(dir, "git checkout main");
return { preAttemptHeadSha };
}
function stageSquash(dir: string, branch: string): void {
git(dir, `git merge --squash ${branch}`);
}
function createMockStore() {
return {
appendAgentLog: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue({ id: "FN-4072", column: "in-review", prompt: "# test" }),
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS, commitAuthorEnabled: false }),
} as any;
}
function mergeAttemptParams(dir: string, branch: string, preAttemptHeadSha: string, store = createMockStore()) {
return {
store,
rootDir: dir,
taskId: "FN-4072",
branch,
commitLog: `- feat: ${branch}`,
diffStat: "1 file changed",
aiSummary: null,
aiSubject: null,
includeTaskId: false,
smartConflictResolution: true,
mergeConflictStrategy: "smart-prefer-main",
attemptNum: 3,
options: {},
result: {},
settings: { ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
preAttemptHeadSha,
} as any;
}
describe("checkDiffVolume", () => {
const createdDirs = new Set<string>();
afterEach(() => {
for (const dir of createdDirs) {
rmSync(dir, { recursive: true, force: true });
createdDirs.delete(dir);
}
});
it("blocks when a large branch contribution is dropped from staged content", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
assertIsolatedWorkspace(dir);
initRepo(dir);
const { preAttemptHeadSha } = createBranchCommit(dir, "feat/drop", "packages/core/src/store.ts", 60, "drop");
stageSquash(dir, "feat/drop");
discardStagedFile(dir, "packages/core/src/store.ts");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/drop",
integrationTargetSha: preAttemptHeadSha,
minLines: 20,
threshold: 0.2,
allowlistGlobs: [],
taskId: "FN-4072",
})).rejects.toMatchObject({
name: "DiffVolumeRegressionError",
findings: [expect.objectContaining({ file: "packages/core/src/store.ts", branchNet: 60, staged: 0 })],
});
});
it("ignores dropped files below minLines", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
initRepo(dir);
const { preAttemptHeadSha } = createBranchCommit(dir, "feat/small", "src/small.ts", 5, "small");
stageSquash(dir, "feat/small");
discardStagedFile(dir, "src/small.ts");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/small",
integrationTargetSha: preAttemptHeadSha,
minLines: 20,
threshold: 0.2,
allowlistGlobs: [],
taskId: "FN-4072",
})).resolves.toBeUndefined();
});
it("skips dropped lockfiles", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
initRepo(dir);
const { preAttemptHeadSha } = createBranchCommit(dir, "feat/lock", "pnpm-lock.yaml", 60, "lock");
stageSquash(dir, "feat/lock");
discardStagedFile(dir, "pnpm-lock.yaml");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/lock",
integrationTargetSha: preAttemptHeadSha,
minLines: 20,
threshold: 0.2,
allowlistGlobs: [],
taskId: "FN-4072",
})).resolves.toBeUndefined();
});
it("honors caller-supplied allowlist globs", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
initRepo(dir);
const { preAttemptHeadSha } = createBranchCommit(dir, "feat/allow", "fixtures/generated.snapshot", 60, "snapshot");
stageSquash(dir, "feat/allow");
discardStagedFile(dir, "fixtures/generated.snapshot");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/allow",
integrationTargetSha: preAttemptHeadSha,
minLines: 20,
threshold: 0.2,
allowlistGlobs: ["fixtures/*.snapshot"],
taskId: "FN-4072",
})).resolves.toBeUndefined();
});
it("treats binary numstat entries as zero without crashing", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
initRepo(dir);
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/binary");
writeFileSync(join(dir, "image.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
git(dir, "git add image.bin");
git(dir, 'git commit -m "feat: add binary"');
git(dir, "git checkout main");
stageSquash(dir, "feat/binary");
discardStagedFile(dir, "image.bin");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/binary",
integrationTargetSha: preAttemptHeadSha,
minLines: 1,
threshold: 0.2,
allowlistGlobs: [],
taskId: "FN-4072",
})).resolves.toBeUndefined();
});
it("exposes a structured error message", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-"));
createdDirs.add(dir);
initRepo(dir);
const { preAttemptHeadSha } = createBranchCommit(dir, "feat/msg", "src/important.ts", 60, "important");
stageSquash(dir, "feat/msg");
discardStagedFile(dir, "src/important.ts");
await expect(checkDiffVolume({
rootDir: dir,
branch: "feat/msg",
integrationTargetSha: preAttemptHeadSha,
minLines: 20,
threshold: 0.2,
allowlistGlobs: [],
taskId: "FN-4072",
})).rejects.toSatisfy((error: unknown) => error instanceof DiffVolumeRegressionError && error.message.includes("branch_net=60") && error.message.includes("ratio=0.000"));
});
});
describe("diff-volume gate merger integration", () => {
const createdDirs = new Set<string>();
afterEach(() => {
for (const dir of createdDirs) {
rmSync(dir, { recursive: true, force: true });
createdDirs.delete(dir);
}
});
it("blocks the FN-3936 replay in attemptWithSideStrategy and leaves the worktree clean", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-"));
createdDirs.add(dir);
initRepo(dir);
writeRepeatedLines(dir, "packages/core/src/store.ts", 1, "base");
git(dir, "git add packages/core/src/store.ts");
git(dir, 'git commit -m "chore: add store"');
git(dir, "git checkout -b feat/fn-3936");
writeRepeatedLines(dir, "packages/core/src/store.ts", 60, "branch");
writeRepeatedLines(dir, "docs/kept.md", 5, "kept");
git(dir, "git add packages/core/src/store.ts docs/kept.md");
git(dir, 'git commit -m "feat: branch store hardening"');
git(dir, "git checkout main");
writeRepeatedLines(dir, "packages/core/src/store.ts", 1, "main");
git(dir, "git add packages/core/src/store.ts");
git(dir, 'git commit -m "fix: main store edit"');
const mainHeadBeforeMerge = git(dir, "git rev-parse HEAD");
const store = createMockStore();
await expect(attemptWithSideStrategy(mergeAttemptParams(dir, "feat/fn-3936", mainHeadBeforeMerge, store), "ours")).rejects.toMatchObject({
name: "DiffVolumeRegressionError",
findings: [expect.objectContaining({ file: "packages/core/src/store.ts", branchNet: 61, staged: 0 })],
});
expect(git(dir, "git rev-parse HEAD")).toBe(mainHeadBeforeMerge);
expect(git(dir, "git status --short")).toBe("");
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-4072",
"Diff-volume gate blocked auto-resolved squash before commit",
"tool_error",
expect.stringContaining("packages/core/src/store.ts"),
"merger",
);
});
it("allows a healthy attempt 2 auto-resolution path when staged volume matches the branch", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-"));
createdDirs.add(dir);
initRepo(dir);
writeRepeatedLines(dir, "src/data.gen.ts", 1, "base");
git(dir, "git add src/data.gen.ts");
git(dir, 'git commit -m "chore: add generated file"');
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/generated");
writeRepeatedLines(dir, "src/data.gen.ts", 60, "branch-generated");
git(dir, "git add src/data.gen.ts");
git(dir, 'git commit -m "feat: regenerate data"');
git(dir, "git checkout main");
writeRepeatedLines(dir, "src/data.gen.ts", 2, "main-generated");
git(dir, "git add src/data.gen.ts");
git(dir, 'git commit -m "chore: main regen"');
const store = createMockStore();
const success = await executeMergeAttempt({
...mergeAttemptParams(dir, "feat/generated", preAttemptHeadSha, store),
attemptNum: 2,
diffStat: " src/data.gen.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++",
}, {} as any);
expect(success).toBe(true);
expect(git(dir, "git rev-parse HEAD")).not.toBe(preAttemptHeadSha);
expect(git(dir, "git show --format= --name-only HEAD").split("\n")).toContain("src/data.gen.ts");
});
it("allows dropped lockfile-only content in attemptWithSideStrategy", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-"));
createdDirs.add(dir);
initRepo(dir);
writeRepeatedLines(dir, "pnpm-lock.yaml", 1, "base-lock");
git(dir, "git add pnpm-lock.yaml");
git(dir, 'git commit -m "chore: add lockfile"');
git(dir, "git checkout -b feat/lock-drop");
writeRepeatedLines(dir, "pnpm-lock.yaml", 60, "branch-lock");
writeRepeatedLines(dir, "src/kept.ts", 5, "kept-lock");
git(dir, "git add pnpm-lock.yaml src/kept.ts");
git(dir, 'git commit -m "feat: lock update"');
git(dir, "git checkout main");
writeRepeatedLines(dir, "pnpm-lock.yaml", 1, "main-lock");
git(dir, "git add pnpm-lock.yaml");
git(dir, 'git commit -m "chore: main lock change"');
const mainHeadBeforeMerge = git(dir, "git rev-parse HEAD");
const merged = await attemptWithSideStrategy(mergeAttemptParams(dir, "feat/lock-drop", mainHeadBeforeMerge), "ours");
expect(merged).toBe(true);
expect(git(dir, "git rev-parse HEAD")).not.toBe(mainHeadBeforeMerge);
});
it("blocks commitOrAmendMergeWithFixes before a fresh finalize commit when staged branch volume was dropped", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-"));
createdDirs.add(dir);
initRepo(dir);
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/finalize-fresh");
writeRepeatedLines(dir, "src/finalize.ts", 60, "fresh");
writeRepeatedLines(dir, "src/kept.ts", 5, "kept-fresh");
git(dir, "git add src/finalize.ts src/kept.ts");
git(dir, 'git commit -m "feat: finalize fresh"');
git(dir, "git checkout main");
stageSquash(dir, "feat/finalize-fresh");
discardStagedFile(dir, "src/finalize.ts");
await expect(commitOrAmendMergeWithFixes(
dir,
"FN-4072",
"feat/finalize-fresh",
"- feat: finalize fresh",
false,
preAttemptHeadSha,
"",
"1 file changed",
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
undefined,
null,
null,
new Set<string>(),
createMockStore(),
)).rejects.toBeInstanceOf(DiffVolumeRegressionError);
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
expect(git(dir, "git status --short")).toBe("");
});
it("blocks commitOrAmendMergeWithFixes before an amend finalize when staged branch volume was dropped", async () => {
const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-"));
createdDirs.add(dir);
initRepo(dir);
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/finalize-amend");
writeRepeatedLines(dir, "src/amend.ts", 60, "amend");
writeRepeatedLines(dir, "src/kept-amend.ts", 5, "kept-amend");
git(dir, "git add src/amend.ts src/kept-amend.ts");
git(dir, 'git commit -m "feat: finalize amend"');
git(dir, "git checkout main");
stageSquash(dir, "feat/finalize-amend");
git(dir, 'git commit -m "feat: ai commit"');
writeRepeatedLines(dir, "README.md", 1, "dirty");
git(dir, "git add README.md");
git(dir, "git reset HEAD -- src/amend.ts");
rmSync(join(dir, "src/amend.ts"), { force: true });
await expect(commitOrAmendMergeWithFixes(
dir,
"FN-4072",
"feat/finalize-amend",
"- feat: finalize amend",
false,
preAttemptHeadSha,
"",
"1 file changed",
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
undefined,
null,
null,
new Set<string>(["README.md"]),
createMockStore(),
)).rejects.toBeInstanceOf(DiffVolumeRegressionError);
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
expect(git(dir, "git status --short")).toBe("");
});
});

View File

@@ -0,0 +1,103 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { GENERATED_PATTERNS, LOCKFILE_PATTERNS, matchGlob } from "./merger.js";
const execFileAsync = promisify(execFile);
export interface DiffVolumeRegressionFinding {
file: string;
branchNet: number;
staged: number;
ratio: number;
}
export class DiffVolumeRegressionError extends Error {
override name = "DiffVolumeRegressionError";
constructor(public readonly findings: DiffVolumeRegressionFinding[]) {
super(buildMessage(findings));
}
}
interface CheckDiffVolumeParams {
rootDir: string;
branch: string;
integrationTargetSha: string;
minLines: number;
threshold: number;
allowlistGlobs: readonly string[];
taskId?: string;
}
function buildMessage(findings: readonly DiffVolumeRegressionFinding[]): string {
const details = findings
.map((finding) => `${finding.file} (branch_net=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`)
.join(", ");
return `Per-file diff-volume regression detected: ${details}`;
}
function parseNumstatTotal(output: string): number {
const line = output
.split("\n")
.map((entry) => entry.trim())
.find(Boolean);
if (!line) return 0;
const [addedRaw, deletedRaw] = line.split("\t");
if (!addedRaw || !deletedRaw) return 0;
if (addedRaw === "-" || deletedRaw === "-") return 0;
const added = Number.parseInt(addedRaw, 10);
const deleted = Number.parseInt(deletedRaw, 10);
return (Number.isFinite(added) ? added : 0) + (Number.isFinite(deleted) ? deleted : 0);
}
function isAllowlisted(file: string, allowlistGlobs: readonly string[]): boolean {
return [...LOCKFILE_PATTERNS, ...GENERATED_PATTERNS, ...allowlistGlobs].some((pattern) => matchGlob(file, pattern));
}
async function execGit(rootDir: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout;
}
export async function checkDiffVolume({
rootDir,
branch,
integrationTargetSha,
minLines,
threshold,
allowlistGlobs,
}: CheckDiffVolumeParams): Promise<void> {
const base = (await execGit(rootDir, ["merge-base", integrationTargetSha, branch])).trim();
const touchedFilesOutput = await execGit(rootDir, ["diff", "--name-only", `${base}...${branch}`]);
const touchedFiles = touchedFilesOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const findings: DiffVolumeRegressionFinding[] = [];
for (const file of touchedFiles) {
if (isAllowlisted(file, allowlistGlobs)) continue;
const branchNet = parseNumstatTotal(
await execGit(rootDir, ["diff", "--numstat", `${base}...${branch}`, "--", file]),
);
if (branchNet <= minLines) continue;
const staged = parseNumstatTotal(
await execGit(rootDir, ["diff", "--cached", "--numstat", "--", file]),
);
const ratio = branchNet === 0 ? 1 : staged / branchNet;
if (ratio < threshold) {
findings.push({ file, branchNet, staged, ratio });
}
}
if (findings.length > 0) {
throw new DiffVolumeRegressionError(findings);
}
}

View File

@@ -74,6 +74,9 @@ import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from
import { createWebFetchTool } from "./agent-tools.js"; import { createWebFetchTool } from "./agent-tools.js";
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeAuditStrategy, type SquashAuditFindings } from "./merger-squash-audit.js"; import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeAuditStrategy, type SquashAuditFindings } from "./merger-squash-audit.js";
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js"; import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
/** Conflict type classification for merge conflict resolution */ /** Conflict type classification for merge conflict resolution */
export type ConflictType = export type ConflictType =
@@ -144,7 +147,7 @@ function truncateWorkflowScriptOutput(output: string): string {
} }
/** Check if a path matches a glob pattern (simple glob support: * and **) */ /** Check if a path matches a glob pattern (simple glob support: * and **) */
function matchGlob(path: string, pattern: string): boolean { export function matchGlob(path: string, pattern: string): boolean {
// Handle ** which matches across directory boundaries (must do before single *) // Handle ** which matches across directory boundaries (must do before single *)
if (pattern.includes("**")) { if (pattern.includes("**")) {
// Convert ** to match any characters including / // Convert ** to match any characters including /
@@ -188,6 +191,77 @@ function matchGlob(path: string, pattern: string): boolean {
return regex.test(fileName) || regex.test(path); return regex.test(fileName) || regex.test(path);
} }
interface DiffVolumeGateSettings {
minLines: number;
threshold: number;
allowlistGlobs: string[];
}
function resolveDiffVolumeGateSettings(settings?: Settings): DiffVolumeGateSettings {
const minLinesRaw = settings?.mergeDiffVolumeMinLines ?? 20;
const thresholdRaw = settings?.mergeDiffVolumeThreshold ?? 0.2;
return {
minLines: Math.max(1, Math.trunc(Number.isFinite(minLinesRaw) ? minLinesRaw : 20)),
threshold: Math.min(1, Math.max(0, Number.isFinite(thresholdRaw) ? thresholdRaw : 0.2)),
allowlistGlobs: Array.isArray(settings?.mergeDiffVolumeAllowlist)
? settings.mergeDiffVolumeAllowlist.filter((glob): glob is string => typeof glob === "string" && glob.trim().length > 0)
: [],
};
}
function formatDiffVolumeFindings(findings: ReadonlyArray<{ file: string; branchNet: number; staged: number; ratio: number }>): string {
return findings
.map((finding) => `${finding.file} (branchNet=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`)
.join("\n");
}
async function resetToIntegrationTarget(rootDir: string, integrationTargetSha: string): Promise<void> {
await execAsync(`git reset --hard ${quoteArg(integrationTargetSha)}`, {
cwd: rootDir,
encoding: "utf-8",
});
await execAsync("git clean -fd", {
cwd: rootDir,
encoding: "utf-8",
});
}
async function runDiffVolumeGate(params: {
rootDir: string;
branch: string;
integrationTargetSha: string;
taskId: string;
settings?: Settings;
store?: TaskStore;
}): Promise<void> {
try {
const gateSettings = resolveDiffVolumeGateSettings(params.settings);
await checkDiffVolume({
rootDir: params.rootDir,
branch: params.branch,
integrationTargetSha: params.integrationTargetSha,
minLines: gateSettings.minLines,
threshold: gateSettings.threshold,
allowlistGlobs: gateSettings.allowlistGlobs,
taskId: params.taskId,
});
} catch (error: unknown) {
if (!(error instanceof DiffVolumeRegressionError)) throw error;
await resetToIntegrationTarget(params.rootDir, params.integrationTargetSha);
const details = formatDiffVolumeFindings(error.findings);
if (params.store) {
await params.store.appendAgentLog(
params.taskId,
`Diff-volume gate blocked auto-resolved squash before commit`,
"tool_error",
details,
"merger",
);
}
throw error;
}
}
export async function getStagedFiles(cwd: string): Promise<string[]> { export async function getStagedFiles(cwd: string): Promise<string[]> {
try { try {
const { stdout } = await execAsync("git diff --cached --name-only", { const { stdout } = await execAsync("git diff --cached --name-only", {
@@ -3341,6 +3415,14 @@ export async function commitOrAmendMergeWithFixes(
// This is the phantom-merge fix: previously the code blindly amended // This is the phantom-merge fix: previously the code blindly amended
// HEAD (the previous task's commit), silently dropping the current // HEAD (the previous task's commit), silently dropping the current
// task's branch and inheriting the prior task's stats. // task's branch and inheriting the prior task's stats.
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha,
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },
@@ -3366,6 +3448,14 @@ export async function commitOrAmendMergeWithFixes(
// HEAD moved — AI agent committed already. Amend with deterministic // HEAD moved — AI agent committed already. Amend with deterministic
// message + any new staged fixes folded in. `--amend -m` replaces both // message + any new staged fixes folded in. `--amend -m` replaces both
// the message and includes any newly-staged content. // the message and includes any newly-staged content.
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha,
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },
@@ -3387,6 +3477,9 @@ export async function commitOrAmendMergeWithFixes(
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`); mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
return { ok: true, reason: "completed" }; return { ok: true, reason: "completed" };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof DiffVolumeRegressionError) {
throw err;
}
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to finalize merge commit: ${errorMessage}`); mergerLog.warn(`${taskId}: failed to finalize merge commit: ${errorMessage}`);
return { ok: false, reason: "unknown-phantom" }; return { ok: false, reason: "unknown-phantom" };
@@ -5974,6 +6067,7 @@ export async function aiMergeTask(
buildSource: effectiveBuildSource, buildSource: effectiveBuildSource,
preMergeRebaseFallthrough, preMergeRebaseFallthrough,
attempt3BranchWinsFiles: preferBranchOnOverlapFiles, attempt3BranchWinsFiles: preferBranchOnOverlapFiles,
preAttemptHeadSha,
}, aiTracker); }, aiTracker);
if (success) { if (success) {
@@ -6012,6 +6106,10 @@ export async function aiMergeTask(
throw error; throw error;
} }
if (error instanceof DiffVolumeRegressionError || error?.name === "DiffVolumeRegressionError") {
throw error;
}
// Check if it's a deterministic verification failure (testCommand or buildCommand failed) // Check if it's a deterministic verification failure (testCommand or buildCommand failed)
// Try in-merge fix attempts before propagating // Try in-merge fix attempts before propagating
if (error.name === "VerificationError") { if (error.name === "VerificationError") {
@@ -6916,6 +7014,8 @@ interface MergeAttemptParams {
* the task branch after the default `-X ours` squash so overlapping files * the task branch after the default `-X ours` squash so overlapping files
* keep the branch's hardening while non-overlapping files still prefer main. */ * keep the branch's hardening while non-overlapping files still prefer main. */
attempt3BranchWinsFiles?: Set<string>; attempt3BranchWinsFiles?: Set<string>;
/** HEAD of the integration target immediately before this squash attempt began. */
preAttemptHeadSha?: string;
} }
/** Mutable flags carried through the merge cascade. */ /** Mutable flags carried through the merge cascade. */
@@ -6932,7 +7032,7 @@ interface AiInvocationTracker {
* Returns true if merge succeeded, false if should retry (for attempts 1-2). * Returns true if merge succeeded, false if should retry (for attempts 1-2).
* Throws on unrecoverable errors. * Throws on unrecoverable errors.
*/ */
async function executeMergeAttempt( export async function executeMergeAttempt(
params: MergeAttemptParams, params: MergeAttemptParams,
aiTracker: AiInvocationTracker, aiTracker: AiInvocationTracker,
): Promise<boolean> { ): Promise<boolean> {
@@ -7087,6 +7187,14 @@ async function executeMergeAttempt(
aiSummary: safeBody, aiSummary: safeBody,
aiSubject, aiSubject,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: params.preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },
@@ -7226,6 +7334,7 @@ async function executeMergeAttempt(
buildCommand, buildCommand,
sourceIssueRef, sourceIssueRef,
preMergeRebaseFallthrough: params.preMergeRebaseFallthrough, preMergeRebaseFallthrough: params.preMergeRebaseFallthrough,
preAttemptHeadSha: params.preAttemptHeadSha,
}); });
// Handle build failure // Handle build failure
@@ -7335,7 +7444,7 @@ async function executeMergeAttempt(
// and trip the phantom-merge guard even though the task's content is // and trip the phantom-merge guard even though the task's content is
// already on HEAD. Retrying with auto-conflict-resolution can't help a // already on HEAD. Retrying with auto-conflict-resolution can't help a
// verification failure anyway — there are no conflicts to resolve. // verification failure anyway — there are no conflicts to resolve.
if (error?.name === "VerificationError") { if (error?.name === "VerificationError" || error?.name === "DiffVolumeRegressionError") {
throw error; throw error;
} }
@@ -7355,7 +7464,7 @@ async function executeMergeAttempt(
* - "theirs" — the task branch wins (mergeConflictStrategy="smart-prefer-branch") * - "theirs" — the task branch wins (mergeConflictStrategy="smart-prefer-branch")
* - "ours" — the main branch wins (mergeConflictStrategy="smart-prefer-main", default) * - "ours" — the main branch wins (mergeConflictStrategy="smart-prefer-main", default)
*/ */
async function attemptWithSideStrategy( export async function attemptWithSideStrategy(
params: MergeAttemptParams, params: MergeAttemptParams,
side: "theirs" | "ours" = "theirs", side: "theirs" | "ours" = "theirs",
aiTracker?: AiInvocationTracker, aiTracker?: AiInvocationTracker,
@@ -7383,7 +7492,7 @@ async function attemptWithSideStrategy(
return finalizeSideStrategyAttempt(params, side, aiTracker); return finalizeSideStrategyAttempt(params, side, aiTracker);
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") { if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
throw error; throw error;
} }
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`); mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
@@ -7424,7 +7533,7 @@ async function attemptWithMixedSideStrategy(
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker); return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") { if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
throw error; throw error;
} }
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`); mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
@@ -7484,6 +7593,14 @@ async function finalizeSideStrategyAttempt(
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody, aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
aiSubject, aiSubject,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: params.preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },
@@ -7538,6 +7655,8 @@ interface AiAgentParams {
* the merge prompt when the pre-merge rebase recovery cascade fell * the merge prompt when the pre-merge rebase recovery cascade fell
* through. See MergePromptParams.preMergeRebaseFallthrough for details. */ * through. See MergePromptParams.preMergeRebaseFallthrough for details. */
preMergeRebaseFallthrough?: string; preMergeRebaseFallthrough?: string;
/** HEAD of the integration target immediately before this squash attempt began. */
preAttemptHeadSha?: string;
} }
/** /**
@@ -7579,6 +7698,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
testCommand, testCommand,
buildCommand, buildCommand,
preMergeRebaseFallthrough, preMergeRebaseFallthrough,
preAttemptHeadSha,
} = params; } = params;
const settings = await store.getSettings(); const settings = await store.getSettings();
@@ -7836,6 +7956,14 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody, aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
aiSubject, aiSubject,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir }, { cwd: rootDir },