feat(FN-4071): add overlap guard to prevent concurrent merge conflicts

Implements an overlap guard for the merger that prevents concurrent merges of tasks with conflicting file changes, wired through a new overlap-aware fallback strategy and exposed via a dashboard setting. The feature includes a 406-line test suite for the overlap guard, a 203-line lifecycle integrati

Fusion-Task-Id: FN-4071
This commit is contained in:
Fusion
2026-05-12 09:02:55 -07:00
committed by gsxdsm
parent 759f8f5ac9
commit 429bdc213b
14 changed files with 1043 additions and 74 deletions

View File

@@ -122,6 +122,7 @@ vi.mock("../context-limit-detector.js", () => ({
}));
vi.mock("../merger-squash-audit.js", () => ({
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS: 30,
auditSquashMerge: vi.fn(async () => ({
squashSha: "mergedcommit123",
parentSha: "parent123",
@@ -138,6 +139,14 @@ vi.mock("../merger-squash-audit.js", () => ({
})),
}));
vi.mock("../merger-overlap-guard.js", () => ({
detectMergeOverlap: vi.fn(async () => ({
overlappingFiles: [],
recentMainCommitsByFile: new Map(),
})),
restoreBranchWinsFiles: vi.fn(async () => undefined),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
@@ -167,12 +176,15 @@ import {
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { auditSquashMerge } from "../merger-squash-audit.js";
import { detectMergeOverlap, restoreBranchWinsFiles } from "../merger-overlap-guard.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 mockedDetectMergeOverlap = vi.mocked(detectMergeOverlap);
const mockedRestoreBranchWinsFiles = vi.mocked(restoreBranchWinsFiles);
const mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
@@ -1221,6 +1233,11 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedDetectMergeOverlap.mockResolvedValue({
overlappingFiles: [],
recentMainCommitsByFile: new Map(),
});
mockedRestoreBranchWinsFiles.mockResolvedValue(undefined);
// Default mock: successful happy path
mockedExecSync.mockImplementation((cmd: any) => {
@@ -1513,6 +1530,192 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
expect(agentCallCount).toBe(1); // Agent was called once (on attempt 2, which failed)
});
it("attempt 3 under smart-prefer-main restores overlapping files from the branch by default", async () => {
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,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
});
mockedDetectMergeOverlap.mockResolvedValue({
overlappingFiles: ["packages/core/src/store.ts"],
recentMainCommitsByFile: new Map([["packages/core/src/store.ts", ["12345678abcdef00"]]]),
});
let squashCallCount = 0;
let oursCallCount = 0;
let hasConflicts = true;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
squashCallCount++;
throw new Error("Merge conflict");
}
if (cmdStr.includes("merge -X ours --squash")) {
oursCallCount++;
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
return hasConflicts ? "packages/core/src/store.ts\n" : "";
}
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return hasConflicts ? "1" : "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("worktree remove") || cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ours");
expect(result.resolutionMethod).toBe("mixed");
expect(result.attemptsMade).toBe(3);
expect(oursCallCount).toBe(1);
expect(mockedRestoreBranchWinsFiles).toHaveBeenCalledWith({
rootDir: "/tmp/root",
branch: "fusion/fn-050",
files: expect.any(Set),
});
expect([...mockedRestoreBranchWinsFiles.mock.calls[0][0].files]).toEqual(["packages/core/src/store.ts"]);
expect(squashCallCount).toBe(2);
});
it("warn-only logs overlap but keeps legacy -X ours fallback", async () => {
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,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "warn-only",
});
mockedDetectMergeOverlap.mockResolvedValue({
overlappingFiles: ["packages/core/src/store.ts"],
recentMainCommitsByFile: new Map([["packages/core/src/store.ts", ["12345678abcdef00"]]]),
});
let hasConflicts = true;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) throw new Error("Merge conflict");
if (cmdStr.includes("merge -X ours --squash")) {
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return hasConflicts ? "packages/core/src/store.ts\n" : "";
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit") || cmdStr.includes("branch -d") || cmdStr.includes("worktree remove") || cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ours");
expect(result.resolutionMethod).toBe("ours");
expect(mockedRestoreBranchWinsFiles).not.toHaveBeenCalled();
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Overlap guard detected 1 recent-main overlap file(s) for smart-prefer-main (warn-only)"),
"text",
undefined,
"merger",
);
});
it("ignore preserves legacy behavior and skips overlap detection", async () => {
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,
mergeConflictStrategy: "smart-prefer-main",
mergeStrategyOverlapBehavior: "ignore",
});
let hasConflicts = true;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something";
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) throw new Error("Merge conflict");
if (cmdStr.includes("merge -X ours --squash")) {
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return hasConflicts ? "packages/core/src/store.ts\n" : "";
if (cmdStr.includes("diff-tree")) {
const error = new Error("exit code 1") as any;
error.stdout = "+const x = 2;\n-const x = 1;";
throw error;
}
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit") || cmdStr.includes("branch -d") || cmdStr.includes("worktree remove") || cmdStr.includes("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
} as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ours");
expect(mockedDetectMergeOverlap).not.toHaveBeenCalled();
expect(mockedRestoreBranchWinsFiles).not.toHaveBeenCalled();
expect(
vi.mocked(store.appendAgentLog).mock.calls.some(([taskId, message]) => taskId === "FN-050" && String(message).includes("Overlap guard detected")),
).toBe(false);
});
it("final cleanup reset succeeds after all 3 attempts fail", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },

View File

@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
vi.mock("../pi.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../pi.js")>();
return {
...actual,
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session, prompt, options) => {
if (options === undefined) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
}),
};
});
vi.mock("../agent-session-helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agent-session-helpers.js")>();
return {
...actual,
createResolvedAgentSession: vi.fn(async () => ({
session: {
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
dispose: vi.fn(),
},
runtimeId: "mock-runtime",
wasConfigured: false,
})),
};
});
vi.mock("../merger-squash-audit.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../merger-squash-audit.js")>();
return {
...actual,
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 type { Task, TaskStore } from "@fusion/core";
import { DEFAULT_SETTINGS } from "@fusion/core";
import { aiMergeTask } from "../merger.js";
import {
detectMergeOverlap,
getBranchTouchedFiles,
getRecentMainTouchedFiles,
restoreBranchWinsFiles,
} from "../merger-overlap-guard.js";
function git(cwd: string, command: string): string {
return execSync(command, { cwd, stdio: "pipe" }).toString().trim();
}
function initRepo(dir: string): void {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
git(dir, 'git config commit.gpgsign false');
writeFileSync(join(dir, "README.md"), "# repo\n");
git(dir, "git add README.md");
git(dir, 'git commit -m "chore: initial commit"');
}
function commitFile(dir: string, file: string, content: string, message: string): string {
writeFileSync(join(dir, file), content);
git(dir, `git add ${file}`);
git(dir, `git commit -m "${message}"`);
return git(dir, "git rev-parse HEAD");
}
function createBranchFromMain(dir: string, branch: string): void {
git(dir, `git checkout -b ${branch} main`);
}
function makeStore(dir: string, taskId: string, settingsOverrides: Record<string, unknown> = {}): TaskStore {
const task: Task = {
id: taskId,
title: "Overlap guard task",
description: "Test overlap-aware merge fallback",
column: "in-review",
baseBranch: "main",
branch: taskId,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([task]),
updateTask: vi.fn().mockResolvedValue(task),
moveTask: vi.fn().mockResolvedValue(task),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
commitAuthorEnabled: false,
mergeConflictStrategy: "smart-prefer-main",
...settingsOverrides,
}),
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
getVerificationCacheHit: vi.fn().mockReturnValue(null),
recordVerificationCachePass: vi.fn(),
} as unknown as TaskStore;
}
function commitSeries(dir: string, prefix: string, count: number): void {
for (let index = 1; index <= count; index += 1) {
commitFile(
dir,
`${prefix}-${index}.txt`,
`${prefix} ${index}\n`,
`chore: ${prefix} ${index}`,
);
}
}
async function runOverlapMerge(dir: string, taskId: string, settingsOverrides: Record<string, unknown> = {}) {
const store = makeStore(dir, taskId, settingsOverrides);
const result = await aiMergeTask(store, dir, taskId);
return { store, result };
}
function assertIsolatedWorkspace(dir: string): void {
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
if (!repoRoot) return;
expect(resolve(dir).startsWith(resolve(repoRoot))).toBe(false);
}
describe("merger overlap guard", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fusion-test-overlap-guard-"));
assertIsolatedWorkspace(dir);
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("detects overlap when branch and recent main commits touch the same file", async () => {
commitFile(dir, "shared.ts", "export const shared = 1;\n", "feat: add shared file");
commitFile(dir, "main-only.ts", "export const mainOnly = true;\n", "feat: add main file");
createBranchFromMain(dir, "feature/overlap");
commitFile(dir, "shared.ts", "export const shared = 2;\n", "feat: harden shared file");
const overlap = await detectMergeOverlap({
rootDir: dir,
branch: "feature/overlap",
baseRef: "main",
mergeTargetBranch: "main",
lookback: 30,
});
expect(overlap.overlappingFiles).toEqual(["shared.ts"]);
expect(overlap.recentMainCommitsByFile.get("shared.ts")).toHaveLength(1);
});
it("returns no overlap when branch files are absent from recent main commits", async () => {
commitFile(dir, "main-only.ts", "export const mainOnly = true;\n", "feat: add main file");
createBranchFromMain(dir, "feature/no-overlap");
commitFile(dir, "branch-only.ts", "export const branchOnly = true;\n", "feat: add branch file");
const overlap = await detectMergeOverlap({
rootDir: dir,
branch: "feature/no-overlap",
baseRef: "main",
mergeTargetBranch: "main",
lookback: 30,
});
expect(overlap.overlappingFiles).toEqual([]);
expect(overlap.recentMainCommitsByFile.size).toBe(0);
});
it("ignores commits outside the lookback window", async () => {
commitFile(dir, "shared.ts", "export const shared = 1;\n", "feat: add shared file");
commitFile(dir, "a.ts", "export const a = 1;\n", "feat: add a");
commitFile(dir, "b.ts", "export const b = 1;\n", "feat: add b");
createBranchFromMain(dir, "feature/lookback");
commitFile(dir, "shared.ts", "export const shared = 2;\n", "feat: update shared file");
const recentMain = await getRecentMainTouchedFiles({
rootDir: dir,
mergeTargetBranch: "main",
lookback: 2,
});
expect(recentMain.has("shared.ts")).toBe(false);
const overlap = await detectMergeOverlap({
rootDir: dir,
branch: "feature/lookback",
baseRef: "main",
mergeTargetBranch: "main",
lookback: 2,
});
expect(overlap.overlappingFiles).toEqual([]);
});
it("uses the provided base ref when collecting branch touched files", async () => {
commitFile(dir, "base.ts", "export const base = 1;\n", "feat: add base file");
createBranchFromMain(dir, "feature/base-ref");
commitFile(dir, "feature.ts", "export const feature = 1;\n", "feat: add feature file");
const files = await getBranchTouchedFiles({
rootDir: dir,
branch: "feature/base-ref",
baseRef: "main",
});
expect(files).toEqual(["feature.ts"]);
});
it("restores the branch version for overlapping files after a -X ours squash", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "feature/mixed");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: harden store");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main store update");
git(dir, "git merge -X ours --squash feature/mixed");
expect(git(dir, "git show :store.ts")).toContain("main fallback");
await restoreBranchWinsFiles({
rootDir: dir,
branch: "feature/mixed",
files: ["store.ts"],
});
expect(git(dir, "git show :store.ts")).toContain("branch hardening");
});
it("preserves legacy main-wins behavior when no overlapping files are restored", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "feature/no-restore");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: harden store");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main store update");
git(dir, "git merge -X ours --squash feature/no-restore");
expect(git(dir, "git show :store.ts")).toContain("main fallback");
});
it("replays the FN-3936 shape so branch hardening survives under overlap protection", async () => {
commitFile(dir, "store.ts", "export function normalize(value) {\n return value?.trim() ?? \"\";\n}\n", "feat: add store normalizer");
createBranchFromMain(dir, "feature/fn-3936");
commitFile(dir, "store.ts", "export function normalize(value) {\n const trimmed = value?.trim() ?? \"\";\n return trimmed.slice(0, 128);\n}\n", "feat: harden normalizer");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export function normalize(value) {\n const trimmed = value?.trim() ?? \"\";\n return trimmed.toLowerCase();\n}\n", "feat: main follow-up normalizer");
git(dir, "git merge -X ours --squash feature/fn-3936");
expect(git(dir, "git show :store.ts")).toContain("toLowerCase");
await restoreBranchWinsFiles({
rootDir: dir,
branch: "feature/fn-3936",
files: ["store.ts"],
});
const stagedStore = git(dir, "git show :store.ts");
expect(stagedStore).toContain("slice(0, 128)");
expect(stagedStore).not.toContain("toLowerCase");
});
});
describe("aiMergeTask overlap-aware fallback integration", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fusion-test-overlap-merge-"));
assertIsolatedWorkspace(dir);
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("defaults to restoring the branch version for overlapping files under smart-prefer-main", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "FN-050");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: branch hardening");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main follow-up");
const { result } = await runOverlapMerge(dir, "FN-050");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ours");
expect(result.resolutionMethod).toBe("mixed");
expect(git(dir, "git show HEAD:store.ts")).toContain("branch hardening");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("main fallback");
});
it("keeps legacy main-wins behavior when the conflicting main edit is outside the overlap lookback window", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "FN-051");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: branch hardening");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main follow-up");
commitSeries(dir, "filler", 31);
const { result } = await runOverlapMerge(dir, "FN-051");
expect(result.merged).toBe(true);
expect(result.resolutionStrategy).toBe("ours");
expect(result.resolutionMethod).toBe("ours");
expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening");
});
it("warn-only logs overlap but preserves main-wins behavior", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "FN-052");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: branch hardening");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main follow-up");
const { store, result } = await runOverlapMerge(dir, "FN-052", {
mergeStrategyOverlapBehavior: "warn-only",
});
expect(result.merged).toBe(true);
expect(result.resolutionMethod).toBe("ours");
expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening");
expect(
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected 1 recent-main overlap file(s)")),
).toBe(true);
});
it("ignore preserves legacy behavior without overlap logging", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
createBranchFromMain(dir, "FN-053");
commitFile(dir, "store.ts", "export const mode = 'branch hardening';\n", "feat: branch hardening");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export const mode = 'main fallback';\n", "feat: main follow-up");
const { store, result } = await runOverlapMerge(dir, "FN-053", {
mergeStrategyOverlapBehavior: "ignore",
});
expect(result.merged).toBe(true);
expect(result.resolutionMethod).toBe("ours");
expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening");
expect(
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected")),
).toBe(false);
});
it("replays FN-3936 through the merger so branch hardening survives the final squash commit", async () => {
commitFile(dir, "store.ts", "export function normalize(value) {\n return value?.trim() ?? \"\";\n}\n", "feat: add store normalizer");
createBranchFromMain(dir, "FN-054");
commitFile(dir, "store.ts", "export function normalize(value) {\n const trimmed = value?.trim() ?? \"\";\n return trimmed.slice(0, 128);\n}\n", "feat: harden normalizer");
git(dir, "git checkout main");
commitFile(dir, "store.ts", "export function normalize(value) {\n const trimmed = value?.trim() ?? \"\";\n return trimmed.toLowerCase();\n}\n", "feat: main follow-up normalizer");
const { result } = await runOverlapMerge(dir, "FN-054");
const mergedStore = git(dir, "git show HEAD:store.ts");
expect(result.merged).toBe(true);
expect(result.resolutionMethod).toBe("mixed");
expect(mergedStore).toContain("slice(0, 128)");
expect(mergedStore).not.toContain("toLowerCase");
});
});

View File

@@ -2036,8 +2036,11 @@ describe("aiMergeTask — in-merge verification fix", () => {
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...DEFAULT_SETTINGS, testCommand: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
expect(buildRuns).toBeGreaterThanOrEqual(0);
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toMatchObject({
branchDeleted: true,
});
expect(vitestRuns).toBe(2);
expect(buildRuns).toBe(1);
});
it("retries when test-failure fix passes tests but full rerun fails on build", async () => {
@@ -2085,7 +2088,10 @@ describe("aiMergeTask — in-merge verification fix", () => {
mockedCreateFnAgent.mockResolvedValue({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any);
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: "vitest run", buildCommand: "pnpm build", verificationFixRetries: 1, buildRetryCount: 0 });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).resolves.toMatchObject({
branchDeleted: true,
});
expect(buildRuns).toBe(2);
});
it("retries when build-failure fix keeps build green but breaks tests in full rerun", async () => {

View File

@@ -0,0 +1,172 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mergerLog } from "./logger.js";
import {
listRecentMainCommits,
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
normalizeMergeOverlapLookback,
} from "./merger-squash-audit.js";
const execFileAsync = promisify(execFile);
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
export interface DetectMergeOverlapResult {
overlappingFiles: string[];
recentMainCommitsByFile: Map<string, string[]>;
}
export async function getBranchTouchedFiles({
rootDir,
branch,
baseRef,
}: {
rootDir: string;
branch: string;
baseRef?: string;
}): Promise<string[]> {
if (!branch.trim()) {
return [];
}
const diffRange = baseRef?.trim() ? `${baseRef.trim()}...${branch}` : `${branch}~1...${branch}`;
const files = await gitLines(rootDir, ["diff", "--name-only", diffRange], "branch touched-files");
return Array.from(new Set(files));
}
export async function getRecentMainTouchedFiles({
rootDir,
mergeTargetBranch,
lookback = MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
}: {
rootDir: string;
mergeTargetBranch: string;
lookback?: number;
}): Promise<Map<string, string[]>> {
const recentMainCommitsByFile = new Map<string, string[]>();
const normalizedLookback = normalizeMergeOverlapLookback(lookback);
const recentMainCommits = await listRecentMainCommits(rootDir, mergeTargetBranch, normalizedLookback)
.catch((error) => {
mergerLog.warn(`overlap guard: failed to read recent main commits: ${formatErrorMessage(error)}`);
return [];
});
for (const { sha: commitSha } of recentMainCommits) {
const touchedFiles = await gitLines(
rootDir,
["diff-tree", "--no-commit-id", "--name-only", "-r", commitSha],
`main touched-files for ${commitSha.slice(0, 8)}`,
);
for (const file of touchedFiles) {
const existing = recentMainCommitsByFile.get(file) ?? [];
existing.push(commitSha);
recentMainCommitsByFile.set(file, existing);
}
}
return recentMainCommitsByFile;
}
export async function detectMergeOverlap({
rootDir,
branch,
baseRef,
mergeTargetBranch,
lookback = MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
}: {
rootDir: string;
branch: string;
baseRef?: string;
mergeTargetBranch: string;
lookback?: number;
}): Promise<DetectMergeOverlapResult> {
const [branchTouchedFiles, recentMainCommitsByFile] = await Promise.all([
getBranchTouchedFiles({ rootDir, branch, baseRef }),
getRecentMainTouchedFiles({ rootDir, mergeTargetBranch, lookback }),
]);
const overlappingFiles = branchTouchedFiles
.filter((file) => recentMainCommitsByFile.has(file))
.sort((a, b) => a.localeCompare(b));
const overlapCommits = new Map<string, string[]>();
for (const file of overlappingFiles) {
overlapCommits.set(file, [...(recentMainCommitsByFile.get(file) ?? [])]);
}
return {
overlappingFiles,
recentMainCommitsByFile: overlapCommits,
};
}
export async function restoreBranchWinsFiles({
rootDir,
branch,
files,
}: {
rootDir: string;
branch: string;
files: Iterable<string>;
}): Promise<void> {
for (const file of files) {
const branchHasFile = await gitExitCode(rootDir, ["cat-file", "-e", `${branch}:${file}`]) === 0;
if (branchHasFile) {
await gitRun(rootDir, ["checkout", branch, "--", file], `restore branch version for ${file}`);
await gitRun(rootDir, ["add", "--", file], `stage branch version for ${file}`);
} else {
await gitRun(rootDir, ["rm", "--force", "--ignore-unmatch", "--", file], `stage branch deletion for ${file}`);
}
}
}
async function gitLines(rootDir: string, args: string[], context: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
});
return normalizeLines(stdout);
} catch (error) {
mergerLog.warn(`overlap guard: failed to read ${context}: ${formatErrorMessage(error)}`);
return [];
}
}
function normalizeLines(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
return trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
}
async function gitRun(rootDir: string, args: string[], context: string): Promise<void> {
try {
await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
});
} catch (error) {
mergerLog.warn(`overlap guard: failed to ${context}: ${formatErrorMessage(error)}`);
throw error;
}
}
async function gitExitCode(rootDir: string, args: string[]): Promise<number> {
try {
await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
});
return 0;
} catch {
return 1;
}
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_LOOKBACK = 30;
export const MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS = 30;
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
export interface SquashAuditRecentMainCommit {
@@ -41,13 +41,13 @@ export interface SquashAuditFindings {
export async function auditSquashMerge({
rootDir,
squashSha,
lookback = DEFAULT_LOOKBACK,
lookback = MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
}: {
rootDir: string;
squashSha: string;
lookback?: number;
}): Promise<SquashAuditFindings> {
const normalizedLookback = normalizeLookback(lookback);
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]))
@@ -161,7 +161,7 @@ function normalizeLines(value: string): string[] {
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 }>> {
export 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) => {
@@ -179,9 +179,9 @@ async function listRecentMainCommits(rootDir: string, parentSha: string, lookbac
.filter((entry): entry is { sha: string; shortSha: string; subject: string } => entry !== null);
}
function normalizeLookback(value: number | undefined): number {
export function normalizeMergeOverlapLookback(value: number | undefined): number {
if (!Number.isFinite(value) || !value || value < 1) {
return DEFAULT_LOOKBACK;
return MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS;
}
return Math.trunc(value);
}

View File

@@ -35,6 +35,7 @@ import {
buildTaskLineageTrailer,
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
normalizeMergeStrategyOverlapBehavior,
resolveTaskMergeTarget,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt,
@@ -70,7 +71,8 @@ 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";
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type SquashAuditFindings } from "./merger-squash-audit.js";
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
/** Conflict type classification for merge conflict resolution */
export type ConflictType =
@@ -4994,6 +4996,9 @@ export async function aiMergeTask(
const mergeConflictStrategy: CanonicalMergeConflictStrategy = normalizeMergeConflictStrategy(
settings.mergeConflictStrategy,
);
const mergeStrategyOverlapBehavior = normalizeMergeStrategyOverlapBehavior(
settings.mergeStrategyOverlapBehavior,
);
// Pre-merge sync: for the smart strategies, opportunistically fast-forward
// local main from origin so a freshly-pushed sibling commit isn't clobbered
@@ -5547,6 +5552,38 @@ export async function aiMergeTask(
baseBranch: task.baseBranch,
baseCommitSha: task.baseCommitSha,
});
const preferBranchOnOverlapFiles = new Set<string>();
if (
mergeConflictStrategy === "smart-prefer-main"
&& mergeStrategyOverlapBehavior !== "ignore"
) {
const overlap = await detectMergeOverlap({
rootDir,
branch,
baseRef: diffBaseRef,
mergeTargetBranch: mergeTarget.branch,
lookback: MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
});
if (overlap.overlappingFiles.length > 0) {
const overlapSummary = formatMergeOverlapSummary(
overlap.overlappingFiles,
overlap.recentMainCommitsByFile,
);
const overlapMessage =
`Overlap guard detected ${overlap.overlappingFiles.length} recent-main overlap file(s) ` +
`for smart-prefer-main (${mergeStrategyOverlapBehavior}): ${overlapSummary}`;
mergerLog.warn(`${taskId}: ${overlapMessage}`);
await store.appendAgentLog(taskId, overlapMessage, "text", undefined, "merger");
await store.logEntry(taskId, overlapMessage);
if (mergeStrategyOverlapBehavior === "flip-to-prefer-branch") {
for (const file of overlap.overlappingFiles) {
preferBranchOnOverlapFiles.add(file);
}
}
}
}
const contextDiffRange = diffBaseRef ? `${diffBaseRef}..${branch}` : `HEAD..${branch}`;
let commitLog = "";
@@ -5631,7 +5668,9 @@ export async function aiMergeTask(
? "Attempt 1: AI merge"
: attemptNum === 2
? "Attempt 2: auto-resolve known conflicts, then AI"
: `Attempt 3: ${mergeConflictStrategy === "smart-prefer-main" ? "-X ours" : "-X theirs"} fallback`;
: mergeConflictStrategy === "smart-prefer-main" && preferBranchOnOverlapFiles.size > 0
? `Attempt 3: overlap-aware -X ours fallback (${preferBranchOnOverlapFiles.size} branch-protected file${preferBranchOnOverlapFiles.size === 1 ? "" : "s"})`
: `Attempt 3: ${mergeConflictStrategy === "smart-prefer-main" ? "-X ours" : "-X theirs"} fallback`;
await store.appendAgentLog(
taskId,
`Starting merge ${attemptLabel}`,
@@ -5680,12 +5719,17 @@ export async function aiMergeTask(
testSource: effectiveTestSource,
buildSource: effectiveBuildSource,
preMergeRebaseFallthrough,
attempt3BranchWinsFiles: preferBranchOnOverlapFiles,
}, aiTracker);
if (success) {
result.attemptsMade = attemptNum;
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
if (attemptNum === 3 && mergeConflictStrategy === "smart-prefer-main" && preferBranchOnOverlapFiles.size > 0) {
result.resolutionMethod = "mixed";
} else {
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
}
result.merged = true;
return true;
}
@@ -6575,6 +6619,10 @@ interface MergeAttemptParams {
* suppress the unsafe `-X ours` Attempt 3. Carries the original rebase
* failure message for diagnostic context. */
preMergeRebaseFallthrough?: string;
/** Under smart-prefer-main overlap protection, these files are restored from
* the task branch after the default `-X ours` squash so overlapping files
* keep the branch's hardening while non-overlapping files still prefer main. */
attempt3BranchWinsFiles?: Set<string>;
}
/** Mutable flags carried through the merge cascade. */
@@ -6622,6 +6670,13 @@ async function executeMergeAttempt(
// before reaching here — only the two smart variants legitimately run attempt 3.
if (attemptNum === 3) {
if (params.mergeConflictStrategy === "smart-prefer-main") {
if (params.attempt3BranchWinsFiles && params.attempt3BranchWinsFiles.size > 0) {
return attemptWithMixedSideStrategy(
params,
{ defaultSide: "ours", branchWinsFiles: params.attempt3BranchWinsFiles },
aiTracker,
);
}
return attemptWithSideStrategy(params, "ours", aiTracker);
}
return attemptWithSideStrategy(params, "theirs", aiTracker);
@@ -7012,7 +7067,7 @@ async function attemptWithSideStrategy(
side: "theirs" | "ours" = "theirs",
aiTracker?: AiInvocationTracker,
): Promise<boolean> {
const { rootDir, branch, commitLog, diffStat, aiSummary, aiSubject, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
const { rootDir, branch, taskId } = params;
mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
@@ -7033,66 +7088,71 @@ async function attemptWithSideStrategy(
return false;
}
// Check if there's anything staged
const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
return finalizeSideStrategyAttempt(params, side, aiTracker);
} catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
}
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
return false;
}
}
async function attemptWithMixedSideStrategy(
params: MergeAttemptParams,
strategy: { defaultSide: "ours" | "theirs"; branchWinsFiles: Set<string> },
aiTracker?: AiInvocationTracker,
): Promise<boolean> {
const { rootDir, branch, taskId } = params;
mergerLog.log(
`${taskId}: attempting overlap-aware merge with -X ${strategy.defaultSide} and branch restoration for ${strategy.branchWinsFiles.size} file(s)`,
);
try {
throwIfAborted(params.options.signal, taskId);
await execAsync(`git merge -X ${strategy.defaultSide} --squash "${branch}"`, {
cwd: rootDir,
});
const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged === "0") {
// Nothing staged - already merged. Mark empty so the metadata block
// doesn't record pre-merge HEAD as this task's commitSha.
if (aiTracker) aiTracker.mergeWasEmpty = true;
// Run deterministic verification even when nothing is staged
if (testCommand || buildCommand) {
throwIfAborted(params.options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
params.options.signal,
);
}
return true;
if (conflictedOutput.length > 0) {
mergerLog.warn(`${taskId}: overlap-aware merge left unresolved conflicts: ${conflictedOutput}`);
return false;
}
// Commit with fallback message. Body cascade: branch's commit log →
// AI summary of diff stat → diff stat itself → synthetic placeholder.
// Guarantees the merge commit carries a non-empty body that downstream
// consumers (release notes, dashboard summaries) can rely on.
throwIfAborted(params.options.signal, taskId);
const safeBody = await resolveSafeCommitBody({
await restoreBranchWinsFiles({
rootDir,
taskId,
branch,
commitLog,
diffStat,
settings: settings as Settings,
signal: params.options.signal,
files: strategy.branchWinsFiles,
});
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskTrailerArgs(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
aiSubject,
});
await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir },
);
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
// Run deterministic verification after committing
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
} catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
}
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
return false;
}
}
async function finalizeSideStrategyAttempt(
params: MergeAttemptParams,
side: "theirs" | "ours",
aiTracker?: AiInvocationTracker,
): Promise<boolean> {
const { rootDir, branch, commitLog, diffStat, aiSummary, aiSubject, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (staged === "0") {
if (aiTracker) aiTracker.mergeWasEmpty = true;
if (testCommand || buildCommand) {
throwIfAborted(params.options.signal, taskId);
await runDeterministicVerification(
@@ -7106,15 +7166,63 @@ async function attemptWithSideStrategy(
params.options.signal,
);
}
return true;
} catch (error) {
if (error instanceof Error && error.name === "MergeAbortedError") {
throw error;
}
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
return false;
}
throwIfAborted(params.options.signal, taskId);
const safeBody = await resolveSafeCommitBody({
rootDir,
taskId,
branch,
commitLog,
diffStat,
settings: settings as Settings,
signal: params.options.signal,
});
const authorArg = getCommitAuthorArg(settings);
const trailerArg = buildTaskTrailerArgs(taskId);
const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
const { subjectArg, bodyArg } = await buildDeterministicMergeMessage({
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
aiSubject,
});
await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir },
);
mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
if (testCommand || buildCommand) {
throwIfAborted(params.options.signal, taskId);
await runDeterministicVerification(
store,
rootDir,
taskId,
testCommand,
buildCommand,
testSource,
buildSource,
params.options.signal,
);
}
return true;
}
function formatMergeOverlapSummary(files: string[], recentMainCommitsByFile: Map<string, string[]>): string {
const displayedFiles = files.slice(0, 8).map((file) => {
const shas = (recentMainCommitsByFile.get(file) ?? []).slice(0, 3).map((sha) => sha.slice(0, 8));
const extraCommits = (recentMainCommitsByFile.get(file)?.length ?? 0) - shas.length;
const commitSummary = shas.join(", ") + (extraCommits > 0 ? `, +${extraCommits} more` : "");
return `${file} [${commitSummary}]`;
});
const extraFiles = files.length - displayedFiles.length;
return displayedFiles.join("; ") + (extraFiles > 0 ? `; +${extraFiles} more file(s)` : "");
}
interface AiAgentParams {