feat(FN-4067): add squash audit gate to merger with integration tests
Adds a squash-merge audit gate that verifies duplicate-cherry-pick commits and file-overlap losses after any squash merge into main, restoring the post-squash audit step to the merge workflow with test coverage for both the audit logic and the broader merger lifecycle. Fusion-Task-Id: FN-4067 Fusion-Task-Lineage: 593aa917-5640-495b-b1ae-80c3eaa09d01
This commit is contained in:
@@ -133,7 +133,7 @@ describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
|
||||
"Task marked complete with summary. All steps done. Moving to in-review.",
|
||||
);
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { paused: false, status: null });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
});
|
||||
|
||||
|
||||
@@ -121,6 +121,23 @@ vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../merger-squash-audit.js", () => ({
|
||||
auditSquashMerge: vi.fn(async () => ({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
@@ -149,11 +166,13 @@ import {
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { auditSquashMerge } from "../merger-squash-audit.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedAuditSquashMerge = vi.mocked(auditSquashMerge);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
@@ -2116,4 +2135,167 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
|
||||
|
||||
// ── New Smart Conflict Resolution API Tests ────────────────────────────
|
||||
|
||||
describe("aiMergeTask post-squash audit gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
});
|
||||
|
||||
function setupAutoResolvedMergeExecSync() {
|
||||
let squashCalls = 0;
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("rev-list --count")) return "1\n" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
|
||||
squashCalls += 1;
|
||||
if (squashCalls === 2) {
|
||||
const error = new Error("merge conflict");
|
||||
(error as Error & { stdout?: string; stderr?: string }).stdout = "";
|
||||
(error as Error & { stdout?: string; stderr?: string }).stderr = "CONFLICT";
|
||||
throw error;
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "pnpm-lock.yaml\n" as any;
|
||||
if (cmdStr.includes("checkout --ours --") || cmdStr.includes("git add -- pnpm-lock.yaml")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("git commit ")) return Buffer.from("");
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
function createAuditStore() {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "pnpm test",
|
||||
mergeConflictStrategy: "ai-only",
|
||||
worktreeRebaseBeforeMerge: false,
|
||||
worktreeRebaseLocalBase: false,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
it("moves the task to done when the post-squash audit is clean", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-squash audit clean", "text", undefined, "merger");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("blocks completion and logs duplicate-subject findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject"],
|
||||
recentMainSubjects: ["feat: duplicate subject"],
|
||||
duplicateSubjects: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
touchedFiles: ["src/example.ts"],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
issueCount: 1,
|
||||
clean: false,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"FN-050: post-squash audit blocked auto-completion for mergedco",
|
||||
);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-squash audit blocked auto-completion"),
|
||||
"tool_error",
|
||||
expect.stringContaining("Duplicate-subject risks:\n- feat: duplicate subject"),
|
||||
"merger",
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
|
||||
it("blocks completion and logs touched-file-overlap findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: branch change"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: ["src/shared.ts"],
|
||||
touchedFileOverlaps: [{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
}],
|
||||
findings: [{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
}],
|
||||
issueCount: 1,
|
||||
clean: false,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"FN-050: post-squash audit blocked auto-completion for mergedco",
|
||||
);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-squash audit blocked auto-completion"),
|
||||
"tool_error",
|
||||
expect.stringContaining("Touched-file overlap risks:\n- src/shared.ts\n - abc1234 fix: recent main change"),
|
||||
"merger",
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
126
packages/engine/src/__tests__/merger-squash-audit.test.ts
Normal file
126
packages/engine/src/__tests__/merger-squash-audit.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { auditSquashMerge } from "../merger-squash-audit.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function write(repo: string, relativePath: string, content: string): void {
|
||||
writeFileSync(join(repo, relativePath), content, "utf-8");
|
||||
}
|
||||
|
||||
describeIfGit("auditSquashMerge", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-squash-audit-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
write(repo, "README.md", "init\n");
|
||||
git(repo, "git add README.md && git commit -m 'init'");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function createSquashCommit(repo: string, bodyLines: string[]): string {
|
||||
const body = bodyLines.map((line) => `- ${line}`).join("\n");
|
||||
git(repo, `git commit -m ${JSON.stringify("feat: squash merge")} -m ${JSON.stringify(body)}`);
|
||||
return git(repo, "git rev-parse HEAD");
|
||||
}
|
||||
|
||||
it("returns a clean result when no duplicate subjects or recent-main file overlaps exist", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
git(repo, "git checkout -b feature/clean");
|
||||
write(repo, "feature.txt", "branch-only\n");
|
||||
git(repo, "git add feature.txt && git commit -m 'feat: branch clean change'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "main-only.txt", "recent main\n");
|
||||
git(repo, "git add main-only.txt && git commit -m 'chore: recent main touch'");
|
||||
|
||||
git(repo, "git merge --squash feature/clean");
|
||||
const squashSha = createSquashCommit(repo, ["feat: branch clean change"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(true);
|
||||
expect(findings.issueCount).toBe(0);
|
||||
expect(findings.duplicateSubjects).toEqual([]);
|
||||
expect(findings.touchedFileOverlaps).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports duplicate branch subjects that match recent main commits", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
git(repo, "git checkout -b feature/dupe");
|
||||
write(repo, "branch.txt", "feature\n");
|
||||
git(repo, "git add branch.txt && git commit -m 'feat: duplicate subject'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "main.txt", "main\n");
|
||||
git(repo, "git add main.txt && git commit -m 'feat: duplicate subject'");
|
||||
|
||||
git(repo, "git merge --squash feature/dupe");
|
||||
const squashSha = createSquashCommit(repo, ["feat: duplicate subject"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.duplicateSubjects).toEqual([
|
||||
{ type: "duplicate-subject", subject: "feat: duplicate subject" },
|
||||
]);
|
||||
expect(findings.issueCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reports touched-file overlaps with recent main commits", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
write(repo, "shared.txt", "alpha\nbeta\ngamma\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'chore: add shared file'");
|
||||
|
||||
git(repo, "git checkout -b feature/overlap");
|
||||
write(repo, "shared.txt", "alpha-branch\nbeta\ngamma\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'feat: branch edits shared file'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "shared.txt", "alpha\nbeta\ngamma-main\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'fix: main edits shared file'");
|
||||
|
||||
git(repo, "git merge --squash feature/overlap");
|
||||
const squashSha = createSquashCommit(repo, ["feat: branch edits shared file"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.duplicateSubjects).toEqual([]);
|
||||
expect(findings.touchedFileOverlaps).toHaveLength(1);
|
||||
expect(findings.touchedFileOverlaps[0]).toMatchObject({
|
||||
type: "touched-file-overlap",
|
||||
file: "shared.txt",
|
||||
});
|
||||
expect(findings.touchedFileOverlaps[0].recentMainCommits).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
sha: expect.any(String),
|
||||
subject: "fix: main edits shared file",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(findings.issueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,15 @@ export {
|
||||
type MergerOptions,
|
||||
type AutostashOrphanRecord,
|
||||
} from "./merger.js";
|
||||
export {
|
||||
auditSquashMerge,
|
||||
formatSquashAuditReport,
|
||||
type SquashAuditFindings,
|
||||
type SquashAuditFinding,
|
||||
type SquashAuditDuplicateSubjectFinding,
|
||||
type SquashAuditTouchedFileOverlapFinding,
|
||||
type SquashAuditRecentMainCommit,
|
||||
} from "./merger-squash-audit.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
|
||||
|
||||
187
packages/engine/src/merger-squash-audit.ts
Normal file
187
packages/engine/src/merger-squash-audit.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_LOOKBACK = 30;
|
||||
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export interface SquashAuditRecentMainCommit {
|
||||
sha: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface SquashAuditDuplicateSubjectFinding {
|
||||
type: "duplicate-subject";
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface SquashAuditTouchedFileOverlapFinding {
|
||||
type: "touched-file-overlap";
|
||||
file: string;
|
||||
recentMainCommits: SquashAuditRecentMainCommit[];
|
||||
}
|
||||
|
||||
export type SquashAuditFinding = SquashAuditDuplicateSubjectFinding | SquashAuditTouchedFileOverlapFinding;
|
||||
|
||||
export interface SquashAuditFindings {
|
||||
squashSha: string;
|
||||
parentSha: string;
|
||||
squashSubject: string;
|
||||
lookback: number;
|
||||
branchSubjects: string[];
|
||||
recentMainSubjects: string[];
|
||||
duplicateSubjects: SquashAuditDuplicateSubjectFinding[];
|
||||
touchedFiles: string[];
|
||||
touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[];
|
||||
findings: SquashAuditFinding[];
|
||||
issueCount: number;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export async function auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha,
|
||||
lookback = DEFAULT_LOOKBACK,
|
||||
}: {
|
||||
rootDir: string;
|
||||
squashSha: string;
|
||||
lookback?: number;
|
||||
}): Promise<SquashAuditFindings> {
|
||||
const normalizedLookback = normalizeLookback(lookback);
|
||||
const parentSha = await git(rootDir, ["rev-parse", `${squashSha}^`]);
|
||||
const squashSubject = await git(rootDir, ["log", "-1", "--format=%s", squashSha]);
|
||||
const branchSubjects = normalizeLines(await git(rootDir, ["log", "-1", "--format=%b", squashSha]))
|
||||
.map((line) => line.replace(/^- /, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const recentMainCommits = await listRecentMainCommits(rootDir, parentSha, normalizedLookback);
|
||||
const recentMainSubjects = recentMainCommits.map((entry) => entry.subject);
|
||||
|
||||
const duplicateSubjects = branchSubjects
|
||||
.filter((subject) => recentMainSubjects.includes(subject))
|
||||
.map((subject) => ({ type: "duplicate-subject", subject }) satisfies SquashAuditDuplicateSubjectFinding);
|
||||
|
||||
const touchedFiles = normalizeLines(await git(rootDir, ["diff", "--name-only", parentSha, squashSha]));
|
||||
const touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[] = [];
|
||||
|
||||
for (const file of touchedFiles) {
|
||||
const overlappingCommits: SquashAuditRecentMainCommit[] = [];
|
||||
for (const commit of recentMainCommits) {
|
||||
const touchedInCommit = await git(rootDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha, "--", file]);
|
||||
if (normalizeLines(touchedInCommit).includes(file)) {
|
||||
overlappingCommits.push({ sha: commit.shortSha, subject: commit.subject });
|
||||
}
|
||||
}
|
||||
|
||||
if (overlappingCommits.length > 0) {
|
||||
touchedFileOverlaps.push({
|
||||
type: "touched-file-overlap",
|
||||
file,
|
||||
recentMainCommits: overlappingCommits,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
|
||||
|
||||
return {
|
||||
squashSha,
|
||||
parentSha,
|
||||
squashSubject,
|
||||
lookback: normalizedLookback,
|
||||
branchSubjects,
|
||||
recentMainSubjects,
|
||||
duplicateSubjects,
|
||||
touchedFiles,
|
||||
touchedFileOverlaps,
|
||||
findings,
|
||||
issueCount: findings.length,
|
||||
clean: findings.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
|
||||
const lines: string[] = [
|
||||
`Auditing squash: ${findings.squashSha} — ${findings.squashSubject}`,
|
||||
`Parent (main before squash): ${findings.parentSha}`,
|
||||
`Lookback window on main: ${findings.lookback} commits`,
|
||||
"",
|
||||
"=== Duplicate-cherry-pick risk ===",
|
||||
];
|
||||
|
||||
if (findings.duplicateSubjects.length === 0) {
|
||||
lines.push("(none — no branch commit subjects match recent main commits)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"WARN: branch contains commits whose subjects match recent main commits.",
|
||||
"Auto-resolve may have picked the older side, dropping refinements.",
|
||||
"Action: diff each main commit below against HEAD and confirm its",
|
||||
"net contribution survived. Restore anything dropped as a follow-up.",
|
||||
"",
|
||||
...findings.duplicateSubjects.map((entry) => ` - ${entry.subject}`),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`=== Touched-file overlap (${findings.touchedFiles.length} files in squash) ===`);
|
||||
if (findings.touchedFileOverlaps.length === 0) {
|
||||
lines.push("(none — squash touches files no recent main commit touched)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"Files the squash touched that also have recent main activity.",
|
||||
"Action: for each commit below, verify its changes still appear",
|
||||
"in HEAD. Reapply any silently dropped changes on the same branch.",
|
||||
"",
|
||||
);
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(` ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Audit complete. ${findings.issueCount} item(s) for the calling agent to review.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function git(rootDir: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function normalizeLines(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
return trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function listRecentMainCommits(rootDir: string, parentSha: string, lookback: number): Promise<Array<{ sha: string; shortSha: string; subject: string }>> {
|
||||
const entries = normalizeLines(await git(rootDir, ["log", `--format=%H~%h~%s`, `-n`, String(lookback), parentSha]));
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const [sha, shortSha, ...subjectParts] = entry.split("~");
|
||||
const subject = subjectParts.join("~").trim();
|
||||
if (!sha?.trim() || !shortSha?.trim() || !subject) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sha: sha.trim(),
|
||||
shortSha: shortSha.trim(),
|
||||
subject,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { sha: string; shortSha: string; subject: string } => entry !== null);
|
||||
}
|
||||
|
||||
function normalizeLookback(value: number | undefined): number {
|
||||
if (!Number.isFinite(value) || !value || value < 1) {
|
||||
return DEFAULT_LOOKBACK;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
||||
import { createWebFetchTool } from "./agent-tools.js";
|
||||
import { auditSquashMerge, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||
|
||||
/** Conflict type classification for merge conflict resolution */
|
||||
export type ConflictType =
|
||||
@@ -475,6 +476,17 @@ export class MergeAbortedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SquashAuditError extends Error {
|
||||
constructor(
|
||||
taskId: string,
|
||||
public readonly squashSha: string,
|
||||
public readonly findings: SquashAuditFindings,
|
||||
) {
|
||||
super(buildSquashAuditBlockingMessage(taskId, squashSha, findings));
|
||||
this.name = "SquashAuditError";
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
|
||||
if (!signal?.aborted) return;
|
||||
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
|
||||
@@ -4143,6 +4155,46 @@ function quoteArg(value: string): string {
|
||||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
function shouldRunPostSquashAudit(result: MergeResult, mergeWasEmpty: boolean, isEmptyCommit: boolean, commitSha?: string): boolean {
|
||||
if (mergeWasEmpty || isEmptyCommit || !commitSha) {
|
||||
return false;
|
||||
}
|
||||
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
||||
}
|
||||
|
||||
function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, findings: SquashAuditFindings): string {
|
||||
const riskParts: string[] = [];
|
||||
if (findings.duplicateSubjects.length > 0) {
|
||||
riskParts.push(`${findings.duplicateSubjects.length} duplicate-subject risk${findings.duplicateSubjects.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (findings.touchedFileOverlaps.length > 0) {
|
||||
riskParts.push(`${findings.touchedFileOverlaps.length} touched-file overlap risk${findings.touchedFileOverlaps.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
const summary = riskParts.length > 0 ? riskParts.join(", ") : `${findings.issueCount} audit finding(s)`;
|
||||
return `${taskId}: post-squash audit blocked auto-completion for ${squashSha.slice(0, 8)} (${summary})`;
|
||||
}
|
||||
|
||||
function formatSquashAuditAgentLog(findings: SquashAuditFindings): string {
|
||||
const lines: string[] = [];
|
||||
if (findings.duplicateSubjects.length > 0) {
|
||||
lines.push("Duplicate-subject risks:");
|
||||
for (const duplicate of findings.duplicateSubjects) {
|
||||
lines.push(`- ${duplicate.subject}`);
|
||||
}
|
||||
}
|
||||
if (findings.touchedFileOverlaps.length > 0) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push("Touched-file overlap risks:");
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(`- ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a non-empty commit body for fallback merge commits. Used by sites
|
||||
* that would otherwise emit `-m ""` when the branch's commit log is empty
|
||||
@@ -6065,6 +6117,27 @@ export async function aiMergeTask(
|
||||
// attemptWithSideStrategy return true without committing when nothing
|
||||
// was staged. The recorded HEAD then has nothing to do with this task.
|
||||
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
||||
|
||||
const auditSha = recordedSha;
|
||||
if (auditSha && shouldRunPostSquashAudit(result, mergeWasEmpty, isEmptyCommit, auditSha)) {
|
||||
const auditFindings = await auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha: auditSha,
|
||||
});
|
||||
if (!auditFindings.clean) {
|
||||
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
|
||||
await store.appendAgentLog(
|
||||
taskId,
|
||||
auditError.message,
|
||||
"tool_error",
|
||||
formatSquashAuditAgentLog(auditFindings),
|
||||
"merger",
|
||||
);
|
||||
await store.updateTask(taskId, { status: null });
|
||||
throw auditError;
|
||||
}
|
||||
await store.appendAgentLog(taskId, "post-squash audit clean", "text", undefined, "merger");
|
||||
}
|
||||
if (isEmptyCommit) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: local squash produced an empty commit (${commitSha?.slice(0, 8)}) — branch likely contained dupes of main. Skipping commitSha; recovery will backfill when real commit lands.`,
|
||||
@@ -6138,6 +6211,9 @@ export async function aiMergeTask(
|
||||
"merger",
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err instanceof SquashAuditError || err?.name === "SquashAuditError") {
|
||||
throw err;
|
||||
}
|
||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user