feat(FN-4717): complete Step 1 — add lazy worktrunk binary resolver

Fusion-Task-Id: FN-4717
Fusion-Task-Lineage: a2533cd8-52c4-43a0-9daf-e0a49745306b
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 01:46:53 -07:00
committed by gsxdsm
parent e1e21f6462
commit e21e72884b
2 changed files with 96 additions and 10 deletions

View File

@@ -142,6 +142,70 @@ describe("WorktrunkWorktreeBackend", () => {
});
});
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" });

View File

@@ -266,24 +266,46 @@ type WorktrunkOperation = keyof typeof WORKTRUNK_TIMEOUTS_MS;
export class WorktrunkWorktreeBackend implements WorktreeBackend {
readonly kind: WorktreeBackendKind = "worktrunk";
private resolvedBinaryPath: string | null = null;
constructor(
private readonly deps: {
binaryPath: string | null;
binaryPath: string | (() => Promise<string | null>) | null;
logger?: { log: (m: string) => void; warn: (m: string) => void };
},
) {}
private async getBinaryPath(operation: WorktrunkOperation): Promise<string> {
const binaryPath = this.deps.binaryPath?.trim() ?? "";
if (!binaryPath) {
throw new WorktrunkOperationError({
operation: operation === "layout" ? "create" : operation,
code: "worktrunk_binary_missing",
stderr: "worktrunk binary not configured",
exitCode: null,
});
private async resolveBinaryPathFromDeps(operation: WorktrunkOperation): Promise<string> {
if (typeof this.deps.binaryPath === "string") {
const literalPath = this.deps.binaryPath.trim();
if (literalPath) return literalPath;
}
if (typeof this.deps.binaryPath === "function") {
if (this.resolvedBinaryPath) return this.resolvedBinaryPath;
const resolvedPath = (await this.deps.binaryPath())?.trim() ?? "";
if (!resolvedPath) {
throw new WorktrunkOperationError({
operation: operation === "layout" ? "create" : operation,
code: "worktrunk_binary_missing",
stderr: "worktrunk binary not configured",
exitCode: null,
});
}
this.resolvedBinaryPath = resolvedPath;
return resolvedPath;
}
throw new WorktrunkOperationError({
operation: operation === "layout" ? "create" : operation,
code: "worktrunk_binary_missing",
stderr: "worktrunk binary not configured",
exitCode: null,
});
}
private async getBinaryPath(operation: WorktrunkOperation): Promise<string> {
const binaryPath = await this.resolveBinaryPathFromDeps(operation);
try {
await access(binaryPath);
} catch {