diff --git a/packages/cli/src/commands/worktree/create.test.ts b/packages/cli/src/commands/worktree/create.test.ts new file mode 100644 index 000000000..e0949601b --- /dev/null +++ b/packages/cli/src/commands/worktree/create.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + buildCreateWorktreeInput, + toDaemonCreateInput, + type WorktreeCreateOptions, +} from "./create.js"; + +const REPO = "/tmp/repo"; + +function build(options: WorktreeCreateOptions): unknown { + try { + return buildCreateWorktreeInput(options, REPO); + } catch (err) { + return err; + } +} + +describe("buildCreateWorktreeInput", () => { + it("requires --mode", () => { + expect(build({})).toMatchObject({ code: "MISSING_MODE" }); + }); + + it("rejects unknown modes", () => { + expect(build({ mode: "fork" })).toMatchObject({ code: "INVALID_MODE" }); + }); + + it("branch-off requires --new-branch", () => { + expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" }); + }); + + it("branch-off parses with new branch only", () => { + expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({ + cwd: REPO, + target: { mode: "branch-off", newBranch: "feature-x" }, + }); + }); + + it("branch-off parses with base ref", () => { + expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({ + cwd: REPO, + target: { mode: "branch-off", newBranch: "feature-x", base: "main" }, + }); + }); + + it("checkout-branch requires --branch", () => { + expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" }); + }); + + it("checkout-branch parses with branch", () => { + expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({ + cwd: REPO, + target: { mode: "checkout-branch", branch: "feat/x" }, + }); + }); + + it("checkout-pr requires --pr-number", () => { + expect(build({ mode: "checkout-pr" })).toMatchObject({ code: "MISSING_PR_NUMBER" }); + }); + + it("checkout-pr rejects non-positive integers", () => { + expect(build({ mode: "checkout-pr", prNumber: "0" })).toMatchObject({ + code: "INVALID_PR_NUMBER", + }); + }); + + it("checkout-pr rejects non-integer values", () => { + expect(build({ mode: "checkout-pr", prNumber: "abc" })).toMatchObject({ + code: "INVALID_PR_NUMBER", + }); + }); + + it("checkout-pr parses positive integers", () => { + expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({ + cwd: REPO, + target: { mode: "checkout-pr", prNumber: 42 }, + }); + }); +}); + +describe("toDaemonCreateInput", () => { + it("maps branch-off without base", () => { + expect( + toDaemonCreateInput({ + cwd: REPO, + target: { mode: "branch-off", newBranch: "feature-x" }, + }), + ).toEqual({ + cwd: REPO, + worktreeSlug: "feature-x", + action: "branch-off", + }); + }); + + it("maps branch-off with base ref", () => { + expect( + toDaemonCreateInput({ + cwd: REPO, + target: { mode: "branch-off", newBranch: "feature-x", base: "main" }, + }), + ).toEqual({ + cwd: REPO, + worktreeSlug: "feature-x", + action: "branch-off", + refName: "main", + }); + }); + + it("maps checkout-branch to action=checkout + refName", () => { + expect( + toDaemonCreateInput({ + cwd: REPO, + target: { mode: "checkout-branch", branch: "feat/x" }, + }), + ).toEqual({ + cwd: REPO, + action: "checkout", + refName: "feat/x", + }); + }); + + it("maps checkout-pr to action=checkout + githubPrNumber", () => { + expect( + toDaemonCreateInput({ + cwd: REPO, + target: { mode: "checkout-pr", prNumber: 42 }, + }), + ).toEqual({ + cwd: REPO, + action: "checkout", + githubPrNumber: 42, + }); + }); +}); diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts new file mode 100644 index 000000000..689723ed4 --- /dev/null +++ b/packages/cli/src/commands/worktree/create.ts @@ -0,0 +1,184 @@ +import path from "node:path"; +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +export interface WorktreeCreateResult { + name: string; + branchName: string; + worktreePath: string; +} + +export const createSchema: OutputSchema = { + idField: "worktreePath", + columns: [ + { header: "NAME", field: "name", width: 24 }, + { header: "BRANCH", field: "branchName", width: 28 }, + { header: "PATH", field: "worktreePath", width: 50 }, + ], +}; + +export interface WorktreeCreateOptions extends CommandOptions { + host?: string; + cwd?: string; + mode?: string; + newBranch?: string; + base?: string; + branch?: string; + prNumber?: string; +} + +export type WorktreeCreateTarget = + | { mode: "branch-off"; newBranch: string; base?: string } + | { mode: "checkout-branch"; branch: string } + | { mode: "checkout-pr"; prNumber: number }; + +export interface ParsedWorktreeCreateInput { + cwd: string; + target: WorktreeCreateTarget; +} + +const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const; + +export function buildCreateWorktreeInput( + options: WorktreeCreateOptions, + cwd: string, +): ParsedWorktreeCreateInput { + const mode = options.mode; + if (!mode) { + throw cmdError( + "MISSING_MODE", + "--mode is required", + `Expected one of: ${VALID_MODES.join(", ")}`, + ); + } + + switch (mode) { + case "branch-off": { + if (!options.newBranch) { + throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off"); + } + return { + cwd, + target: { + mode: "branch-off", + newBranch: options.newBranch, + ...(options.base ? { base: options.base } : {}), + }, + }; + } + case "checkout-branch": { + if (!options.branch) { + throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch"); + } + return { cwd, target: { mode: "checkout-branch", branch: options.branch } }; + } + case "checkout-pr": { + if (options.prNumber === undefined || options.prNumber === "") { + throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr"); + } + const parsed = Number(options.prNumber); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw cmdError( + "INVALID_PR_NUMBER", + `Invalid --pr-number: ${options.prNumber}`, + "Expected a positive integer", + ); + } + return { cwd, target: { mode: "checkout-pr", prNumber: parsed } }; + } + default: + throw cmdError( + "INVALID_MODE", + `Invalid --mode: ${mode}`, + `Expected one of: ${VALID_MODES.join(", ")}`, + ); + } +} + +export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) { + switch (parsed.target.mode) { + case "branch-off": + return { + cwd: parsed.cwd, + worktreeSlug: parsed.target.newBranch, + action: "branch-off" as const, + ...(parsed.target.base ? { refName: parsed.target.base } : {}), + }; + case "checkout-branch": + return { + cwd: parsed.cwd, + action: "checkout" as const, + refName: parsed.target.branch, + }; + case "checkout-pr": + return { + cwd: parsed.cwd, + action: "checkout" as const, + githubPrNumber: parsed.target.prNumber, + }; + } +} + +function cmdError(code: string, message: string, details?: string): CommandError { + return details ? { code, message, details } : { code, message }; +} + +export async function runCreateCommand( + options: WorktreeCreateOptions, + _command: Command, +): Promise> { + const cwd = options.cwd ?? process.cwd(); + const parsed = buildCreateWorktreeInput(options, cwd); + + const host = getDaemonHost({ host: options.host }); + let client: DaemonClient; + try { + client = await connectToDaemon({ host: options.host }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw cmdError( + "DAEMON_NOT_RUNNING", + `Cannot connect to daemon at ${host}: ${message}`, + "Start the daemon with: paseo daemon start", + ); + } + + try { + const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed)); + + const workspace = response.workspace; + if (!workspace || response.error) { + throw cmdError( + "WORKTREE_CREATE_FAILED", + `Failed to create worktree: ${response.error ?? "no workspace returned"}`, + ); + } + + const worktreePath = workspace.workspaceDirectory ?? workspace.id; + + return { + type: "single", + data: { + name: path.basename(worktreePath), + branchName: workspace.name, + worktreePath, + }, + schema: createSchema, + }; + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw cmdError("WORKTREE_CREATE_FAILED", `Failed to create worktree: ${message}`); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts index de9554a9b..f35f001a3 100644 --- a/packages/cli/src/commands/worktree/index.ts +++ b/packages/cli/src/commands/worktree/index.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { runLsCommand } from "./ls.js"; import { runArchiveCommand } from "./archive.js"; +import { runCreateCommand } from "./create.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; @@ -11,6 +12,21 @@ export function createWorktreeCommand(): Command { worktree.command("ls").description("List Paseo-managed git worktrees"), ).action(withOutput(runLsCommand)); + addJsonAndDaemonHostOptions( + worktree + .command("create") + .description("Create a Paseo-managed git worktree") + .option("--mode ", "Creation mode: branch-off, checkout-branch, or checkout-pr") + .option("--new-branch ", "New branch name (--mode branch-off)") + .option( + "--base ", + "Base ref for new branch (--mode branch-off, defaults to repo default)", + ) + .option("--branch ", "Existing branch to check out (--mode checkout-branch)") + .option("--pr-number ", "Pull request number (--mode checkout-pr)") + .option("--cwd ", "Repository directory (default: current)"), + ).action(withOutput(runCreateCommand)); + addJsonAndDaemonHostOptions( worktree .command("archive")