feat(FN-5329): remove orphan rescue and branch-recovery primitives from eng

Removes the branch-recovery CLI surface, orphan-rescue engine primitives, and their associated tests (over 1,500 lines deleted), while restoring a minimal prune-only orphan branch sweep with proper git audit mutation types. Documentation across `cli-reference.md`, `task-management.md`, and `AGENTS.m

Fusion-Task-Id: FN-5329
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 16:42:50 -07:00
committed by gsxdsm
parent 0e5cb4d292
commit fd202e9356
33 changed files with 131 additions and 1644 deletions

View File

@@ -47,7 +47,6 @@ import {
BranchCrossContaminationError,
assertCleanBranchAtBase,
inspectBranchConflict,
listBranchRecoveryCandidates,
listUniqueBranchCommits,
} from "../branch-conflicts.js";
@@ -307,7 +306,7 @@ describe("branch-conflicts", () => {
}
expect(result.error).toBeInstanceOf(BranchConflictError);
expect(result.error.message).toContain("1 stranded commit since main");
expect(result.error.message).toContain("Run branch recovery");
expect(result.error.message).toContain("Inspect/reclaim or discard the conflicting local branch/worktree");
});
it("lists zero unique commits when git cherry has no plus entries", async () => {
@@ -429,67 +428,5 @@ describe("branch-conflicts", () => {
await expect(assertion).resolves.toBeUndefined();
});
it("lists canonical and sibling recovery candidates with worktrees and stranded commits", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git for-each-ref --format='%(refname:short)' refs/heads/fusion/fn-4068 refs/heads/fusion/fn-4068-*") {
return Buffer.from("fusion/fn-4068\nfusion/fn-4068-2\n");
}
if (command === "git worktree list --porcelain") {
return Buffer.from([
"worktree /tmp/repo",
"HEAD 1111111",
"branch refs/heads/main",
"",
"worktree /tmp/fn-4068",
"HEAD 2222222",
"branch refs/heads/fusion/fn-4068",
"",
"worktree /tmp/fn-4068-2",
"HEAD 3333333",
"branch refs/heads/fusion/fn-4068-2",
"",
].join("\n"));
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123\n");
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068-2^{commit}'")) {
return Buffer.from("def456\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
return Buffer.from("aaa111\tCanonical fix\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068-2'")) {
return Buffer.from("bbb222\tSibling patch\nccc333\tMore work\n");
}
throw new Error(`Unexpected command: ${command}`);
});
const result = await listBranchRecoveryCandidates({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
startPoint: "main",
});
expect(result).toEqual([
{
branchName: "fusion/fn-4068",
tipSha: "abc123",
worktreePath: "/tmp/fn-4068",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-4068-2",
tipSha: "def456",
worktreePath: "/tmp/fn-4068-2",
strandedCommits: [
{ sha: "bbb222", subject: "Sibling patch" },
{ sha: "ccc333", subject: "More work" },
],
isCanonical: false,
},
]);
});
});

View File

@@ -1634,7 +1634,7 @@ describe("TaskExecutor worktree recovery", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
// Should have triggered cleanup (stale branch recovery)
// Should have triggered cleanup (stale branch reclaim)
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git worktree prune"),
expect.any(Object),

View File

@@ -1,111 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as branchConflicts from "../../branch-conflicts.js";
import * as worktreePool from "../../worktree-pool.js";
import { RestartRecoveryCoordinator } from "../../restart-recovery-coordinator.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getSettings = vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false });
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
(emitter as any).getBootstrappedAt = vi.fn(() => null);
(emitter as any).createTask = vi.fn();
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
return emitter;
}
describe("reliability interactions: branch recovery + orphan rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
it("keeps userPaused tasks unswept even if reclaimable", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([{ id: "FN-4429", column: "todo", checkedOutBy: null, branch: "fusion/fn-4429", worktree: "/tmp/fn-4429", paused: true, userPaused: true, pausedReason: "branch-conflict-unrecoverable" }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const inspectSpy = vi.spyOn(branchConflicts, "inspectBranchConflict");
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(inspectSpy).not.toHaveBeenCalled();
});
it("restart recovery safe-requeue and reclaim sweep do not race on paused branch-conflict tasks", async () => {
const task: any = {
id: "FN-6000",
column: "in-progress",
checkedOutBy: null,
branch: "fusion/fn-6000",
worktree: "/tmp/fn-6000",
paused: true,
userPaused: false,
pausedReason: "branch-conflict-unrecoverable",
status: "failed",
error: "Agent exited without calling fn_task_done",
steps: [{ name: "A", status: "pending" }],
};
const statefulStore: any = createStore();
statefulStore.listTasks = vi.fn(async ({ column }: { column?: string }) => {
if (!column) return [task];
return task.column === column ? [task] : [];
});
statefulStore.updateTask = vi.fn(async (_id: string, updates: Record<string, unknown>) => {
Object.assign(task, updates);
});
statefulStore.moveTask = vi.fn(async (_id: string, column: string) => {
task.column = column;
});
const restart = new RestartRecoveryCoordinator(statefulStore, { resumeOrphaned: vi.fn().mockResolvedValue(undefined) } as any);
const localManager = new SelfHealingManager(statefulStore, { rootDir: "/tmp/repo" });
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: "/tmp/fn-6000",
tipSha: "abc123def456",
taskAttributedCommitCount: 0,
strandedCommits: [],
} as any);
await restart.recoverInterruptedRuns();
const recovered = await localManager.reclaimSelfOwnedBranchConflicts();
expect(task.column).toBe("in-progress");
expect(task.branch).toBe("fusion/fn-6000");
expect(task.worktree).toBe("/tmp/fn-6000");
expect(recovered).toBe(1);
});
it("orphan-rescue sweep is idempotent across consecutive runs", async () => {
const branch = "fusion/fn-4470";
vi.spyOn(worktreePool, "scanOrphanedBranches")
.mockResolvedValueOnce([branch])
.mockResolvedValueOnce([branch]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null });
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "FN-5001", column: "triage", branch }]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
await manager.cleanupOrphanedBranches();
await manager.cleanupOrphanedBranches();
expect(store.createTask).toHaveBeenCalledTimes(1);
});
});

View File

@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
return emitter;
}
describe("reliability interactions: branch recovery stale cached base", () => {
describe("reliability interactions: stale cached-base branch reclaim", () => {
let store: TaskStore & EventEmitter;
beforeEach(() => {

View File

@@ -1,129 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import { execSync } from "node:child_process";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as worktreePool from "../../worktree-pool.js";
function createStore(bootstrappedAt: number | null, tasks: any[] = []): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => bootstrappedAt);
(emitter as any).listTasks = vi.fn().mockResolvedValue(tasks);
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
(emitter as any).createTask = vi.fn();
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
return emitter;
}
describe("reliability interactions: orphan-rescue fresh-db gate", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("leaves both subsumed and unique orphan branches untouched for a fresh DB", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
const inspectSpy = vi.spyOn(manager as any, "inspectOrphanedBranch");
const execSyncMock = vi.mocked(execSync);
const cleaned = await manager.cleanupOrphanedBranches();
expect(cleaned).toBe(0);
expect(scanSpy).not.toHaveBeenCalled();
expect(inspectSpy).not.toHaveBeenCalled();
expect(execSyncMock).not.toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.anything(),
);
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "self-healing:orphan-rescue-skipped-fresh-db" }),
);
});
it("preserves prune-and-rescue behavior for non-fresh DBs", async () => {
const store = createStore(Date.now() - 1_000_000, [{ id: "FN-0001", column: "done" }]);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({
branch: "fusion/fn-subsumed",
tipSha: "aaa111",
uniqueCommitCount: 0,
uniqueCommitSubjects: [],
derivedTaskId: "FN-SUBSUMED",
registeredWorktreePath: null,
})
.mockResolvedValueOnce({
branch: "fusion/fn-unique",
tipSha: "bbb222",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: keep work"],
derivedTaskId: "FN-UNIQUE",
registeredWorktreePath: null,
});
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
const cleaned = await manager.cleanupOrphanedBranches();
expect(cleaned).toBe(1);
expect(vi.mocked(execSync)).toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.objectContaining({ cwd: "/tmp/repo" }),
);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ title: "Recover orphaned branch fusion/fn-unique" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-prune" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
);
});
it("remains idempotent across repeated fresh-DB sweeps", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
const execSyncMock = vi.mocked(execSync);
const first = await manager.cleanupOrphanedBranches();
const second = await manager.cleanupOrphanedBranches();
const third = await manager.cleanupOrphanedBranches();
expect([first, second, third]).toEqual([0, 0, 0]);
expect(scanSpy).not.toHaveBeenCalled();
expect(execSyncMock).not.toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.anything(),
);
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(3);
});
});

View File

@@ -3,12 +3,11 @@ import { EventEmitter } from "node:events";
import type { RunAuditEventInput, Settings, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, scanOrphanedBranchesSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
execSpy: vi.fn(),
execSyncSpy: vi.fn(),
resolveBackendSpy: vi.fn(),
scanIdleSpy: vi.fn(),
scanOrphanedBranchesSpy: vi.fn().mockResolvedValue([]),
readdirSpy: vi.fn(),
existsSpy: vi.fn().mockReturnValue(false),
inspectBranchConflictSpy: vi.fn(),
@@ -38,7 +37,6 @@ vi.mock("../../worktree-pool.js", async () => {
...actual,
resolveWorktreeBackend: resolveBackendSpy,
scanIdleWorktrees: scanIdleSpy,
scanOrphanedBranches: scanOrphanedBranchesSpy,
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
};
});
@@ -93,8 +91,6 @@ describe("reliability interactions: worktrunk x self-healing", () => {
execSyncSpy.mockReset();
resolveBackendSpy.mockReset();
scanIdleSpy.mockReset();
scanOrphanedBranchesSpy.mockReset();
scanOrphanedBranchesSpy.mockResolvedValue([]);
readdirSpy.mockReset();
existsSpy.mockReset();
existsSpy.mockReturnValue(false);

View File

@@ -44,7 +44,6 @@ describe("FN-4733: self-healing chat cleanup maintenance", () => {
const manager = new SelfHealingManager(store, { rootDir: tmpRoot, chatStore });
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);

View File

@@ -33,7 +33,6 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
const BATCH1_METHODS = [
"pruneWorktrees",
"cleanupOrphans",
"cleanupOrphanedBranches",
"enforceWorktreeCap",
] as const;

View File

@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
return emitter;
}
describe("self-healing ghost branch recovery", () => {
describe("self-healing ghost branch reclaim", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;

View File

@@ -28,13 +28,11 @@ vi.mock("../worktree-pool.js", () => ({
SelfHealingReclaim: "self-healing-reclaim",
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
},
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
removeWorktree: vi.fn().mockResolvedValue(undefined),
resolveWorktreeBackend: vi.fn(),

View File

@@ -41,7 +41,6 @@ describe("FN-4743: self-healing mail cleanup maintenance", () => {
});
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);

View File

@@ -1,103 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => null);
(emitter as any).listTasks = vi.fn();
(emitter as any).createTask = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing orphan branch rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
});
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "abc123",
uniqueCommitCount: 0,
uniqueCommitSubjects: [],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
});
it("creates a rescue triage task when unique commits exist and no task row exists", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: "/tmp/wt-fn-4470",
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5000", lineageId: "lin-5000" });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Recover orphaned branch fusion/fn-4470",
column: "triage",
branch: "fusion/fn-4470",
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-5000", { worktree: "/tmp/wt-fn-4470" });
expect(store.logEntry).toHaveBeenCalledWith("FN-5000", expect.stringContaining("[recovery] orphan-rescue-created"));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-rescued" }));
});
it("leaves archived matching tasks untouched and only acknowledges once", async () => {
const archivedTask = { id: "FN-4470", column: "archived", metadata: {} };
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 1,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([archivedTask]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.createTask).not.toHaveBeenCalled();
});
});

View File

@@ -1,86 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(bootstrappedAt: number | null, tasks: any[] = []): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => bootstrappedAt);
(emitter as any).listTasks = vi.fn().mockResolvedValue(tasks);
(emitter as any).createTask = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing fresh-db orphan rescue gate", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("skips orphan rescue entirely for fresh databases with zero task history", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/foo",
"fusion/bar",
]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(scanSpy).not.toHaveBeenCalled();
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
mutationType: "self-healing:orphan-rescue-skipped-fresh-db",
metadata: expect.objectContaining({
bootstrappedAt: expect.any(Number),
processBootStartedAt: expect.any(Number),
taskCount: 0,
candidateBranches: 0,
}),
}),
);
});
it("preserves existing orphan rescue behavior when the database is not fresh", async () => {
const store = createStore(Date.now() - 1_000_000, []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5000", lineageId: "lin-5000" });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ title: "Recover orphaned branch fusion/fn-4470" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
);
});
});

View File

@@ -66,13 +66,12 @@ vi.mock("../worktree-pool.js", () => ({
SelfHealingReclaim: "self-healing-reclaim",
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
},
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
removeWorktree: vi.fn().mockResolvedValue(undefined),
resolveWorktreeBackend: vi.fn(),
@@ -111,11 +110,11 @@ import { classifyOwnedLandedEvidence } from "../merger.js";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
const mockedRemoveWorktree = vi.mocked(removeWorktree);
const mockedResolveWorktreeBackend = vi.mocked(resolveWorktreeBackend);
const mockedScanIdleWorktrees = vi.mocked(scanIdleWorktrees);
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
const mockedReaddirSync = vi.mocked(readdirSync);
const mockedCreateLogger = vi.mocked(createLogger);
const mockedClassifyOwnedLandedEvidence = vi.mocked(classifyOwnedLandedEvidence);
@@ -1366,73 +1365,6 @@ describe("SelfHealingManager", () => {
});
});
// ── cleanupOrphanedBranches ────────────────────────────────────────
describe("cleanupOrphanedBranches", () => {
it("returns 0 when no orphaned branches found", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("deletes only subsumed orphaned branches with safe delete (-d)", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-001", "fusion/fn-002"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-001", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-001", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-002", tipSha: "def", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-002", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(2);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git branch -d 'fusion/fn-001'"),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git branch -d 'fusion/fn-002'"),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
});
it("does not force-delete unique-commit orphaned branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-003"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-003", tipSha: "abc", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep"], derivedTaskId: "FN-003", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalledWith(
expect.stringContaining('git branch -D "fusion/fn-003"'),
expect.any(Object),
);
});
it("counts only successfully pruned subsumed branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-004", "fusion/fn-005"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-004", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-004", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-005", tipSha: "def", uniqueCommitCount: 1, uniqueCommitSubjects: ["feat"], derivedTaskId: "FN-005", registeredWorktreePath: null });
mockedExecSync.mockReset();
mockedExecSync.mockImplementation(() => Buffer.from(""));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
});
it("returns 0 when scanOrphanedBranches throws", async () => {
mockedScanOrphanedBranches.mockRejectedValueOnce(new Error("git error"));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
});
});
// ── Auto-archive ────────────────────────────────────────────────────
describe("archiveStaleDoneTasks", () => {
@@ -7137,6 +7069,55 @@ describe("worktrunk-aware cleanup sweeps", () => {
});
});
describe("cleanupOrphanedBranches", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createMockStore({
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
});
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
mockedScanOrphanedBranches.mockReset();
mockedExecSync.mockReset();
});
afterEach(() => {
manager.stop();
});
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-777"]);
mockedExecSync.mockImplementation((command: string) => {
if (command.startsWith("git rev-parse --verify")) return "abc123\n" as any;
if (command.startsWith("git rev-list --count")) return "0\n" as any;
if (command.startsWith("git branch -d")) return "" as any;
return "" as any;
});
const result = await (manager as any).cleanupOrphanedBranches();
expect(result).toBe(1);
expect(mockedExecSync).toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
expect(vi.mocked(store.recordRunAuditEvent)).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
});
it("leaves unique-commit orphan branches untouched", async () => {
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-888"]);
mockedExecSync.mockImplementation((command: string) => {
if (command.startsWith("git rev-parse --verify")) return "def456\n" as any;
if (command.startsWith("git rev-list --count")) return "2\n" as any;
return "" as any;
});
const result = await (manager as any).cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
expect(vi.mocked(store.createTask)).not.toHaveBeenCalled();
});
});
describe("maintenance cycle concurrency", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
@@ -7202,7 +7183,6 @@ describe("maintenance cycle concurrency", () => {
it("resets maintenanceRunning flag on success", async () => {
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
@@ -7228,7 +7208,6 @@ describe("maintenance cycle concurrency", () => {
it("uses a passive WAL checkpoint during maintenance", async () => {
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
@@ -7268,7 +7247,6 @@ describe("maintenance cycle concurrency", () => {
makeSlow("pruneWorktrees");
makeSlow("cleanupOrphans");
makeSlow("cleanupOrphanedBranches");
makeSlow("enforceWorktreeCap");
// checkpointWal is synchronous, no need to mock
@@ -7281,7 +7259,6 @@ describe("maintenance cycle concurrency", () => {
// All operations should have run
expect(executionOrder).toContain("pruneWorktrees");
expect(executionOrder).toContain("cleanupOrphans");
expect(executionOrder).toContain("cleanupOrphanedBranches");
expect(executionOrder).toContain("enforceWorktreeCap");
});
@@ -7362,7 +7339,6 @@ describe("maintenance cycle concurrency", () => {
// Mock batch 1 and 3 as well
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "archiveStaleDoneTasks").mockResolvedValue(0) as any);

View File

@@ -63,7 +63,6 @@ import {
scanIdleWorktrees,
cleanupOrphanedWorktrees,
reapOrphanWorktrees,
scanOrphanedBranches,
} from "../worktree-pool.js";
import { BranchConflictError } from "../branch-conflicts.js";
import * as branchConflictModule from "../branch-conflicts.js";
@@ -398,7 +397,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -441,7 +440,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "fusion/fn-041",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -477,7 +476,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -556,7 +555,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -1010,346 +1009,3 @@ describe("cleanupOrphanedWorktrees", () => {
});
});
// ── scanOrphanedBranches tests ────────────────────────────────────────
describe("scanOrphanedBranches", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: return empty string (no branches)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "";
}
return Buffer.from("");
});
});
it("identifies branches not associated with any active task", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "in-progress"),
makeTask("FN-002", "todo"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-003"]);
});
it("excludes in-review and done tasks (merger manages those)", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "in-review"),
makeTask("FN-002", "done"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toContain("fusion/fn-001");
expect(orphaned).toContain("fusion/fn-002");
expect(orphaned).toContain("fusion/fn-003");
});
it("excludes archived tasks", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "archived"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-001"]);
});
it("uses task.branch field when set", async () => {
const task = makeTask("FN-001", "in-progress");
task.branch = "fusion/fn-001-custom";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-001-custom\n fusion/fn-002\n";
}
return Buffer.from("");
});
const store = createMockStore([task]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-002"]);
});
it("returns empty array when git branch fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git branch")) {
throw new Error("not a git repo");
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[worktree-pool] Failed to list fusion/* branches: not a git repo"),
);
});
it("returns empty array when no fusion/* branches exist", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "";
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual([]);
});
it("strips leading * and whitespace from branch names", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "* fusion/fn-001\n fusion/fn-002\n";
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toContain("fusion/fn-001");
expect(orphaned).toContain("fusion/fn-002");
});
});
// ── reapOrphanWorktrees tests ─────────────────────────────────────────
describe("reapOrphanWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: .worktrees/ exists, lstatSync returns a real directory (not a symlink)
mockedExistsSync.mockReturnValue(true);
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
mockedReaddirSync.mockReturnValue([]);
// Default: no registered worktrees
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return "worktree /root\nHEAD abc123\nbranch refs/heads/main\n\n" as any;
}
return Buffer.from("");
});
});
it("returns 0 when .worktrees/ does not exist", async () => {
mockedExistsSync.mockReturnValue(false);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("returns 0 when .worktrees/ is empty", async () => {
mockedReaddirSync.mockReturnValue([] as any);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("removes a directory that has no .git file and is not registered", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("pale-raven")] as any);
// .gitkeep exists but NOT a .git file — simulate with existsSync returning false for .git
mockedExistsSync.mockImplementation((p: any) => {
if (String(p) === "/root/.worktrees") return true;
if (String(p).endsWith("/.git")) return false;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/pale-raven", {
recursive: true,
force: true,
});
expect(mockedPruneWorktreeAdminEntries).toHaveBeenCalledWith(
expect.objectContaining({ reason: "pool-reap-orphan", target: "/root/.worktrees/pale-raven" }),
);
});
it("does NOT remove a directory that is a registered git worktree", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("swift-falcon")] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /root",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /root/.worktrees/swift-falcon",
"HEAD def456",
"branch refs/heads/fusion/swift-falcon",
"",
].join("\n") as any;
}
return Buffer.from("");
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("does NOT remove a directory that has a .git file (may be partially registered)", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("amber-wolf")] as any);
mockedExistsSync.mockImplementation((p: any) => {
if (String(p) === "/root/.worktrees") return true;
if (String(p) === "/root/.worktrees/amber-wolf/.git") return true;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("does NOT remove symlinks", async () => {
mockedReaddirSync.mockReturnValue([
{ name: "linked-wt", isDirectory: () => true } as any,
] as any);
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => true } as any);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("handles multiple orphans and multiple registered worktrees correctly", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("orphan-1"),
makeDirEntry("orphan-2"),
makeDirEntry("good-wt"),
] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /root",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /root/.worktrees/good-wt",
"HEAD def456",
"branch refs/heads/fusion/good-wt",
"",
].join("\n") as any;
}
return Buffer.from("");
});
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(2);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-1", {
recursive: true,
force: true,
});
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-2", {
recursive: true,
force: true,
});
expect(mockedRmSync).not.toHaveBeenCalledWith(
expect.stringContaining("good-wt"),
expect.anything(),
);
});
it("continues and logs a warning when rmSync throws for one orphan", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("bad-orphan"),
makeDirEntry("good-orphan"),
] as any);
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
let callCount = 0;
mockedRmSync.mockImplementation(() => {
callCount++;
if (callCount === 1) throw new Error("permission denied");
});
const removed = await reapOrphanWorktrees("/root");
// Only the second one succeeds
expect(removed).toBe(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("reapOrphanWorktrees: failed to remove bad-orphan"),
);
});
it("returns 0 and logs warning when git worktree list fails", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("some-dir")] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
throw new Error("not a git repo");
}
return Buffer.from("");
});
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
// When git list fails, getRegisteredWorktreePaths returns an empty Set,
// so any unregistered dir without a .git file would be reaped.
// In this test we verify behavior is safe: no crash, returns a count.
const removed = await reapOrphanWorktrees("/root");
// some-dir has no .git, not registered (empty set due to failure) — gets reaped
expect(removed).toBe(1);
// The warn from getRegisteredWorktreePaths should appear
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to list registered worktrees"),
);
});
});