feat(FN-4685): complete Step 1 — define native backend

Fusion-Task-Id: FN-4685
Fusion-Task-Lineage: 406ac389-c360-4e4d-b9ac-f5349a241907
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 16:52:13 -07:00
committed by gsxdsm
parent a88857a876
commit 00a44e82af
2 changed files with 102 additions and 121 deletions

View File

@@ -41,86 +41,56 @@ describe("NativeWorktreeBackend", () => {
);
});
it("removes worktree", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
it("retries with sibling branch suffixes when rename enabled", async () => {
execMock
.mockRejectedValueOnce(new Error("branch exists"))
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
await backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", taskId: "FN-1" });
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo" }),
);
});
it("prunes worktrees", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
await backend.prune({ rootDir: "/repo", taskId: "FN-1" });
expect(execMock).toHaveBeenCalledWith(
"git worktree prune",
expect.objectContaining({ cwd: "/repo" }),
);
});
it("implements required methods", () => {
const backend = new NativeWorktreeBackend();
expect(backend.kind).toBe("native");
expect(typeof backend.create).toBe("function");
expect(typeof backend.remove).toBe("function");
expect(typeof backend.sync).toBe("function");
expect(typeof backend.prune).toBe("function");
});
});
describe("WorktrunkWorktreeBackend", () => {
it("throws typed error when binary is missing", 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",
exitCode: null,
});
});
it("maps non-zero exit to worktrunk_operation_failed and preserves stderr", async () => {
execMock.mockRejectedValue({ stderr: "bad", code: 17 });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await expect(
backend.prune({ rootDir: "/repo", taskId: "FN-1" }),
).rejects.toEqual(
expect.objectContaining<Partial<WorktrunkOperationError>>({
code: "worktrunk_operation_failed",
stderr: "bad",
exitCode: 17,
operation: "prune",
}),
);
});
it("passes timeout and maxBuffer to exec", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
await backend.sync({
const result = await backend.create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
allowSiblingBranchRename: true,
});
expect(execMock).toHaveBeenCalledWith(
'"worktrunk" --help',
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 120000, maxBuffer: 10485760 }),
expect(result).toEqual({ path: "/repo/.worktrees/fn-1", branch: "fusion/fn-1-2" });
expect(execMock).toHaveBeenNthCalledWith(
2,
'git worktree add -b "fusion/fn-1-2" "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo" }),
);
});
it("removes worktree with force command", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const backend = new NativeWorktreeBackend();
await backend.remove({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", taskId: "FN-1" });
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
);
});
});
describe("WorktrunkOperationError", () => {
it("preserves operation, stderr, exitCode, and code", () => {
const error = new WorktrunkOperationError({
operation: "create",
stderr: "failure",
exitCode: 2,
code: "worktrunk_operation_failed",
});
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", () => {
@@ -136,7 +106,25 @@ describe("resolveWorktreeBackend", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true, binaryPath: "worktrunk" } as any }).kind).toBe("worktrunk");
});
it("uses worktrunk when enabled=true and binaryPath missing", () => {
expect(resolveWorktreeBackend({ worktrunk: { enabled: true } 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",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing" });
});
});
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" });
});
});

View File

@@ -6,9 +6,15 @@ 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 };
@@ -41,30 +47,27 @@ export interface WorktreePruneInput {
}
export interface WorktreeBackend {
kind: WorktreeBackendKind;
readonly kind: WorktreeBackendKind;
create(input: WorktreeCreateInput): Promise<{ path: string; branch: string }>;
remove(input: WorktreeRemoveInput): Promise<void>;
sync(input: WorktreeSyncInput): Promise<void>;
sync(input: WorktreeSyncInput): Promise<{ skipped: boolean }>;
prune(input: WorktreePruneInput): Promise<void>;
}
export type WorktrunkOperation = "create" | "remove" | "sync" | "prune";
export type WorktrunkOperationErrorCode = "worktrunk_operation_failed" | "worktrunk_binary_missing";
export class WorktrunkOperationError extends Error {
readonly name = "WorktrunkOperationError";
readonly operation: WorktrunkOperation;
readonly stderr: string;
readonly exitCode: number | null;
readonly stderr?: string;
readonly exitCode?: number | null;
readonly code: WorktrunkOperationErrorCode;
constructor(input: {
operation: WorktrunkOperation;
stderr: string;
exitCode: number | null;
stderr?: string;
exitCode?: number | null;
code: WorktrunkOperationErrorCode;
}) {
super(`worktrunk ${input.operation} failed: ${input.stderr || "unknown error"}`);
super(`worktrunk ${input.operation} failed: ${input.stderr || input.code}`);
this.operation = input.operation;
this.stderr = input.stderr;
this.exitCode = input.exitCode;
@@ -76,11 +79,15 @@ function quoteShellArg(value: string): string {
return JSON.stringify(value);
}
async function runCommand(command: string, cwd: string): Promise<{ stdout: string; stderr: string }> {
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: GIT_TIMEOUT_MS,
timeout,
maxBuffer: GIT_MAX_BUFFER,
});
return {
@@ -117,7 +124,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
await create(candidateBranch);
return { path: input.worktreePath, branch: candidateBranch };
} catch {
// try next suffix
// continue suffix probing
}
}
@@ -145,13 +152,13 @@ export class NativeWorktreeBackend implements WorktreeBackend {
}
async remove(input: WorktreeRemoveInput): Promise<void> {
await runCommand(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, input.rootDir);
// 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(input: WorktreeSyncInput): Promise<void> {
await runCommand("git fetch --all --prune", input.worktreePath);
const target = input.startPoint ?? "main";
await runCommand(`git rebase ${quoteShellArg(target)}`, input.worktreePath);
async sync(): Promise<{ skipped: boolean }> {
// Native backend has no dedicated sync semantic today.
return { skipped: true };
}
async prune(input: WorktreePruneInput): Promise<void> {
@@ -164,7 +171,7 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
constructor(private readonly deps: { binaryPath: string | null; logger?: LoggerLike }) {}
private async runOperation(operation: WorktrunkOperation, cwd: string): Promise<void> {
private throwUnsupported(operation: WorktrunkOperation): never {
if (!this.deps.binaryPath || !this.deps.binaryPath.trim()) {
throw new WorktrunkOperationError({
operation,
@@ -174,44 +181,30 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
});
}
try {
// FN-4623: replace placeholder with real worktrunk subcommand.
await execAsync(`${quoteShellArg(this.deps.binaryPath)} --help`, {
cwd,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
} catch (error) {
const err = error as { stderr?: string; code?: number };
const stderr = typeof err?.stderr === "string" ? err.stderr : String(err ?? "");
this.deps.logger?.warn?.(
`[worktree-backend] worktrunk ${operation} failed: ${stderr || formatError(error).detail}`,
);
throw new WorktrunkOperationError({
operation,
code: "worktrunk_operation_failed",
stderr,
exitCode: typeof err?.code === "number" ? err.code : 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,
});
}
async create(input: WorktreeCreateInput): Promise<{ path: string; branch: string }> {
await this.runOperation("create", input.rootDir);
return { path: input.worktreePath, branch: input.branch };
async create(): Promise<{ path: string; branch: string }> {
return this.throwUnsupported("create");
}
async remove(input: WorktreeRemoveInput): Promise<void> {
await this.runOperation("remove", input.rootDir);
async remove(): Promise<void> {
return this.throwUnsupported("remove");
}
async sync(input: WorktreeSyncInput): Promise<void> {
await this.runOperation("sync", input.worktreePath);
async sync(): Promise<{ skipped: boolean }> {
return this.throwUnsupported("sync");
}
async prune(input: WorktreePruneInput): Promise<void> {
await this.runOperation("prune", input.rootDir);
async prune(): Promise<void> {
return this.throwUnsupported("prune");
}
}