feat(FN-5350): switch merge integration advance to ref-only git update-ref

Implements ref-only integration branch advancement via `git update-ref` instead of checkout+commit, including a new `merger-ref-update-advance.ts` helper, a reduced and clarified `merger.ts`, comprehensive unit and real-git regression coverage, and audit event wiring.

Fusion-Task-Id: FN-5350
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 13:55:51 -07:00
committed by gsxdsm
parent 109f969f06
commit caeb6a7e00
7 changed files with 531 additions and 119 deletions

View File

@@ -311,6 +311,7 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
8. **Smart-prefer-main overlap guard.** When `mergeConflictStrategy="smart-prefer-main"`, recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (`mergeStrategyOverlapBehavior="flip-to-prefer-branch"`).
9. **Layer 3 scope partition for AI arbitration (FN-4956).** Before handing conflicted files to the Layer 3 AI arbiter, merger partitions conflicts against declared task File Scope. Out-of-scope conflicts are resolved to main (`--ours`) and unstaged so they cannot enter the squash; only in-scope conflicts reach AI. `task.scopeOverride=true` bypasses this partition. Run-audit emits `merge:layer3:foreign-file-skipped` (skip path) or `merge:layer3:scope-override-bypass` (override path).
10. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 13 cascade; worktrunk-enabled paths defer this layer.
11. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
### Gitignored-path guard on squash merges

View File

@@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { advanceIntegrationBranchRef } from "../merger-ref-update-advance.js";
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
}
function setupRepo(defaultBranch: "main" | "master" = "main") {
const dir = mkdtempSync(join(tmpdir(), "fusion-test-ref-advance-"));
git(dir, `git init -b ${defaultBranch}`);
git(dir, "git config user.name tester");
git(dir, "git config user.email tester@example.com");
writeFileSync(join(dir, "tracked.txt"), "one\n");
git(dir, "git add tracked.txt");
git(dir, "git commit -m init");
return dir;
}
describe("advanceIntegrationBranchRef", () => {
it.each(["main", "master"] as const)("advances %s via update-ref happy path", async (integrationBranch) => {
const dir = setupRepo(integrationBranch);
const events: Array<{ type: string; target?: string; metadata?: Record<string, unknown> }> = [];
try {
const expectedCurrentSha = git(dir, `git rev-parse refs/heads/${integrationBranch}`);
git(dir, "git checkout -b feat");
writeFileSync(join(dir, "feature.txt"), "feature\n");
git(dir, "git add feature.txt");
git(dir, "git commit -m feat");
const newSha = git(dir, "git rev-parse HEAD");
const result = await advanceIntegrationBranchRef({
rootDir: dir,
projectRootDir: dir,
integrationBranch,
newSha,
expectedCurrentSha,
taskId: "FN-5350",
audit: {
git: async (event: any) => events.push(event),
} as any,
});
expect(result).toEqual({ advanced: true, previousSha: expectedCurrentSha, newSha });
expect(git(dir, `git rev-parse refs/heads/${integrationBranch}`)).toBe(newSha);
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advanced");
expect(events[0]?.metadata?.via).toBe("update-ref");
expect(events[0]?.target).toBe(integrationBranch);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns concurrent-advance when observed tip differs from expected", async () => {
const dir = setupRepo("main");
const events: Array<{ type: string; metadata?: Record<string, unknown> }> = [];
try {
const staleExpectedSha = git(dir, "git rev-parse refs/heads/main");
git(dir, "git checkout -b other");
writeFileSync(join(dir, "other.txt"), "other\n");
git(dir, "git add other.txt");
git(dir, "git commit -m other");
const observedCurrentSha = git(dir, "git rev-parse HEAD");
git(dir, `git update-ref refs/heads/main ${observedCurrentSha} ${staleExpectedSha}`);
git(dir, "git checkout -b feat2");
writeFileSync(join(dir, "feature2.txt"), "feature2\n");
git(dir, "git add feature2.txt");
git(dir, "git commit -m feat2");
const newSha = git(dir, "git rev-parse HEAD");
const result = await advanceIntegrationBranchRef({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
newSha,
expectedCurrentSha: staleExpectedSha,
taskId: "FN-5350",
audit: {
git: async (event: any) => events.push(event),
} as any,
});
expect(result.advanced).toBe(false);
if (result.advanced) throw new Error("expected refusal");
expect(result.reason).toBe("concurrent-advance");
expect(result.observedCurrentSha).toBe(observedCurrentSha);
expect(git(dir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
expect(events[0]?.type).toBe("merge:reuse-integration-branch-advance-failed");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("keeps dirty and untracked files untouched while advancing", async () => {
const dir = setupRepo("main");
try {
const expectedCurrentSha = git(dir, "git rev-parse refs/heads/main");
git(dir, "git checkout -b feat");
writeFileSync(join(dir, "feature.txt"), "feature\n");
git(dir, "git add feature.txt");
git(dir, "git commit -m feat");
const newSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
writeFileSync(join(dir, "tracked.txt"), "one\nuser-local-edit\n");
writeFileSync(join(dir, "untracked.txt"), "untracked\n");
const trackedBefore = readFileSync(join(dir, "tracked.txt"), "utf-8");
const untrackedBefore = readFileSync(join(dir, "untracked.txt"), "utf-8");
const result = await advanceIntegrationBranchRef({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
newSha,
expectedCurrentSha,
taskId: "FN-5350",
audit: { git: async () => undefined } as any,
});
expect(result.advanced).toBe(true);
expect(readFileSync(join(dir, "tracked.txt"), "utf-8")).toBe(trackedBefore);
expect(readFileSync(join(dir, "untracked.txt"), "utf-8")).toBe(untrackedBefore);
expect(existsSync(join(dir, "untracked.txt"))).toBe(true);
const status = git(dir, "git status --porcelain");
expect(status).toContain("tracked.txt");
expect(status).toContain("untracked.txt");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("throws on missing precondition shas", async () => {
const dir = setupRepo("main");
try {
await expect(advanceIntegrationBranchRef({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
newSha: "",
expectedCurrentSha: "abc",
taskId: "FN-5350",
audit: { git: async () => undefined } as any,
})).rejects.toThrow("newSha");
await expect(advanceIntegrationBranchRef({
rootDir: dir,
projectRootDir: dir,
integrationBranch: "main",
newSha: "abc",
expectedCurrentSha: "",
taskId: "FN-5350",
audit: { git: async () => undefined } as any,
})).rejects.toThrow("expectedCurrentSha");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { git, hasGit } from "./_helpers.js";
import { advanceIntegrationBranchRef, __test__ } from "../../merger-ref-update-advance.js";
describe.skipIf(!hasGit)("reliability interaction: dirty integration worktree with ref-only advance", () => {
it.each(["main", "master"] as const)("advances %s without touching dirty/untracked files", async (integrationBranch) => {
const projectRootDir = mkdtempSync(join(tmpdir(), "fusion-test-ref-project-"));
const rootDir = join(projectRootDir, "task-wt");
const events: any[] = [];
const runGitSpy = vi.spyOn(__test__, "runGit");
try {
git(projectRootDir, `git init -b ${integrationBranch}`);
git(projectRootDir, "git config user.name tester");
git(projectRootDir, "git config user.email tester@example.com");
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\n");
git(projectRootDir, "git add tracked.ts");
git(projectRootDir, "git commit -m init");
const expectedCurrentSha = git(projectRootDir, `git rev-parse refs/heads/${integrationBranch}`);
git(projectRootDir, "git checkout -b feature");
writeFileSync(join(projectRootDir, "feature.ts"), "export const feature = true;\n");
git(projectRootDir, "git add feature.ts");
git(projectRootDir, "git commit -m feature");
const newSha = git(projectRootDir, "git rev-parse HEAD");
git(projectRootDir, `git checkout ${integrationBranch}`);
git(projectRootDir, `git branch task-wt feature`);
git(projectRootDir, `git worktree add ${JSON.stringify(rootDir)} task-wt`);
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\nuser-local-edit\n");
writeFileSync(join(projectRootDir, "new-untracked.txt"), "untracked\n");
const trackedBefore = readFileSync(join(projectRootDir, "tracked.ts"), "utf-8");
const untrackedBefore = readFileSync(join(projectRootDir, "new-untracked.txt"), "utf-8");
const result = await advanceIntegrationBranchRef({
rootDir,
projectRootDir,
integrationBranch,
newSha,
expectedCurrentSha,
taskId: "FN-5350",
audit: { git: async (event: any) => events.push(event) } as any,
});
expect(result).toEqual({ advanced: true, previousSha: expectedCurrentSha, newSha });
expect(git(projectRootDir, `git rev-parse refs/heads/${integrationBranch}`)).toBe(newSha);
expect(readFileSync(join(projectRootDir, "tracked.ts"), "utf-8")).toBe(trackedBefore);
expect(readFileSync(join(projectRootDir, "new-untracked.txt"), "utf-8")).toBe(untrackedBefore);
expect(existsSync(join(projectRootDir, "new-untracked.txt"))).toBe(true);
expect(git(projectRootDir, "git status --porcelain")).toContain("new-untracked.txt");
const updateRefCalls = runGitSpy.mock.calls.filter(([args]) => Array.isArray(args) && args[0] === "update-ref");
expect(updateRefCalls).toHaveLength(1);
expect(updateRefCalls[0]?.[1]).toBe(rootDir);
expect(runGitSpy.mock.calls.some(([args, cwd]) => {
if (cwd !== projectRootDir || !Array.isArray(args)) return false;
return ["checkout", "merge", "rebase", "update-ref"].includes(args[0] ?? "");
})).toBe(false);
const advanceEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advanced");
expect(advanceEvent?.metadata?.via).toBe("update-ref");
expect(advanceEvent?.target).toBe(integrationBranch);
if (integrationBranch === "master") {
expect(JSON.stringify(advanceEvent)).not.toContain('"main"');
}
} finally {
runGitSpy.mockRestore();
rmSync(projectRootDir, { recursive: true, force: true });
}
});
it("returns concurrent-advance and preserves concurrent ref", async () => {
const projectRootDir = mkdtempSync(join(tmpdir(), "fusion-test-ref-concurrent-project-"));
const rootDir = join(projectRootDir, "task-wt");
const events: any[] = [];
try {
git(projectRootDir, "git init -b main");
git(projectRootDir, "git config user.name tester");
git(projectRootDir, "git config user.email tester@example.com");
writeFileSync(join(projectRootDir, "tracked.ts"), "export const a = 1;\n");
git(projectRootDir, "git add tracked.ts");
git(projectRootDir, "git commit -m init");
const expectedCurrentSha = git(projectRootDir, "git rev-parse refs/heads/main");
git(projectRootDir, "git checkout -b concurrent");
writeFileSync(join(projectRootDir, "concurrent.ts"), "export const concurrent = 1;\n");
git(projectRootDir, "git add concurrent.ts");
git(projectRootDir, "git commit -m concurrent");
const observedCurrentSha = git(projectRootDir, "git rev-parse HEAD");
git(projectRootDir, `git update-ref refs/heads/main ${observedCurrentSha} ${expectedCurrentSha}`);
git(projectRootDir, "git checkout -b feature");
writeFileSync(join(projectRootDir, "feature.ts"), "export const feature = true;\n");
git(projectRootDir, "git add feature.ts");
git(projectRootDir, "git commit -m feature");
const newSha = git(projectRootDir, "git rev-parse HEAD");
git(projectRootDir, "git checkout main");
git(projectRootDir, "git branch task-wt feature");
git(projectRootDir, `git worktree add ${JSON.stringify(rootDir)} task-wt`);
const result = await advanceIntegrationBranchRef({
rootDir,
projectRootDir,
integrationBranch: "main",
newSha,
expectedCurrentSha,
taskId: "FN-5350",
audit: { git: async (event: any) => events.push(event) } as any,
});
expect(result.advanced).toBe(false);
if (result.advanced) throw new Error("expected refusal");
expect(result.reason).toBe("concurrent-advance");
expect(git(projectRootDir, "git rev-parse refs/heads/main")).toBe(observedCurrentSha);
const failureEvent = events.find((event) => event.type === "merge:reuse-integration-branch-advance-failed");
expect(failureEvent?.metadata?.reason).toBe("concurrent-advance");
expect(failureEvent?.metadata?.expectedCurrentSha).toBe(expectedCurrentSha);
expect(failureEvent?.metadata?.observedCurrentSha).toBe(observedCurrentSha);
} finally {
rmSync(projectRootDir, { recursive: true, force: true });
}
});
});

View File

@@ -78,9 +78,11 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
const advanced = audits.find(
(event) => event.mutationType === "merge:reuse-integration-branch-advanced",
);
expect(advanced?.metadata).toMatchObject({ via: "ff-merge" });
expect(advanced?.metadata).toMatchObject({ via: "update-ref" });
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
expect(git(rootDir, "git status --porcelain --untracked-files=no")).toBe(rootTrackedStatusBefore);
const rootTrackedStatusAfter = git(rootDir, "git status --porcelain --untracked-files=no");
expect(rootTrackedStatusAfter).not.toBe(rootTrackedStatusBefore);
expect(rootTrackedStatusAfter).toContain("fn-5279-ri-happy.ts");
} finally {
await fixture.cleanup();
}

View File

@@ -0,0 +1,188 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { RunAuditor } from "./run-audit.js";
const execFileAsync = promisify(execFile);
async function runGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
return await execFileAsync("git", args, {
cwd,
encoding: "utf-8",
timeout: 30_000,
});
}
const testHooks = {
runGit,
};
export class IntegrationBranchConcurrentAdvanceError extends Error {
readonly integrationBranch: string;
readonly expectedCurrentSha: string;
readonly observedCurrentSha?: string;
readonly newSha: string;
readonly taskId: string;
constructor(args: {
integrationBranch: string;
expectedCurrentSha: string;
observedCurrentSha?: string;
newSha: string;
taskId: string;
}) {
const { integrationBranch, expectedCurrentSha, observedCurrentSha, newSha, taskId } = args;
super(
`Integration branch ${integrationBranch} advanced concurrently (expected ${expectedCurrentSha}, observed ${observedCurrentSha ?? "unknown"}) while applying ${newSha} for ${taskId}`,
);
this.name = "IntegrationBranchConcurrentAdvanceError";
this.integrationBranch = integrationBranch;
this.expectedCurrentSha = expectedCurrentSha;
this.observedCurrentSha = observedCurrentSha;
this.newSha = newSha;
this.taskId = taskId;
}
}
export async function advanceIntegrationBranchRef(args: {
rootDir: string;
projectRootDir: string;
integrationBranch: string;
newSha: string;
expectedCurrentSha: string;
taskId: string;
audit: RunAuditor;
}): Promise<
| { advanced: true; previousSha: string; newSha: string }
| {
advanced: false;
reason: "concurrent-advance" | "ref-update-refused" | "missing-current-sha";
diagnostic: string;
observedCurrentSha?: string;
}
> {
const {
rootDir,
projectRootDir,
integrationBranch,
newSha,
expectedCurrentSha,
taskId,
audit,
} = args;
if (!integrationBranch?.trim()) {
throw new Error("advanceIntegrationBranchRef requires integrationBranch");
}
if (!newSha?.trim()) {
throw new Error("advanceIntegrationBranchRef requires newSha");
}
if (!expectedCurrentSha?.trim()) {
throw new Error("advanceIntegrationBranchRef requires expectedCurrentSha");
}
const ref = `refs/heads/${integrationBranch}`;
let observedCurrentSha = "";
try {
const { stdout } = await testHooks.runGit(["rev-parse", "--verify", ref], rootDir);
observedCurrentSha = stdout.trim();
} catch (error: unknown) {
const diagnostic = error instanceof Error ? error.message : String(error);
await audit.git({
type: "merge:reuse-integration-branch-advance-failed",
target: integrationBranch,
metadata: {
taskId,
newSha,
expectedCurrentSha,
reason: "missing-current-sha",
diagnostic,
projectRootDir,
},
});
return {
advanced: false,
reason: "missing-current-sha",
diagnostic,
};
}
if (!observedCurrentSha) {
const diagnostic = `Missing current sha for ${ref}`;
await audit.git({
type: "merge:reuse-integration-branch-advance-failed",
target: integrationBranch,
metadata: {
taskId,
newSha,
expectedCurrentSha,
reason: "missing-current-sha",
diagnostic,
projectRootDir,
},
});
return {
advanced: false,
reason: "missing-current-sha",
diagnostic,
};
}
if (observedCurrentSha !== expectedCurrentSha) {
const diagnostic = `Expected ${expectedCurrentSha} but observed ${observedCurrentSha} for ${ref}`;
await audit.git({
type: "merge:reuse-integration-branch-advance-failed",
target: integrationBranch,
metadata: {
taskId,
newSha,
expectedCurrentSha,
observedCurrentSha,
reason: "concurrent-advance",
diagnostic,
projectRootDir,
},
});
return {
advanced: false,
reason: "concurrent-advance",
diagnostic,
observedCurrentSha,
};
}
try {
await testHooks.runGit(["update-ref", ref, newSha, expectedCurrentSha], rootDir);
await audit.git({
type: "merge:reuse-integration-branch-advanced",
target: integrationBranch,
metadata: { taskId, sha: newSha, via: "update-ref", expectedCurrentSha, projectRootDir },
});
return { advanced: true, previousSha: expectedCurrentSha, newSha };
} catch (error: unknown) {
const diagnostic = error instanceof Error ? error.message : String(error);
const lower = diagnostic.toLowerCase();
const isConcurrent = lower.includes("cannot lock ref") || lower.includes("is at") || lower.includes("expected");
const reason = isConcurrent ? "concurrent-advance" : "ref-update-refused";
await audit.git({
type: "merge:reuse-integration-branch-advance-failed",
target: integrationBranch,
metadata: {
taskId,
newSha,
expectedCurrentSha,
observedCurrentSha,
reason,
diagnostic,
projectRootDir,
},
});
return {
advanced: false,
reason,
diagnostic,
observedCurrentSha,
};
}
}
export const __test__ = testHooks;

View File

@@ -109,8 +109,10 @@ import {
} from "./merger-integration-worktree.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
/** Conflict type classification for merge conflict resolution */
export type ConflictType =
@@ -8895,15 +8897,13 @@ export async function aiMergeTask(
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
}
// 5c. Apply squash commit to the project root's local integration branch
// (FN-5279). In reuse-task-worktree mode step 3b detached HEAD in the task
// worktree, so the squash commit landed on a detached HEAD and the local
// integration branch ref in the project root still points at the pre-merge
// tip. Advance it now via `git merge --ff-only` so the changes are
// actually applied to local main. If main has diverged in the meantime
// (rare — merge queue lease should prevent concurrent advances), fall back
// to a regular merge and let the existing AI conflict resolution helper
// resolve any conflicts.
// 5c. Advance integration branch ref after squash
// FN-5350 invariant: the integration branch is assumed checked out in
// projectRootDir with possibly dirty + untracked files. Advance refs/heads/<integration>
// via `git update-ref` only — NEVER `git checkout` / `git merge` / `git rebase`
// in projectRootDir. Compare-and-swap (expectedCurrentSha → newSha) preserves
// the FN concurrent-advance rule: if integration moved, we refuse and let
// upstream re-rebase machinery (FN-4500 / FN-5083 rebind / standard re-execution) recover.
if (reuseTaskWorktreeMerge) {
try {
const worktreeHeadSha = execSyncText("git rev-parse HEAD", {
@@ -8912,122 +8912,48 @@ export async function aiMergeTask(
encoding: "utf-8",
}).trim();
if (worktreeHeadSha) {
const integrationBranch = mergeTarget.branch;
try {
await execAsync(`git merge --ff-only ${quoteArg(worktreeHeadSha)}`, {
cwd: projectRootDir,
encoding: "utf-8",
timeout: 60_000,
});
await (audit as any).git({
type: "merge:reuse-integration-branch-advanced",
target: integrationBranch,
metadata: { taskId, sha: worktreeHeadSha, via: "ff-merge", projectRootDir },
});
mergerLog.log(
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (ff-merge)`,
);
} catch (ffErr: unknown) {
const ffMsg = ffErr instanceof Error ? ffErr.message : String(ffErr);
mergerLog.warn(
`${taskId}: ff-merge of ${worktreeHeadSha.slice(0, 8)} into ${integrationBranch} failed (${ffMsg}); attempting non-ff merge with AI conflict resolution`,
);
try {
await execAsync(`git merge --no-edit ${quoteArg(worktreeHeadSha)}`, {
cwd: projectRootDir,
encoding: "utf-8",
timeout: 120_000,
});
await (audit as any).git({
type: "merge:reuse-integration-branch-advanced",
target: integrationBranch,
metadata: { taskId, sha: worktreeHeadSha, via: "non-ff-merge", projectRootDir },
});
mergerLog.log(
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root (non-ff merge, no conflicts)`,
);
} catch (mergeErr: unknown) {
const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
const conflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
if (conflicted.length === 0) {
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
throw new Error(
`Non-ff merge of ${worktreeHeadSha} into ${integrationBranch} failed without conflict markers: ${mergeMsg}`,
);
}
mergerLog.log(
`${taskId}: ${conflicted.length} conflict(s) applying squash to ${integrationBranch} — invoking AI conflict resolution`,
);
const conflictAssignedAgentId = task.assignedAgentId?.trim();
const conflictAgentStoreWithGetAgent = options.agentStore
&& typeof (options.agentStore as { getAgent?: unknown }).getAgent === "function"
? options.agentStore
: null;
const conflictAssignedAgent = conflictAssignedAgentId && conflictAgentStoreWithGetAgent
? await (conflictAgentStoreWithGetAgent as any).getAgent(conflictAssignedAgentId).catch(() => null)
: null;
const conflictRuntimeHint = extractRuntimeHint(conflictAssignedAgent?.runtimeConfig);
await resolveComplexRebaseConflictsWithAi(
store,
projectRootDir,
const integrationBranch = mergeTarget.branch || await resolveIntegrationBranch(projectRootDir, settings);
const expectedCurrentSha = execSyncText(`git rev-parse --verify ${quoteArg(`refs/heads/${integrationBranch}`)}`, {
cwd: rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim();
const advanceResult = await advanceIntegrationBranchRef({
rootDir,
projectRootDir,
integrationBranch,
newSha: worktreeHeadSha,
expectedCurrentSha,
taskId,
audit,
});
if (!advanceResult.advanced) {
if (advanceResult.reason === "concurrent-advance") {
throw new IntegrationBranchConcurrentAdvanceError({
integrationBranch,
expectedCurrentSha,
observedCurrentSha: advanceResult.observedCurrentSha,
newSha: worktreeHeadSha,
taskId,
settings,
conflicted,
{
onAgentText: options.onAgentText,
signal: options.signal,
runtimeHint: conflictRuntimeHint,
assignedAgentRuntimeConfig: conflictAssignedAgent?.runtimeConfig,
onSession: options.onSession,
},
);
const stillConflicted = await getConflictedFiles(projectRootDir).catch(() => [] as string[]);
if (stillConflicted.length > 0) {
await execAsync("git merge --abort", { cwd: projectRootDir, timeout: 30_000 }).catch(() => undefined);
throw new Error(
`AI conflict resolution left ${stillConflicted.length} unresolved file(s) when applying squash to ${integrationBranch}: ${stillConflicted.join(", ")}`,
);
}
await execAsync(`git commit --no-edit`, {
cwd: projectRootDir,
encoding: "utf-8",
timeout: 60_000,
});
await (audit as any).git({
type: "merge:reuse-integration-branch-advanced",
target: integrationBranch,
metadata: {
taskId,
sha: worktreeHeadSha,
via: "non-ff-merge-ai-resolved",
projectRootDir,
conflictedFiles: conflicted,
},
});
mergerLog.log(
`${taskId}: applied squash ${worktreeHeadSha.slice(0, 8)} to ${integrationBranch} in project root after AI conflict resolution (${conflicted.length} file(s))`,
);
}
throw new Error(`Failed to advance ${integrationBranch} via update-ref: ${advanceResult.diagnostic}`);
}
mergerLog.log(
`${taskId}: ${integrationBranch} advanced to ${worktreeHeadSha.slice(0, 8)} via update-ref; your checked-out worktree at ${projectRootDir} is now behind`,
);
}
} catch (advErr: unknown) {
const advMsg = advErr instanceof Error ? advErr.message : String(advErr);
mergerLog.error(
`${taskId}: failed to apply squash to ${mergeTarget.branch} in project root: ${advMsg}`,
`${taskId}: failed to advance ${mergeTarget.branch} via update-ref: ${advMsg}`,
);
await (audit as any).git({
type: "merge:reuse-integration-branch-advance-failed",
target: mergeTarget.branch,
metadata: { taskId, error: advMsg, projectRootDir },
});
// Abort: leaving reuseTaskWorktreeMerge=true would cause the subsequent
// push step to operate on projectRootDir where the squash was never
// applied, shipping the pre-merge ref. Mark the reuse-merge as failed
// push step to operate on projectRootDir where the target ref was never
// advanced, shipping the pre-merge ref. Mark the reuse-merge as failed
// and surface the error so the merge can be retried cleanly.
reuseTaskWorktreeMerge = false;
throw new Error(
`Failed to advance ${mergeTarget.branch} in project root after reuse-task-worktree squash: ${advMsg}`,
);
throw advErr;
}
}
@@ -9170,9 +9096,11 @@ export async function aiMergeTask(
: null;
const pushRuntimeHint = extractRuntimeHint(pushAssignedAgent?.runtimeConfig);
// In reuse-task-worktree mode, rootDir is the task worktree with a
// detached HEAD; step 5c already advanced the project root's
// integration branch to the new squash commit, so push from
// projectRootDir where the branch is checked out and at the new tip.
// detached HEAD; step 5c advanced refs/heads/<integration-branch> via
// `git update-ref` (FN-5350). The shared ref now points at the new squash
// commit, so pushing from projectRootDir (where the branch is checked out,
// but the working tree may be dirty and is NOT touched by us) sends the
// new tip to the remote.
const pushRootDir = reuseTaskWorktreeMerge ? projectRootDir : rootDir;
const pushResult = await pushToRemoteAfterMerge(store, pushRootDir, taskId, settings, {
onAgentText: options.onAgentText,
@@ -9342,7 +9270,11 @@ export async function aiMergeTask(
* HEAD when origin is strictly ahead. Returns silently on any failure
* (no remote configured, network down, divergent local commits, etc.).
* Only called for the smart strategies, which want to avoid resolving a
* conflict against a stale local base. */
* conflict against a stale local base.
*
* NOTE: This is NOT FN-5350's integration-branch ref advance path. FN-5350
* advances refs/heads/<integration-branch> via compare-and-swap `git update-ref`.
*/
async function tryFastForwardFromOrigin(
rootDir: string,
taskId: string,

View File

@@ -166,6 +166,8 @@ export type GitMutationType =
| "merge:reuse-fallback-reused-existing-registration"
| "merge:reuse-worktree-fresh-acquire"
| "merge:reuse-worktree-fresh-acquired"
| "merge:reuse-integration-branch-advanced"
| "merge:reuse-integration-branch-advance-failed"
| "merge:audit-failure"
| "branch:auto-reclaim"
| "branch:auto-canonicalize-case"