feat(FN-4014): split merger monolith into domain-specific test suites

The monolithic `merger.test.ts` (8,229 lines) has been decomposed into nine focused test suites covering conflict resolution, diff scope, merge details, merge lifecycle, post-merge, prompt and utils, session recovery, skills, and verification, along with a shared `merger-test-helpers.ts` module for

Fusion-Task-Id: FN-4014
This commit is contained in:
Fusion
2026-05-11 14:19:47 -07:00
committed by gsxdsm
parent e6e596ebb7
commit 08ab057375
12 changed files with 10675 additions and 8230 deletions

View File

@@ -2074,7 +2074,7 @@ describe("Merger worktree pool integration", () => {
);
});
// Full merger worktree pool integration tests are in merger.test.ts
// Full merger worktree pool integration tests are split across merger-merge-lifecycle.test.ts and related merger-*.test.ts files
// which tests aiMergeTask with real implementation
});

View File

@@ -0,0 +1,898 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.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 mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
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 = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("detectResolvableConflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty array when no conflicts exist", async () => {
mockedExecSync.mockReturnValue(""); // Empty output = no conflicts
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toEqual([]);
});
it("detects package-lock.json as auto-resolvable with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("package-lock.json\n");
const result = await 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", async () => {
mockedExecSync.mockReturnValue("pnpm-lock.yaml\n");
const result = await 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", async () => {
mockedExecSync.mockReturnValue("yarn.lock\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects Gemfile.lock as lock file with 'ours' strategy", async () => {
mockedExecSync.mockReturnValue("Gemfile.lock\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
});
});
it("detects .gen.ts files as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("detects dist/ paths as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockReturnValue("dist/index.js\n");
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("detects coverage/ paths as generated files with 'theirs' strategy", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "coverage/lcov.info\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
});
});
it("marks regular source files as complex conflicts", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/components/App.tsx\n";
// git diff-tree for trivial detection — return real diff content to indicate non-trivial
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result[0]).toMatchObject({
filePath: "src/components/App.tsx",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflicted files with mixed categories", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only"))
return "package-lock.json\nsrc/components/App.tsx\ndist/bundle.js\n";
// git diff-tree for trivial detection — return real diff for source files
if (cmdStr.includes("diff-tree")) return "+real change\n-old line\n";
return Buffer.from("");
});
const result = await 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", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("git command failed");
});
const result = await 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", async () => {
await 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", async () => {
await 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", async () => {
await 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", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd).includes("checkout")) {
throw new Error("checkout failed");
}
return Buffer.from("");
});
await expect(autoResolveFile("file.ts", "theirs", "/tmp/root")).rejects.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", async () => {
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 = await 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", async () => {
const categories: ConflictCategory[] = [
{ filePath: "src/App.tsx", autoResolvable: false, reason: "complex" },
{ filePath: "src/utils.ts", autoResolvable: false, reason: "complex" },
];
const remaining = await 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", async () => {
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 = await resolveConflicts(categories, "/tmp/root");
expect(remaining).toEqual([]);
});
});
// ── Trivial Conflict Detection Tests ──────────────────────────────────────
describe("trivial conflict detection (isTrivialWhitespaceConflict via detectResolvableConflicts)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("detects whitespace-only conflicts as trivial", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// git diff-tree with -w returns empty = trivial whitespace
if (cmdStr.includes("diff-tree")) return "";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: true,
strategy: "ours",
reason: "trivial",
});
});
it("marks conflicts with actual content differences as complex", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// git diff-tree returns real content changes = non-trivial
if (cmdStr.includes("diff-tree")) return "+return 2;\n-return 1;\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
filePath: "src/utils.ts",
autoResolvable: false,
reason: "complex",
});
});
it("handles multiple conflict sections - one non-trivial makes complex", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
// Real diff content = non-trivial
if (cmdStr.includes("diff-tree")) return "+const x = 999;\n-const x = 2;\n";
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
it("handles git command errors as complex conflicts", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --name-only")) return "src/utils.ts\n";
if (cmdStr.includes("diff-tree")) throw new Error("git error");
return Buffer.from("");
});
const result = await detectResolvableConflicts("/tmp/root");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
autoResolvable: false,
reason: "complex",
});
});
});
// ── Retry Logic Tests ───────────────────────────────────────────────────
describe("classifyConflict", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("classifies package-lock.json as 'lockfile-ours'", async () => {
const result = await classifyConflict("package-lock.json", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies pnpm-lock.yaml as 'lockfile-ours'", async () => {
const result = await classifyConflict("pnpm-lock.yaml", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies yarn.lock as 'lockfile-ours'", async () => {
const result = await classifyConflict("yarn.lock", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies Gemfile.lock as 'lockfile-ours'", async () => {
const result = await classifyConflict("Gemfile.lock", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies bun.lockb as 'lockfile-ours'", async () => {
const result = await classifyConflict("bun.lockb", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies go.sum as 'lockfile-ours'", async () => {
const result = await classifyConflict("go.sum", "/tmp/root");
expect(result).toBe("lockfile-ours");
});
it("classifies *.gen.ts files as 'generated-theirs'", async () => {
const result = await classifyConflict("src/types.gen.ts", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies dist/* files as 'generated-theirs'", async () => {
const result = await classifyConflict("dist/bundle.js", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies build/* files as 'generated-theirs'", async () => {
const result = await classifyConflict("build/index.html", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies *.min.js files as 'generated-theirs'", async () => {
const result = await classifyConflict("app.min.js", "/tmp/root");
expect(result).toBe("generated-theirs");
});
it("classifies regular source files as 'complex'", async () => {
// Mock git diff-tree to return actual content changes (non-trivial)
mockedExecSync.mockImplementation(() => {
const error = new Error("exit code 1") as any;
error.stdout = `diff --git a/src/components/App.tsx b/src/components/App.tsx
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1 +1 @@
-const x = 1;
+const x = 2;`;
throw error;
});
mockedReadFileSync.mockReturnValue("const x = 1;");
const result = await classifyConflict("src/components/App.tsx", "/tmp/root");
expect(result).toBe("complex");
});
});
describe("getConflictedFiles", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns array of conflicted file paths", async () => {
mockedExecSync.mockReturnValue("package-lock.json\nsrc/index.ts\n");
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual(["package-lock.json", "src/index.ts"]);
});
it("returns empty array when no conflicts", async () => {
mockedExecSync.mockReturnValue("");
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual([]);
});
it("returns empty array on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("git error");
});
const result = await getConflictedFiles("/tmp/root");
expect(result).toEqual([]);
});
});
describe("resolveWithOurs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --ours and git add", async () => {
await resolveWithOurs("package-lock.json", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("checkout --ours"),
);
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(checkoutCall).toBeDefined();
expect(addCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("package-lock.json");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("checkout failed");
});
await expect(resolveWithOurs("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveWithTheirs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git checkout --theirs and git add", async () => {
await resolveWithTheirs("dist/bundle.js", "/tmp/root");
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("checkout --theirs"),
);
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(checkoutCall).toBeDefined();
expect(addCall).toBeDefined();
expect(String(checkoutCall![0])).toContain("dist/bundle.js");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("checkout failed");
});
await expect(resolveWithTheirs("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("resolveTrivialWhitespace", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("calls git add to resolve trivial whitespace conflict", async () => {
await resolveTrivialWhitespace("src/utils.ts", "/tmp/root");
const addCall = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git add"),
);
expect(addCall).toBeDefined();
expect(String(addCall![0])).toContain("src/utils.ts");
});
it("throws on git error", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("add failed");
});
await expect(resolveTrivialWhitespace("file.ts", "/tmp/root")).rejects.toThrow(
"Failed to auto-resolve",
);
});
});
describe("LOCKFILE_PATTERNS and GENERATED_PATTERNS", () => {
it("LOCKFILE_PATTERNS contains expected lock file patterns", () => {
expect(LOCKFILE_PATTERNS).toContain("package-lock.json");
expect(LOCKFILE_PATTERNS).toContain("pnpm-lock.yaml");
expect(LOCKFILE_PATTERNS).toContain("yarn.lock");
expect(LOCKFILE_PATTERNS).toContain("Gemfile.lock");
expect(LOCKFILE_PATTERNS).toContain("bun.lockb");
expect(LOCKFILE_PATTERNS).toContain("go.sum");
expect(LOCKFILE_PATTERNS).toContain("composer.lock");
expect(LOCKFILE_PATTERNS).toContain("poetry.lock");
expect(LOCKFILE_PATTERNS).not.toContain("Cargo.lock"); // Not in task spec
});
it("GENERATED_PATTERNS contains expected generated file patterns", () => {
expect(GENERATED_PATTERNS).toContain("*.gen.ts");
expect(GENERATED_PATTERNS).toContain("*.gen.js");
expect(GENERATED_PATTERNS).toContain("*.min.js");
expect(GENERATED_PATTERNS).toContain("*.min.css");
expect(GENERATED_PATTERNS).toContain("dist/*");
expect(GENERATED_PATTERNS).toContain("build/*");
expect(GENERATED_PATTERNS).toContain("coverage/*");
expect(GENERATED_PATTERNS).toContain("out/*");
});
});
describe("isTrivialWhitespaceConflict", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns true when diff contains only whitespace changes", async () => {
// Mock git diff-tree to return empty diff (no content changes)
mockedExecSync.mockReturnValue(
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
);
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(true);
});
it("returns false when diff contains content changes", async () => {
// Mock git diff-tree to return diff with actual content changes
mockedExecSync.mockImplementation(() => {
const error = new Error("exit code 1") as any;
error.stdout = `diff --git a/file.ts b/file.ts
--- a/file.ts
+++ b/file.ts
@@ -1 +1 @@
-const x = 1;
+const x = 2;`;
throw error;
});
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(false);
});
it("returns true when only line endings differ (CRLF vs LF)", async () => {
// Mock git diff-tree -w to show no content changes (whitespace ignored)
mockedExecSync.mockReturnValue(
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
);
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(true);
});
it("returns false when git diff-tree fails unexpectedly", async () => {
mockedExecSync.mockImplementation(() => {
throw new Error("fatal: not a git repository");
});
// Mock readFileSync for the fallback
mockedReadFileSync.mockReturnValue("content without conflict markers");
const result = await isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
expect(result).toBe(false);
});
it("calls git diff-tree with correct index references (:2: and :3:)", async () => {
mockedExecSync.mockReturnValue("");
await isTrivialWhitespaceConflict("src/utils.ts", "/tmp/root");
const call = mockedExecSync.mock.calls.find((call) =>
String(call[0]).includes("git diff-tree")
);
expect(call).toBeDefined();
const cmdStr = String(call![0]);
expect(cmdStr).toContain("-w"); // whitespace ignored
expect(cmdStr).toContain(':2:"src/utils.ts"');
expect(cmdStr).toContain(':3:"src/utils.ts"');
});
});
// ── Build Verification Tests ─────────────────────────────────────────

View File

@@ -0,0 +1,609 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.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 mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
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 = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("shouldSyncDependenciesForMerge", () => {
it("returns true when install state is missing", () => {
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);
});
it("returns true when staged files change package manifests or lockfiles", () => {
expect(shouldSyncDependenciesForMerge(["packages/desktop/package.json"], true)).toBe(true);
expect(shouldSyncDependenciesForMerge(["pnpm-lock.yaml"], true)).toBe(true);
});
it("returns false for regular source-only changes when install state exists", () => {
expect(shouldSyncDependenciesForMerge(["packages/engine/src/merger.ts"], true)).toBe(false);
});
});
// ── Pre-merge diffstat scope validation tests ────────────────────────
describe("parseDiffStat", () => {
it("parses standard diffstat output", () => {
const stat = [
" packages/core/src/types.ts | 9 ++--",
" packages/engine/src/notifier.ts | 46 +-----",
" 2 files changed, 10 insertions(+), 45 deletions(-)",
].join("\n");
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(2);
expect(entries[0].file).toBe("packages/core/src/types.ts");
// Rounding may shift total by ±1, so check approximate range
expect(entries[0].insertions + entries[0].deletions).toBeGreaterThanOrEqual(9);
expect(entries[0].insertions + entries[0].deletions).toBeLessThanOrEqual(10);
expect(entries[1].file).toBe("packages/engine/src/notifier.ts");
expect(entries[1].deletions).toBeGreaterThan(entries[1].insertions);
});
it("handles pure-deletion lines", () => {
const stat = " packages/engine/src/usage.ts | 527 ---";
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(1);
expect(entries[0].insertions).toBe(0);
expect(entries[0].deletions).toBe(527);
});
it("handles pure-insertion lines", () => {
const stat = " packages/engine/src/new.ts | 100 +++";
const entries = parseDiffStat(stat);
expect(entries).toHaveLength(1);
expect(entries[0].insertions).toBe(100);
expect(entries[0].deletions).toBe(0);
});
it("returns empty for unreadable stat", () => {
expect(parseDiffStat("(unable to read diff)")).toEqual([]);
expect(parseDiffStat("")).toEqual([]);
});
it("skips summary line", () => {
const stat = " 1 file changed, 5 insertions(+)";
expect(parseDiffStat(stat)).toEqual([]);
});
});
describe("extractFileScope", () => {
it("extracts file patterns from PROMPT.md", () => {
const prompt = [
"# Task: FN-100 - Add feature",
"",
"## File Scope",
"",
"- `packages/core/src/types.ts`",
"- `packages/engine/src/notifier.ts`",
"- `packages/dashboard/app/components/*`",
"",
"## Steps",
"",
"### Step 1: Do things",
].join("\n");
const scope = extractFileScope(prompt);
expect(scope).toEqual([
"packages/core/src/types.ts",
"packages/engine/src/notifier.ts",
"packages/dashboard/app/components/*",
]);
});
it("handles patterns with artifact annotations", () => {
const prompt = [
"## File Scope",
"",
"- `src/foo.ts` (new)",
"- `src/bar.ts` (modified)",
"",
"## Steps",
].join("\n");
const scope = extractFileScope(prompt);
expect(scope).toEqual(["src/foo.ts", "src/bar.ts"]);
});
it("returns empty for missing File Scope section", () => {
const prompt = "# Task\n\n## Steps\n### Step 1\n";
expect(extractFileScope(prompt)).toEqual([]);
});
});
describe("validateDiffScope", () => {
it("returns warnings for large deletions outside scope", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: [
"## File Scope",
"",
"- `packages/dashboard/app/components/Header.tsx`",
"",
"## Steps",
].join("\n"),
}),
logEntry: vi.fn(),
} as unknown as TaskStore;
const diffStat = [
" packages/dashboard/app/components/Header.tsx | 20 ++--",
" packages/engine/src/usage.ts | 527 ---",
" packages/engine/src/usage.test.ts | 524 ---",
" 3 files changed, 5 insertions(+), 1066 deletions(-)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.ts");
expect(result.outOfScopeFiles).toContain("packages/engine/src/usage.test.ts");
expect(result.largeOutOfScopeDeletions).toHaveLength(2);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain("SCOPE WARNING");
});
it("allows changeset files outside scope", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "## File Scope\n\n- `src/foo.ts`\n\n## Steps",
}),
} as unknown as TaskStore;
const diffStat = [
" src/foo.ts | 10 +++",
" .changeset/my-change.md | 5 +++",
" 2 files changed, 15 insertions(+)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).not.toContain(".changeset/my-change.md");
expect(result.warnings).toHaveLength(0);
});
it("returns empty result when no scope is declared", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "# Task\n\n## Steps\n",
}),
} as unknown as TaskStore;
const result = await validateDiffScope(store, "FN-100", " foo.ts | 500 ---");
expect(result.warnings).toHaveLength(0);
});
it("does not warn for in-scope changes", async () => {
const store = {
getTask: vi.fn().mockResolvedValue({
prompt: "## File Scope\n\n- `packages/engine/src/*`\n\n## Steps",
}),
} as unknown as TaskStore;
const diffStat = [
" packages/engine/src/executor.ts | 50 +++---",
" packages/engine/src/triage.ts | 30 +++---",
" 2 files changed, 40 insertions(+), 40 deletions(-)",
].join("\n");
const result = await validateDiffScope(store, "FN-100", diffStat);
expect(result.outOfScopeFiles).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
});
describe("resolveTaskDiffBaseRef", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("prefers merge-base when it differs from head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "merge-base-123" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("merge-base-123");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes("merge-base --is-ancestor"),
),
).toBe(false);
});
it("uses baseCommitSha when merge-base equals head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "head-456" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
if (cmdStr === 'git merge-base --is-ancestor "task-base-789" "HEAD"') return "" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("task-base-789");
});
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') return "parent-123" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBe("parent-123");
});
it("returns undefined when no merge base, no valid baseCommitSha, and no parent commit are available", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') {
throw new Error("single commit repo");
}
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBeUndefined();
});
// FN-3898 regression: legacy/imported tasks may have a stale baseCommitSha
// and no baseBranch. After pre-merge rebase the recorded SHA is older than
// the new merge-base, so `baseCommitSha..branch` includes every unrelated
// commit landed on main since the fork — inflating scope warnings.
it("tightens to merge-base(HEAD, main) when baseBranch is missing and baseCommitSha is an outdated ancestor", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
// No baseBranch → outer merge-base block is skipped.
// Display recovery: merge-base(HEAD, main).
if (cmdStr === 'git merge-base "HEAD" main') return "current-main-sha" as any;
// baseCommitSha is still an ancestor of HEAD…
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "HEAD"') return "" as any;
// …and recoveredBase descends baseCommitSha (rebase fast-forwarded).
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "current-main-sha"') return "" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: undefined,
baseCommitSha: "old-base-sha",
});
expect(diffBase).toBe("current-main-sha");
});
// Preserves the FN-2855 path: when the recovered merge-base is NOT a
// descendant of baseCommitSha (e.g., baseCommitSha lives on a deleted
// upstream feature branch), keep the task-scoped SHA rather than widening
// the diff range.
it("keeps baseCommitSha when recoveredBase does not descend it", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" main') return "unrelated-main-sha" as any;
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "HEAD"') return "" as any;
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "unrelated-main-sha"') {
throw new Error("not an ancestor");
}
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: undefined,
baseCommitSha: "feature-base-sha",
});
expect(diffBase).toBe("feature-base-sha");
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,904 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.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 mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
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 = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("aiMergeTask — post-merge workflow steps", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
} as any);
});
it("runs post-merge workflow steps after successful merge", async () => {
const store = createMockStore();
// Add getWorkflowStep to mock
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Override getTask to include enabledWorkflowSteps
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// getWorkflowStep should have been called for the post-merge step
expect((store as any).getWorkflowStep).toHaveBeenCalledWith("WS-001");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall).toBeDefined();
expect(postMergeAgentCall?.[0]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
expect(postMergeAgentCall?.[0]?.cwd).not.toBe("/tmp/root");
// Task should still move to done even though post-merge step ran
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("uses assigned agent runtime model for post-merge prompt step when workflow step has no override", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
assignedAgentId: "agent-001",
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
runtimeConfig: {
model: "anthropic/claude-3-5-sonnet-20241022",
},
}),
} as any,
});
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("anthropic");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("claude-3-5-sonnet-20241022");
});
it("uses workflow-step model override over assigned agent runtime model", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
modelProvider: "openai",
modelId: "gpt-4.1-mini",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
assignedAgentId: "agent-001",
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
runtimeConfig: {
model: "anthropic/claude-3-5-sonnet-20241022",
},
}),
} as any,
});
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4.1-mini");
const modelLogCall = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any) => String(call[1]).includes("Workflow step 'Post-merge Notify' using model:"),
);
expect(modelLogCall?.[1]).toContain("(workflow step override)");
});
it("falls back to project default override model when no workflow-step or assigned-agent model is set", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check merged code.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
defaultProvider: "anthropic",
defaultModelId: "claude-3-5-haiku-latest",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.defaultProvider).toBe("openai");
expect(postMergeAgentCall?.[0]?.defaultModelId).toBe("gpt-4o-mini");
});
it("does not run pre-merge workflow steps in merger", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Pre-merge Check",
description: "Check before merge",
prompt: "Run pre-merge checks.",
phase: "pre-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// getWorkflowStep may be called but pre-merge steps should not trigger agent creation
// beyond the merge agent itself. We verify createFnAgent was called only once (merge agent)
// since pre-merge steps are skipped in the merger
const mergeAgentCalls = mockedCreateFnAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("You are a merge agent")
);
const postMergeCalls = mockedCreateFnAgent.mock.calls.filter(
(c: any) => c[0]?.systemPrompt?.includes("post-merge")
);
// No post-merge agent should be created for a pre-merge step
expect(postMergeCalls).toHaveLength(0);
});
it("appends post-merge results to existing pre-merge results", async () => {
const existingPreMergeResults = [{
workflowStepId: "WS-001",
workflowStepName: "Pre-merge Check",
phase: "pre-merge",
status: "passed",
output: "All good",
}];
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-002",
name: "Post-merge Verify",
description: "Verify after merge",
prompt: "Check merged state.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001", "WS-002"],
workflowStepResults: existingPreMergeResults,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
// Should have called updateTask with workflow results containing both pre and post
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
const resultsCall = updateCalls.find((c: any) =>
Array.isArray(c[1]?.workflowStepResults) && c[1].workflowStepResults.length > 1
);
if (resultsCall) {
const results = resultsCall[1].workflowStepResults;
// Should contain both pre-merge and post-merge results
expect(results.some((r: any) => r.phase === "pre-merge")).toBe(true);
expect(results.some((r: any) => r.phase === "post-merge")).toBe(true);
}
});
it("moves task to done even when post-merge step fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Fail",
description: "Will fail",
prompt: "Fail this check.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Make the post-merge agent throw
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
if (opts.systemPrompt?.includes("post-merge")) {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Post-merge agent failed")),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}) as any);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed regardless of post-merge step failure
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("runs script-mode post-merge steps", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Build",
description: "Verify build passes",
phase: "post-merge",
mode: "script",
scriptName: "build",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
// Override settings to include scripts
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
scripts: { build: "pnpm build" },
});
// Mock execSync to handle the script execution
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr === "pnpm build") return "Build successful" as any;
return Buffer.from("");
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
const scriptExecCall = mockedExec.mock.calls.find((call: any) => String(call[0]) === "pnpm build");
expect(scriptExecCall).toBeDefined();
expect(scriptExecCall?.[1]?.cwd).toMatch(/\.worktrees\/post-merge-FN-050-[a-z0-9]+/);
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("creates temporary worktree for post-merge steps", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
);
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeAddCall).toBeDefined();
expect(worktreeRemoveCall).toBeDefined();
});
it("falls back to rootDir when worktree creation fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Notify",
description: "Send notifications after merge",
prompt: "Check the merged code and confirm all is well.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
const baseExecImpl = mockedExecSync.getMockImplementation();
mockedExecSync.mockImplementation((cmd: any, opts: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git worktree add") && cmdStr.includes("post-merge-FN-050-")) {
const error: any = new Error("cannot create worktree");
error.stderr = "cannot create worktree";
throw error;
}
return baseExecImpl ? baseExecImpl(cmd, opts) : Buffer.from("");
});
const warnSpy = vi.spyOn(mergerLog, "warn");
await aiMergeTask(store, "/tmp/root", "FN-050");
const postMergeAgentCall = mockedCreateFnAgent.mock.calls.find(
(c: any) => c[0]?.systemPrompt?.includes("post-merge workflow step agent"),
);
expect(postMergeAgentCall?.[0]?.cwd).toBe("/tmp/root");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not create post-merge worktree — falling back to rootDir"));
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("cleans up temporary worktree even when post-merge step fails", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Post-merge Fail",
description: "Will fail",
prompt: "Fail this check.",
phase: "post-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
if (opts.systemPrompt?.includes("post-merge")) {
throw new Error("Post-merge agent creation failed");
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
},
};
}) as any);
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeRemoveCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree remove --force") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeRemoveCall).toBeDefined();
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("does not create post-merge worktree when no post-merge steps exist", async () => {
const store = createMockStore();
(store as any).getWorkflowStep = vi.fn().mockResolvedValue({
id: "WS-001",
name: "Pre-merge Check",
description: "Check before merge",
prompt: "Run pre-merge checks.",
phase: "pre-merge",
mode: "prompt",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const baseTask = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask = vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" });
await aiMergeTask(store, "/tmp/root", "FN-050");
const worktreeAddCall = mockedExec.mock.calls.find((call: any) =>
String(call[0]).includes("git worktree add") && String(call[0]).includes("post-merge-FN-050-"),
);
expect(worktreeAddCall).toBeUndefined();
});
});
// ── Merge Details Collection Tests ─────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,579 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.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 mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
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 = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("aiMergeTask — fresh session and compaction recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
function setupFreshSessionExecSync() {
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
it("creates a fresh session for merge agent via createFnAgent", async () => {
setupFreshSessionExecSync();
const sessionInstances: any[] = [];
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
};
sessionInstances.push(session);
// Use type assertion to match expected return type
return { session } 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],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
// Session should be created once for the merge agent
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
expect(sessionInstances.length).toBe(1);
});
it("disposes session after merge agent completes (finally block)", async () => {
setupFreshSessionExecSync();
const mockDispose = vi.fn();
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: mockDispose,
};
mockedCreateFnAgent.mockResolvedValue({ session: mockSession } 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],
);
await aiMergeTask(store, "/tmp/root", "FN-050");
// Session should be disposed via finally block
expect(mockDispose).toHaveBeenCalledTimes(1);
});
it("imports compactSessionContext and isContextLimitError from respective modules", async () => {
// This test verifies the imports are present in merger.ts
// The actual functionality is tested via behavior verification
const mergerModule = await import("../merger.js");
expect(mergerModule).toBeDefined();
});
});
// ── Merge Prompt Truncation Tests ─────────────────────────────────────
describe("aiMergeTask — context limit recovery with truncation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
function setupContextLimitExecSync() {
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1";
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
it("retries with minimal prompt when context limit hit after auto-compaction", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
const promptCalls: string[] = [];
let firstCall = true;
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
if (firstCall) {
firstCall = false;
throw new Error("context window exceeds limit (2013)");
}
// Second call succeeds
}),
dispose: vi.fn(),
};
return { session } as any;
});
setupContextLimitExecSync();
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],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed after truncated retry
expect(result.merged).toBe(true);
// Should have made 2 prompt calls
expect(promptCalls).toHaveLength(2);
// First call had original prompt, second call should have simplified prompt
expect(promptCalls[0]).toContain("## Branch commits");
// Second call uses simplifiedContext=true, so it should NOT contain "## Files changed"
expect(promptCalls[1]).not.toContain("## Files changed");
// Second call should have the minimal placeholder
expect(promptCalls[1]).toContain("(see git log)");
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("throws when truncated retry also fails with context limit", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls to verify both original and truncated prompts were tried
const promptCalls: string[] = [];
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
// Both calls fail with context limit error
throw new Error("context window exceeds limit (2013)");
}),
dispose: vi.fn(),
};
return { session } as any;
});
// Setup that simulates both attempts failing (first fails, attempts 2 and 3 also fail)
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
// All merge attempts fail
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
throw new Error("merge conflict");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
// git diff-tree for trivial whitespace detection - return real changes (non-trivial)
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("reset --merge")) return Buffer.from("");
return Buffer.from("");
});
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,
smartConflictResolution: true, // Enable all 3 attempts
});
// Should throw after all attempts exhausted
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
// Verify both original and truncated prompts were attempted (2 attempts for attempt 1)
// Each merge attempt calls promptWithFallback twice (original + truncated when compaction fails)
// With 3 merge attempts, this means we should have at least 6 prompt calls total
expect(promptCalls.length).toBeGreaterThan(0);
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("succeeds when prompt succeeds on retry after context error", async () => {
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
const promptCalls: string[] = [];
let firstCall = true;
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
promptCalls.push(prompt);
if (firstCall) {
firstCall = false;
throw new Error("context window exceeds limit (2013)");
}
// Second call succeeds
}),
dispose: vi.fn(),
};
return { session } as any;
});
setupContextLimitExecSync();
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],
);
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed after retry
expect(result.merged).toBe(true);
// Should have made 2 prompt calls
expect(promptCalls).toHaveLength(2);
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("does not attempt truncation retry for non-context errors", async () => {
const { compactSessionContext } = await import("../pi.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// Non-context error should not trigger recovery path
vi.mocked(compactSessionContext).mockResolvedValue(null);
vi.mocked(isContextLimitError).mockReturnValue(false);
// Mock non-context error
mockedCreateFnAgent.mockImplementation(async () => {
const session = {
prompt: vi.fn().mockRejectedValue(new Error("connection refused")),
dispose: vi.fn(),
};
return { session } as any;
});
// Setup that simulates merge failing - make merge --squash throw so auto-resolution isn't triggered
// Also make commit fail so all attempts exhaust
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("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("--stat")) return "1 file changed";
// All merge attempts fail - make merge --squash throw with conflicts
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
const err = new Error("merge conflict");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
if (cmdStr.includes("reset --merge")) return Buffer.from("");
// Make commit fail so attempt 2's auto-resolution also fails
if (cmdStr.includes("git commit")) {
const err = new Error("commit failed");
err.name = "ExecSyncError";
throw err;
}
return Buffer.from("");
});
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,
smartConflictResolution: true,
});
// Should throw without attempting compaction or truncation
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
// Compaction should NOT have been called for non-context errors
expect(vi.mocked(compactSessionContext)).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,668 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification commands (vitest run,
// pnpm build, etc.) keep working unchanged. `promisify(exec)` in merger.ts
// resolves/rejects based on the callback wired here.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.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 mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
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 = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached --quiet (squash check),
* diff --cached (post-agent verify), branch -d
*
* Both `-X ours` and `-X theirs` final-fallback merges return success — the
* default settings strategy is "smart-prefer-main" (-X ours), but a few tests
* still exercise -X theirs explicitly via `mergeConflictStrategy: "smart-prefer-branch"`.
*
* For tests that want the merge to fail after 3 attempts, call
* setupFailingFallbackStrategy() instead.
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/**
* Same as setupHappyPathExecSync but makes the final fallback merge fail
* (both `-X theirs` and `-X ours`). Use this for tests that expect the merge
* to throw after 3 attempts fail.
*/
function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
// -X theirs / -X ours should fail for these tests (they expect merge to throw)
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
// Post-squash check: --quiet means "did squash stage anything?" → "1" = yes
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
// Post-agent check: "did agent commit?" → "0" = yes
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
/** @deprecated Renamed to setupFailingFallbackStrategy. */
const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("passes skillSelection to createFnAgent when agentStore is provided", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
// Find the first createFnAgent call (main merger agent)
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/root");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]);
expect(opts.skillSelection!.sessionPurpose).toBe("merger");
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill", "another-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill", "another-skill"],
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]);
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
// skillSelection should not be present when context is undefined
expect("skillSelection" in opts).toBe(false);
});
it("does not pass skillSelection when agentStore is not provided", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
// No agentStore provided
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Should not throw - graceful fallback
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect("skillSelection" in opts).toBe(false);
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: resolvedNames,
sessionPurpose: "merger",
},
resolvedSkillNames: resolvedNames,
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(resolvedNames);
});
it("uses sessionPurpose='merger' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.sessionPurpose).toBe("merger");
});
});
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("merge continues when skill selection produces diagnostics", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
// Simulate diagnostics being logged - the resolver would produce these
// when requested skills are not found or filtered
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["nonexistent-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: [],
skillSource: "none",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
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,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
// Merge should succeed even when skill diagnostics are present
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("records skill source in context result for debugging", async () => {
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["custom-skill"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["custom-skill"],
skillSource: "assigned-agent",
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
},
} as any);
setupHappyPathExecSync();
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", assignedAgentId: "agent-001" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
});
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
};
const result = await aiMergeTask(store, "/tmp/root", "FN-050", {
agentStore: mockAgentStore as any,
});
// Result should be successful regardless of skill source
expect(result.merged).toBe(true);
// Verify skillSelection was passed with the custom skill
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.skillSelection?.requestedSkillNames).toEqual(["custom-skill"]);
});
});

View File

@@ -0,0 +1,266 @@
import { vi } from "vitest";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
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);
}
}),
compactSessionContext: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFn[promisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn(),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
buildSourceIssueRef,
buildMergePrompt,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
import { type TaskStore, type Task, DEFAULT_SETTINGS } from "@fusion/core";
export {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
resolveConflicts,
classifyConflict,
getConflictedFiles,
isTrivialWhitespaceConflict,
resolveWithOurs,
resolveWithTheirs,
resolveTrivialWhitespace,
LOCKFILE_PATTERNS,
GENERATED_PATTERNS,
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
commitOrAmendMergeWithFixes,
MergeAbortedError,
buildSourceIssueRef,
buildMergePrompt,
mergerLog,
core,
};
export type { ConflictCategory, Task };
export const mockedCreateFnAgent = vi.mocked(createFnAgent);
export const mockedExecSync = vi.mocked(execSync);
export const mockedExec = vi.mocked(exec);
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
export const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
export const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
export function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "FN-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
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;
}
export function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
return Buffer.from("");
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
export function setupFailingFallbackStrategy() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("merge -X theirs --squash") || cmdStr.includes("merge -X ours --squash")) {
const err = new Error("fatal: git merge -X fallback failed with unresolved conflicts");
err.name = "ExecSyncError";
throw err;
}
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
export const setupFailingTheirsStrategy = setupFailingFallbackStrategy;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff