feat(FN-4623): complete Step 1 — add worktrunk CLI helper and mappings
Fusion-Task-Id: FN-4623 Fusion-Task-Lineage: 58122853-cab7-4102-9649-4ceda4c5959e
This commit is contained in:
committed by
gsxdsm
parent
9ac503fc0f
commit
35279a2517
@@ -6,19 +6,25 @@ import {
|
||||
resolveWorktreeBackend,
|
||||
} from "../worktree-backend.js";
|
||||
|
||||
const { execMock } = vi.hoisted(() => {
|
||||
const { execMock, execFileMock, accessMock } = vi.hoisted(() => {
|
||||
const mock = vi.fn();
|
||||
const fileMock = vi.fn();
|
||||
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
|
||||
return { execMock: mock };
|
||||
(fileMock as any)[Symbol.for("nodejs.util.promisify.custom")] = fileMock;
|
||||
return { execMock: mock, execFileMock: fileMock, accessMock: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
vi.mock("node:child_process", () => ({ exec: execMock, execFile: execFileMock }));
|
||||
vi.mock("node:fs/promises", () => ({ access: accessMock }));
|
||||
vi.mock("../branch-conflicts.js", () => ({
|
||||
inspectBranchConflict: vi.fn().mockResolvedValue({ kind: "stale" }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
execMock.mockReset();
|
||||
execFileMock.mockReset();
|
||||
accessMock.mockReset();
|
||||
accessMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("NativeWorktreeBackend", () => {
|
||||
@@ -129,7 +135,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
});
|
||||
|
||||
it("throws operation failed with stderr/exitCode", async () => {
|
||||
execMock.mockRejectedValue({ stderr: "bad news", status: 7 });
|
||||
execFileMock.mockRejectedValue({ stderr: "bad news", status: 7 });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await expect(
|
||||
@@ -143,7 +149,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
});
|
||||
|
||||
it("invokes create mapping with timeout/maxBuffer and cwd", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await backend.create({
|
||||
@@ -154,14 +160,15 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
taskId: "FN-1",
|
||||
});
|
||||
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
'"worktrunk" "switch" "--create" "fusion/fn-1" "main"',
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"worktrunk",
|
||||
["switch", "--create", "fusion/fn-1", "main"],
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes remove mapping", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await backend.remove({
|
||||
@@ -170,12 +177,41 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
branch: "fusion/fn-1",
|
||||
});
|
||||
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
'"worktrunk" "remove" "fusion/fn-1"',
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"worktrunk",
|
||||
["remove", "fusion/fn-1"],
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps ENOENT to worktrunk_binary_missing", async () => {
|
||||
execFileMock.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 () => {
|
||||
execFileMock.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("throws unsupported operation for sync/prune", async () => {
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import { inspectBranchConflict } from "./branch-conflicts.js";
|
||||
import { formatError } from "./logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const NATIVE_TIMEOUT_MS = 120_000;
|
||||
const WORKTRUNK_TIMEOUT_MS = 120_000;
|
||||
const REMOVE_TIMEOUT_MS = 60_000;
|
||||
const MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* worktrunk CLI mapping (verified 2026-05-15 from README + worktrunk.dev docs):
|
||||
* - create -> `wt switch --create <branch> [--base <startPoint>]`
|
||||
* - remove -> `wt remove <branch> --foreground`
|
||||
* - sync -> no dedicated `wt sync/rebase` primitive; fallback to git fetch+rebase
|
||||
* - prune -> no dedicated `wt prune` primitive; backend-owned prune implementation
|
||||
* - layout -> no dedicated path-query command; derive from worktrunk template/config
|
||||
*/
|
||||
const WORKTRUNK_TIMEOUTS_MS = {
|
||||
create: 120_000,
|
||||
sync: 180_000,
|
||||
prune: 60_000,
|
||||
remove: 60_000,
|
||||
layout: 5_000,
|
||||
} as const;
|
||||
|
||||
export type WorktreeBackendKind = "native" | "worktrunk";
|
||||
export type WorktreeOperation = "create" | "remove" | "sync" | "prune";
|
||||
|
||||
@@ -56,6 +73,8 @@ export interface WorktreeBackend {
|
||||
export type WorktrunkOperationCode =
|
||||
| "worktrunk_operation_failed"
|
||||
| "worktrunk_binary_missing"
|
||||
| "worktrunk_timeout"
|
||||
| "worktrunk_sync_conflict"
|
||||
| "worktrunk_unsupported_operation";
|
||||
|
||||
export class WorktrunkOperationError extends Error {
|
||||
@@ -194,6 +213,8 @@ export class NativeWorktreeBackend implements WorktreeBackend {
|
||||
}
|
||||
}
|
||||
|
||||
type WorktrunkOperation = keyof typeof WORKTRUNK_TIMEOUTS_MS;
|
||||
|
||||
export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
readonly kind: WorktreeBackendKind = "worktrunk";
|
||||
|
||||
@@ -204,41 +225,66 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
},
|
||||
) {}
|
||||
|
||||
private getBinaryPath(operation: WorktreeOperation): string {
|
||||
private async getBinaryPath(operation: WorktrunkOperation): Promise<string> {
|
||||
const binaryPath = this.deps.binaryPath?.trim() ?? "";
|
||||
if (!binaryPath) {
|
||||
throw new WorktrunkOperationError({
|
||||
operation,
|
||||
operation: operation === "layout" ? "create" : operation,
|
||||
code: "worktrunk_binary_missing",
|
||||
stderr: "worktrunk binary not configured",
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await access(binaryPath);
|
||||
} catch {
|
||||
if (binaryPath.includes("/") || binaryPath.includes("\\")) {
|
||||
throw new WorktrunkOperationError({
|
||||
operation: operation === "layout" ? "create" : operation,
|
||||
code: "worktrunk_binary_missing",
|
||||
stderr: `worktrunk binary not found at path: ${binaryPath}`,
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
private async runWorktrunk(operation: WorktreeOperation, rootDir: string, args: string[]): Promise<void> {
|
||||
const binaryPath = this.getBinaryPath(operation);
|
||||
const command = `${quoteShellArg(binaryPath)} ${args.map((arg) => quoteShellArg(arg)).join(" ")}`;
|
||||
this.deps.logger?.log?.(`[worktree-backend] running worktrunk command: ${command}`);
|
||||
private async runWorktrunk(
|
||||
args: string[],
|
||||
opts: { cwd: string; operation: WorktrunkOperation; signal?: AbortSignal },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const binaryPath = await this.getBinaryPath(opts.operation);
|
||||
this.deps.logger?.log?.(`[worktree-backend] running worktrunk command: ${binaryPath} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
await execAsync(command, {
|
||||
cwd: rootDir,
|
||||
return await execFileAsync(binaryPath, args, {
|
||||
cwd: opts.cwd,
|
||||
encoding: "utf-8",
|
||||
timeout: WORKTRUNK_TIMEOUT_MS,
|
||||
timeout: WORKTRUNK_TIMEOUTS_MS[opts.operation],
|
||||
maxBuffer: MAX_BUFFER,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = getErrorStderr(error) ?? String(error);
|
||||
const signal =
|
||||
error && typeof error === "object" && "signal" in error
|
||||
? ((error as { signal?: unknown }).signal as string | null | undefined)
|
||||
: undefined;
|
||||
const syscallCode =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? ((error as { code?: unknown }).code as string | number | undefined)
|
||||
: undefined;
|
||||
const exitCode = getErrorExitCode(error);
|
||||
this.deps.logger?.warn?.(`[worktree-backend] worktrunk ${operation} failed: ${stderr}`);
|
||||
throw new WorktrunkOperationError({
|
||||
operation,
|
||||
code: "worktrunk_operation_failed",
|
||||
stderr,
|
||||
exitCode,
|
||||
});
|
||||
const op = opts.operation === "layout" ? "create" : opts.operation;
|
||||
let code: WorktrunkOperationCode = "worktrunk_operation_failed";
|
||||
if (syscallCode === "ENOENT") {
|
||||
code = "worktrunk_binary_missing";
|
||||
} else if (signal === "SIGTERM") {
|
||||
code = "worktrunk_timeout";
|
||||
}
|
||||
this.deps.logger?.warn?.(`[worktree-backend] worktrunk ${opts.operation} failed: ${stderr}`);
|
||||
throw new WorktrunkOperationError({ operation: op, code, stderr, exitCode });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +295,13 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
// worktree path resolution so callers can keep using `input.worktreePath`.
|
||||
const args = ["switch", "--create", input.branch];
|
||||
if (input.startPoint) args.push(input.startPoint);
|
||||
await this.runWorktrunk("create", input.rootDir, args);
|
||||
await this.runWorktrunk(args, { cwd: input.rootDir, operation: "create" });
|
||||
return { path: input.worktreePath, branch: input.branch };
|
||||
}
|
||||
|
||||
async remove(input: WorktreeRemoveInput): Promise<void> {
|
||||
// worktrunk mapping: `wt remove <branch>` from repo root.
|
||||
await this.runWorktrunk("remove", input.rootDir, ["remove", input.branch ?? input.worktreePath]);
|
||||
await this.runWorktrunk(["remove", input.branch ?? input.worktreePath], { cwd: input.rootDir, operation: "remove" });
|
||||
}
|
||||
|
||||
async sync(_input: WorktreeSyncInput): Promise<{ skipped: boolean }> {
|
||||
|
||||
Reference in New Issue
Block a user