FN-8132: recover bare worktree branch collisions

Recover safe worktree creation when a dangling task branch already exists.

- Classify bare branch collisions and preserve foreign or mixed commit history.
- Reuse task-owned branches or recreate merged branches from the pinned start point.
- Audit recovery outcomes and cover native, fallback, and workspace acquisition paths.

Files changed:
 .../fn-8132-worktree-branch-collision-recovery.md  |   7 ++
 docs/architecture.md                               |   1 +
 .../__tests__/worktree-acquisition-backend.test.ts |  69 +++++++++++
 .../worktree-acquisition-workspace.test.ts         |  21 ++++
 .../worktree-backend-branch-collision.test.ts      | 132 +++++++++++++++++++++
 packages/engine/src/branch-conflicts.ts            | 121 +++++++++++++++++++
 packages/engine/src/run-audit.ts                   |   1 +
 packages/engine/src/worktree-acquisition.ts        |   3 +-
 packages/engine/src/worktree-backend.ts            |  94 +++++++++++++++-
 9 files changed, 447 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8132

Fusion-Task-Lineage: f09d140f-4fcd-48a6-99b1-a351630f37bd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 13:48:00 -07:00
parent 60b6e3e048
commit 2c3a777bca
9 changed files with 447 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix tasks stalling when a leftover git branch collided with a new worktree.
category: fix
dev: NativeWorktreeBackend.create now runs a collision-specific classifier that reconciles a bare "branch already exists" collision (reuse reclaimable branches, recreate merged/orphaned branches from the pinned start point) instead of re-throwing; unmerged foreign/unattributed branches are preserved and live-foreign branches still raise BranchConflictError.

View File

@@ -2213,6 +2213,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Merge-seam abort provenance (FN-6568/FN-6735/FN-7749)**: workflow graph merge-node failures must not be classified as pause/resume aborts merely because the merge seam hard-canceled an in-flight session. `TaskExecutor` tracks paused-abort provenance separately (`global-pause`, `merge-seam`, `hard-cancel`); genuine user/global pauses still preserve FN-6478/FN-5147 parking, while non-paused merge-seam graph failures (`merge`, `requestMerge`, built-in merge-region node ids, `merge-manual-hold`, and `merge-retry`) route back into the bounded auto-merge retry path instead of being parked `status:"failed"` with `mergeRetries=NULL`. Benign pause/resume aborts at these seams are retryable when the task is already `in-review`, has no durable failure/status, has not confirmed a merge, remains auto-merge eligible (or is a shared-branch local integration), and has merge retries remaining. When auto-merge is off (`settings.autoMerge:false` or an explicit task-level `autoMerge:false`), a benign hard-cancel at a non-terminal merge-region/manual-hold node is instead preserved cleanly in `in-review` for human Merge & Close; stale pause-abort status/error of this exact shape is cleared in place and never requeued. Conflict/contamination/foreign-work/retry-exhaustion values, pre-existing failures, global/user pauses, shared-branch member integrations, and post-confirmation partial landings retain their existing terminal/retry/finalize behavior.
- **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool.
- **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events.
- **Bare branch-collision recovery (FN-8132)**: after stale lock/registration recovery, `NativeWorktreeBackend.create` classifies a `git worktree add -b` “branch already exists” error even when its requested target path is absent. It attaches an unregistered branch only when every unique commit is attributed to the requesting task, recreates merged/subsumed or no-unique-work branches from the caller-pinned start point, and refuses foreign, unattributed, or mixed unique history without moving its ref. A live foreign worktree remains a `BranchConflictError`; recovery dispositions emit `worktree:branch-collision-recovery`.
- **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class).
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session. The corresponding `task:auto-archive-meta-resolved-skipped` and `task:auto-archive-meta-stalled-skipped` run-audit rows are transition-only per task+guard-reason signature: emit once on first skip, suppress repeated sweeps while the same reasons persist, clear when the skip no longer applies, and re-emit if a different reason later blocks archival.
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.

View File

@@ -123,6 +123,36 @@ describe("acquireTaskWorktree backend wiring", () => {
);
});
it("passes audit into worktrunk-to-native fallback collision recovery", async () => {
let nativeAddAttempts = 0;
execMock.mockImplementation((command: string) => {
if (command.includes('"wt" "switch" "--create"')) {
return Promise.reject({ stderr: "worktrunk create failed", status: 1 });
}
if (command.startsWith("git worktree add -b")) {
nativeAddAttempts += 1;
return nativeAddAttempts === 1
? Promise.reject({ message: "branch collision", stderr: "fatal: a branch named 'fusion/fn-1' already exists" })
: Promise.resolve({ stdout: "", stderr: "" });
}
if (command === "git worktree list --porcelain") return Promise.resolve({ stdout: "worktree /repo\nbranch refs/heads/main\n", stderr: "" });
if (command.startsWith("git cherry")) return Promise.resolve({ stdout: "", stderr: "" });
return Promise.resolve({ stdout: "deadbeef\n", stderr: "" });
});
const audit = { git: vi.fn().mockResolvedValue(undefined) };
await acquireTaskWorktree({
task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store,
settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fallback-native" } } as any, audit: audit as any,
});
expect(nativeAddAttempts).toBe(2);
expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({
type: "worktree:branch-collision-recovery",
metadata: expect.objectContaining({ taskId: "FN-1", disposition: "recreate-from-startpoint" }),
}));
});
it("throws worktrunk_binary_missing with no binaryPath", async () => {
await expect(
acquireTaskWorktree({
@@ -169,6 +199,45 @@ describe("acquireTaskWorktree backend wiring", () => {
expect(execMock.mock.calls.some((call) => String(call[0]).includes(`"${explicitBinaryPath}" "switch" "--create"`))).toBe(true);
});
it("forwards canonical branch and pinned execution start point to an injected backend", async () => {
const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-1" });
const backend: WorktreeBackend = {
kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(),
resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/backend"),
};
await acquireTaskWorktree({
task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store,
settings: { worktreeNaming: "task-id" } as any, backend,
});
expect(create).toHaveBeenCalledWith(expect.objectContaining({
branch: "fusion/fn-1", startPoint: "release", taskId: "FN-1",
}));
});
it("forwards canonical branch and pinned start point on pool fresh fallback", async () => {
const create = vi.fn().mockResolvedValue({ path: "/tmp/fresh", branch: "fusion/fn-1" });
const backend: WorktreeBackend = {
kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(),
resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/fresh"),
};
const pool = {
acquire: vi.fn().mockReturnValue("/tmp/pooled"),
prepareForTask: vi.fn().mockRejectedValue(new Error("pool unavailable")),
release: vi.fn(),
};
await acquireTaskWorktree({
task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store,
settings: { worktreeNaming: "task-id", recycleWorktrees: true } as any, backend, pool: pool as any,
});
expect(create).toHaveBeenCalledWith(expect.objectContaining({
branch: "fusion/fn-1", startPoint: "release", taskId: "FN-1",
}));
});
it("uses explicit backend override", async () => {
const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-backend" });
const backend: WorktreeBackend = {

View File

@@ -133,6 +133,27 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout:
expect(result.baseCommitSha).toBe(developTip);
});
it("reconciles a sub-repo dangling collision branch from its resolved integration tip", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const repoA = fixture.repoPath("repo-a");
const mainTip = git(repoA, "git rev-parse main");
// Leave ambient HEAD on unrelated work to prove fresh acquisition uses the
// sub-repo integration branch, not whichever branch the root currently has checked out.
git(repoA, "git checkout -qb ambient-work");
git(repoA, "git commit --allow-empty -m 'chore: ambient work'");
git(repoA, "git branch fusion/fn-7 main");
const { store, current } = makeFakeStore(makeTask("FN-7"));
const result = await acquireWorkspaceRepoWorktree({
repoRelPath: "repo-a", workspaceRootDir: fixture.rootDir, task: current(), store,
settings: SETTINGS, registry: new ActiveSessionRegistry(),
});
expect(result.branch).toBe("fusion/fn-7");
expect(git(repoA, "git rev-parse fusion/fn-7")).toBe(mainTip);
expect(git(repoA, "git merge-base fusion/fn-7 main")).toBe(mainTip);
});
it("installs the identity-guard hook so a commit on a non-fusion branch is rejected", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const { store, current } = makeFakeStore(makeTask("FN-3"));

View File

@@ -0,0 +1,132 @@
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BranchConflictError } from "../branch-conflicts.js";
import { NativeWorktreeBackend } from "../worktree-backend.js";
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function assertRegisteredWorktree(repo: string, worktreePath: string, branch: string): void {
const porcelain = git(repo, "git worktree list --porcelain");
expect(porcelain).toContain(`worktree ${realpathSync(worktreePath)}`);
expect(porcelain).toContain(`branch refs/heads/${branch}`);
}
describe("NativeWorktreeBackend bare branch collision recovery", { timeout: 60_000 }, () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function setup(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-8132-collision-"));
dirs.push(repo);
git(repo, "git init -q -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
writeFileSync(join(repo, "base.txt"), "base\n");
git(repo, "git add base.txt && git commit -qm base");
return repo;
}
function commit(repo: string, file: string, message: string): void {
writeFileSync(join(repo, file), `${message}\n`);
git(repo, `git add ${JSON.stringify(file)} && git commit -m ${JSON.stringify(message)}`);
}
async function create(repo: string, branch: string, taskId: string, allowSiblingBranchRename = false) {
const target = join(repo, ".worktrees", `target-${taskId.toLowerCase()}`);
const events: any[] = [];
const result = await new NativeWorktreeBackend({ audit: { git: async (event: any) => { events.push(event); } } as any }).create({
rootDir: repo,
branch,
worktreePath: target,
startPoint: "main",
taskId,
allowSiblingBranchRename,
});
return { result, target, events };
}
it("recreates a dangling canonical branch from main without creating a sibling", async () => {
const repo = setup();
git(repo, "git branch fusion/fn-100 main");
const { result, target, events } = await create(repo, "fusion/fn-100", "FN-100");
expect(result).toEqual({ path: target, branch: "fusion/fn-100" });
assertRegisteredWorktree(repo, target, "fusion/fn-100");
expect(git(repo, "git branch --list fusion/fn-100-2")).toBe("");
const recovery = events.find((event) => event.type === "worktree:branch-collision-recovery");
expect(recovery).toMatchObject({ target, metadata: { taskId: "FN-100", disposition: "recreate-from-startpoint" } });
expect(Object.keys(recovery.metadata).sort()).toEqual(["disposition", "taskId"]);
});
it("recreates fully subsumed branch history from the pinned start point", async () => {
const repo = setup();
git(repo, "git checkout -qb fusion/fn-101 main");
commit(repo, "subsumed.txt", "feat(FN-101): represented upstream");
const branchTip = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -q main");
git(repo, `git cherry-pick ${branchTip}`);
const mainTip = git(repo, "git rev-parse main");
const { target } = await create(repo, "fusion/fn-101", "FN-101");
expect(git(repo, "git rev-parse fusion/fn-101")).toBe(mainTip);
assertRegisteredWorktree(repo, target, "fusion/fn-101");
});
it("attaches a reclaimable branch and preserves exclusively task-attributed commits", async () => {
const repo = setup();
git(repo, "git checkout -qb fusion/fn-102 main");
commit(repo, "own.txt", "feat(FN-102): preserve own work\n\nFusion-Task-Id: FN-102");
const tip = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -q main");
const { target } = await create(repo, "fusion/fn-102", "FN-102", true);
expect(git(repo, "git rev-parse fusion/fn-102")).toBe(tip);
expect(git(target, "git log -1 --format=%s")).toContain("feat(FN-102): preserve own work");
expect(git(repo, "git branch --list fusion/fn-102-2")).toBe("");
});
it("preserves foreign and mixed unmerged histories rather than attaching or deleting", async () => {
const repo = setup();
for (const [branch, taskId, messages] of [
["fusion/next-1378", "FN-103", ["feat(FN-999): foreign work"]],
["fusion/fn-104", "FN-104", ["feat(FN-104): own work\n\nFusion-Task-Id: FN-104", "feat(FN-999): mixed foreign work"]],
] as const) {
git(repo, `git checkout -qb ${branch} main`);
for (const [index, message] of messages.entries()) commit(repo, `${taskId}-${index}.txt`, message);
const tip = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -q main");
const target = join(repo, ".worktrees", `refused-${taskId}`);
await expect(new NativeWorktreeBackend().create({
rootDir: repo, branch, worktreePath: target, startPoint: "main", taskId, allowSiblingBranchRename: false,
})).rejects.toBeInstanceOf(BranchConflictError);
expect(git(repo, `git rev-parse ${branch}`)).toBe(tip);
expect(existsSync(target)).toBe(false);
}
});
it("refuses a live foreign checkout when the requested target path is absent", async () => {
const repo = setup();
git(repo, "git branch fusion/fn-105 main");
const foreignPath = join(repo, ".worktrees", "foreign");
git(repo, `git worktree add ${JSON.stringify(foreignPath)} fusion/fn-105`);
const tip = git(repo, "git rev-parse fusion/fn-105");
const target = join(repo, ".worktrees", "missing-target");
await expect(new NativeWorktreeBackend().create({
rootDir: repo, branch: "fusion/fn-105", worktreePath: target, startPoint: "main", taskId: "FN-105", allowSiblingBranchRename: false,
})).rejects.toBeInstanceOf(BranchConflictError);
expect(existsSync(target)).toBe(false);
expect(git(repo, "git rev-parse fusion/fn-105")).toBe(tip);
});
});

View File

@@ -101,6 +101,22 @@ export type BranchConflictInspectionResult =
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
/**
* FNXC:WorktreeAcquisition 2026-07-16-00:00:
* FN-8132 / #2232 needs a classifier for a bare `git worktree add -b` collision,
* where the requested path normally does not exist. Unlike inspectBranchConflict,
* this path must enumerate live worktrees before considering branch recovery:
* only exclusively task-attributed unique commits are reclaimable; any foreign or
* unattributed unique commit, including mixed history, remains protected.
*/
export type BareBranchCollisionInspectionResult =
| { kind: "missing" }
| { kind: "tip-already-merged"; tipSha: string; integrationRef: string }
| { kind: "fully-subsumed"; tipSha: string }
| { kind: "reclaimable"; tipSha: string; taskAttributedCommitCount: number; uniqueCommitCount: number }
| { kind: "foreign-unmerged"; tipSha: string; uniqueCommitCount: number; error: BranchConflictError }
| { kind: "live-foreign"; tipSha: string; error: BranchConflictError };
interface UniqueBranchCommitListResult {
commits: BranchConflictCommit[];
mainRef: string;
@@ -931,6 +947,111 @@ async function isZeroUniqueCommitBranchViaPatchIdFallback(
return true;
}
/**
* Inspect a branch-name collision from `git worktree add -b` without requiring
* the requested destination path to exist. Existing callers must use
* inspectBranchConflict, whose missing-path short-circuit is intentionally kept.
*/
export async function inspectBareBranchCollision(
input: InspectBranchConflictInput,
): Promise<BareBranchCollisionInspectionResult> {
const startPoint = input.startPoint ?? "HEAD";
try {
await runGit(input.repoDir, "git worktree prune");
} catch {
// Best-effort: the mapping check below still protects a registered live worktree.
}
try {
await revParse(input.repoDir, `refs/heads/${input.branchName}`);
} catch {
return { kind: "missing" };
}
let worktreeMap = await getWorktreeBranchMap(input.repoDir);
let livePath = worktreeMap.get(input.branchName);
if (livePath && !existsSync(livePath)) {
try {
await runGit(input.repoDir, "git worktree prune");
} catch {
// Best-effort: a still-present mapping is not considered live below.
}
worktreeMap = await getWorktreeBranchMap(input.repoDir);
livePath = worktreeMap.get(input.branchName);
}
const tipSha = await revParse(input.repoDir, input.branchName);
const uniqueCommitResult = await listUniqueBranchCommits(input.repoDir, startPoint, input.branchName);
const requestedIntegrationRef = input.integrationRef ?? await resolveIntegrationBranch(input.repoDir, undefined);
const integrationRef = await resolveBranchComparisonRef(input.repoDir, requestedIntegrationRef, input.branchName);
if (livePath && existsSync(livePath)) {
return {
kind: "live-foreign",
tipSha,
error: new BranchConflictError({
branchName: input.branchName,
conflictingWorktreePath: livePath,
existingTipSha: tipSha,
strandedCommits: uniqueCommitResult.commits,
startPoint: uniqueCommitResult.mainRef,
recommendedAction: "Inspect the live conflicting worktree before retrying.",
}),
};
}
if (await isAncestor(input.repoDir, tipSha, integrationRef)) {
return { kind: "tip-already-merged", tipSha, integrationRef };
}
const zeroUnique = uniqueCommitResult.commits.length === 0 && (
!uniqueCommitResult.degraded || await isZeroUniqueCommitBranchViaPatchIdFallback(
input.repoDir,
startPoint,
input.branchName,
uniqueCommitResult.mainRef,
)
);
if (zeroUnique) {
return { kind: "fully-subsumed", tipSha };
}
const attribution = await reportBranchAttribution(
input.repoDir,
input.branchName,
uniqueCommitResult.mainRef,
input.requestingTaskId,
);
const taskAttributedCommitCount = attribution.ownTrailed + attribution.ownUntrailed.length;
const foreignOrUnattributedCount = attribution.foreign.length + attribution.unattributed.length;
if (
taskAttributedCommitCount === uniqueCommitResult.commits.length
&& foreignOrUnattributedCount === 0
) {
return {
kind: "reclaimable",
tipSha,
taskAttributedCommitCount,
uniqueCommitCount: uniqueCommitResult.commits.length,
};
}
return {
kind: "foreign-unmerged",
tipSha,
uniqueCommitCount: uniqueCommitResult.commits.length,
error: new BranchConflictError({
branchName: input.branchName,
conflictingWorktreePath: input.conflictingWorktreePath,
existingTipSha: tipSha,
strandedCommits: uniqueCommitResult.commits,
startPoint: uniqueCommitResult.mainRef,
recommendedAction: "Preserve this unregistered branch and inspect its foreign or unattributed commits before retrying.",
}),
};
}
export async function inspectBranchConflict(
input: InspectBranchConflictInput,
): Promise<BranchConflictInspectionResult> {

View File

@@ -152,6 +152,7 @@ export type GitMutationType =
| "worktree:stale-registration-detected"
| "worktree:stale-registration-recovered"
| "worktree:stale-registration-recovery-failed"
| "worktree:branch-collision-recovery"
| "branch:create"
| "branch:delete"
| "branch:checkout"

View File

@@ -302,7 +302,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
return created;
} catch (error) {
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined });
// FNXC:WorktreeAcquisition 2026-07-16-00:00: FN-8132 requires native fallback collision dispositions to be audited just like direct native acquisition.
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined, audit });
const fallback = () => nativeBackend.create({
rootDir,
branch: createBranch,

View File

@@ -12,7 +12,7 @@ import {
} from "./active-session-registry.js";
import type { RunAuditor } from "./run-audit.js";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { inspectBareBranchCollision, inspectBranchConflict } from "./branch-conflicts.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { formatError } from "./logger.js";
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
@@ -392,6 +392,25 @@ export class NativeWorktreeBackend implements WorktreeBackend {
});
return { path: input.worktreePath, branch: branchName };
};
const attachExistingBranch = async (): Promise<WorktreeCreateResult> => {
await execAsync(`git worktree add ${quoteShellArg(input.worktreePath)} ${quoteShellArg(input.branch)}`, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: NATIVE_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
return { path: input.worktreePath, branch: input.branch };
};
const cleanupPartialCollisionRecovery = async () => {
await rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
await pruneWorktreeAdminEntries({
rootDir: input.rootDir,
auditor: this.deps.audit,
reason: "backend-branch-collision-recovery-failed",
target: input.worktreePath,
logger: this.deps.logger,
}).catch(() => undefined);
};
let staleLockRecoveryAttempted = false;
let staleRegistrationRecoveryAttempted = false;
@@ -520,6 +539,79 @@ export class NativeWorktreeBackend implements WorktreeBackend {
});
}
const isBareBranchCollision = /(?:a\s+)?branch named ["']?.+["']? already exists|branch ["']?.+["']? already exists/i.test(combinedErrorOutput);
if (isBareBranchCollision) {
/*
* FNXC:WorktreeAcquisition 2026-07-16-00:00:
* FN-8132 / #2232 recovers only a bare branch-name collision after the
* stale-lock and stale-registration ladder. A live foreign checkout still
* fails even when this target path is absent. Unregistered branches are
* attached only when every unique commit belongs to this task; merged or
* empty branches are recreated from the caller-pinned startPoint, while
* any foreign/unattributed (including mixed) history is never deleted.
*/
const inspection = await inspectBareBranchCollision({
repoDir: input.rootDir,
branchName: input.branch,
conflictingWorktreePath: input.worktreePath,
requestingTaskId: input.taskId,
startPoint: input.startPoint,
integrationRef: await resolveIntegrationBranch(input.rootDir, undefined),
});
if (inspection.kind === "live-foreign") {
await this.deps.audit?.git({
type: "worktree:branch-collision-recovery",
target: input.worktreePath,
metadata: { taskId: input.taskId, disposition: "threw-live-foreign" },
});
throw inspection.error;
}
if (inspection.kind === "foreign-unmerged") {
await this.deps.audit?.git({
type: "worktree:branch-collision-recovery",
target: input.worktreePath,
metadata: { taskId: input.taskId, disposition: "refused-foreign-unmerged", uniqueCommitCount: inspection.uniqueCommitCount },
});
throw inspection.error;
}
if (inspection.kind === "reclaimable") {
try {
const created = await attachExistingBranch();
await installGuardOrCleanup(created.path);
await this.deps.audit?.git({
type: "worktree:branch-collision-recovery",
target: input.worktreePath,
metadata: { taskId: input.taskId, disposition: "reuse-existing-branch", uniqueCommitCount: inspection.uniqueCommitCount },
});
return created;
} catch (recoveryError) {
await cleanupPartialCollisionRecovery();
throw recoveryError;
}
}
if (inspection.kind === "tip-already-merged" || inspection.kind === "fully-subsumed") {
try {
await execAsync(`git branch -D ${quoteShellArg(input.branch)}`, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: NATIVE_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
const created = await createWithBranch(input.branch);
await installGuardOrCleanup(created.path);
await this.deps.audit?.git({
type: "worktree:branch-collision-recovery",
target: input.worktreePath,
metadata: { taskId: input.taskId, disposition: "recreate-from-startpoint" },
});
return created;
} catch (recoveryError) {
await cleanupPartialCollisionRecovery();
throw recoveryError;
}
}
}
if (!input.allowSiblingBranchRename) {
throw error;
}