feat(FN-4069): add direct merge commit routing to merger

Adds direct merge commit routing to the merger, allowing non-squash merges to bypass the squash-audit path when configured. The feature includes new `mergeCommitStrategy` settings, updated dashboard UI, expanded merger lifecycle tests, and documentation.

Fusion-Task-Id: FN-4069
This commit is contained in:
Fusion
2026-05-12 15:50:22 -07:00
committed by gsxdsm
parent dfb613e2f1
commit be5e1fbd97
17 changed files with 899 additions and 135 deletions

View File

@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it, vi } 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 type { Settings, Task, TaskStore } from "@fusion/core";
type TaskWithPromptOverride = Partial<Task> & Pick<Task, "id"> & { prompt?: string };
import { DEFAULT_SETTINGS } from "@fusion/core";
import { aiMergeTask } from "../merger.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 makeTask(overrides: TaskWithPromptOverride): Task {
const { id, ...rest } = overrides;
return {
...rest,
id,
title: overrides.title ?? id,
description: overrides.description ?? id,
column: overrides.column ?? "in-review",
dependencies: overrides.dependencies ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
log: overrides.log ?? [],
createdAt: overrides.createdAt ?? new Date().toISOString(),
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
} as Task;
}
function createStore(task: Task, settings: Partial<Settings>): TaskStore {
let currentTask = { ...task };
const mergedSettings: Settings = {
...DEFAULT_SETTINGS,
mergeStrategy: "direct",
directMergeCommitStrategy: "auto",
autoMerge: true,
includeTaskIdInCommit: false,
commitAuthorEnabled: false,
useAiMergeCommitSummary: false,
...settings,
} as Settings;
return {
getTask: vi.fn(async () => currentTask),
getSettings: vi.fn(async () => mergedSettings),
listTasks: vi.fn(async () => [currentTask]),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
currentTask = { ...currentTask, ...updates, updatedAt: new Date().toISOString() } as Task;
return currentTask;
}),
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
currentTask = {
...currentTask,
column,
columnMovedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
return currentTask;
}),
logEntry: vi.fn(async () => undefined),
appendAgentLog: vi.fn(async () => undefined),
updateSettings: vi.fn(async () => mergedSettings),
getActiveMergingTask: vi.fn(() => null),
emit: vi.fn(),
on: vi.fn(),
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
getVerificationCacheHit: vi.fn(() => null),
recordVerificationCachePass: vi.fn(() => undefined),
upsertTaskCommitAssociation: vi.fn(async () => undefined),
} as unknown as TaskStore;
}
describeIfGit("aiMergeTask direct merge commit routing (real git)", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true });
}
});
function setupRepo(): { repo: string; initSha: string } {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-commit-strategy-"));
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"');
writeFileSync(join(repo, "README.md"), "init\n", "utf-8");
git(repo, "git add README.md && git commit -m 'chore: init'");
return { repo, initSha: git(repo, "git rev-parse HEAD") };
}
it("auto-routes multi-substantive branches to history-preserving direct merge", async () => {
const { repo, initSha } = setupRepo();
const branch = "fusion/fn-4069-test";
git(repo, `git checkout -b ${branch}`);
writeFileSync(join(repo, "src-fix.ts"), "export const fix = 1;\n", "utf-8");
git(repo, "git add src-fix.ts && git commit -m 'fix: preserve original bugfix'");
writeFileSync(join(repo, ".changeset-fn-4069.md"), "noop\n", "utf-8");
git(repo, "mkdir -p .changeset && mv .changeset-fn-4069.md .changeset/fn-4069.md && git add .changeset/fn-4069.md && git commit -m 'chore: add changeset'");
writeFileSync(join(repo, "src-style.css"), ".root { display: block; }\n", "utf-8");
git(repo, "git add src-style.css && git commit -m 'feat: preserve follow-up polish'");
git(repo, "git checkout main");
const task = makeTask({
id: "FN-4069",
branch,
baseBranch: "main",
column: "in-review",
prompt: "# Task\n",
});
const store = createStore(task, {});
await aiMergeTask(store, repo, "FN-4069");
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
const subjects = git(repo, `git log --reverse --format=%s ${initSha}..HEAD`).split("\n");
expect(subjects).toEqual([
"fix: preserve original bugfix",
"chore: add changeset",
"feat: preserve follow-up polish",
]);
const landedShas = git(repo, `git rev-list --reverse ${initSha}..HEAD`).split("\n");
expect(landedShas).toHaveLength(3);
for (const sha of landedShas) {
const body = git(repo, `git log -1 --format=%B ${sha}`);
expect(body).toContain("Fusion-Task-Id: FN-4069");
}
}, 20_000);
});

View File

@@ -124,8 +124,10 @@ vi.mock("../context-limit-detector.js", () => ({
vi.mock("../merger-squash-audit.js", () => ({
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS: 30,
auditSquashMerge: vi.fn(async () => ({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: [],
@@ -2349,8 +2351,10 @@ describe("aiMergeTask post-squash audit gate", () => {
},
} as any);
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: [],
@@ -2396,10 +2400,13 @@ describe("aiMergeTask post-squash audit gate", () => {
});
}
function createAuditStore(overrides: Partial<typeof DEFAULT_SETTINGS> = {}) {
function createAuditStore(
overrides: Partial<typeof DEFAULT_SETTINGS> = {},
taskOverrides: Partial<Task> & { prompt?: string } = {},
) {
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],
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", ...taskOverrides } as Task & { prompt?: string },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review", ...taskOverrides } as Task & { prompt?: string }],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
@@ -2486,6 +2493,41 @@ describe("aiMergeTask post-squash audit gate", () => {
});
}
function setupRebaseRouteExecSync() {
let headIndex = 0;
const landedHeads = ["landedcommit001", "landedcommit002"];
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 (headIndex > 0 ? landedHeads[Math.min(headIndex - 1, landedHeads.length - 1)] : "basehead123") as any;
}
if (cmdStr.includes('git rev-parse "main"') || cmdStr.includes("git rev-parse main")) return "basehead123" as any;
if (cmdStr.includes('git rev-list --count "main..fusion/fn-050"')) return "2\n" as any;
if (cmdStr.includes('git rev-list --reverse "main..fusion/fn-050"') || cmdStr.includes('git rev-list --reverse "basehead123..fusion/fn-050"')) {
return "commit-a\ncommit-b\ncommit-c\n" as any;
}
if (cmdStr.includes('git log -1 --format=%s "commit-a"')) return "fix: substantive one" as any;
if (cmdStr.includes('git log -1 --format=%s "commit-b"')) return "chore: changeset only" as any;
if (cmdStr.includes('git log -1 --format=%s "commit-c"')) return "feat: substantive two" as any;
if (cmdStr.includes('git log "main..fusion/fn-050" --format="- %s"')) return "- fix: substantive one\n- chore: changeset only\n- feat: substantive two" as any;
if (cmdStr.includes("git log -1 --pretty=%B")) return "commit body" as any;
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-a"')) return "M\tsrc/feature-a.ts\n" as any;
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-b"')) return "A\t.changeset/fn-050.md\n" as any;
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-c"')) return "M\tsrc/feature-b.ts\n" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
if (cmdStr.includes('git cherry-pick "commit-a"') || cmdStr.includes('git cherry-pick "commit-b"') || cmdStr.includes('git cherry-pick "commit-c"')) {
headIndex += 1;
return Buffer.from("");
}
if (cmdStr.includes("git -c trailer.ifExists=addIfDifferent commit --amend --no-edit")) return Buffer.from("");
if (cmdStr.includes('git diff --shortstat "basehead123..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D") || cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
it("moves the task to done when the post-squash audit is clean", async () => {
setupAutoResolvedMergeExecSync();
const store = createAuditStore();
@@ -2495,17 +2537,107 @@ describe("aiMergeTask post-squash audit gate", () => {
expect(result.merged).toBe(true);
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "squash",
squashSha: "mergedcommit123",
});
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-squash audit clean", "text", undefined, "merger");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("routes multi-substantive auto branches through rebase range audit", async () => {
setupRebaseRouteExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "rebase",
rangeBaseSha: "basehead123",
rangeHeadSha: "landedcommit002",
parentSha: "basehead123",
auditTargetLabel: "basehead123..landedcommit002",
lookback: 30,
branchSubjects: ["fix: substantive one", "feat: substantive two"],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: ["src/feature-a.ts", "src/feature-b.ts"],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
});
const store = createAuditStore();
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "rebase",
rangeBaseSha: "basehead123",
rangeHeadSha: "landedcommit002",
});
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-rebase range audit clean", "text", undefined, "merger");
});
it("honors the per-task always-rebase override", async () => {
setupRebaseRouteExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "rebase",
rangeBaseSha: "basehead123",
rangeHeadSha: "landedcommit002",
parentSha: "basehead123",
auditTargetLabel: "basehead123..landedcommit002",
lookback: 30,
branchSubjects: ["fix: substantive one", "feat: substantive two"],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: [],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
});
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "rebase",
rangeBaseSha: "basehead123",
rangeHeadSha: "landedcommit002",
});
});
it("keeps the squash path for single-substantive branches in auto mode", async () => {
setupAutoResolvedMergeExecSync();
const store = createAuditStore();
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "squash",
squashSha: "mergedcommit123",
});
});
it("honors the per-task always-squash override", async () => {
setupAutoResolvedMergeExecSync();
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-squash" });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "squash",
squashSha: "mergedcommit123",
});
});
it("blocks completion and logs duplicate-subject findings", async () => {
setupAutoResolvedMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: duplicate subject"],
@@ -2537,8 +2669,10 @@ describe("aiMergeTask post-squash audit gate", () => {
it("blocks completion and logs touched-file-overlap findings", async () => {
setupAutoResolvedMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: branch change"],
@@ -2578,8 +2712,10 @@ describe("aiMergeTask post-squash audit gate", () => {
it("blocks completion and logs combined duplicate-subject and touched-file findings", async () => {
setupAutoResolvedMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: duplicate subject", "feat: branch change"],
@@ -2634,8 +2770,10 @@ describe("aiMergeTask post-squash audit gate", () => {
it("runs the post-squash audit after the attempt 3 -X ours fallback path", async () => {
setupAttempt3FallbackMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "mergedcommit123",
parentSha: "parent123",
auditTargetLabel: "mergedcommit123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: duplicate subject"],
@@ -2662,6 +2800,7 @@ describe("aiMergeTask post-squash audit gate", () => {
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
strategy: "squash",
squashSha: "mergedcommit123",
});
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });

View File

@@ -23,10 +23,34 @@ export interface SquashAuditTouchedFileOverlapFinding {
export type SquashAuditFinding = SquashAuditDuplicateSubjectFinding | SquashAuditTouchedFileOverlapFinding;
export interface SquashAuditFindings {
export type PostMergeAuditStrategy = "squash" | "rebase";
interface PostMergeAuditBaseInput {
rootDir: string;
lookback?: number;
}
export interface PostSquashAuditInput extends PostMergeAuditBaseInput {
strategy?: "squash";
squashSha: string;
}
export interface PostRebaseAuditInput extends PostMergeAuditBaseInput {
strategy: "rebase";
rangeBaseSha: string;
rangeHeadSha: string;
}
export type PostMergeAuditInput = PostSquashAuditInput | PostRebaseAuditInput;
export interface SquashAuditFindings {
strategy: PostMergeAuditStrategy;
squashSha?: string;
rangeBaseSha?: string;
rangeHeadSha?: string;
parentSha: string;
squashSubject: string;
squashSubject?: string;
auditTargetLabel: string;
lookback: number;
branchSubjects: string[];
recentMainSubjects: string[];
@@ -38,30 +62,137 @@ export interface SquashAuditFindings {
clean: boolean;
}
export async function auditSquashMerge({
rootDir,
squashSha,
lookback = MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
}: {
rootDir: string;
squashSha: string;
lookback?: number;
}): Promise<SquashAuditFindings> {
const normalizedLookback = normalizeMergeOverlapLookback(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]))
/**
* Strategy-aware post-merge audit.
*
* - squash: audit the synthetic squash commit and compare its branch-subject list
* against recent pre-squash main history.
* - rebase: audit the landed commit range base..head and compare those preserved
* commit subjects/files against recent pre-merge main history.
*/
export async function auditSquashMerge(input: PostMergeAuditInput): Promise<SquashAuditFindings> {
const normalizedLookback = normalizeMergeOverlapLookback(input.lookback);
if (input.strategy === "rebase") {
const parentSha = input.rangeBaseSha;
const auditTargetLabel = `${input.rangeBaseSha.slice(0, 8)}..${input.rangeHeadSha.slice(0, 8)}`;
const branchSubjects = normalizeLines(
await git(input.rootDir, ["log", "--format=%s", `${input.rangeBaseSha}..${input.rangeHeadSha}`]),
);
const recentMainCommits = await listRecentMainCommits(input.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(input.rootDir, ["diff", "--name-only", input.rangeBaseSha, input.rangeHeadSha]));
const touchedFileOverlaps = await collectTouchedFileOverlaps(input.rootDir, touchedFiles, recentMainCommits);
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
return {
strategy: "rebase",
rangeBaseSha: input.rangeBaseSha,
rangeHeadSha: input.rangeHeadSha,
parentSha,
auditTargetLabel,
lookback: normalizedLookback,
branchSubjects,
recentMainSubjects,
duplicateSubjects,
touchedFiles,
touchedFileOverlaps,
findings,
issueCount: findings.length,
clean: findings.length === 0,
};
}
const squashSha = input.squashSha;
const parentSha = await git(input.rootDir, ["rev-parse", `${squashSha}^`]);
const squashSubject = await git(input.rootDir, ["log", "-1", "--format=%s", squashSha]);
const branchSubjects = normalizeLines(await git(input.rootDir, ["log", "-1", "--format=%b", squashSha]))
.map((line) => line.replace(/^- /, "").trim())
.filter(Boolean);
const recentMainCommits = await listRecentMainCommits(rootDir, parentSha, normalizedLookback);
const recentMainCommits = await listRecentMainCommits(input.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 touchedFiles = normalizeLines(await git(input.rootDir, ["diff", "--name-only", parentSha, squashSha]));
const touchedFileOverlaps = await collectTouchedFileOverlaps(input.rootDir, touchedFiles, recentMainCommits);
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
return {
strategy: "squash",
squashSha,
parentSha,
squashSubject,
auditTargetLabel: squashSha,
lookback: normalizedLookback,
branchSubjects,
recentMainSubjects,
duplicateSubjects,
touchedFiles,
touchedFileOverlaps,
findings,
issueCount: findings.length,
clean: findings.length === 0,
};
}
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
const heading = findings.strategy === "rebase"
? `Auditing landed range: ${findings.auditTargetLabel}`
: `Auditing squash: ${findings.squashSha}${findings.squashSubject}`;
const parentLabel = findings.strategy === "rebase"
? `Base (main before preserved-commit landing): ${findings.parentSha}`
: `Parent (main before squash): ${findings.parentSha}`;
const lines: string[] = [heading, parentLabel, `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 ${findings.strategy === "rebase" ? "landed range" : "squash"}) ===`);
if (findings.touchedFileOverlaps.length === 0) {
lines.push("(none — merged result touches files no recent main commit touched)", "");
} else {
lines.push(
"Files the merged result 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 collectTouchedFileOverlaps(
rootDir: string,
touchedFiles: string[],
recentMainCommits: Array<{ sha: string; shortSha: string; subject: string }>,
): Promise<SquashAuditTouchedFileOverlapFinding[]> {
const touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[] = [];
for (const file of touchedFiles) {
@@ -82,68 +213,7 @@ export async function auditSquashMerge({
}
}
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");
return touchedFileOverlaps;
}
async function git(rootDir: string, args: string[]): Promise<string> {

View File

@@ -51,6 +51,7 @@ import {
type Settings,
type AgentPromptsConfig,
type CanonicalMergeConflictStrategy,
type DirectMergeCommitStrategy,
type TaskSourceIssue,
type Task,
type AutostashOrphanRecord,
@@ -71,7 +72,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, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, 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";
/** Conflict type classification for merge conflict resolution */
@@ -484,7 +485,7 @@ export class SquashAuditError extends Error {
public readonly squashSha: string,
public readonly findings: SquashAuditFindings,
) {
super(buildSquashAuditBlockingMessage(taskId, squashSha, findings));
super(buildPostMergeAuditBlockingMessage(taskId, findings));
this.name = "SquashAuditError";
}
}
@@ -3957,6 +3958,126 @@ async function ensureTaskTrailersOnHead(rootDir: string, task: Pick<Task, "id">
}
}
async function cherryPickCommitPreservingTaskTrailers(
rootDir: string,
commitSha: string,
task: Pick<Task, "id"> & { lineageId?: string },
mergeConflictStrategy: CanonicalMergeConflictStrategy,
smartConflictResolution: boolean,
result: MergeResult,
): Promise<void> {
try {
await execAsync(`git cherry-pick ${quoteArg(commitSha)}`, { cwd: rootDir });
} catch (error) {
const conflictedFiles = await getConflictedFiles(rootDir);
if (conflictedFiles.length === 0) {
throw error;
}
if (smartConflictResolution) {
let unresolvedComplex = 0;
for (const file of conflictedFiles) {
const type = await classifyConflict(file, rootDir);
if (type === "lockfile-ours") {
await resolveWithOurs(file, rootDir);
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
} else if (type === "generated-theirs") {
await resolveWithTheirs(file, rootDir);
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
} else if (type === "trivial-whitespace") {
await resolveTrivialWhitespace(file, rootDir);
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
} else {
unresolvedComplex += 1;
}
}
if (unresolvedComplex === 0) {
await execAsync("git cherry-pick --continue", { cwd: rootDir });
await ensureTaskTrailersOnHead(rootDir, task);
return;
}
}
try {
await execAsync("git cherry-pick --abort", { cwd: rootDir });
} catch {
// best effort
}
if (mergeConflictStrategy === "smart-prefer-main") {
await execAsync(`git cherry-pick -X ours ${quoteArg(commitSha)}`, { cwd: rootDir });
} else if (mergeConflictStrategy === "smart-prefer-branch") {
await execAsync(`git cherry-pick -X theirs ${quoteArg(commitSha)}`, { cwd: rootDir });
} else {
throw error;
}
}
await ensureTaskTrailersOnHead(rootDir, task);
}
async function applyBranchCommitsPreservingHistory(params: {
rootDir: string;
baseRef: string;
branch: string;
task: Pick<Task, "id"> & { lineageId?: string };
taskId: string;
store: TaskStore;
mergeConflictStrategy: CanonicalMergeConflictStrategy;
smartConflictResolution: boolean;
result: MergeResult;
testCommand?: string;
buildCommand?: string;
testSource?: "explicit" | "inferred";
buildSource?: "explicit" | "inferred";
signal?: AbortSignal;
}): Promise<{ landedCommitCount: number; landedCommitShas: string[]; baseSha: string }> {
const { rootDir, baseRef, branch, task, taskId, store, mergeConflictStrategy, smartConflictResolution, result, testCommand, buildCommand, testSource, buildSource, signal } = params;
const { stdout: baseShaStdout } = await execAsync(`git rev-parse ${quoteArg(baseRef)}`, { cwd: rootDir, encoding: "utf-8" });
const baseSha = baseShaStdout.trim();
const { stdout: commitStdout } = await execAsync(`git rev-list --reverse ${quoteArg(`${baseSha}..${branch}`)}`, {
cwd: rootDir,
encoding: "utf-8",
});
const commitShas = commitStdout.trim().split("\n").map((line) => line.trim()).filter(Boolean);
const landedCommitShas: string[] = [];
for (const commitSha of commitShas) {
throwIfAborted(signal, taskId);
await cherryPickCommitPreservingTaskTrailers(
rootDir,
commitSha,
task,
mergeConflictStrategy,
smartConflictResolution,
result,
);
const { stdout: landedShaOut } = await execAsync("git rev-parse HEAD", { cwd: rootDir, encoding: "utf-8" });
landedCommitShas.push(landedShaOut.trim());
}
if (testCommand || buildCommand) {
throwIfAborted(signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
signal,
);
}
return {
landedCommitCount: landedCommitShas.length,
landedCommitShas,
baseSha,
};
}
/** Build the --author flag for git commits based on project settings. */
function getCommitAuthorArg(settings: {
commitAuthorEnabled?: boolean;
@@ -4157,14 +4278,115 @@ function quoteArg(value: string): string {
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
}
function shouldRunPostSquashAudit(result: MergeResult, mergeWasEmpty: boolean, isEmptyCommit: boolean, commitSha?: string): boolean {
function parseDirectMergeCommitStrategyOverride(prompt: string | undefined): DirectMergeCommitStrategy | undefined {
if (!prompt) return undefined;
const match = prompt.match(/^\*\*Direct Merge Commit Strategy:\*\*\s*(auto|always-squash|always-rebase)\s*$/im);
return match?.[1] as DirectMergeCommitStrategy | undefined;
}
function resolveDirectMergeCommitStrategy(
settings: Pick<Settings, "directMergeCommitStrategy">,
prompt: string | undefined,
): { strategy: DirectMergeCommitStrategy; source: "project" | "prompt" } {
const promptOverride = parseDirectMergeCommitStrategyOverride(prompt);
if (promptOverride) {
return { strategy: promptOverride, source: "prompt" };
}
return {
strategy: settings.directMergeCommitStrategy ?? "auto",
source: "project",
};
}
interface BranchCommitClassification {
sha: string;
subject: string;
substantive: boolean;
}
function isGeneratedOnlyPath(filePath: string): boolean {
return GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern))
|| LOCKFILE_PATTERNS.some((pattern) => matchGlob(filePath, pattern));
}
function isNonSubstantiveCommitChange(change: { status: string; filePath: string }): boolean {
if (change.filePath.startsWith(".changeset/")) {
return change.status === "A";
}
return isGeneratedOnlyPath(change.filePath);
}
async function classifyBranchCommitsForDirectMerge(
rootDir: string,
baseRef: string,
branch: string,
): Promise<{ commits: BranchCommitClassification[]; substantiveCommitCount: number }> {
const { stdout: commitStdout } = await execAsync(`git rev-list --reverse ${quoteArg(`${baseRef}..${branch}`)}`, {
cwd: rootDir,
encoding: "utf-8",
});
const commitShas = commitStdout.trim().split("\n").map((line) => line.trim()).filter(Boolean);
const commits: BranchCommitClassification[] = [];
for (const sha of commitShas) {
let subject = sha;
try {
const { stdout } = await execAsync(`git log -1 --format=%s ${quoteArg(sha)}`, {
cwd: rootDir,
encoding: "utf-8",
});
subject = stdout.trim() || sha;
} catch {
// best-effort subject lookup
}
let substantive = true;
try {
const { stdout } = await execAsync(`git diff-tree --root --no-commit-id --name-status -r ${quoteArg(sha)}`, {
cwd: rootDir,
encoding: "utf-8",
});
const changes = stdout
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [status, ...pathParts] = line.split(/\s+/);
return { status: status ?? "", filePath: pathParts[pathParts.length - 1] ?? "" };
})
.filter((change) => change.filePath);
substantive = changes.length === 0 || changes.some((change) => !isNonSubstantiveCommitChange(change));
} catch {
substantive = true;
}
commits.push({ sha, subject, substantive });
}
return {
commits,
substantiveCommitCount: commits.filter((commit) => commit.substantive).length,
};
}
function shouldRunPostMergeAudit(
strategy: PostMergeAuditStrategy,
result: MergeResult,
mergeWasEmpty: boolean,
isEmptyCommit: boolean,
commitSha?: string,
): boolean {
if (mergeWasEmpty || isEmptyCommit || !commitSha) {
return false;
}
if (strategy === "rebase") {
return true;
}
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
}
function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, findings: SquashAuditFindings): string {
function buildPostMergeAuditBlockingMessage(taskId: 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"}`);
@@ -4173,7 +4395,8 @@ function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, find
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})`;
const label = findings.strategy === "rebase" ? "post-rebase range audit" : "post-squash audit";
return `${taskId}: ${label} blocked auto-completion for ${findings.auditTargetLabel.slice(0, 8)} (${summary})`;
}
function formatSquashAuditAgentLog(findings: SquashAuditFindings): string {
@@ -5607,6 +5830,37 @@ export async function aiMergeTask(
diffStat = "(unable to read diff)";
}
let selectedPostMergeAuditStrategy: PostMergeAuditStrategy = "squash";
let classifiedBranchCommits: BranchCommitClassification[] = [];
if (settings.mergeStrategy !== "pull-request") {
const configuredRoute = resolveDirectMergeCommitStrategy(settings, task.prompt);
if (configuredRoute.strategy === "auto") {
try {
const classification = await classifyBranchCommitsForDirectMerge(
rootDir,
diffBaseRef || mergeTarget.branch,
branch,
);
classifiedBranchCommits = classification.commits;
selectedPostMergeAuditStrategy = classification.substantiveCommitCount >= 2 ? "rebase" : "squash";
} catch (error) {
mergerLog.warn(`${taskId}: failed to classify branch commits for direct-merge routing: ${getCommandErrorMessage(error)}`);
selectedPostMergeAuditStrategy = "squash";
}
} else {
selectedPostMergeAuditStrategy = configuredRoute.strategy === "always-rebase" ? "rebase" : "squash";
}
const classificationSummary = classifiedBranchCommits.length > 0
? ` [${classifiedBranchCommits.map((commit) => `${commit.substantive ? "substantive" : "generated-only"}:${commit.subject}`).join("; ")}]`
: "";
const routeMessage =
`Direct merge commit routing: ${selectedPostMergeAuditStrategy} ` +
`(setting ${configuredRoute.strategy} from ${configuredRoute.source})${classificationSummary}`;
mergerLog.log(`${taskId}: ${routeMessage}`);
await store.appendAgentLog(taskId, routeMessage, "text", undefined, "merger");
}
const aiMergeSummary = settings.useAiMergeCommitSummary
? await generateAiMergeSummary(commitLog, diffStat, settings, rootDir)
: null;
@@ -6061,48 +6315,70 @@ export async function aiMergeTask(
// Track AI agent invocation for resolutionMethod calculation
const aiTracker: AiInvocationTracker = { aiWasInvoked: false };
let rebaseMergeBaseSha: string | undefined;
// Execute attempts with escalation
let merged = false;
// Attempt 1: Standard AI merge
merged = await mergeAttempt(1);
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
merged = await mergeAttempt(2);
}
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
//
// Also skipped when `preMergeRebaseFallthrough` is set: under prefer-main
// the whole purpose of refusing -X ours after a failed rebase is to
// prevent silent re-introduction of main's deletions. Layers 1+2 couldn't
// unblock the rebase, so the worktree is still in a state where -X ours
// would re-introduce branch-only content. Trust only AI Attempts 1+2 here
// — their output is gated by deterministic verification (test + build),
// which is what enforces the prefer-main safety contract.
if (
!merged
&& smartConflictResolution
&& mergeConflictStrategy !== "ai-only"
&& mergeConflictStrategy !== "abort"
&& !preMergeRebaseFallthrough
) {
merged = await mergeAttempt(3);
} else if (!merged && preMergeRebaseFallthrough) {
await store.logEntry(
if (selectedPostMergeAuditStrategy === "rebase") {
const rebaseResult = await applyBranchCommitsPreservingHistory({
rootDir,
baseRef: diffBaseRef || mergeTarget.branch,
branch,
task,
taskId,
`Attempt 3 (-X ours fallback) suppressed: pre-merge rebase recovery layers 1+2 failed under smart-prefer-main, so the unsafe ours-side fallback is skipped to honor the strategy's safety contract. Verification-gated AI Attempts 1+2 already exhausted; merge cannot complete safely without manual intervention.`,
"PreMergeRebaseFallthrough",
);
}
store,
mergeConflictStrategy,
smartConflictResolution,
result,
testCommand: effectiveTestCommand,
buildCommand: effectiveBuildCommand,
testSource: effectiveTestSource,
buildSource: effectiveBuildSource,
signal: options.signal,
});
rebaseMergeBaseSha = rebaseResult.baseSha;
merged = true;
} else {
// Attempt 1: Standard AI merge
merged = await mergeAttempt(1);
// Bubble the empty-merge flag up to the metadata block.
if (aiTracker.mergeWasEmpty) {
mergeWasEmpty = true;
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
merged = await mergeAttempt(2);
}
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
//
// Also skipped when `preMergeRebaseFallthrough` is set: under prefer-main
// the whole purpose of refusing -X ours after a failed rebase is to
// prevent silent re-introduction of main's deletions. Layers 1+2 couldn't
// unblock the rebase, so the worktree is still in a state where -X ours
// would re-introduce branch-only content. Trust only AI Attempts 1+2 here
// — their output is gated by deterministic verification (test + build),
// which is what enforces the prefer-main safety contract.
if (
!merged
&& smartConflictResolution
&& mergeConflictStrategy !== "ai-only"
&& mergeConflictStrategy !== "abort"
&& !preMergeRebaseFallthrough
) {
merged = await mergeAttempt(3);
} else if (!merged && preMergeRebaseFallthrough) {
await store.logEntry(
taskId,
`Attempt 3 (-X ours fallback) suppressed: pre-merge rebase recovery layers 1+2 failed under smart-prefer-main, so the unsafe ours-side fallback is skipped to honor the strategy's safety contract. Verification-gated AI Attempts 1+2 already exhausted; merge cannot complete safely without manual intervention.`,
"PreMergeRebaseFallthrough",
);
}
// Bubble the empty-merge flag up to the metadata block.
if (aiTracker.mergeWasEmpty) {
mergeWasEmpty = true;
}
}
// If all attempts failed
@@ -6134,7 +6410,10 @@ export async function aiMergeTask(
let deletions: number | undefined;
try {
const { stdout: statsOutput } = await execAsync("git show --shortstat --format= HEAD", {
const statsCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
? `git diff --shortstat ${quoteArg(`${rebaseMergeBaseSha}..HEAD`)}`
: "git show --shortstat --format= HEAD";
const { stdout: statsOutput } = await execAsync(statsCommand, {
cwd: rootDir,
encoding: "utf-8",
});
@@ -6163,11 +6442,19 @@ export async function aiMergeTask(
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
const auditSha = recordedSha;
if (auditSha && shouldRunPostSquashAudit(result, mergeWasEmpty, isEmptyCommit, auditSha)) {
const auditFindings = await auditSquashMerge({
rootDir,
squashSha: auditSha,
});
if (auditSha && shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)) {
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
? await auditSquashMerge({
rootDir,
strategy: "rebase",
rangeBaseSha: rebaseMergeBaseSha,
rangeHeadSha: auditSha,
})
: await auditSquashMerge({
rootDir,
strategy: "squash",
squashSha: auditSha,
});
if (!auditFindings.clean) {
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
await store.appendAgentLog(
@@ -6180,7 +6467,13 @@ export async function aiMergeTask(
await store.updateTask(taskId, { status: null });
throw auditError;
}
await store.appendAgentLog(taskId, "post-squash audit clean", "text", undefined, "merger");
await store.appendAgentLog(
taskId,
selectedPostMergeAuditStrategy === "rebase" ? "post-rebase range audit clean" : "post-squash audit clean",
"text",
undefined,
"merger",
);
}
if (isEmptyCommit) {
mergerLog.warn(