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:
7
.changeset/fn-4072-diff-volume-gate.md
Normal file
7
.changeset/fn-4072-diff-volume-gate.md
Normal 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 `[]`).
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
402
packages/engine/src/__tests__/merger-diff-volume-gate.test.ts
Normal file
402
packages/engine/src/__tests__/merger-diff-volume-gate.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
103
packages/engine/src/merger-diff-volume-gate.ts
Normal file
103
packages/engine/src/merger-diff-volume-gate.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user