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:
7
.changeset/fn-8132-worktree-branch-collision-recovery.md
Normal file
7
.changeset/fn-8132-worktree-branch-collision-recovery.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user