Files
fusion/packages/engine/src/__tests__/worktree-backend.test.ts
2026-05-22 22:43:26 -07:00

1024 lines
38 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ActiveSessionWorktreeRemovalError,
NativeWorktreeBackend,
WorktrunkOperationError,
WorktrunkWorktreeBackend,
removeWorktree,
resolveWorktreeBackend,
RemovalReason,
} from "../worktree-backend.js";
import { activeSessionRegistry } from "../active-session-registry.js";
const { execMock, accessMock, rmMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock, parseStaleRegistrationPathMock, recoverStaleRegistrationMock, installGuardMock } = vi.hoisted(() => {
const mock = vi.fn();
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
return {
execMock: mock,
accessMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn(),
parseIndexLockPathMock: vi.fn(),
classifyStaleLockMock: vi.fn(),
tryRemoveStaleLockMock: vi.fn(),
parseStaleRegistrationPathMock: vi.fn(),
recoverStaleRegistrationMock: vi.fn(),
installGuardMock: vi.fn(),
};
});
vi.mock("node:child_process", () => ({ exec: execMock }));
vi.mock("node:fs", () => ({ existsSync: existsSyncMock }));
vi.mock("node:fs/promises", () => ({ access: accessMock, rm: rmMock }));
vi.mock("../branch-conflicts.js", () => ({
inspectBranchConflict: vi.fn().mockResolvedValue({ kind: "stale" }),
}));
vi.mock("../worktree-hooks.js", () => ({
installTaskWorktreeIdentityGuard: installGuardMock,
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
}));
vi.mock("../worktree-stale-lock.js", () => ({
StaleWorktreeIndexLockError: class StaleWorktreeIndexLockError extends Error {
lockPath: string;
classification: string;
reason: string;
constructor(input: { message: string; lockPath: string; classification: string; reason: string }) {
super(input.message);
this.name = "StaleWorktreeIndexLockError";
this.lockPath = input.lockPath;
this.classification = input.classification;
this.reason = input.reason;
}
},
parseIndexLockPath: parseIndexLockPathMock,
classifyStaleLock: classifyStaleLockMock,
tryRemoveStaleLock: tryRemoveStaleLockMock,
}));
vi.mock("../worktree-stale-registration.js", () => ({
parseStaleRegistrationPath: parseStaleRegistrationPathMock,
recoverStaleRegistration: recoverStaleRegistrationMock,
}));
beforeEach(() => {
execMock.mockReset();
accessMock.mockReset();
rmMock.mockReset();
rmMock.mockResolvedValue(undefined as never);
existsSyncMock.mockReset();
accessMock.mockResolvedValue(undefined);
existsSyncMock.mockReturnValue(true);
parseIndexLockPathMock.mockReset();
classifyStaleLockMock.mockReset();
tryRemoveStaleLockMock.mockReset();
installGuardMock.mockReset();
installGuardMock.mockResolvedValue(undefined);
parseIndexLockPathMock.mockReturnValue(null);
parseStaleRegistrationPathMock.mockReset();
parseStaleRegistrationPathMock.mockReturnValue(null);
recoverStaleRegistrationMock.mockReset();
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "fresh" });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
activeSessionRegistry.clear();
});
describe("NativeWorktreeBackend", () => {
it("creates worktree with expected command", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
const result = await backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
startPoint: "main",
taskId: "FN-1",
});
expect(result).toEqual({ path: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" });
expect(execMock).toHaveBeenCalledWith(
'git worktree add -b "fusion/fn-1" "/repo/.worktrees/fn-1" "main"',
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
);
expect(installGuardMock).toHaveBeenCalledWith({ worktreePath: "/repo/.worktrees/fn-1", taskId: "FN-1" });
});
it("propagates installer failure after cleanup", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
installGuardMock.mockRejectedValueOnce(new Error("guard failed"));
await expect(
new NativeWorktreeBackend().create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toThrow("guard failed");
expect(rmMock).toHaveBeenCalledWith("/repo/.worktrees/fn-1", { recursive: true, force: true });
});
it("retries with suffix and resolves", async () => {
execMock.mockRejectedValueOnce(new Error("exists")).mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend().create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
allowSiblingBranchRename: true,
});
expect(result.branch).toBe("fusion/fn-1-2");
expect(execMock).toHaveBeenNthCalledWith(
2,
'git worktree add -b "fusion/fn-1-2" "/repo/.worktrees/fn-1"',
expect.any(Object),
);
});
it("removes worktree with expected command", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
await new NativeWorktreeBackend().remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
});
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
});
it("syncs by fetching then rebasing", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend().sync({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "main",
});
expect(result).toEqual({ skipped: false });
expect(execMock).toHaveBeenNthCalledWith(
1,
"git fetch --all --prune",
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 120000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
'git rebase "origin/main"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 120000, maxBuffer: 10485760 }),
);
});
it("prunes worktrees with expected command", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
await new NativeWorktreeBackend().prune({ rootDir: "/repo" });
expect(execMock).toHaveBeenCalledWith(
"git worktree prune",
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
);
});
it("resolves stale index.lock and retries create once", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
classifyStaleLockMock.mockResolvedValue({ kind: "stale", reason: "old-lock", ageMs: 60000 });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
execMock
.mockRejectedValueOnce({ message: "fatal", stderr: "fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" });
expect(tryRemoveStaleLockMock).toHaveBeenCalledWith({ lockPath: "/repo/.git/worktrees/fn-1/index.lock" });
expect(audit.git).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ type: "worktree:stale-lock-detected" }),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ type: "worktree:stale-lock-recovered" }),
);
});
it("throws StaleWorktreeIndexLockError when lock is non-stale", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "lock-younger-than-threshold", ageMs: 1000 });
execMock.mockRejectedValueOnce({
message: "fatal",
stderr: "fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists",
});
await expect(
new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ name: "StaleWorktreeIndexLockError" });
expect(tryRemoveStaleLockMock).not.toHaveBeenCalled();
expect(audit.git).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ type: "worktree:stale-lock-detected" }),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ type: "worktree:stale-lock-refused" }),
);
});
it("recovers stale registration and retries add", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock
.mockReturnValueOnce(stalePath)
.mockReturnValueOnce(null);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune", "remove-force"] });
execMock
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: stalePath, branch: "fusion/fn-1" });
expect(recoverStaleRegistrationMock).toHaveBeenCalledWith({
rootDir: "/repo",
worktreePath: stalePath,
logger: undefined,
});
expect(audit.git).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ type: "worktree:stale-registration-detected" }),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ type: "worktree:stale-registration-recovered", metadata: { actions: ["prune", "remove-force"] } }),
);
expect(installGuardMock).toHaveBeenCalledTimes(1);
});
it("uses add -f retry when stale registration persists", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock.mockReturnValue(stalePath);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
execMock
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: stalePath, branch: "fusion/fn-1" });
expect(execMock).toHaveBeenNthCalledWith(
3,
'git worktree add -f "/repo/.worktrees/fn-1" "fusion/fn-1"',
expect.any(Object),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: "worktree:stale-registration-recovered",
metadata: { actions: ["prune", "add-force-retry"] },
}),
);
});
it("emits recovery failed and throws when add -f also fails", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock.mockReturnValue(stalePath);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
const staleError = { message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` };
execMock.mockRejectedValueOnce(staleError).mockRejectedValueOnce(staleError).mockRejectedValueOnce(staleError);
await expect(
new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ stderr: expect.stringContaining("missing but already registered worktree") });
expect(audit.git).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "worktree:stale-registration-recovery-failed" }),
);
});
it("does not emit stale-registration events on healthy create", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
execMock.mockResolvedValue({ stdout: "", stderr: "" });
await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(recoverStaleRegistrationMock).not.toHaveBeenCalled();
expect(audit.git).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^worktree:stale-registration-/) }));
});
it("prefers stale-lock recovery when both stale-lock and stale-registration signatures appear", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
classifyStaleLockMock.mockResolvedValue({ kind: "stale", reason: "old-lock", ageMs: 60000 });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
parseStaleRegistrationPathMock.mockReturnValue("/repo/.worktrees/fn-1");
execMock
.mockRejectedValueOnce({
message: "fatal",
stderr:
"fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists\nfatal: '/repo/.worktrees/fn-1' is a missing but already registered worktree",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(tryRemoveStaleLockMock).toHaveBeenCalledTimes(1);
expect(recoverStaleRegistrationMock).not.toHaveBeenCalled();
expect(audit.git).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "worktree:stale-lock-detected" }));
});
it("resolves native worktree path via configured worktreesDir", async () => {
const backend = new NativeWorktreeBackend({ settings: { worktreesDir: "../{repo}.worktrees" } as any });
await expect(
backend.resolveWorktreePath({ rootDir: "/repo/project", worktreeName: "fn-1", branch: "fusion/fn-1" }),
).resolves.toBe("/repo/project.worktrees/fn-1");
});
});
describe("WorktrunkWorktreeBackend", () => {
it("throws missing binary error", async () => {
const backend = new WorktrunkWorktreeBackend({ binaryPath: null });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_binary_missing",
operation: "create",
stderr: "worktrunk binary not configured",
exitCode: null,
});
});
it("memoizes successful binary path resolver results", async () => {
const binaryPathResolver = vi.fn().mockResolvedValue("/p");
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "worktree /repo/.worktrees/fn-1\nbranch refs/heads/fusion/fn-1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: binaryPathResolver });
await backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
await backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" });
expect(binaryPathResolver).toHaveBeenCalledTimes(1);
expect(execMock).toHaveBeenNthCalledWith(
1,
'"/p" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd"',
expect.objectContaining({ cwd: "/repo" }),
);
expect(execMock).toHaveBeenNthCalledWith(
3,
'"/p" "remove" "--foreground" "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo" }),
);
});
it("does not negative-cache null resolver results", async () => {
const binaryPathResolver = vi.fn().mockResolvedValue(null);
const backend = new WorktrunkWorktreeBackend({ binaryPath: binaryPathResolver });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "create" });
await expect(
backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" }),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "remove" });
expect(binaryPathResolver).toHaveBeenCalledTimes(2);
});
it("propagates WorktrunkOperationError thrown by resolver", async () => {
const resolverError = new WorktrunkOperationError({
operation: "remove",
code: "worktrunk_timeout",
stderr: "timed out",
exitCode: null,
});
const binaryPathResolver = vi.fn().mockRejectedValue(resolverError);
const backend = new WorktrunkWorktreeBackend({ binaryPath: binaryPathResolver });
await expect(
backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" }),
).rejects.toBe(resolverError);
});
it("throws operation failed with stderr/exitCode", async () => {
execMock.mockRejectedValue({ stderr: "bad news", status: 7 });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_operation_failed", stderr: "bad news", exitCode: 7 });
});
it("invokes create mapping with timeout/maxBuffer and cwd", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({
stdout: "worktree /repo/.worktrees/fusion/fn-1\nbranch refs/heads/fusion/fn-1\n",
stderr: "",
});
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
startPoint: "main",
taskId: "FN-1",
});
expect(execMock).toHaveBeenNthCalledWith(
1,
'"worktrunk" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd" "--base" "main"',
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
);
});
describe("create() — path resolution", () => {
it("returns porcelain-resolved path and warns on drift", async () => {
const logger = { log: vi.fn(), warn: vi.fn() };
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({
stdout:
"worktree /repo/.worktrees/custom/fusion-fn-1\nbranch refs/heads/fusion/fn-1\n\nworktree /repo\nbranch refs/heads/main\n",
stderr: "",
});
existsSyncMock.mockImplementation((path: string) => path === "/repo/.worktrees/custom/fusion-fn-1");
const result = await new WorktrunkWorktreeBackend({ binaryPath: "worktrunk", logger }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: "/repo/.worktrees/custom/fusion-fn-1", branch: "fusion/fn-1" });
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
"[worktree-backend] worktrunk created branch fusion/fn-1 at /repo/.worktrees/custom/fusion-fn-1 (fusion assumed /repo/.worktrees/fn-1); using worktrunk-assigned path",
);
});
it("fails when no branch match exists", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "worktree /repo/.worktrees/other\nbranch refs/heads/other\n", stderr: "" });
await expect(
new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_operation_failed",
stderr: expect.stringContaining("fusion/fn-1"),
});
});
it("fails when multiple branch matches exist", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({
stdout:
"worktree /repo/.worktrees/a\nbranch refs/heads/fusion/fn-1\n\nworktree /repo/.worktrees/b\nbranch refs/heads/fusion/fn-1\n",
stderr: "",
});
await expect(
new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_operation_failed",
stderr: expect.stringContaining("/repo/.worktrees/a, /repo/.worktrees/b"),
});
});
it("fails when resolved path does not exist on disk", async () => {
existsSyncMock.mockReturnValue(false);
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({
stdout: "worktree /repo/.worktrees/missing\nbranch refs/heads/fusion/fn-1\n",
stderr: "",
});
await expect(
new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_operation_failed",
stderr: "worktrunk reported worktree at /repo/.worktrees/missing but the path does not exist",
});
});
it("wraps porcelain command failures as worktrunk operation errors", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockRejectedValueOnce({ stderr: "porcelain failed", status: 2 });
await expect(
new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_operation_failed",
stderr: "porcelain failed",
exitCode: 2,
});
});
});
it("invokes remove mapping", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await backend.remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
});
expect(execMock).toHaveBeenCalledWith(
'"worktrunk" "remove" "--foreground" "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
});
it("treats remove not-found style failures as idempotent success", async () => {
execMock.mockRejectedValue({ stderr: "branch not found", status: 1 });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" }),
).resolves.toBeUndefined();
});
it("maps ENOENT to worktrunk_binary_missing", async () => {
execMock.mockRejectedValue({ code: "ENOENT", stderr: "not found" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing" });
});
it("maps SIGTERM timeout to worktrunk_timeout", async () => {
execMock.mockRejectedValue({ signal: "SIGTERM", stderr: "timed out" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_timeout" });
});
it("syncs by fetching then rebasing resolved integration branch", async () => {
execMock.mockResolvedValue({ stdout: "origin/main\n", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "main" }),
).resolves.toEqual({ skipped: false });
expect(execMock).toHaveBeenNthCalledWith(
1,
"git symbolic-ref --short refs/remotes/origin/HEAD",
expect.objectContaining({ cwd: "/repo", timeout: 5000, maxBuffer: 1048576 }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
'git fetch origin "main"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenNthCalledWith(
3,
'git rebase "main"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
);
});
it("sync supports explicit trunk target", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "fusion/fn-1", trunk: "release" });
expect(execMock).toHaveBeenNthCalledWith(
1,
'git fetch origin "release"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1" }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
'git rebase "release"',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1" }),
);
});
it("maps rebase conflicts to worktrunk_sync_conflict", async () => {
execMock.mockResolvedValueOnce({ stdout: "", stderr: "" }).mockRejectedValueOnce({ stderr: "CONFLICT" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "main" }),
).rejects.toMatchObject({ code: "worktrunk_sync_conflict", operation: "sync" });
});
it("resolves worktrunk path from wt config show template", async () => {
execMock.mockResolvedValue({ stdout: '{"config":{"worktree-path":"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"}}', stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.resolveWorktreePath({ rootDir: "/repo/project", worktreeName: "ignored", branch: "fusion/fn-1" }),
).resolves.toBe("/repo/project.fusion-fn-1");
expect(execMock).toHaveBeenCalledWith(
'"worktrunk" "config" "show" "--format" "json"',
expect.objectContaining({ cwd: "/repo/project", timeout: 5000, maxBuffer: 10485760 }),
);
});
it("falls back to default layout template when config cannot be read", async () => {
execMock.mockRejectedValue(new Error("missing config"));
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.resolveWorktreePath({ rootDir: "/repo/project", worktreeName: "ignored", branch: "fusion/fn-1" }),
).resolves.toBe("/repo/project/.worktrees/fusion-fn-1");
});
it("prunes by listing worktrees and removing worktrunk managed entries", async () => {
execMock
.mockResolvedValueOnce({
stdout:
"worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/fusion-fn-1\nbranch refs/heads/fusion/fn-1\n\n",
stderr: "",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(backend.prune({ rootDir: "/repo" })).resolves.toBeUndefined();
expect(execMock).toHaveBeenNthCalledWith(
1,
"git worktree list --porcelain",
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenNthCalledWith(
2,
'"worktrunk" "remove" "--foreground" "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
});
});
describe("WorktrunkOperationError", () => {
it("preserves shape", () => {
const error = new WorktrunkOperationError({
operation: "create",
code: "worktrunk_operation_failed",
stderr: "stderr",
exitCode: 2,
});
expect(error.name).toBe("WorktrunkOperationError");
expect(error.operation).toBe("create");
expect(error.code).toBe("worktrunk_operation_failed");
expect(error.stderr).toBe("stderr");
expect(error.exitCode).toBe(2);
});
});
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,
reason: RemovalReason.SelfHealingReclaim,
});
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",
reason: RemovalReason.SelfHealingReclaim,
});
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,
reason: RemovalReason.SelfHealingReclaim,
});
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 },
reason: RemovalReason.SelfHealingReclaim,
}),
).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 },
reason: RemovalReason.SelfHealingReclaim,
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "remove" });
});
it("reconciles same-task stale active session when defensive owner probe says not live", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const audit = { git: vi.fn().mockResolvedValue(undefined) } as any;
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
audit,
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => false,
// FN-5256: opt out of the min-idle window so this defensive-reconcile test
// is unaffected by the new warm-up gate.
reconcileMinIdleMs: 0,
});
expect(activeSessionRegistry.lookupByPath("/repo/.worktrees/fn-1")).toBeNull();
expect(audit.git).toHaveBeenCalledWith(
expect.objectContaining({
type: "worktree:active-session-reconciled",
target: "/repo/.worktrees/fn-1",
metadata: { taskId: "FN-1", source: "removeWorktree-defensive" },
}),
);
});
it("preserves refusal when same-task owner is still live", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => true,
}),
).rejects.toBeInstanceOf(ActiveSessionWorktreeRemovalError);
});
it("preserves foreign-owner refusal with defensive owner hints", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-2",
kind: "executor",
ownerKey: "FN-2/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => false,
}),
).rejects.toMatchObject({
name: "ActiveSessionWorktreeRemovalError",
details: expect.objectContaining({ taskId: "FN-2" }),
});
});
it("keeps pre-FN-5346 behavior when defensive owner hints are omitted", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
}),
).rejects.toBeInstanceOf(ActiveSessionWorktreeRemovalError);
});
});
describe("resolveWorktreeBackend", () => {
it("uses native for undefined worktrunk", () => {
expect(resolveWorktreeBackend({}).kind).toBe("native");
});
it("uses native when disabled", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: false } as any }).kind).toBe("native");
});
it("uses worktrunk when enabled with binaryPath", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true, binaryPath: "worktrunk" } as any }).kind).toBe("worktrunk");
});
it("uses literal binaryPath over resolver when both are provided", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const resolver = vi.fn().mockResolvedValue("/resolved");
const backend = resolveWorktreeBackend(
{ worktrunk: { enabled: true, binaryPath: " /literal " } as any },
{ binaryPathResolver: resolver },
);
await (backend as WorktrunkWorktreeBackend).remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
});
expect(resolver).not.toHaveBeenCalled();
expect(execMock).toHaveBeenCalledWith(
'"/literal" "remove" "--foreground" "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo" }),
);
});
it("wires binaryPathResolver when literal is absent", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const resolver = vi.fn().mockResolvedValue("/resolved");
const backend = resolveWorktreeBackend({ worktrunk: { enabled: true } as any }, { binaryPathResolver: resolver });
await (backend as WorktrunkWorktreeBackend).remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
});
expect(resolver).toHaveBeenCalledTimes(1);
expect(execMock).toHaveBeenCalledWith(
'"/resolved" "remove" "--foreground" "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo" }),
);
});
it("preserves null behavior when literal and resolver are absent", async () => {
const backend = resolveWorktreeBackend({ worktrunk: { enabled: true } as any });
await expect(
(backend as WorktrunkWorktreeBackend).remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "remove" });
});
it("uses worktrunk when enabled without binaryPath", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true } as any }).kind).toBe("worktrunk");
});
});