Add paperclipai worktree:make command

This commit is contained in:
Dotta
2026-03-10 16:52:26 -05:00
parent 21eb904a4d
commit 80d87d3b4e
3 changed files with 206 additions and 5 deletions

View File

@@ -62,6 +62,8 @@ type WorktreeInitOptions = {
force?: boolean;
};
type WorktreeMakeOptions = WorktreeInitOptions;
type WorktreeEnvOptions = {
config?: string;
json?: boolean;
@@ -115,6 +117,62 @@ function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function resolveWorktreeMakeName(name: string): string {
const value = nonEmpty(name);
if (!value) {
throw new Error("Worktree name is required.");
}
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
throw new Error(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
}
return value;
}
export function resolveWorktreeMakeTargetPath(name: string): string {
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
}
function extractExecSyncErrorMessage(error: unknown): string | null {
if (!error || typeof error !== "object") {
return error instanceof Error ? error.message : null;
}
const stderr = "stderr" in error ? error.stderr : null;
if (typeof stderr === "string") {
return nonEmpty(stderr);
}
if (stderr instanceof Buffer) {
return nonEmpty(stderr.toString("utf8"));
}
return error instanceof Error ? nonEmpty(error.message) : null;
}
function localBranchExists(cwd: string, branchName: string): boolean {
try {
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
export function resolveGitWorktreeAddArgs(input: {
branchName: string;
targetPath: string;
branchExists: boolean;
}): string[] {
if (input.branchExists) {
return ["worktree", "add", input.targetPath, input.branchName];
}
return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"];
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
@@ -538,10 +596,7 @@ async function seedWorktreeDatabase(input: {
}
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
cwd,
@@ -642,6 +697,57 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
);
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
await runWorktreeInit(opts);
}
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
const name = resolveWorktreeMakeName(nameArg);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists: ${targetPath}`);
}
mkdirSync(path.dirname(targetPath), { recursive: true });
const worktreeArgs = resolveGitWorktreeAddArgs({
branchName: name,
targetPath,
branchExists: localBranchExists(sourceCwd, name),
});
const spinner = p.spinner();
spinner.start(`Creating git worktree at ${targetPath}...`);
try {
execFileSync("git", worktreeArgs, {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Created git worktree at ${targetPath}.`);
} catch (error) {
spinner.stop(pc.red("Failed to create git worktree."));
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
const originalCwd = process.cwd();
try {
process.chdir(targetPath);
await runWorktreeInit({
...opts,
name,
});
} catch (error) {
throw error;
} finally {
process.chdir(originalCwd);
}
}
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const envPath = resolvePaperclipEnvFile(configPath);
@@ -665,6 +771,22 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
program
.command("worktree:make")
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
worktree
.command("init")
.description("Create repo-local config/env and an isolated instance for this worktree")