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:
@@ -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" },
|
||||
|
||||
406
packages/engine/src/__tests__/merger-overlap-guard.test.ts
Normal file
406
packages/engine/src/__tests__/merger-overlap-guard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
172
packages/engine/src/merger-overlap-guard.ts
Normal file
172
packages/engine/src/merger-overlap-guard.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user