feat(KB-010): add autoResolveConflicts setting with intelligent merge conflict resolution
- Add autoResolveConflicts setting to types and store (default: true) - Implement smart conflict detection for lock files and generated files - Add 3-attempt retry logic with escalating strategies to merger - Auto-resolve lock files using 'ours', trivial whitespace conflicts - Track mergeRetries per task for retry loop management - Update AGENTS.md with conflict resolution documentation - Add comprehensive merger tests for retry scenarios
This commit is contained in:
@@ -20,6 +20,8 @@ function makeMockStore() {
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn().mockResolvedValue({ column: "in-review", paused: false }),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
}),
|
||||
@@ -726,3 +728,201 @@ describe("runDashboard — --paused flag", () => {
|
||||
expect(pausedMessageCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Merge conflict retry logic tests ────────────────────────────────────
|
||||
|
||||
describe("runDashboard — merge conflict retry logic", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
|
||||
// Default mock store.getTask implementation
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
}));
|
||||
|
||||
const engine = await import("@kb/engine");
|
||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ merged: true }),
|
||||
);
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("increments mergeRetries and re-enqueues on conflict error", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
// Simulate merge failure with conflict
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected in package-lock.json"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-RETRY", column: "in-review", paused: false },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Wait for retry scheduling
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should have incremented mergeRetries
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-RETRY",
|
||||
expect.objectContaining({ mergeRetries: 1 }),
|
||||
);
|
||||
|
||||
// Should log retry attempt
|
||||
const retryLog = consoleSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("retry 1/3"),
|
||||
);
|
||||
expect(retryLog).toBeDefined();
|
||||
});
|
||||
|
||||
it("gives up after max retries (3) exceeded", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
// Task already has 3 retries
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 3,
|
||||
}));
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-MAX", column: "in-review", paused: false, mergeRetries: 3 },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should log max retries exceeded
|
||||
const maxRetryLog = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === "string" && call[0].includes("max retries (3) exceeded"),
|
||||
);
|
||||
expect(maxRetryLog).toBeDefined();
|
||||
|
||||
// Should reset mergeRetries on the task
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-MAX",
|
||||
expect.objectContaining({ status: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips retry when autoResolveConflicts is disabled", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: false, // Disabled
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-NO-AUTO", column: "in-review", paused: false },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should log that auto-resolve is disabled
|
||||
const disabledLog = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === "string" &&
|
||||
call[0].includes("autoResolveConflicts disabled"),
|
||||
);
|
||||
expect(disabledLog).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears mergeRetries on successful merge after retries", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockResolvedValue({ merged: true });
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
// Task had previous retries
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 2,
|
||||
}));
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-SUCCESS", column: "in-review", paused: false, mergeRetries: 2 },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should clear mergeRetries on success
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-SUCCESS",
|
||||
expect.objectContaining({ mergeRetries: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,12 +164,53 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
console.log(`[auto-merge] Merging ${taskId}...`);
|
||||
await onMerge(taskId);
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged`);
|
||||
// Clear mergeRetries on success
|
||||
if (task.mergeRetries && task.mergeRetries > 0) {
|
||||
await store.updateTask(taskId, { mergeRetries: 0 });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`[auto-merge] ✗ ${taskId}: ${err.message ?? err}`);
|
||||
// Reset task status so it doesn't appear stuck as "merging" in the UI
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
const errorMsg = err.message ?? String(err);
|
||||
console.log(`[auto-merge] ✗ ${taskId}: ${errorMsg}`);
|
||||
|
||||
// Check if this is a conflict error and if we should retry
|
||||
const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict");
|
||||
const task = await store.getTask(taskId).catch(() => null);
|
||||
|
||||
if (task && isConflictError) {
|
||||
const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true }));
|
||||
const currentRetries = task.mergeRetries ?? 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) {
|
||||
// Increment retry counter and re-enqueue with delay
|
||||
const newRetryCount = currentRetries + 1;
|
||||
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
|
||||
|
||||
// Calculate exponential backoff delay: 5s, 10s, 20s
|
||||
const delayMs = 5000 * Math.pow(2, currentRetries);
|
||||
console.log(`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`);
|
||||
|
||||
setTimeout(() => {
|
||||
enqueueMerge(taskId);
|
||||
}, delayMs);
|
||||
} else {
|
||||
// Max retries exceeded or auto-resolve disabled - keep in in-review
|
||||
if (currentRetries >= maxRetries) {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`);
|
||||
} else {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: autoResolveConflicts disabled — manual resolution required`);
|
||||
}
|
||||
// Reset task status so it doesn't appear stuck as "merging" in the UI
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
} else {
|
||||
// Non-conflict error - reset task status
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
} finally {
|
||||
mergeActive.delete(taskId);
|
||||
}
|
||||
|
||||
@@ -345,6 +345,19 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoResolveConflicts setting", () => {
|
||||
it("persists autoResolveConflicts and returns it via getSettings", async () => {
|
||||
await store.updateSettings({ autoResolveConflicts: false });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoResolveConflicts).toBe(false);
|
||||
});
|
||||
|
||||
it("default settings have autoResolveConflicts set to true", async () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.autoResolveConflicts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent stress test ───────────────────────────────────────
|
||||
|
||||
describe("concurrent stress", () => {
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface Task {
|
||||
log: TaskLogEntry[];
|
||||
size?: "S" | "M" | "L";
|
||||
reviewLevel?: number;
|
||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||
mergeRetries?: number;
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
@@ -177,6 +179,11 @@ export interface Settings {
|
||||
* produce better results but cost more. When undefined, the engine
|
||||
* uses the model's default thinking level. */
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
/** When true, auto-merge will automatically resolve common conflict patterns
|
||||
* (lock files, generated files, trivial conflicts) without requiring AI
|
||||
* intervention. When AI resolution fails, the system will retry with escalating
|
||||
* strategies. Default: true. */
|
||||
autoResolveConflicts?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -194,6 +201,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
autoResolveConflicts: true,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
@@ -208,6 +216,10 @@ export interface MergeResult {
|
||||
worktreeRemoved: boolean;
|
||||
branchDeleted: boolean;
|
||||
error?: string;
|
||||
/** Strategy that successfully resolved the merge, if any */
|
||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
|
||||
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
|
||||
attemptsMade?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export const COLUMN_LABELS: Record<Column, string> = {
|
||||
|
||||
@@ -11,17 +11,26 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { aiMergeTask, findWorktreeUser } from "./merger.js";
|
||||
import {
|
||||
aiMergeTask,
|
||||
findWorktreeUser,
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
type ConflictCategory,
|
||||
} from "./merger.js";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@kb/core";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
@@ -679,3 +688,693 @@ describe("aiMergeTask — onSession callback", () => {
|
||||
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Conflict Detection & Auto-Resolution ─────────────────────────────────
|
||||
|
||||
describe("detectResolvableConflicts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array when no conflicts exist", () => {
|
||||
mockedExecSync.mockReturnValue(""); // Empty output = no conflicts
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects package-lock.json as auto-resolvable with 'theirs' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("package-lock.json\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "package-lock.json",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects pnpm-lock.yaml as lock file with 'ours' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("pnpm-lock.yaml\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "pnpm-lock.yaml",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects yarn.lock as lock file with 'ours' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("yarn.lock\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Gemfile.lock as lock file with 'ours' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("Gemfile.lock\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects .gen.ts files as generated files with 'ours' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects dist/ paths as generated files", () => {
|
||||
mockedExecSync.mockReturnValue("dist/index.js\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects coverage/ paths as generated files", () => {
|
||||
mockedExecSync.mockReturnValue("coverage/lcov-report/index.html\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks regular source files as complex conflicts", () => {
|
||||
mockedExecSync.mockReturnValue("src/components/App.tsx\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/components/App.tsx",
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple conflicted files with mixed categories", () => {
|
||||
mockedExecSync.mockReturnValue(
|
||||
"package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n",
|
||||
);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const lockFile = result.find((r) => r.filePath === "package-lock.json");
|
||||
const sourceFile = result.find((r) => r.filePath === "src/components/App.tsx");
|
||||
const distFile = result.find((r) => r.filePath === "dist/bundle.js");
|
||||
|
||||
expect(lockFile).toMatchObject({ autoResolvable: true, reason: "lock-file" });
|
||||
expect(sourceFile).toMatchObject({ autoResolvable: false, reason: "complex" });
|
||||
expect(distFile).toMatchObject({ autoResolvable: true, reason: "generated-file" });
|
||||
});
|
||||
|
||||
it("returns empty array on git command failure", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("git command failed");
|
||||
});
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoResolveFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock returns empty buffer for all git commands
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --theirs for 'theirs' resolution", () => {
|
||||
autoResolveFile("package-lock.json", "theirs", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git checkout --theirs"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("calls git checkout --ours for 'ours' resolution", () => {
|
||||
autoResolveFile("config.json", "ours", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git checkout --ours"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("config.json");
|
||||
});
|
||||
|
||||
it("stages the resolved file with git add", () => {
|
||||
autoResolveFile("package-lock.json", "theirs", "/tmp/root");
|
||||
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(addCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("throws error when git checkout fails", () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd).includes("checkout")) {
|
||||
throw new Error("checkout failed");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
expect(() => autoResolveFile("file.ts", "theirs", "/tmp/root")).toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConflicts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock - success
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("resolves lock files and returns remaining complex conflicts", () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
|
||||
{ filePath: "dist/bundle.js", autoResolvable: true, strategy: "ours", reason: "generated-file" },
|
||||
];
|
||||
|
||||
const remaining = resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
// Should have resolved package-lock.json and dist/bundle.js
|
||||
expect(remaining).toEqual(["src/App.tsx"]);
|
||||
|
||||
// Should have called checkout and add for resolved files
|
||||
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("checkout"),
|
||||
);
|
||||
expect(checkoutCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns all files when none are auto-resolvable", () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
|
||||
{ filePath: "src/utils.ts", autoResolvable: false, reason: "complex" },
|
||||
];
|
||||
|
||||
const remaining = resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
expect(remaining).toEqual(["src/App.tsx", "src/utils.ts"]);
|
||||
// No checkout calls should be made
|
||||
const checkoutCalls = mockedExecSync.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("checkout"),
|
||||
);
|
||||
expect(checkoutCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when all conflicts are resolved", () => {
|
||||
const categories: ConflictCategory[] = [
|
||||
{ filePath: "package-lock.json", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
{ filePath: "yarn.lock", autoResolvable: true, strategy: "ours", reason: "lock-file" },
|
||||
];
|
||||
|
||||
const remaining = resolveConflicts(categories, "/tmp/root");
|
||||
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Trivial Conflict Detection Tests ──────────────────────────────────────
|
||||
|
||||
describe("trivial conflict detection (isTrivialConflict via detectResolvableConflicts)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("detects whitespace-only conflicts as trivial", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
|
||||
const fileContent = `function foo() {
|
||||
<<<<<<< HEAD
|
||||
return 1;
|
||||
=======
|
||||
return 1;
|
||||
>>>>>>> feature-branch
|
||||
}`;
|
||||
|
||||
mockedReadFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/utils.ts",
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "trivial",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects conflicts with different line endings as trivial", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
|
||||
// Same content but different line ending style - CRLF vs LF
|
||||
const fileContent = "const x = 1;\r\n<<<<<<< HEAD\r\nconst y = 2;\r\n=======\r\nconst y = 2;\n>>>>>>> feature-branch";
|
||||
|
||||
mockedReadFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
reason: "trivial",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks conflicts with actual content differences as complex", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
|
||||
const fileContent = `function foo() {
|
||||
<<<<<<< HEAD
|
||||
return 1;
|
||||
=======
|
||||
return 2;
|
||||
>>>>>>> feature-branch
|
||||
}`;
|
||||
|
||||
mockedReadFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: "src/utils.ts",
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple conflict sections - all trivial", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
|
||||
const fileContent = `function foo() {
|
||||
<<<<<<< HEAD
|
||||
return 1;
|
||||
=======
|
||||
return 1;
|
||||
>>>>>>> feature-branch
|
||||
}
|
||||
function bar() {
|
||||
<<<<<<< Updated upstream
|
||||
const x = 2;
|
||||
=======
|
||||
const x = 2;
|
||||
>>>>>>> feature-branch
|
||||
}`;
|
||||
|
||||
mockedReadFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
reason: "trivial",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple conflict sections - one non-trivial makes complex", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
|
||||
const fileContent = `function foo() {
|
||||
<<<<<<< HEAD
|
||||
return 1;
|
||||
=======
|
||||
return 1;
|
||||
>>>>>>> feature-branch
|
||||
}
|
||||
function bar() {
|
||||
<<<<<<< Updated upstream
|
||||
const x = 2;
|
||||
=======
|
||||
const x = 999;
|
||||
>>>>>>> feature-branch
|
||||
}`;
|
||||
|
||||
mockedReadFileSync.mockReturnValue(fileContent);
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles file read errors as complex conflicts", () => {
|
||||
mockedExecSync.mockReturnValue("src/utils.ts\n");
|
||||
mockedReadFileSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT: no such file");
|
||||
});
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Retry Logic Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("aiMergeTask — retry logic with escalating strategies", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
// Default mock: successful happy path
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) return Buffer.from("");
|
||||
// Post-squash check: "1" = has staged changes
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
// Post-agent check: "0" = committed
|
||||
if (cmdStr.includes("diff --cached") && !cmdStr.includes("--quiet")) return "" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("attempt 1 success: sets resolutionStrategy to 'ai' and attemptsMade to 1", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
// Clean merge with no conflicts - simulate empty diff for conflicts
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
// No conflicts
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
|
||||
// Has staged changes that need committing
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.resolutionStrategy).toBe("ai");
|
||||
expect(result.attemptsMade).toBe(1);
|
||||
});
|
||||
|
||||
it("with autoResolveConflicts disabled: only makes 1 attempt on conflict", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
autoResolveConflicts: false, // Disabled
|
||||
});
|
||||
|
||||
let agentCallCount = 0;
|
||||
|
||||
// Simulate: merge succeeds but leaves conflicts, agent is called but fails
|
||||
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("--stat")) return "1 file changed";
|
||||
|
||||
if (cmdStr.includes("merge --squash")) {
|
||||
// Merge command succeeds but leaves conflict markers
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
// Conflict detection returns conflicts
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
return "src/file.ts\n";
|
||||
}
|
||||
|
||||
// Staged changes check after merge (conflicts present but not staged)
|
||||
if (cmdStr.includes("diff --cached --quiet")) {
|
||||
return "1"; // Has staged changes from the merge
|
||||
}
|
||||
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
// Agent will be called and will fail
|
||||
mockedCreateHaiAgent.mockImplementation(() => {
|
||||
agentCallCount++;
|
||||
return Promise.resolve({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow();
|
||||
|
||||
// Should have called agent exactly once (no retries since autoResolve is disabled)
|
||||
expect(agentCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it("attempt 1 fails, attempt 2 auto-resolves lock files: sets resolutionStrategy to 'auto-resolve'", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
let mergeCallCount = 0;
|
||||
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("--stat")) return "1 file changed";
|
||||
|
||||
if (cmdStr.includes("merge --squash")) {
|
||||
mergeCallCount++;
|
||||
if (mergeCallCount === 1) {
|
||||
// First attempt: conflict
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
// Second attempt succeeds after auto-resolution
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
// First time: return lock file, second time: empty
|
||||
if (mergeCallCount === 1) return "package-lock.json\n";
|
||||
return "";
|
||||
}
|
||||
|
||||
if (cmdStr.includes("checkout --ours")) return Buffer.from("");
|
||||
if (cmdStr.includes("git add")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "0"; // All resolved
|
||||
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
// Agent should not be called since all conflicts are auto-resolved
|
||||
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.resolutionStrategy).toBe("auto-resolve");
|
||||
expect(result.attemptsMade).toBe(2);
|
||||
});
|
||||
|
||||
it("attempt 3 uses -X theirs strategy: sets resolutionStrategy to 'theirs'", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
let squashCallCount = 0;
|
||||
let theirsCallCount = 0;
|
||||
let hasConflicts = true;
|
||||
let agentCallCount = 0;
|
||||
|
||||
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("--stat")) return "1 file changed";
|
||||
|
||||
// First two regular squash merges fail with conflicts
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
|
||||
squashCallCount++;
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
|
||||
// Third attempt with -X theirs succeeds (no conflicts)
|
||||
if (cmdStr.includes("merge -X theirs --squash")) {
|
||||
theirsCallCount++;
|
||||
hasConflicts = false;
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
// After -X theirs, no conflicts
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
return hasConflicts ? "src/complex.ts\n" : "";
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --cached --quiet")) return hasConflicts ? "1" : "0";
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
// Agent fails on attempt 2 (when called to resolve complex conflicts)
|
||||
mockedCreateHaiAgent.mockImplementation(() => {
|
||||
agentCallCount++;
|
||||
if (agentCallCount === 1) {
|
||||
// First agent call (attempt 2) fails
|
||||
return Promise.resolve({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
// Should not reach here
|
||||
return Promise.resolve({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.resolutionStrategy).toBe("theirs");
|
||||
expect(result.attemptsMade).toBe(3);
|
||||
expect(theirsCallCount).toBe(1); // -X theirs was used once
|
||||
expect(agentCallCount).toBe(1); // Agent was called once (on attempt 2, which failed)
|
||||
});
|
||||
|
||||
it("all 3 attempts fail: throws error and calls git reset --merge", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const resetCalls: string[] = [];
|
||||
|
||||
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("--stat")) return "1 file changed";
|
||||
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X theirs")) {
|
||||
// All merge attempts fail with conflicts
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
return "src/always-conflicts.ts\n"; // Always has conflicts
|
||||
}
|
||||
|
||||
if (cmdStr.includes("reset --merge")) {
|
||||
resetCalls.push(cmdStr);
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
// Agent will also fail
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
|
||||
"all 3 attempts exhausted",
|
||||
);
|
||||
|
||||
// Should have cleanup calls after each failed attempt plus final cleanup
|
||||
expect(resetCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
// Clean merge with no conflicts
|
||||
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("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return ""; // No conflicts
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1"; // Has staged changes
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.resolutionStrategy).toBe("ai");
|
||||
expect(result.attemptsMade).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { TaskStore, Task, MergeResult } from "@kb/core";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
@@ -7,6 +7,190 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
/** Conflict category for a file with merge conflicts */
|
||||
export type ConflictResolution = "ours" | "theirs";
|
||||
|
||||
export interface ConflictCategory {
|
||||
filePath: string;
|
||||
/** Whether this conflict can be auto-resolved without AI */
|
||||
autoResolvable: boolean;
|
||||
/** Resolution strategy: 'ours' = take current branch, 'theirs' = take incoming branch */
|
||||
strategy?: ConflictResolution;
|
||||
/** Reason for the categorization */
|
||||
reason: "lock-file" | "generated-file" | "trivial" | "complex";
|
||||
}
|
||||
|
||||
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
|
||||
const LOCK_FILE_PATTERNS = [
|
||||
/package-lock\.json$/,
|
||||
/pnpm-lock\.yaml$/,
|
||||
/yarn\.lock$/,
|
||||
/Gemfile\.lock$/,
|
||||
/Cargo\.lock$/,
|
||||
/composer\.lock$/,
|
||||
/poetry\.lock$/,
|
||||
];
|
||||
|
||||
/** Generated file patterns that should auto-resolve using "ours" */
|
||||
const GENERATED_FILE_PATTERNS = [
|
||||
/\.gen\.(ts|js|tsx|jsx|mjs|cjs)$/,
|
||||
/dist\//,
|
||||
/coverage\//,
|
||||
/\.next\//,
|
||||
/\.nuxt\//,
|
||||
/\.output\//,
|
||||
/\.cache\//,
|
||||
/__generated__\//,
|
||||
/generated\//,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect and categorize merge conflicts in the working directory.
|
||||
* Returns array of ConflictCategory for each conflicted file.
|
||||
*/
|
||||
export function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
|
||||
try {
|
||||
// Get list of conflicted files
|
||||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (!conflictedOutput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const conflictedFiles = conflictedOutput.split("\n").filter(Boolean);
|
||||
|
||||
return conflictedFiles.map((filePath): ConflictCategory => {
|
||||
// Check for lock files - always take "ours" (current branch's version)
|
||||
if (LOCK_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
||||
return {
|
||||
filePath,
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "lock-file",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for generated files - take "ours" (regenerate after merge)
|
||||
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
||||
return {
|
||||
filePath,
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
reason: "generated-file",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for trivial conflicts (whitespace-only)
|
||||
if (isTrivialConflict(filePath, rootDir)) {
|
||||
return {
|
||||
filePath,
|
||||
autoResolvable: true,
|
||||
strategy: "ours", // Either would work, but ours is current branch
|
||||
reason: "trivial",
|
||||
};
|
||||
}
|
||||
|
||||
// Complex conflicts require AI intervention
|
||||
return {
|
||||
filePath,
|
||||
autoResolvable: false,
|
||||
reason: "complex",
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
mergerLog.error(`Failed to detect conflicts: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conflicted file has only trivial changes (whitespace-only differences).
|
||||
* Reads the working directory file and compares the conflict sections.
|
||||
*/
|
||||
function isTrivialConflict(filePath: string, rootDir: string): boolean {
|
||||
try {
|
||||
const fullPath = `${rootDir}/${filePath}`;
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
|
||||
// Look for conflict markers - support any text after <<<<<<< (HEAD, ours, Updated upstream, etc.)
|
||||
const conflictRegex = /<<<<<<<\s+.+?[\s\S]*?^=======([\s\S]*?)^>>>>>>>\s+/gm;
|
||||
let hasConflicts = false;
|
||||
|
||||
for (const match of content.matchAll(conflictRegex)) {
|
||||
hasConflicts = true;
|
||||
const fullMatch = match[0];
|
||||
const theirsContent = match[1];
|
||||
|
||||
// Extract "ours" content (between <<<<<<< line and ======= line)
|
||||
const oursMatch = fullMatch.match(/<<<<<<<\s+.+?\n([\s\S]*?)\n=======/);
|
||||
if (!oursMatch) continue;
|
||||
|
||||
const oursContent = oursMatch[1];
|
||||
|
||||
// Normalize: remove all whitespace and compare
|
||||
const oursNormalized = oursContent.replace(/\s+/g, "");
|
||||
const theirsNormalized = theirsContent.replace(/\s+/g, "");
|
||||
|
||||
// If content is the same after stripping whitespace, it's trivial
|
||||
if (oursNormalized !== theirsNormalized) {
|
||||
return false; // Real content difference found
|
||||
}
|
||||
}
|
||||
|
||||
return hasConflicts; // Only trivial if we found conflicts and they're all trivial
|
||||
} catch {
|
||||
return false; // On error, assume complex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resolve a single file using git checkout --ours or --theirs.
|
||||
* Stages the resolved file.
|
||||
*/
|
||||
export function autoResolveFile(
|
||||
filePath: string,
|
||||
resolution: ConflictResolution,
|
||||
rootDir: string,
|
||||
): void {
|
||||
try {
|
||||
execSync(`git checkout --${resolution} "${filePath}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git add "${filePath}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
mergerLog.log(`Auto-resolved ${filePath} using --${resolution}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resolve all resolvable conflicts from the categorization.
|
||||
* Returns the list of remaining complex conflicts that need AI resolution.
|
||||
*/
|
||||
export function resolveConflicts(
|
||||
categories: ConflictCategory[],
|
||||
rootDir: string,
|
||||
): string[] {
|
||||
const remainingComplex: string[] = [];
|
||||
|
||||
for (const category of categories) {
|
||||
if (category.autoResolvable && category.strategy) {
|
||||
autoResolveFile(category.filePath, category.strategy, rootDir);
|
||||
} else {
|
||||
remainingComplex.push(category.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return remainingComplex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the merge system prompt. When `includeTaskId` is true (default),
|
||||
* the commit format uses `<type>(<scope>): <summary>` where scope is the
|
||||
@@ -114,8 +298,11 @@ export interface MergerOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-powered merge: resolves conflicts with a pi agent and
|
||||
* writes a commit message that summarizes the branch's work.
|
||||
* AI-powered merge with 3-attempt retry logic when autoResolveConflicts is enabled.
|
||||
*
|
||||
* Attempt 1: Standard merge + AI agent with full context
|
||||
* Attempt 2 (if enabled and Attempt 1 failed): Auto-resolve lock/generated files, retry AI
|
||||
* Attempt 3 (if enabled and Attempt 2 failed): Reset and use git merge -X theirs --squash
|
||||
*
|
||||
* When `options.pool` is provided and `recycleWorktrees` is enabled in
|
||||
* settings, the worktree is detached from its branch and released to the
|
||||
@@ -151,9 +338,10 @@ export async function aiMergeTask(
|
||||
mergerLog.warn(`${taskId}: no worktree path set — skipping worktree cleanup`);
|
||||
}
|
||||
|
||||
// 2. Read settings early (reused later for recycleWorktrees)
|
||||
// 2. Read settings
|
||||
const settings = await store.getSettings();
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
const autoResolveConflicts = settings.autoResolveConflicts !== false;
|
||||
|
||||
// 3. Check branch exists
|
||||
try {
|
||||
@@ -167,7 +355,7 @@ export async function aiMergeTask(
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Gather context for the agent
|
||||
// 4. Gather context for the agent (used in all attempts)
|
||||
let commitLog = "";
|
||||
let diffStat = "";
|
||||
try {
|
||||
@@ -187,131 +375,82 @@ export async function aiMergeTask(
|
||||
diffStat = "(unable to read diff)";
|
||||
}
|
||||
|
||||
// 4. Start the merge (--no-commit so the agent controls the message)
|
||||
let hasConflicts = false;
|
||||
try {
|
||||
execSync(`git merge --squash "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
// 5. Execute merge with retry logic
|
||||
await store.updateTask(taskId, { status: "merging" });
|
||||
|
||||
// If the squash staged nothing, the branch's changes are already on main
|
||||
// (e.g. branch was based on a dep that has since been merged). Skip the
|
||||
// agent entirely — there is nothing to commit.
|
||||
const squashIsEmpty = execSync(
|
||||
"git diff --cached --quiet 2>&1; echo $?",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
).trim() === "0";
|
||||
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
|
||||
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
|
||||
|
||||
if (squashIsEmpty) {
|
||||
mergerLog.log(`${taskId}: squash merge staged nothing — branch already merged via dependency`);
|
||||
result.merged = true;
|
||||
}
|
||||
} catch {
|
||||
// Conflicts or other merge issue — check if it's conflicts
|
||||
try {
|
||||
const conflicted = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
hasConflicts = conflicted.length > 0;
|
||||
// Try the merge with appropriate strategy for this attempt
|
||||
const success = await executeMergeAttempt({
|
||||
store,
|
||||
rootDir,
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
autoResolveConflicts,
|
||||
attemptNum,
|
||||
options,
|
||||
});
|
||||
|
||||
if (!hasConflicts) {
|
||||
// Not conflicts — some other merge failure. Abort and throw.
|
||||
if (success) {
|
||||
result.attemptsMade = attemptNum;
|
||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, autoResolveConflicts);
|
||||
result.merged = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not successful and we have more attempts, clean up and try again
|
||||
if (attemptNum < 3) {
|
||||
mergerLog.log(`${taskId}: attempt ${attemptNum} failed, cleaning up for retry...`);
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`Merge failed for branch '${branch}'`);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message.includes("Merge failed")) throw e;
|
||||
// git diff itself failed — abort
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`Merge failed for branch '${branch}'`);
|
||||
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
// Clean up on error before potentially rethrowing or retrying
|
||||
if (attemptNum < 3 && autoResolveConflicts) {
|
||||
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
return false; // Allow retry
|
||||
}
|
||||
throw error; // Last attempt or auto-resolve disabled - propagate error
|
||||
}
|
||||
};
|
||||
|
||||
// Execute attempts with escalation
|
||||
let merged = false;
|
||||
|
||||
// Attempt 1: Standard AI merge
|
||||
merged = await mergeAttempt(1);
|
||||
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
|
||||
if (!merged && autoResolveConflicts) {
|
||||
merged = await mergeAttempt(2);
|
||||
}
|
||||
|
||||
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message.
|
||||
// Skip entirely when the squash staged nothing (branch already merged via dep).
|
||||
if (!result.merged) {
|
||||
await store.updateTask(taskId, { status: "merging" });
|
||||
|
||||
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
store,
|
||||
taskId,
|
||||
agent: "merger",
|
||||
// Merger callbacks don't include taskId — wrap to match AgentLogger signature
|
||||
onAgentText: options.onAgentText
|
||||
? (_id, delta) => options.onAgentText!(delta)
|
||||
: undefined,
|
||||
onAgentTool: options.onAgentTool
|
||||
? (_id, name) => options.onAgentTool!(name)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Forward model settings from store so the merger honours the user's model choice
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
||||
tools: "coding",
|
||||
onText: agentLogger.onText,
|
||||
onThinking: agentLogger.onThinking,
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
// Notify the caller so it can track/dispose the session externally (e.g. on global pause)
|
||||
options.onSession?.(session);
|
||||
// Attempt 3: Use -X theirs merge strategy (if enabled)
|
||||
if (!merged && autoResolveConflicts) {
|
||||
merged = await mergeAttempt(3);
|
||||
}
|
||||
|
||||
// If all attempts failed
|
||||
if (!merged) {
|
||||
// Final cleanup
|
||||
try {
|
||||
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
checkSessionError(session);
|
||||
|
||||
// 6. Verify the commit happened — if there are still staged changes, agent didn't commit
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (staged !== "0") {
|
||||
mergerLog.log("Agent didn't commit — committing with fallback message");
|
||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
execSync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
|
||||
result.merged = true;
|
||||
} catch (err: any) {
|
||||
// Agent failed — try to abort the merge
|
||||
mergerLog.error(`Agent failed: ${err.message}`);
|
||||
// Check if the error is a usage-limit error and trigger global pause
|
||||
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
|
||||
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
|
||||
}
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
|
||||
} finally {
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
}
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
|
||||
}
|
||||
|
||||
// 7. Delete branch (always per-task, regardless of worktree sharing)
|
||||
// 6. Delete branch
|
||||
try {
|
||||
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||
result.branchDeleted = true;
|
||||
@@ -322,7 +461,7 @@ export async function aiMergeTask(
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 8. Clean up worktree — only if no other non-done task still references it
|
||||
// 7. Clean up worktree
|
||||
if (worktreePath && existsSync(worktreePath)) {
|
||||
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
|
||||
if (otherUser) {
|
||||
@@ -342,31 +481,367 @@ export async function aiMergeTask(
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Move task to done
|
||||
// 8. Move task to done
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function completeTask(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
result: MergeResult,
|
||||
): Promise<void> {
|
||||
// Clear transient status before moving to done
|
||||
await store.updateTask(taskId, { status: null });
|
||||
// Use moveTask for proper event emission
|
||||
const task = await store.moveTask(taskId, "done");
|
||||
result.task = task;
|
||||
store.emit("task:merged", result);
|
||||
/** Get the resolution strategy based on attempt number and settings */
|
||||
function getResolutionStrategy(
|
||||
attemptNum: 1 | 2 | 3,
|
||||
autoResolveConflicts: boolean,
|
||||
): MergeResult["resolutionStrategy"] {
|
||||
if (!autoResolveConflicts || attemptNum === 1) {
|
||||
return "ai";
|
||||
}
|
||||
if (attemptNum === 2) {
|
||||
return "auto-resolve";
|
||||
}
|
||||
return "theirs";
|
||||
}
|
||||
|
||||
function buildMergePrompt(
|
||||
taskId: string,
|
||||
branch: string,
|
||||
commitLog: string,
|
||||
diffStat: string,
|
||||
hasConflicts: boolean,
|
||||
): string {
|
||||
interface MergeAttemptParams {
|
||||
store: TaskStore;
|
||||
rootDir: string;
|
||||
taskId: string;
|
||||
branch: string;
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
includeTaskId: boolean;
|
||||
autoResolveConflicts: boolean;
|
||||
attemptNum: 1 | 2 | 3;
|
||||
options: MergerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single merge attempt with the specified strategy.
|
||||
* Returns true if merge succeeded, false if should retry (for attempts 1-2).
|
||||
* Throws on unrecoverable errors.
|
||||
*/
|
||||
async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean> {
|
||||
const {
|
||||
store,
|
||||
rootDir,
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
autoResolveConflicts,
|
||||
attemptNum,
|
||||
options,
|
||||
} = params;
|
||||
|
||||
// Attempt 3: Use -X theirs strategy
|
||||
if (attemptNum === 3) {
|
||||
return attemptWithTheirsStrategy(params);
|
||||
}
|
||||
|
||||
// Attempt 1 & 2: Standard squash merge
|
||||
let hasConflicts = false;
|
||||
try {
|
||||
// For attempt 2, try with auto-resolution first
|
||||
if (attemptNum === 2 && autoResolveConflicts) {
|
||||
// First, do a standard merge to get conflicts
|
||||
// Note: git merge --squash exits with code 1 when conflicts exist
|
||||
// This is expected - we catch it and proceed with auto-resolution
|
||||
let mergeExitedWithConflicts = false;
|
||||
try {
|
||||
execSync(`git merge --squash "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Merge exits with code 1 when conflicts exist - this is expected
|
||||
mergeExitedWithConflicts = true;
|
||||
}
|
||||
|
||||
// Check if we have conflicts (either from merge throwing or detected conflicts)
|
||||
const conflictCategories = detectResolvableConflicts(rootDir);
|
||||
if (conflictCategories.length > 0 || mergeExitedWithConflicts) {
|
||||
const autoResolvable = conflictCategories.filter((c) => c.autoResolvable);
|
||||
const complex = conflictCategories.filter((c) => !c.autoResolvable);
|
||||
|
||||
if (autoResolvable.length > 0) {
|
||||
mergerLog.log(
|
||||
`${taskId}: auto-resolving ${autoResolvable.length} lock/generated file(s) before AI retry`,
|
||||
);
|
||||
resolveConflicts(conflictCategories, rootDir);
|
||||
}
|
||||
|
||||
// If only auto-resolvable conflicts, commit them directly
|
||||
if (complex.length === 0) {
|
||||
// All conflicts auto-resolved, commit with fallback message
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (staged !== "0") {
|
||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
execSync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed after auto-resolving conflicts`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Has complex conflicts - continue to AI agent with simplified context
|
||||
hasConflicts = true;
|
||||
} else {
|
||||
// No conflicts - check if squash is empty
|
||||
const squashIsEmpty = execSync(
|
||||
"git diff --cached --quiet 2>&1; echo $?",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
).trim() === "0";
|
||||
|
||||
if (squashIsEmpty) {
|
||||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
||||
return true;
|
||||
}
|
||||
// No conflicts but has staged changes - continue to AI for commit message
|
||||
}
|
||||
} else {
|
||||
// Attempt 1: Standard merge
|
||||
execSync(`git merge --squash "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Check if squash is empty
|
||||
const squashIsEmpty = execSync(
|
||||
"git diff --cached --quiet 2>&1; echo $?",
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
).trim() === "0";
|
||||
|
||||
if (squashIsEmpty) {
|
||||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
hasConflicts = conflictedOutput.length > 0;
|
||||
|
||||
if (hasConflicts && !autoResolveConflicts) {
|
||||
// No auto-resolve - AI will handle all conflicts
|
||||
mergerLog.log(`${taskId}: conflicts detected, AI will resolve`);
|
||||
} else if (hasConflicts && autoResolveConflicts) {
|
||||
// Has conflicts and auto-resolve enabled - should be handled in attempt 2
|
||||
// Reset and return false to trigger attempt 2
|
||||
mergerLog.log(`${taskId}: conflicts detected, will retry with auto-resolution`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, either:
|
||||
// - No conflicts (attempt 1) - AI writes commit message
|
||||
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
|
||||
// Spawn AI agent
|
||||
return await runAiAgentForCommit({
|
||||
store,
|
||||
rootDir,
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
hasConflicts,
|
||||
simplifiedContext: attemptNum === 2,
|
||||
options,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Check if it's a non-conflict merge failure
|
||||
if (error.message?.includes("Merge failed")) {
|
||||
throw error; // Fatal
|
||||
}
|
||||
|
||||
// For attempt 1, return false to trigger attempt 2
|
||||
if (attemptNum === 1 && autoResolveConflicts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise propagate
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt 3: Use git merge -X theirs --squash strategy
|
||||
*/
|
||||
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
|
||||
const { rootDir, branch, commitLog, includeTaskId, taskId } = params;
|
||||
|
||||
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
|
||||
|
||||
try {
|
||||
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
|
||||
execSync(`git merge -X theirs --squash "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Check if there are still conflicts (some types can't be auto-resolved)
|
||||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (conflictedOutput.length > 0) {
|
||||
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
|
||||
return false; // Still has conflicts after -X theirs
|
||||
}
|
||||
|
||||
// Check if there's anything staged
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (staged === "0") {
|
||||
// Nothing staged - already merged
|
||||
return true;
|
||||
}
|
||||
|
||||
// Commit with fallback message
|
||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
execSync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface AiAgentParams {
|
||||
store: TaskStore;
|
||||
rootDir: string;
|
||||
taskId: string;
|
||||
branch: string;
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
includeTaskId: boolean;
|
||||
hasConflicts: boolean;
|
||||
simplifiedContext: boolean;
|
||||
options: MergerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the AI agent to resolve conflicts and/or write commit message.
|
||||
*/
|
||||
async function runAiAgentForCommit(params: AiAgentParams): Promise<boolean> {
|
||||
const {
|
||||
store,
|
||||
rootDir,
|
||||
taskId,
|
||||
branch,
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
hasConflicts,
|
||||
simplifiedContext,
|
||||
options,
|
||||
} = params;
|
||||
|
||||
const settings = await store.getSettings();
|
||||
|
||||
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
store,
|
||||
taskId,
|
||||
agent: "merger",
|
||||
onAgentText: options.onAgentText
|
||||
? (_id, delta) => options.onAgentText!(delta)
|
||||
: undefined,
|
||||
onAgentTool: options.onAgentTool
|
||||
? (_id, name) => options.onAgentTool!(name)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
||||
tools: "coding",
|
||||
onText: agentLogger.onText,
|
||||
onThinking: agentLogger.onThinking,
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
options.onSession?.(session);
|
||||
|
||||
try {
|
||||
// Build appropriate prompt
|
||||
const prompt = buildMergePrompt({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog: simplifiedContext ? "(see branch commits)" : commitLog,
|
||||
diffStat,
|
||||
hasConflicts,
|
||||
simplifiedContext,
|
||||
});
|
||||
await session.prompt(prompt);
|
||||
|
||||
checkSessionError(session);
|
||||
|
||||
// Verify commit happened
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (staged !== "0") {
|
||||
mergerLog.log("Agent didn't commit — committing with fallback message");
|
||||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||||
execSync(
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
mergerLog.error(`Agent failed: ${err.message}`);
|
||||
|
||||
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
|
||||
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
interface MergePromptParams {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
hasConflicts: boolean;
|
||||
simplifiedContext?: boolean;
|
||||
}
|
||||
|
||||
function buildMergePrompt(params: MergePromptParams): string {
|
||||
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext } = params;
|
||||
|
||||
const parts = [
|
||||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
||||
"",
|
||||
@@ -374,13 +849,18 @@ function buildMergePrompt(
|
||||
"```",
|
||||
commitLog,
|
||||
"```",
|
||||
"",
|
||||
"## Files changed",
|
||||
"```",
|
||||
diffStat,
|
||||
"```",
|
||||
];
|
||||
|
||||
if (!simplifiedContext) {
|
||||
parts.push(
|
||||
"",
|
||||
"## Files changed",
|
||||
"```",
|
||||
diffStat,
|
||||
"```",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
parts.push(
|
||||
"",
|
||||
@@ -400,3 +880,16 @@ function buildMergePrompt(
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
async function completeTask(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
result: MergeResult,
|
||||
): Promise<void> {
|
||||
// Clear transient status before moving to done
|
||||
await store.updateTask(taskId, { status: null });
|
||||
// Use moveTask for proper event emission
|
||||
const task = await store.moveTask(taskId, "done");
|
||||
result.task = task;
|
||||
store.emit("task:merged", result);
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("In-review merge handling after restart", () => {
|
||||
} as any);
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "KB-055")).rejects.toThrow(
|
||||
"AI merge failed for KB-055: merge agent crashed",
|
||||
"AI merge failed for KB-055: all 3 attempts exhausted",
|
||||
);
|
||||
|
||||
// Should have attempted git reset --merge cleanup
|
||||
|
||||
Reference in New Issue
Block a user