feat(FN-4678): add removeWorktree helper to worktree-backend

Adds a `removeWorktree` helper to the worktree backend with corresponding unit tests, and wires it into the worktree pool.

Fusion-Task-Id: FN-4678
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 22:44:36 -07:00
committed by gsxdsm
parent 401094c6a9
commit 156392410a
3 changed files with 139 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import {
NativeWorktreeBackend,
WorktrunkOperationError,
WorktrunkWorktreeBackend,
removeWorktree,
resolveWorktreeBackend,
} from "../worktree-backend.js";
@@ -334,6 +335,84 @@ describe("WorktrunkOperationError", () => {
});
});
describe("removeWorktree", () => {
it("uses native remove and emits worktree:remove audit", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const audit = { git: vi.fn().mockResolvedValue(undefined) } as any;
await removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
audit,
});
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000 }),
);
expect(audit.git).toHaveBeenCalledWith({ type: "worktree:remove", target: "/repo/.worktrees/fn-1" });
});
it("uses worktrunk remove and emits worktree:worktrunk-remove", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const audit = { git: vi.fn().mockResolvedValue(undefined) } as any;
await removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fail" } as any },
audit,
taskId: "FN-1",
});
expect(audit.git).toHaveBeenCalledWith({ type: "worktree:worktrunk-remove", target: "/repo/.worktrees/fn-1" });
});
it("falls back to native when worktrunk remove fails and onFailure=fallback-native", async () => {
execMock
.mockRejectedValueOnce(new WorktrunkOperationError({ operation: "remove", code: "worktrunk_operation_failed", stderr: "boom", exitCode: 1 }))
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const audit = { git: vi.fn().mockResolvedValue(undefined) } as any;
await removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fallback-native" } as any },
audit,
});
expect(audit.git).toHaveBeenCalledWith(
expect.objectContaining({ type: "worktree:worktrunk-fallback", target: "/repo/.worktrees/fn-1" }),
);
expect(audit.git).toHaveBeenCalledWith({ type: "worktree:remove", target: "/repo/.worktrees/fn-1" });
});
it("rethrows worktrunk remove failure when onFailure=fail", async () => {
execMock.mockRejectedValue(
new WorktrunkOperationError({ operation: "remove", code: "worktrunk_operation_failed", stderr: "boom", exitCode: 1 }),
);
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fail" } as any },
}),
).rejects.toMatchObject({ code: "worktrunk_operation_failed", operation: "remove" });
});
it("surfaces missing worktrunk binary errors", async () => {
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: { worktrunk: { enabled: true, onFailure: "fail" } as any },
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "remove" });
});
});
describe("resolveWorktreeBackend", () => {
it("uses native for undefined worktrunk", () => {
expect(resolveWorktreeBackend({}).kind).toBe("native");

View File

@@ -3,6 +3,7 @@ import { access } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { promisify } from "node:util";
import type { Settings } from "@fusion/core";
import type { RunAuditor } from "./run-audit.js";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { formatError } from "./logger.js";
@@ -457,6 +458,64 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
}
}
export async function removeWorktree(input: {
worktreePath: string;
rootDir: string;
settings: Partial<Settings>;
taskId?: string;
audit?: RunAuditor;
force?: boolean;
timeout?: number;
}): Promise<void> {
const logger = {
log: (_message: string): void => {},
warn: (_message: string): void => {},
};
const backend = resolveWorktreeBackend(input.settings, { logger });
const removeInput: WorktreeRemoveInput = {
rootDir: input.rootDir,
worktreePath: input.worktreePath,
taskId: input.taskId,
};
if (input.force === false || typeof input.timeout === "number") {
// Backwards-compatible helper signature for callers that carried raw git flags/timeouts.
// Current backend remove implementations are forceful and use backend-owned timeouts.
}
try {
await backend.remove(removeInput);
if (input.audit) {
await input.audit.git({
type: backend.kind === "worktrunk" ? "worktree:worktrunk-remove" : "worktree:remove",
target: input.worktreePath,
});
}
return;
} catch (error) {
if (!(error instanceof WorktrunkOperationError) || input.settings.worktrunk?.onFailure !== "fallback-native") {
throw error;
}
logger.warn(`[worktree-backend] falling back to native remove for ${input.worktreePath}`);
await input.audit?.git({
type: "worktree:worktrunk-fallback",
target: input.worktreePath,
metadata: {
op: "fallback-native",
stderrPreview: error.stderr?.slice(0, 4096),
exitCode: error.exitCode ?? null,
},
});
const native = new NativeWorktreeBackend({ logger, settings: input.settings });
await native.remove(removeInput);
await input.audit?.git({ type: "worktree:remove", target: input.worktreePath });
}
}
export function resolveWorktreeBackend(
settings: Partial<Settings>,
deps: { logger?: { log: (m: string) => void; warn: (m: string) => void } } = {},

View File

@@ -14,6 +14,7 @@ export {
NativeWorktreeBackend,
WorktrunkOperationError,
WorktrunkWorktreeBackend,
removeWorktree,
resolveWorktreeBackend,
} from "./worktree-backend.js";
export type { WorktreeBackend, WorktreeBackendKind } from "./worktree-backend.js";