1024 lines
38 KiB
TypeScript
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");
|
|
});
|
|
});
|