feat(FN-4623): complete Step 2 — implement worktrunk lifecycle operations
Fusion-Task-Id: FN-4623 Fusion-Task-Lineage: 58122853-cab7-4102-9649-4ceda4c5959e
This commit is contained in:
committed by
gsxdsm
parent
bcf140a92a
commit
67b8051b91
@@ -150,6 +150,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
|
||||
it("invokes create mapping with timeout/maxBuffer and cwd", async () => {
|
||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
execMock.mockResolvedValue({ stdout: "worktree /repo/.worktrees/fusion/fn-1\n", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await backend.create({
|
||||
@@ -162,7 +163,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"worktrunk",
|
||||
["switch", "--create", "fusion/fn-1", "--base", "main"],
|
||||
["switch", "--create", "fusion/fn-1", "--no-hooks", "--no-cd", "--base", "main"],
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
||||
);
|
||||
});
|
||||
@@ -184,6 +185,15 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats remove not-found style failures as idempotent success", async () => {
|
||||
execFileMock.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 () => {
|
||||
execFileMock.mockRejectedValue({ code: "ENOENT", stderr: "not found" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
@@ -212,16 +222,44 @@ describe("WorktrunkWorktreeBackend", () => {
|
||||
).rejects.toMatchObject({ code: "worktrunk_timeout" });
|
||||
});
|
||||
|
||||
it("throws unsupported operation for sync/prune", async () => {
|
||||
it("syncs by fetching then rebasing branch", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await expect(
|
||||
backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "main" }),
|
||||
).rejects.toMatchObject({ code: "worktrunk_unsupported_operation", operation: "sync" });
|
||||
await expect(backend.prune({ rootDir: "/repo" })).rejects.toMatchObject({
|
||||
code: "worktrunk_unsupported_operation",
|
||||
operation: "prune",
|
||||
});
|
||||
).resolves.toEqual({ skipped: false });
|
||||
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'git fetch origin "main"',
|
||||
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
|
||||
);
|
||||
expect(execMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git rebase "main"',
|
||||
expect.objectContaining({ cwd: "/repo/.worktrees/fn-1", timeout: 180000, maxBuffer: 10485760 }),
|
||||
);
|
||||
});
|
||||
|
||||
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("prunes via git worktree prune fallback", async () => {
|
||||
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||
|
||||
await expect(backend.prune({ rootDir: "/repo" })).resolves.toBeUndefined();
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
"git worktree prune",
|
||||
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,6 +116,14 @@ function getErrorExitCode(error: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseWorktreePathsFromPorcelain(porcelain: string): string[] {
|
||||
return porcelain
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => line.slice("worktree ".length).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export class NativeWorktreeBackend implements WorktreeBackend {
|
||||
readonly kind: WorktreeBackendKind = "native";
|
||||
|
||||
@@ -289,39 +297,80 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
||||
}
|
||||
|
||||
async create(input: WorktreeCreateInput): Promise<WorktreeCreateResult> {
|
||||
// worktrunk mapping: `wt switch --create <branch> [startPoint]`.
|
||||
// NOTE: Worktrunk computes the worktree path via its own template; this
|
||||
// backend currently assumes that template aligns with Fusion's configured
|
||||
// worktree path resolution so callers can keep using `input.worktreePath`.
|
||||
const args = ["switch", "--create", input.branch];
|
||||
const args = ["switch", "--create", input.branch, "--no-hooks", "--no-cd"];
|
||||
if (input.startPoint) args.push("--base", input.startPoint);
|
||||
await this.runWorktrunk(args, { cwd: input.rootDir, operation: "create" });
|
||||
return { path: input.worktreePath, branch: input.branch };
|
||||
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: input.rootDir,
|
||||
encoding: "utf-8",
|
||||
timeout: WORKTRUNK_TIMEOUTS_MS.layout,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
});
|
||||
const paths = parseWorktreePathsFromPorcelain(stdout);
|
||||
const resolved = paths.find((path) => path.endsWith(input.branch) || path === input.worktreePath) ?? input.worktreePath;
|
||||
return { path: resolved, branch: input.branch };
|
||||
}
|
||||
|
||||
async remove(input: WorktreeRemoveInput): Promise<void> {
|
||||
// worktrunk mapping: `wt remove <branch>` from repo root.
|
||||
await this.runWorktrunk(["remove", "--foreground", input.branch ?? input.worktreePath], {
|
||||
const target = input.branch ?? input.worktreePath;
|
||||
try {
|
||||
await this.runWorktrunk(["remove", "--foreground", target], {
|
||||
cwd: input.rootDir,
|
||||
operation: "remove",
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof WorktrunkOperationError &&
|
||||
error.code === "worktrunk_operation_failed" &&
|
||||
/(not managed|not found|already removed)/i.test(error.stderr ?? "")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sync(input: WorktreeSyncInput): Promise<{ skipped: boolean }> {
|
||||
try {
|
||||
await execAsync(`git fetch origin ${quoteShellArg(input.branch)}`, {
|
||||
cwd: input.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: WORKTRUNK_TIMEOUTS_MS.sync,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
});
|
||||
await execAsync(`git rebase ${quoteShellArg(input.branch)}`, {
|
||||
cwd: input.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: WORKTRUNK_TIMEOUTS_MS.sync,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
});
|
||||
return { skipped: false };
|
||||
} catch (error) {
|
||||
const stderr = getErrorStderr(error) ?? String(error);
|
||||
if (/conflict|could not apply|resolve all conflicts/i.test(stderr)) {
|
||||
throw new WorktrunkOperationError({
|
||||
operation: "sync",
|
||||
code: "worktrunk_sync_conflict",
|
||||
stderr,
|
||||
exitCode: getErrorExitCode(error),
|
||||
});
|
||||
}
|
||||
throw new WorktrunkOperationError({
|
||||
operation: "sync",
|
||||
code: "worktrunk_operation_failed",
|
||||
stderr,
|
||||
exitCode: getErrorExitCode(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async prune(input: WorktreePruneInput): Promise<void> {
|
||||
await execAsync("git worktree prune", {
|
||||
cwd: input.rootDir,
|
||||
operation: "remove",
|
||||
});
|
||||
}
|
||||
|
||||
async sync(_input: WorktreeSyncInput): Promise<{ skipped: boolean }> {
|
||||
throw new WorktrunkOperationError({
|
||||
operation: "sync",
|
||||
code: "worktrunk_unsupported_operation",
|
||||
stderr: "worktrunk sync operation is not mapped by this backend",
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
|
||||
async prune(_input: WorktreePruneInput): Promise<void> {
|
||||
throw new WorktrunkOperationError({
|
||||
operation: "prune",
|
||||
code: "worktrunk_unsupported_operation",
|
||||
stderr: "worktrunk prune operation is not mapped by this backend",
|
||||
exitCode: null,
|
||||
encoding: "utf-8",
|
||||
timeout: WORKTRUNK_TIMEOUTS_MS.prune,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user