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:
5
.changeset/fn-4067-post-squash-audit.md
Normal file
5
.changeset/fn-4067-post-squash-audit.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Block auto-resolved squash merge completion when the post-squash audit flags duplicate-subject or touched-file overlap risks, while keeping the audit script available for manual follow-up.
|
||||||
@@ -219,13 +219,15 @@ Two rules, learned the hard way (FN-2370 silently reverted three commits' work):
|
|||||||
|
|
||||||
2. **Prefer rebase-and-merge over squash for branches spanning multiple feature commits.** Squash collapses authorship and makes per-commit reverts impossible. Rebase-and-merge preserves the commit boundary so a regression can be reverted cleanly without losing the rest of the branch.
|
2. **Prefer rebase-and-merge over squash for branches spanning multiple feature commits.** Squash collapses authorship and makes per-commit reverts impossible. Rebase-and-merge preserves the commit boundary so a regression can be reverted cleanly without losing the rest of the branch.
|
||||||
|
|
||||||
After any squash that auto-resolved conflicts, the merging agent MUST run:
|
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`.
|
||||||
|
|
||||||
|
For manual follow-up, standalone auditing, or post-incident inspection, the script remains available:
|
||||||
|
|
||||||
```
|
```
|
||||||
node scripts/audit-squash-merge.mjs <squash-sha>
|
node scripts/audit-squash-merge.mjs <squash-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent then reviews each flagged item itself (no human handoff): for every duplicate-cherry-pick subject, diff the matching main commit against HEAD and confirm its net contribution survived; for every touched-file overlap, confirm the recent main commits' changes still appear in HEAD. If anything was silently dropped, restore it as a follow-up commit on the same branch before reporting the merge complete. Only if the audit is clean (or all losses have been restored) is the merge done.
|
Review every flagged item yourself (no human handoff): for every duplicate-cherry-pick subject, diff the matching main commit against HEAD and confirm its net contribution survived; for every touched-file overlap, confirm the recent main commits' changes still appear in HEAD. If anything was silently dropped, restore it as a follow-up commit on the same branch before reporting the merge complete. Only if the audit is clean (or all losses have been restored) is the merge done.
|
||||||
|
|
||||||
## Node Dashboard
|
## Node Dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -663,6 +663,67 @@ describe("TaskStore", () => {
|
|||||||
expect(respec.recoveryRetryCount).toBeUndefined();
|
expect(respec.recoveryRetryCount).toBeUndefined();
|
||||||
expect(respec.nextRecoveryAt).toBeUndefined();
|
expect(respec.nextRecoveryAt).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FN-3964 regression: reopen transitions must clear paused state
|
||||||
|
it("clears paused and pausedByAgentId when reopening in-progress+paused task to todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "test reopen clears paused" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a paused task (e.g., from a stuck/retry flow)
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
paused: true,
|
||||||
|
pausedByAgentId: "agent-test-001",
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.column).toBe("todo");
|
||||||
|
expect(moved.paused).toBeUndefined();
|
||||||
|
expect(moved.pausedByAgentId).toBeUndefined();
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears paused and pausedByAgentId when reopening done+paused task to triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "test reopen done to triage clears paused" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
// Simulate stale paused state on a done task
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
paused: true,
|
||||||
|
pausedByAgentId: "agent-test-002",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reopened = await store.moveTask(task.id, "triage");
|
||||||
|
expect(reopened.column).toBe("triage");
|
||||||
|
expect(reopened.paused).toBeUndefined();
|
||||||
|
expect(reopened.pausedByAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears paused when reopening in-review task to todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "test reopen in-review to todo clears paused" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
// Simulate paused state during review
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
paused: true,
|
||||||
|
pausedByAgentId: "agent-test-003",
|
||||||
|
status: "failed",
|
||||||
|
error: "Review failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const retried = await store.moveTask(task.id, "todo");
|
||||||
|
expect(retried.column).toBe("todo");
|
||||||
|
expect(retried.paused).toBeUndefined();
|
||||||
|
expect(retried.pausedByAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
|
|||||||
"Task marked complete with summary. All steps done. Moving to in-review.",
|
"Task marked complete with summary. All steps done. Moving to in-review.",
|
||||||
);
|
);
|
||||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
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");
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ vi.mock("../context-limit-detector.js", () => ({
|
|||||||
isContextLimitError: vi.fn(),
|
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 {
|
import {
|
||||||
aiMergeTask,
|
aiMergeTask,
|
||||||
pushToRemoteAfterMerge,
|
pushToRemoteAfterMerge,
|
||||||
@@ -149,11 +166,13 @@ import {
|
|||||||
} from "../merger.js";
|
} from "../merger.js";
|
||||||
import { mergerLog } from "../logger.js";
|
import { mergerLog } from "../logger.js";
|
||||||
import { createFnAgent } from "../pi.js";
|
import { createFnAgent } from "../pi.js";
|
||||||
|
import { auditSquashMerge } from "../merger-squash-audit.js";
|
||||||
import { execSync, exec } from "node:child_process";
|
import { execSync, exec } from "node:child_process";
|
||||||
import * as core from "@fusion/core";
|
import * as core from "@fusion/core";
|
||||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||||
|
|
||||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||||
|
const mockedAuditSquashMerge = vi.mocked(auditSquashMerge);
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
const mockedExec = vi.mocked(exec);
|
const mockedExec = vi.mocked(exec);
|
||||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
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 ────────────────────────────
|
// ── 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 MergerOptions,
|
||||||
type AutostashOrphanRecord,
|
type AutostashOrphanRecord,
|
||||||
} from "./merger.js";
|
} 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 { 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";
|
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 { Type } from "typebox";
|
||||||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
||||||
import { createWebFetchTool } from "./agent-tools.js";
|
import { createWebFetchTool } from "./agent-tools.js";
|
||||||
|
import { auditSquashMerge, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||||
|
|
||||||
/** Conflict type classification for merge conflict resolution */
|
/** Conflict type classification for merge conflict resolution */
|
||||||
export type ConflictType =
|
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 {
|
export function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
|
||||||
if (!signal?.aborted) return;
|
if (!signal?.aborted) return;
|
||||||
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
|
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
|
||||||
@@ -4143,6 +4155,46 @@ function quoteArg(value: string): string {
|
|||||||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
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
|
* 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
|
* 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
|
// attemptWithSideStrategy return true without committing when nothing
|
||||||
// was staged. The recorded HEAD then has nothing to do with this task.
|
// was staged. The recorded HEAD then has nothing to do with this task.
|
||||||
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
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) {
|
if (isEmptyCommit) {
|
||||||
mergerLog.warn(
|
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.`,
|
`${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",
|
"merger",
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof SquashAuditError || err?.name === "SquashAuditError") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
// Example:
|
// Example:
|
||||||
// node scripts/audit-squash-merge.mjs 7c1a1c36c
|
// node scripts/audit-squash-merge.mjs 7c1a1c36c
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { tsImport } from "tsx/esm/api";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const squashSha = args.find((a) => !a.startsWith("--"));
|
const squashSha = args.find((a) => !a.startsWith("--"));
|
||||||
@@ -36,74 +36,18 @@ if (!squashSha) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sh = (cmd) => execSync(cmd, { encoding: "utf8" }).trim();
|
const moduleUrl = new globalThis.URL("../packages/engine/src/merger-squash-audit.ts", import.meta.url).href;
|
||||||
|
const { auditSquashMerge, formatSquashAuditReport } = await tsImport(moduleUrl, import.meta.url);
|
||||||
|
|
||||||
const parent = sh(`git rev-parse ${squashSha}^`);
|
try {
|
||||||
const subject = sh(`git log -1 --format=%s ${squashSha}`);
|
const findings = await auditSquashMerge({
|
||||||
const branchSubjects = sh(`git log -1 --format=%b ${squashSha}`)
|
rootDir: process.cwd(),
|
||||||
.split("\n")
|
squashSha,
|
||||||
.map((l) => l.replace(/^- /, "").trim())
|
lookback,
|
||||||
.filter(Boolean);
|
});
|
||||||
|
console.log(formatSquashAuditReport(findings));
|
||||||
console.log(`Auditing squash: ${squashSha} — ${subject}`);
|
process.exit(findings.clean ? 0 : 1);
|
||||||
console.log(`Parent (main before squash): ${parent}`);
|
} catch (error) {
|
||||||
console.log(`Lookback window on main: ${lookback} commits\n`);
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
// --- 1. Duplicate-cherry-pick detection ---
|
|
||||||
const recentMainSubjects = sh(
|
|
||||||
`git log --format=%s ${parent}~${lookback}..${parent}`,
|
|
||||||
).split("\n");
|
|
||||||
|
|
||||||
const dupes = branchSubjects.filter((s) => recentMainSubjects.includes(s));
|
|
||||||
|
|
||||||
console.log("=== Duplicate-cherry-pick risk ===");
|
|
||||||
if (dupes.length === 0) {
|
|
||||||
console.log("(none — no branch commit subjects match recent main commits)\n");
|
|
||||||
} else {
|
|
||||||
console.log("WARN: branch contains commits whose subjects match recent main commits.");
|
|
||||||
console.log("Auto-resolve may have picked the older side, dropping refinements.");
|
|
||||||
console.log("Action: diff each main commit below against HEAD and confirm its");
|
|
||||||
console.log("net contribution survived. Restore anything dropped as a follow-up.\n");
|
|
||||||
for (const s of dupes) {
|
|
||||||
console.log(` - ${s}`);
|
|
||||||
}
|
|
||||||
console.log();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Touched-file overlap ---
|
|
||||||
const touched = sh(`git diff --name-only ${parent} ${squashSha}`)
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
console.log(`=== Touched-file overlap (${touched.length} files in squash) ===`);
|
|
||||||
const overlaps = [];
|
|
||||||
for (const file of touched) {
|
|
||||||
const recent = sh(
|
|
||||||
`git log --format=%h~%s ${parent}~${lookback}..${parent} -- ${JSON.stringify(file)}`,
|
|
||||||
)
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean);
|
|
||||||
if (recent.length > 0) {
|
|
||||||
overlaps.push({ file, recent });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlaps.length === 0) {
|
|
||||||
console.log("(none — squash touches files no recent main commit touched)\n");
|
|
||||||
} else {
|
|
||||||
console.log("Files the squash touched that also have recent main activity.");
|
|
||||||
console.log("Action: for each commit below, verify its changes still appear");
|
|
||||||
console.log("in HEAD. Reapply any silently dropped changes on the same branch.\n");
|
|
||||||
for (const { file, recent } of overlaps) {
|
|
||||||
console.log(` ${file}`);
|
|
||||||
for (const entry of recent) {
|
|
||||||
const [sha, ...subj] = entry.split("~");
|
|
||||||
console.log(` - ${sha} ${subj.join("~")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
const issues = dupes.length + overlaps.length;
|
|
||||||
console.log(`Audit complete. ${issues} item(s) for the calling agent to review.`);
|
|
||||||
process.exit(issues === 0 ? 0 : 1);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user