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
104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
}
|