Files
fusion/packages/engine/src/__tests__/merger-prompt-and-utils.test.ts
gsxdsm c89ad28e7b FN-5646: preserve AI body when canonicalizing merge commits
Fusion-Task-Id: FN-5646

Fusion-Task-Lineage: db989a0f-a7d9-4060-9754-84d71f21a8c2
2026-05-29 00:55:17 -07:00

1018 lines
38 KiB
TypeScript

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,
mergeIntegrationWorktree: "cwd-main" as const,
}),
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("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBe("FN-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "FN-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "FN-051", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "FN-050");
expect(result).toBeNull();
});
});
describe("push-after-merge", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
function setupAiMergeExecSyncWithPush(pushBehavior?: (attempt: number) => void) {
let pushAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
if (cmdStr.includes("git log HEAD..")) 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("git merge --squash")) return Buffer.from("");
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --quiet")) {
// First call: squash-empty check, second call: post-agent commit verification.
return "1" as any;
}
if (cmdStr.startsWith("git commit ")) return Buffer.from("");
if (cmdStr.includes("git 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("");
if (cmdStr.startsWith("git pull --rebase")) return Buffer.from("");
if (cmdStr.startsWith("git push ")) {
pushAttempts += 1;
pushBehavior?.(pushAttempts);
return Buffer.from("");
}
return Buffer.from("");
});
}
it("pushes merged result when pushAfterMerge is enabled", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git pull --rebase "origin" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git push "origin" "main"')),
).toBe(true);
});
it("does not push when pushAfterMerge is disabled (default)", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("does not push for pull-request merge strategy", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeStrategy: "pull-request",
pushAfterMerge: true,
pushRemote: "origin",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("records push error but still completes merge when push fails", async () => {
setupAiMergeExecSyncWithPush((attempt) => {
if (attempt === 1) {
const err = new Error("failed to push some refs");
(err as Error & { stderr?: string }).stderr = "remote: permission denied";
throw err;
}
});
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.pushedToRemote).toBe(false);
expect(result.pushError).toContain("permission denied");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("uses custom remote and branch when configured", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith("git pull --rebase \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.startsWith("git push \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision");
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "upstream main",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git pull --rebase "upstream" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git push "upstream" "main"')),
).toBe(true);
});
it("auto-resolves lock-file rebase conflicts and continues", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in pnpm-lock.yaml";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "pnpm-lock.yaml" as any : "" as any;
}
if (cmdStr.includes("checkout --ours") && cmdStr.includes("pnpm-lock.yaml")) return Buffer.from("");
if (cmdStr.includes("git add") && cmdStr.includes("pnpm-lock.yaml")) {
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("checkout --ours") && String(call[0]).includes("pnpm-lock.yaml")),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")),
).toBe(true);
});
it("uses AI to resolve complex rebase conflicts", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(async () => {
hasConflicts = false;
}),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "src/app.ts" as any : "" as any;
}
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalled();
});
it("retries push once after non-fast-forward rejection", async () => {
let pushAttempts = 0;
let pullAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
pullAttempts += 1;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) {
pushAttempts += 1;
if (pushAttempts === 1) {
const err = new Error("non-fast-forward") as Error & { stderr?: string };
err.stderr = "[rejected] main -> main (non-fast-forward)";
throw err;
}
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(pullAttempts).toBe(2);
expect(pushAttempts).toBe(2);
});
it("aborts rebase when conflicts remain unresolved", async () => {
let rebaseInProgress = false;
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "src/app.ts" as any;
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("git rebase --abort")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(false);
expect(result.error).toContain("unable to resolve rebase conflicts");
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git rebase --abort")),
).toBe(true);
});
});
describe("buildSourceIssueRef", () => {
it("returns owner/repo#number for GitHub source issues", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "123",
issueNumber: 123,
})).toBe("runfusion/fusion#123");
});
it("falls back to externalIssueId when issueNumber is missing", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "321",
} as any)).toBe("runfusion/fusion#321");
});
it("returns empty string for non-GitHub providers", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "gitlab",
repository: "group/project",
externalIssueId: "123",
issueNumber: 123,
})).toBe("");
});
it("returns empty string for nullish source issue", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef(undefined)).toBe("");
expect(buildSourceIssueRef(null)).toBe("");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "not-a-number",
} as any)).toBe("");
});
});
describe("buildMergePrompt — truncation behavior", () => {
it("truncates commit log when exceeding MERGE_COMMIT_LOG_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("../merger.js");
// Create a commit log that exceeds 5000 characters
const longCommitLog = "- " + "a".repeat(6000);
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: longCommitLog,
diffStat: "1 file changed",
hasConflicts: false,
});
// The prompt should contain truncation indicator
expect(prompt).toContain("... (truncated)");
// The truncated version should be shorter than original
expect(prompt.indexOf("- " + "a".repeat(5000))).toBe(-1);
});
it("truncates diff stat when exceeding MERGE_DIFF_STAT_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("../merger.js");
// Create a diff stat that exceeds 3000 characters
const longDiffStat = "file.ts | " + " ".repeat(10) + "x".repeat(4000);
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: longDiffStat,
hasConflicts: false,
});
// The prompt should contain truncation indicator
expect(prompt).toContain("... (truncated)");
// The diff stat section should not contain the full long content
expect(prompt.indexOf("x".repeat(3000))).toBe(-1);
});
it("preserves short content unchanged (under limits)", async () => {
const { buildMergePrompt } = await import("../merger.js");
const shortCommitLog = "- feat: add login\n- fix: correct typo";
const shortDiffStat = "src/login.ts | 5 +++\n1 file changed";
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: shortCommitLog,
diffStat: shortDiffStat,
hasConflicts: false,
});
// Should not contain truncation markers
expect(prompt).not.toContain("... (truncated)");
// Should contain original content
expect(prompt).toContain(shortCommitLog);
expect(prompt).toContain(shortDiffStat);
});
it("truncates commit log but not diff stat when only commit log is over limit", async () => {
const { buildMergePrompt } = await import("../merger.js");
const longCommitLog = "- " + "b".repeat(6000);
const shortDiffStat = "1 file changed";
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: longCommitLog,
diffStat: shortDiffStat,
hasConflicts: false,
});
// Should contain truncation for commit log
expect(prompt).toContain("... (truncated)");
// Diff stat should be unchanged
expect(prompt).toContain(shortDiffStat);
});
it("includes co-author trailer arg in no-conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
authorArg: ' -m "Co-authored-by: Fusion <noreply@runfusion.ai>"',
});
expect(prompt).toContain('Be sure to append `-m "Co-authored-by: Fusion <noreply@runfusion.ai>"` to the commit command');
});
it("includes co-author trailer arg in conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: true,
authorArg: ' -m "Co-authored-by: CustomBot <bot@example.com>"',
});
expect(prompt).toContain('Be sure to append `-m "Co-authored-by: CustomBot <bot@example.com>"` to the commit command');
});
it("omits author instruction when authorArg is not provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
});
expect(prompt).not.toContain("Be sure to include");
expect(prompt).toContain("Write and run the `git commit` command with a good message summarizing the work");
});
it("handles empty authorArg gracefully", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
authorArg: "",
});
expect(prompt).not.toContain("Be sure to include");
});
it("includes source issue reference guidance when provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
sourceIssueRef: "runfusion/fusion#2915",
});
expect(prompt).toContain("Include this in the commit message body:");
expect(prompt).toContain("Ref: runfusion/fusion#2915");
});
it("omits source issue reference guidance when not provided", async () => {
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
branch: "fusion/fn-001",
commitLog: "- feat: something",
diffStat: "1 file changed",
hasConflicts: false,
});
expect(prompt).not.toContain("Include this in the commit message body:");
expect(prompt).not.toContain("Ref: runfusion/fusion#2915");
});
});
// ── Context Limit Recovery Tests ─────────────────────────────────────
describe("commitOrAmendMergeWithFixes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns already-merged success when branch tip is ancestor of integration target and finalize has no staged content", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --cached --name-only")) return "" as any;
if (cmdStr === "git diff --name-only") return "" as any;
if (cmdStr.includes("git status -z --porcelain")) return "" as any;
if (cmdStr === "git rev-parse HEAD") return "abc123" as any;
if (cmdStr === "git rev-parse fusion/fn-9999") return "def456" as any;
if (cmdStr === "git merge-base def456 abc123") return "abc123" as any;
if (cmdStr === "git diff --stat abc123..fusion/fn-9999") return "" as any;
if (cmdStr === "git ls-files --others --exclude-standard") return "" as any;
if (cmdStr.includes("git log -1 --pretty=%B HEAD")) return "commit message without trailer" as any;
if (cmdStr === "git merge-base --is-ancestor def456 abc123") return "" as any;
return "" as any;
});
const result = await commitOrAmendMergeWithFixes(
"/tmp/root",
"FN-9999",
"fusion/fn-9999",
"",
true,
"abc123",
"",
undefined,
DEFAULT_SETTINGS,
undefined,
null,
null,
null,
new Set(),
);
expect(result).toEqual({ ok: true, reason: "branch-already-merged" });
expect(mockedExecSync.mock.calls.some((call) => String(call[0]).includes("git merge-base --is-ancestor"))).toBe(true);
});
it("persists dirty leftovers before finalize reset in no-content fallback path", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff -z --cached --name-only")) return "" as any;
if (cmdStr.includes("git diff -z --name-only")) return "orphan.txt\0" as any;
if (cmdStr.includes("git diff --cached --name-only")) return "" as any;
if (cmdStr === "git diff --name-only") return "orphan.txt" as any;
if (cmdStr.includes("git status -z --porcelain")) return "" as any;
if (cmdStr === "git rev-parse HEAD") return "abc123" as any;
if (cmdStr === "git rev-parse fusion/fn-9999") return "def456" as any;
if (cmdStr === "git merge-base def456 abc123") return "zzz999" as any;
if (cmdStr === "git diff --stat abc123..fusion/fn-9999") return "" as any;
if (cmdStr === "git ls-files --others --exclude-standard") return "" as any;
if (cmdStr.includes("git log -1 --pretty=%B HEAD")) return "commit message without trailer" as any;
if (cmdStr.includes("git merge-base --is-ancestor")) throw new Error("not ancestor");
if (cmdStr === "git add -A") return "" as any;
if (cmdStr === "git stash create") return "ff00aa" as any;
if (cmdStr.startsWith("git stash store -m")) return "" as any;
if (cmdStr === "git reset") return "" as any;
if (cmdStr === "git reset --hard abc123") return "" as any;
if (cmdStr === "git clean -fd") return "" as any;
if (cmdStr === "git merge --squash fusion/fn-9999") return "Already up to date." as any;
return "" as any;
});
const store = createMockStore();
const result = await commitOrAmendMergeWithFixes(
"/tmp/root",
"FN-9999",
"fusion/fn-9999",
"",
true,
"abc123",
"",
undefined,
DEFAULT_SETTINGS,
undefined,
null,
null,
null,
new Set(),
store,
);
expect(result).toEqual({ ok: true, reason: "branch-already-merged" });
expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0);
});
it("treats squash-restore 'Already up to date' with no staged changes as already-merged success", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff --cached --name-only")) return "" as any;
if (cmdStr === "git diff --name-only") return "" as any;
if (cmdStr.includes("git status -z --porcelain")) return "" as any;
if (cmdStr === "git rev-parse HEAD") return "abc123" as any;
if (cmdStr === "git rev-parse fusion/fn-9999") return "def456" as any;
if (cmdStr === "git merge-base def456 abc123") return "zzz999" as any;
if (cmdStr === "git diff --stat abc123..fusion/fn-9999") return "" as any;
if (cmdStr === "git ls-files --others --exclude-standard") return "" as any;
if (cmdStr.includes("git log -1 --pretty=%B HEAD")) return "commit message without trailer" as any;
if (cmdStr.includes("git merge-base --is-ancestor")) throw new Error("not ancestor");
if (cmdStr === "git reset --hard abc123") return "" as any;
if (cmdStr === "git clean -fd") return "" as any;
if (cmdStr === "git merge --squash fusion/fn-9999") return "Already up to date." as any;
return "" as any;
});
const result = await commitOrAmendMergeWithFixes(
"/tmp/root",
"FN-9999",
"fusion/fn-9999",
"",
true,
"abc123",
"",
undefined,
DEFAULT_SETTINGS,
undefined,
null,
null,
null,
new Set(),
);
expect(result).toEqual({ ok: true, reason: "branch-already-merged" });
});
});