feat(FN-5349): add integration branch resolver with auto-recovery fallback

FN-5349 adds a dedicated integration branch resolution module (`packages/engine/src/integration-branch.ts`) replacing ad-hoc dynamic fallbacks, routes merger branch conflict resolution through it, wires auto-recovery handlers (branch-worktree, contamination) to use integration branch fallback, and w

Fusion-Task-Id: FN-5349
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 12:04:19 -07:00
committed by gsxdsm
parent e58530aa37
commit 79850b233f
28 changed files with 525 additions and 50 deletions

View File

@@ -101,6 +101,43 @@ describe("branch-conflicts", () => {
expect(result).toEqual({ kind: "stale-resolved" });
});
it("prefers explicit integrationRef when inspecting branch conflicts", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git worktree prune") return Buffer.from("");
if (command === "git worktree list --porcelain") {
return Buffer.from(["worktree /tmp/existing-wt", "HEAD 2222222", "branch refs/heads/fusion/fn-4068", ""].join("\n"));
}
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git rev-parse --verify 'master^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git merge-base 'master' 'fusion/fn-4068'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git merge-base --is-ancestor 'abc123def456' 'master'")) {
return Buffer.from("");
}
throw new Error(`Unexpected command: ${command}`);
});
const result = await inspectBranchConflict({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/existing-wt",
requestingTaskId: "FN-4068",
startPoint: "master",
integrationRef: "master",
});
expect(result).toMatchObject({ kind: "tip-already-merged", integrationRef: "master" });
});
it("FN-4476/FN-4471: classifies live branch at main tip as tip-already-merged even with stale-base churn", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { execMock, execSyncMock } = vi.hoisted(() => ({
execMock: vi.fn(),
execSyncMock: vi.fn(),
}));
vi.mock("node:child_process", () => ({
exec: execMock,
execSync: execSyncMock,
}));
import {
__resetIntegrationBranchCacheForTests,
INTEGRATION_BRANCH_FALLBACK,
resolveIntegrationBranch,
resolveIntegrationBranchSync,
} from "../integration-branch.js";
describe("integration-branch resolver", () => {
beforeEach(() => {
__resetIntegrationBranchCacheForTests();
execMock.mockReset();
execSyncMock.mockReset();
});
afterEach(() => {
__resetIntegrationBranchCacheForTests();
vi.restoreAllMocks();
});
it("integrationBranch override wins over baseBranch and origin/HEAD", async () => {
const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " trunk ", baseBranch: "develop" } as any);
expect(resolved).toBe("trunk");
expect(execMock).not.toHaveBeenCalled();
});
it("baseBranch wins over origin/HEAD", async () => {
const resolved = await resolveIntegrationBranch("/repo", { baseBranch: " develop " } as any);
expect(resolved).toBe("develop");
expect(execMock).not.toHaveBeenCalled();
});
it("strips refs/remotes/origin and origin prefixes", async () => {
execMock.mockImplementationOnce((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(null, { stdout: "refs/remotes/origin/master\n" });
return {};
});
execMock.mockImplementationOnce((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(null, { stdout: "origin/develop\n" });
return {};
});
const first = await resolveIntegrationBranch("/repo-a", {} as any);
const second = await resolveIntegrationBranch("/repo-b", {} as any);
expect(first).toBe("master");
expect(second).toBe("develop");
});
it("treats whitespace and empty settings as unset", async () => {
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(null, { stdout: "origin/master\n" });
return {};
});
const resolved = await resolveIntegrationBranch("/repo", { integrationBranch: " ", baseBranch: "" } as any);
expect(resolved).toBe("master");
});
it("falls back to main and warns once per rootDir", async () => {
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(new Error("no symbolic ref"), { stdout: "" });
return {};
});
const warn = vi.fn();
const first = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } });
const second = await resolveIntegrationBranch("/repo", undefined, { logger: { warn } });
expect(first).toBe(INTEGRATION_BRANCH_FALLBACK);
expect(second).toBe(INTEGRATION_BRANCH_FALLBACK);
expect(warn).toHaveBeenCalledTimes(1);
});
it("sync and async variants match", async () => {
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(null, { stdout: "refs/remotes/origin/master\n" });
return {};
});
execSyncMock.mockReturnValue("origin/master\n");
const asyncResolved = await resolveIntegrationBranch("/repo", undefined);
const syncResolved = resolveIntegrationBranchSync("/repo", undefined);
expect(syncResolved).toEqual(asyncResolved);
expect(syncResolved).toBe("master");
});
it("swallows git failures and does not throw", async () => {
execMock.mockImplementation((_command: string, _opts: object, cb: (error: Error | null, result: { stdout: string }) => void) => {
cb(new Error("git failed"), { stdout: "" });
return {};
});
execSyncMock.mockImplementation(() => {
throw new Error("git failed");
});
await expect(resolveIntegrationBranch("/repo", undefined)).resolves.toBe(INTEGRATION_BRANCH_FALLBACK);
expect(() => resolveIntegrationBranchSync("/repo", undefined)).not.toThrow();
});
});

View File

@@ -148,6 +148,7 @@ import {
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { __resetIntegrationBranchCacheForTests } from "../integration-branch.js";
import { createFnAgent } from "../pi.js";
import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
@@ -384,6 +385,38 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
});
describe("aiMergeTask — integration branch resolution", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetIntegrationBranchCacheForTests();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("threads settings.baseBranch into merge target branch", async () => {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
baseBranch: "trunk",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
expect(commands.some((cmd) => cmd.includes("checkout \"trunk\""))).toBe(true);
});
});
describe("aiMergeTask — model settings threading", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1084,19 +1084,24 @@ describe("aiMergeTask — merge-target branch resolution", () => {
).toBe(true);
});
it("defaults merge-target context to main when task.baseBranch is missing", async () => {
it("defaults merge-target context to integrationBranch when task.baseBranch is missing", async () => {
const store = createMockStore({
id: "FN-050",
branch: "feature/fn-050-work",
baseBranch: undefined,
worktree: "/tmp/root",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
integrationBranch: "master",
});
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes('git merge-base "feature/fn-050-work" "main"'),
String(cmd).includes('git merge-base "feature/fn-050-work" "master"'),
),
).toBe(true);

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { inspectBranchConflict } from "../../branch-conflicts.js";
import { resolveIntegrationBranch } from "../../integration-branch.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
describeIfGit("integration branch resolution (real git, master)", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
function setupRepo(): string {
const repo = mkdtempSync(path.join(os.tmpdir(), "fn-5349-"));
repos.push(repo);
git(repo, "git init -b master");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test"');
git(repo, "git commit --allow-empty -m 'init'");
git(repo, "git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/master");
return repo;
}
it("resolves origin/HEAD and respects explicit override", async () => {
const repo = setupRepo();
await expect(resolveIntegrationBranch(repo, {})).resolves.toBe("master");
await expect(resolveIntegrationBranch(repo, { integrationBranch: "trunk" })).resolves.toBe("trunk");
});
it("inspects branch conflicts against master without disturbing dirty root worktree", async () => {
const repo = setupRepo();
git(repo, "git checkout -b fusion/fn-5349-check");
mkdirSync(path.join(repo, "src"), { recursive: true });
writeFileSync(path.join(repo, "src", "task.txt"), "task\n", "utf-8");
git(repo, "git add src/task.txt && git commit -m 'task change'");
git(repo, "git checkout master");
const conflictWorktree = path.join(repo, ".worktrees", "fn-5349-check");
mkdirSync(path.dirname(conflictWorktree), { recursive: true });
git(repo, `git worktree add ${JSON.stringify(conflictWorktree)} fusion/fn-5349-check`);
writeFileSync(path.join(repo, "dirty.txt"), "dirty\n", "utf-8");
writeFileSync(path.join(repo, "untracked.txt"), "untracked\n", "utf-8");
const preStatus = git(repo, "git status --short");
const result = await inspectBranchConflict({
repoDir: repo,
branchName: "fusion/fn-5349-check",
conflictingWorktreePath: conflictWorktree,
requestingTaskId: "FN-5349",
ownerTaskId: "FN-5349",
startPoint: "master",
integrationRef: "master",
});
expect(["reclaimable", "live-foreign", "fully-subsumed", "tip-already-merged"]).toContain(result.kind);
expect(git(repo, "git symbolic-ref --short HEAD")).toBe("master");
expect(readFileSync(path.join(repo, "dirty.txt"), "utf-8")).toBe("dirty\n");
expect(readFileSync(path.join(repo, "untracked.txt"), "utf-8")).toBe("untracked\n");
expect(git(repo, "git status --short")).toBe(preStatus);
});
});

View File

@@ -61,7 +61,7 @@ describe("reliability interaction: foreign-only contamination recovery", () => {
baseCommitSha: baseSha,
baseBranch: "main",
executionStartBranch: "fusion/fn-y",
} as any, { repoDir, taskStore: store, runAudit });
} as any, { repoDir, taskStore: store, runAudit, integrationBranch: "main" });
expect(result.recovered).toBe(true);
expect(["reanchor", "branch-discard"]).toContain(result.subtype);
@@ -90,7 +90,7 @@ describe("reliability interaction: foreign-only contamination recovery", () => {
baseCommitSha: baseSha,
baseBranch: "main",
executionStartBranch: "fusion/fn-y",
} as any, { repoDir, taskStore: store, runAudit });
} as any, { repoDir, taskStore: store, runAudit, integrationBranch: "main" });
expect(result.recovered).toBe(false);
expect(result.reason).toBe("active-session");

View File

@@ -666,8 +666,8 @@ describe("WorktrunkWorktreeBackend", () => {
).rejects.toMatchObject({ code: "worktrunk_timeout" });
});
it("syncs by fetching then rebasing branch", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
it("syncs by fetching then rebasing resolved integration branch", async () => {
execMock.mockResolvedValue({ stdout: "origin/main\n", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
@@ -676,11 +676,16 @@ describe("WorktrunkWorktreeBackend", () => {
expect(execMock).toHaveBeenNthCalledWith(
1,
"git symbolic-ref --short refs/remotes/origin/HEAD",
expect.objectContaining({ cwd: "/repo", timeout: 5000, maxBuffer: 1048576 }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
'git fetch origin "main"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
3,
'git rebase "main"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
);