feat(FN-4686): complete Step 2 — add worktrunk create scaffold

Fusion-Task-Id: FN-4686
Fusion-Task-Lineage: a1e58bd8-fd94-471a-b164-c396c5512fea
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 17:18:50 -07:00
committed by gsxdsm
parent a3d25ce8c5
commit 3391477716
2 changed files with 192 additions and 179 deletions

View File

@@ -41,13 +41,29 @@ describe("NativeWorktreeBackend", () => {
);
});
it("retries with sibling branch suffixes when rename enabled", async () => {
it("rethrows immediately when rename disabled", async () => {
const error = new Error("branch exists");
execMock.mockRejectedValue(error);
await expect(
new NativeWorktreeBackend().create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
allowSiblingBranchRename: false,
}),
).rejects.toBe(error);
expect(execMock).toHaveBeenCalledTimes(1);
});
it("retries with suffixes and resolves on first success", async () => {
execMock
.mockRejectedValueOnce(new Error("branch exists"))
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
const result = await backend.create({
const result = await new NativeWorktreeBackend().create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
@@ -63,52 +79,28 @@ describe("NativeWorktreeBackend", () => {
);
});
it("removes worktree with force command", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
it("rethrows original error after exhausting suffix retries", async () => {
const originalError = new Error("branch exists");
execMock.mockRejectedValue(originalError);
await backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", taskId: "FN-1" });
await expect(
new NativeWorktreeBackend().create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
allowSiblingBranchRename: true,
}),
).rejects.toBe(originalError);
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenCalledTimes(50);
});
});
describe("WorktrunkOperationError", () => {
it("preserves operation, stderr, exitCode, and code", () => {
const error = new WorktrunkOperationError({
operation: "create",
stderr: "failure",
exitCode: 2,
code: "worktrunk_operation_failed",
});
describe("WorktrunkWorktreeBackend", () => {
it("throws missing binary error", async () => {
const backend = new WorktrunkWorktreeBackend({ binaryPath: null });
expect(error.name).toBe("WorktrunkOperationError");
expect(error.operation).toBe("create");
expect(error.stderr).toBe("failure");
expect(error.exitCode).toBe(2);
expect(error.code).toBe("worktrunk_operation_failed");
});
});
describe("resolveWorktreeBackend", () => {
it("defaults to native when worktrunk undefined", () => {
expect(resolveWorktreeBackend({}).kind).toBe("native");
});
it("uses native when enabled=false", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: false } as any }).kind).toBe("native");
});
it("uses worktrunk when enabled=true and binaryPath present", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true, binaryPath: "worktrunk" } as any }).kind).toBe("worktrunk");
});
it("uses worktrunk when enabled=true and binaryPath missing", async () => {
const backend = resolveWorktreeBackend({ worktrunk: { enabled: true } as any });
expect(backend.kind).toBe("worktrunk");
await expect(
backend.create({
rootDir: "/repo",
@@ -116,15 +108,86 @@ describe("resolveWorktreeBackend", () => {
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing" });
).rejects.toMatchObject({
name: "WorktrunkOperationError",
code: "worktrunk_binary_missing",
stderr: "worktrunk binary not configured",
exitCode: null,
});
});
it("throws operation failed with stderr/exitCode", async () => {
execMock.mockRejectedValue({ stderr: "bad news", code: 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("returns input path/branch on success", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).resolves.toEqual({ path: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" });
});
it("passes timeout/maxBuffer and cwd", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(execMock).toHaveBeenCalledWith(
'"worktrunk" switch --create "fusion/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
);
});
});
describe("WorktrunkWorktreeBackend", () => {
it("throws unsupported operation when configured", async () => {
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.prune({ rootDir: "/repo", taskId: "FN-1" }),
).rejects.toMatchObject({ code: "worktrunk_unsupported_operation", operation: "prune" });
describe("WorktrunkOperationError", () => {
it("preserves shape", () => {
const error = new WorktrunkOperationError("create", "worktrunk_operation_failed", "stderr", 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("resolveWorktreeBackend", () => {
it("uses native for empty settings", () => {
expect(resolveWorktreeBackend({}).kind).toBe("native");
});
it("uses native for empty worktrunk object", () => {
expect(resolveWorktreeBackend({ worktrunk: {} }).kind).toBe("native");
});
it("uses native when worktrunk disabled", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: false } }).kind).toBe("native");
});
it("uses worktrunk when enabled with or without binaryPath", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true, binaryPath: "worktrunk" } }).kind).toBe("worktrunk");
expect(resolveWorktreeBackend({ worktrunk: { enabled: true } }).kind).toBe("worktrunk");
});
});

View File

@@ -1,77 +1,42 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Settings } from "@fusion/core";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { formatError, worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec);
const GIT_TIMEOUT_MS = 120_000;
const GIT_REMOVE_TIMEOUT_MS = 60_000;
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
export type WorktreeBackendKind = "native" | "worktrunk";
export type WorktrunkOperation = "create" | "remove" | "sync" | "prune";
export type WorktrunkOperationErrorCode =
| "worktrunk_operation_failed"
| "worktrunk_binary_missing"
| "worktrunk_unsupported_operation";
type LoggerLike = { log?: (message: string) => void; warn?: (message: string) => void };
export interface WorktreeBackend {
kind: WorktreeBackendKind;
create(input: WorktreeCreateInput): Promise<WorktreeCreateResult>;
}
export interface WorktreeCreateInput {
rootDir: string;
worktreePath: string;
branch: string;
worktreePath: string;
startPoint?: string;
taskId: string;
allowSiblingBranchRename?: boolean;
}
export interface WorktreeRemoveInput {
rootDir: string;
worktreePath: string;
taskId: string;
}
export interface WorktreeSyncInput {
rootDir: string;
worktreePath: string;
export interface WorktreeCreateResult {
path: string;
branch: string;
startPoint?: string;
taskId: string;
}
export interface WorktreePruneInput {
rootDir: string;
taskId: string;
}
export interface WorktreeBackend {
readonly kind: WorktreeBackendKind;
create(input: WorktreeCreateInput): Promise<{ path: string; branch: string }>;
remove(input: WorktreeRemoveInput): Promise<void>;
sync(input: WorktreeSyncInput): Promise<{ skipped: boolean }>;
prune(input: WorktreePruneInput): Promise<void>;
}
export class WorktrunkOperationError extends Error {
readonly name = "WorktrunkOperationError";
readonly operation: WorktrunkOperation;
readonly stderr?: string;
readonly exitCode?: number | null;
readonly code: WorktrunkOperationErrorCode;
constructor(input: {
operation: WorktrunkOperation;
stderr?: string;
exitCode?: number | null;
code: WorktrunkOperationErrorCode;
}) {
super(`worktrunk ${input.operation} failed: ${input.stderr || input.code}`);
this.operation = input.operation;
this.stderr = input.stderr;
this.exitCode = input.exitCode;
this.code = input.code;
constructor(
public readonly operation: string,
public readonly code: "worktrunk_operation_failed" | "worktrunk_binary_missing",
public readonly stderr: string,
public readonly exitCode: number | null,
) {
super(`worktrunk ${operation} failed: ${stderr}`);
this.name = "WorktrunkOperationError";
}
}
@@ -79,40 +44,26 @@ function quoteShellArg(value: string): string {
return JSON.stringify(value);
}
async function runCommand(
command: string,
cwd: string,
timeout: number = GIT_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
const result = await execAsync(command, {
cwd,
encoding: "utf-8",
timeout,
maxBuffer: GIT_MAX_BUFFER,
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
export class NativeWorktreeBackend implements WorktreeBackend {
readonly kind: WorktreeBackendKind = "native";
kind: WorktreeBackendKind = "native";
constructor(private readonly deps: { logger?: LoggerLike } = {}) {}
async create(input: WorktreeCreateInput): Promise<{ path: string; branch: string }> {
async create(input: WorktreeCreateInput): Promise<WorktreeCreateResult> {
const startArg = input.startPoint ? ` ${quoteShellArg(input.startPoint)}` : "";
const create = async (branchName: string): Promise<void> => {
await runCommand(
const createWithBranch = async (branchName: string): Promise<WorktreeCreateResult> => {
await execAsync(
`git worktree add -b ${quoteShellArg(branchName)} ${quoteShellArg(input.worktreePath)}${startArg}`,
input.rootDir,
{
cwd: input.rootDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
},
);
return { path: input.worktreePath, branch: branchName };
};
try {
await create(input.branch);
return { path: input.worktreePath, branch: input.branch };
return await createWithBranch(input.branch);
} catch (error) {
if (!input.allowSiblingBranchRename) {
throw error;
@@ -121,10 +72,9 @@ export class NativeWorktreeBackend implements WorktreeBackend {
for (let suffix = 2; suffix <= 50; suffix += 1) {
const candidateBranch = `${input.branch}-${suffix}`;
try {
await create(candidateBranch);
return { path: input.worktreePath, branch: candidateBranch };
return await createWithBranch(candidateBranch);
} catch {
// continue suffix probing
// keep probing suffixes
}
}
@@ -138,7 +88,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
startPoint: input.startPoint,
});
} catch (inspectError) {
this.deps.logger?.warn?.(
worktreePoolLog.warn(
`[worktree-backend] ${input.taskId}: failed to inspect branch conflict: ${formatError(inspectError).detail}`,
);
}
@@ -150,67 +100,66 @@ export class NativeWorktreeBackend implements WorktreeBackend {
throw error;
}
}
async remove(input: WorktreeRemoveInput): Promise<void> {
// FN-4678: removal call sites migrate to this backend in follow-up.
await runCommand(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, input.rootDir, GIT_REMOVE_TIMEOUT_MS);
}
async sync(): Promise<{ skipped: boolean }> {
// Native backend has no dedicated sync semantic today.
return { skipped: true };
}
async prune(input: WorktreePruneInput): Promise<void> {
await runCommand("git worktree prune", input.rootDir);
}
}
export class WorktrunkWorktreeBackend implements WorktreeBackend {
readonly kind: WorktreeBackendKind = "worktrunk";
kind: WorktreeBackendKind = "worktrunk";
constructor(private readonly deps: { binaryPath: string | null; logger?: LoggerLike }) {}
constructor(private readonly deps: { binaryPath: string | null; logger?: { warn: (m: string) => void } }) {}
private throwUnsupported(operation: WorktrunkOperation): never {
async create(input: WorktreeCreateInput): Promise<WorktreeCreateResult> {
if (!this.deps.binaryPath || !this.deps.binaryPath.trim()) {
throw new WorktrunkOperationError({
operation,
code: "worktrunk_binary_missing",
stderr: "worktrunk binary not configured",
exitCode: null,
});
throw new WorktrunkOperationError(
"create",
"worktrunk_binary_missing",
"worktrunk binary not configured",
null,
);
}
this.deps.logger?.warn?.(`[worktree-backend] worktrunk ${operation} is not implemented in FN-4685`);
// TODO(FN-4623): map backend operations to real worktrunk CLI subcommands.
throw new WorktrunkOperationError({
operation,
code: "worktrunk_unsupported_operation",
stderr: "worktrunk backend is not implemented yet",
exitCode: null,
});
}
// Placeholder command wiring for FN-4622; FN-4623 will map this to the finalized worktrunk README contract.
const command = `${quoteShellArg(this.deps.binaryPath)} switch --create ${quoteShellArg(input.branch)}`;
async create(_input: WorktreeCreateInput): Promise<{ path: string; branch: string }> {
return this.throwUnsupported("create");
}
async remove(_input: WorktreeRemoveInput): Promise<void> {
return this.throwUnsupported("remove");
}
async sync(_input: WorktreeSyncInput): Promise<{ skipped: boolean }> {
return this.throwUnsupported("sync");
}
async prune(_input: WorktreePruneInput): Promise<void> {
return this.throwUnsupported("prune");
try {
await execAsync(command, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return { path: input.worktreePath, branch: input.branch };
} catch (error) {
const stderr =
error && typeof error === "object" && "stderr" in error
? String((error as { stderr?: unknown }).stderr ?? "")
: "";
const exitCode =
error && typeof error === "object" && "code" in error
? (typeof (error as { code?: unknown }).code === "number"
? (error as { code: number }).code
: null)
: null;
this.deps.logger?.warn?.(
`[worktree-backend] worktrunk create failed: ${stderr || String(error)}`,
);
throw new WorktrunkOperationError(
"create",
"worktrunk_operation_failed",
stderr,
exitCode,
);
}
}
}
export interface ResolveWorktreeBackendDeps {
logger?: { warn: (m: string) => void };
}
export function resolveWorktreeBackend(
settings: Partial<Settings>,
deps: { logger?: LoggerLike } = {},
// Intentionally structural so this file can land before FN-4621 adds the typed worktrunk settings schema.
settings: Partial<{ worktrunk?: { enabled?: boolean; binaryPath?: string } }>,
deps: ResolveWorktreeBackendDeps = {},
): WorktreeBackend {
if (settings.worktrunk?.enabled === true) {
return new WorktrunkWorktreeBackend({
@@ -218,5 +167,6 @@ export function resolveWorktreeBackend(
logger: deps.logger,
});
}
return new NativeWorktreeBackend({ logger: deps.logger ?? worktreePoolLog });
return new NativeWorktreeBackend();
}