mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(cli): paseo worktree create with MCP parity (#686)
Mirrors the MCP create_worktree tool's three modes (branch-off, checkout-branch, checkout-pr) against the daemon's existing createPaseoWorktree RPC, so CLI users (especially over --host) can provision worktrees in the paseo-managed location.
This commit is contained in:
133
packages/cli/src/commands/worktree/create.test.ts
Normal file
133
packages/cli/src/commands/worktree/create.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
184
packages/cli/src/commands/worktree/create.ts
Normal file
184
packages/cli/src/commands/worktree/create.ts
Normal file
@@ -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<WorktreeCreateResult> = {
|
||||
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<SingleResult<WorktreeCreateResult>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 <mode>", "Creation mode: branch-off, checkout-branch, or checkout-pr")
|
||||
.option("--new-branch <name>", "New branch name (--mode branch-off)")
|
||||
.option(
|
||||
"--base <ref>",
|
||||
"Base ref for new branch (--mode branch-off, defaults to repo default)",
|
||||
)
|
||||
.option("--branch <name>", "Existing branch to check out (--mode checkout-branch)")
|
||||
.option("--pr-number <n>", "Pull request number (--mode checkout-pr)")
|
||||
.option("--cwd <path>", "Repository directory (default: current)"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
worktree
|
||||
.command("archive")
|
||||
|
||||
Reference in New Issue
Block a user