import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { createServer } from "node:net"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { eq } from "drizzle-orm"; import { applyPendingMigrations, createDb, ensurePostgresDatabase, formatDatabaseBackupResult, projectWorkspaces, runDatabaseBackup, runDatabaseRestore, } from "@paperclipai/db"; import type { Command } from "commander"; import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; import { expandHomePrefix } from "../config/home.js"; import type { PaperclipConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, generateWorktreeColor, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, sanitizeWorktreeInstanceId, type WorktreeSeedMode, type WorktreeLocalPaths, } from "./worktree-lib.js"; type WorktreeInitOptions = { name?: string; instance?: string; home?: string; fromConfig?: string; fromDataDir?: string; fromInstance?: string; sourceConfigPathOverride?: string; serverPort?: number; dbPort?: number; seed?: boolean; seedMode?: string; force?: boolean; }; type WorktreeMakeOptions = WorktreeInitOptions & { startPoint?: string; }; type WorktreeEnvOptions = { config?: string; json?: boolean; }; type EmbeddedPostgresInstance = { initialise(): Promise; start(): Promise; stop(): Promise; }; type EmbeddedPostgresCtor = new (opts: { databaseDir: string; user: string; password: string; port: number; persistent: boolean; initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; type EmbeddedPostgresHandle = { port: number; startedByThisProcess: boolean; stop: () => Promise; }; type GitWorkspaceInfo = { root: string; commonDir: string; gitDir: string; hooksPath: string; }; type CopiedGitHooksResult = { sourceHooksPath: string; targetHooksPath: string; copied: boolean; }; type SeedWorktreeDatabaseResult = { backupSummary: string; reboundWorkspaces: Array<{ name: string; fromCwd: string; toCwd: string; }>; }; function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { const currentConfigPath = process.env.PAPERCLIP_CONFIG; if (!currentConfigPath || currentConfigPath.trim().length === 0) { return false; } return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); } const WORKTREE_NAME_PREFIX = "paperclip-"; 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.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`; } function resolveWorktreeHome(explicit?: string): string { return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME; } function resolveWorktreeStartPoint(explicit?: string): string | undefined { return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined; } 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; startPoint?: string; }): string[] { if (input.branchExists && !input.startPoint) { return ["worktree", "add", input.targetPath, input.branchName]; } const commitish = input.startPoint ?? "HEAD"; return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; } function readPidFilePort(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { const lines = readFileSync(postmasterPidFile, "utf8").split("\n"); const port = Number(lines[3]?.trim()); return Number.isInteger(port) && port > 0 ? port : null; } catch { return null; } } function readRunningPostmasterPid(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); if (!Number.isInteger(pid) || pid <= 0) return null; process.kill(pid, 0); return pid; } catch { return null; } } async function isPortAvailable(port: number): Promise { return await new Promise((resolve) => { const server = createServer(); server.unref(); server.once("error", () => resolve(false)); server.listen(port, "127.0.0.1", () => { server.close(() => resolve(true)); }); }); } async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { let port = Math.max(1, Math.trunc(preferredPort)); while (reserved.has(port) || !(await isPortAvailable(port))) { port += 1; } return port; } function detectGitBranchName(cwd: string): string | null { try { const value = execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); return nonEmpty(value); } catch { return null; } } function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { try { const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); return { root: path.resolve(root), commonDir: path.resolve(root, commonDirRaw), gitDir: path.resolve(root, gitDirRaw), hooksPath: path.resolve(root, hooksPathRaw), }; } catch { return null; } } function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { if (!existsSync(sourceDir)) return false; const entries = readdirSync(sourceDir, { withFileTypes: true }); if (entries.length === 0) return false; mkdirSync(targetDir, { recursive: true }); let copied = false; for (const entry of entries) { const sourcePath = path.resolve(sourceDir, entry.name); const targetPath = path.resolve(targetDir, entry.name); if (entry.isDirectory()) { mkdirSync(targetPath, { recursive: true }); copyDirectoryContents(sourcePath, targetPath); copied = true; continue; } if (entry.isSymbolicLink()) { rmSync(targetPath, { recursive: true, force: true }); symlinkSync(readlinkSync(sourcePath), targetPath); copied = true; continue; } copyFileSync(sourcePath, targetPath); try { chmodSync(targetPath, statSync(sourcePath).mode & 0o777); } catch { // best effort } copied = true; } return copied; } export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null { const workspace = detectGitWorkspaceInfo(cwd); if (!workspace) return null; const sourceHooksPath = workspace.hooksPath; const targetHooksPath = path.resolve(workspace.gitDir, "hooks"); if (sourceHooksPath === targetHooksPath) { return { sourceHooksPath, targetHooksPath, copied: false, }; } return { sourceHooksPath, targetHooksPath, copied: copyDirectoryContents(sourceHooksPath, targetHooksPath), }; } export function rebindWorkspaceCwd(input: { sourceRepoRoot: string; targetRepoRoot: string; workspaceCwd: string; }): string | null { const sourceRepoRoot = path.resolve(input.sourceRepoRoot); const targetRepoRoot = path.resolve(input.targetRepoRoot); const workspaceCwd = path.resolve(input.workspaceCwd); const relative = path.relative(sourceRepoRoot, workspaceCwd); if (!relative || relative === "") { return targetRepoRoot; } if (relative.startsWith("..") || path.isAbsolute(relative)) { return null; } return path.resolve(targetRepoRoot, relative); } async function rebindSeededProjectWorkspaces(input: { targetConnectionString: string; currentCwd: string; }): Promise { const targetRepo = detectGitWorkspaceInfo(input.currentCwd); if (!targetRepo) return []; const db = createDb(input.targetConnectionString); const closableDb = db as typeof db & { $client?: { end?: (opts?: { timeout?: number }) => Promise }; }; try { const rows = await db .select({ id: projectWorkspaces.id, name: projectWorkspaces.name, cwd: projectWorkspaces.cwd, }) .from(projectWorkspaces); const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; for (const row of rows) { const workspaceCwd = nonEmpty(row.cwd); if (!workspaceCwd) continue; const sourceRepo = detectGitWorkspaceInfo(workspaceCwd); if (!sourceRepo) continue; if (sourceRepo.commonDir !== targetRepo.commonDir) continue; const reboundCwd = rebindWorkspaceCwd({ sourceRepoRoot: sourceRepo.root, targetRepoRoot: targetRepo.root, workspaceCwd, }); if (!reboundCwd) continue; const normalizedCurrent = path.resolve(workspaceCwd); if (reboundCwd === normalizedCurrent) continue; if (!existsSync(reboundCwd)) continue; await db .update(projectWorkspaces) .set({ cwd: reboundCwd, updatedAt: new Date(), }) .where(eq(projectWorkspaces.id, row.id)); rebound.push({ name: row.name, fromCwd: normalizedCurrent, toCwd: reboundCwd, }); } return rebound; } finally { await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); } } export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); if (opts.fromConfig) return path.resolve(opts.fromConfig); if (!opts.fromDataDir && !opts.fromInstance) { return resolveConfigPath(); } const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); } function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record, portOverride?: number): string { if (config.database.mode === "postgres") { const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); if (!connectionString) { throw new Error( "Source instance uses postgres mode but has no connection string in config or adjacent .env.", ); } return connectionString; } const port = portOverride ?? config.database.embeddedPostgresPort; return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } export function copySeededSecretsKey(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; sourceEnvEntries: Record; targetKeyFilePath: string; }): void { if (input.sourceConfig.secrets.provider !== "local_encrypted") { return; } mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); const sourceInlineMasterKey = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null); if (sourceInlineMasterKey) { writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { encoding: "utf8", mode: 0o600, }); try { chmodSync(input.targetKeyFilePath, 0o600); } catch { // best effort } return; } const sourceKeyFileOverride = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null); const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); if (!existsSync(sourceKeyFilePath)) { throw new Error( `Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`, ); } copyFileSync(sourceKeyFilePath, input.targetKeyFilePath); try { chmodSync(input.targetKeyFilePath, 0o600); } catch { // best effort } } async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { const mod = await import(moduleName); EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; } catch { throw new Error( "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", ); } const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const runningPid = readRunningPostmasterPid(postmasterPidFile); if (runningPid) { return { port: readPidFilePort(postmasterPidFile) ?? preferredPort, startedByThisProcess: false, stop: async () => {}, }; } const port = await findAvailablePort(preferredPort); const instance = new EmbeddedPostgres({ databaseDir: dataDir, user: "paperclip", password: "paperclip", port, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C"], onLog: () => {}, onError: () => {}, }); if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { await instance.initialise(); } if (existsSync(postmasterPidFile)) { rmSync(postmasterPidFile, { force: true }); } await instance.start(); return { port, startedByThisProcess: true, stop: async () => { await instance.stop(); }, }; } async function seedWorktreeDatabase(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; targetConfig: PaperclipConfig; targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; }): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); copySeededSecretsKey({ sourceConfigPath: input.sourceConfigPath, sourceConfig: input.sourceConfig, sourceEnvEntries, targetKeyFilePath: input.targetPaths.secretsKeyFilePath, }); let sourceHandle: EmbeddedPostgresHandle | null = null; let targetHandle: EmbeddedPostgresHandle | null = null; try { if (input.sourceConfig.database.mode === "embedded-postgres") { sourceHandle = await ensureEmbeddedPostgres( input.sourceConfig.database.embeddedPostgresDataDir, input.sourceConfig.database.embeddedPostgresPort, ); } const sourceConnectionString = resolveSourceConnectionString( input.sourceConfig, sourceEnvEntries, sourceHandle?.port, ); const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), retentionDays: 7, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, nullifyColumns: seedPlan.nullifyColumns, }); targetHandle = await ensureEmbeddedPostgres( input.targetConfig.database.embeddedPostgresDataDir, input.targetConfig.database.embeddedPostgresPort, ); const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; await ensurePostgresDatabase(adminConnectionString, "paperclip"); const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`; await runDatabaseRestore({ connectionString: targetConnectionString, backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); const reboundWorkspaces = await rebindSeededProjectWorkspaces({ targetConnectionString, currentCwd: input.targetPaths.cwd, }); return { backupSummary: formatDatabaseBackupResult(backup), reboundWorkspaces, }; } finally { if (targetHandle?.startedByThisProcess) { await targetHandle.stop(); } if (sourceHandle?.startedByThisProcess) { await sourceHandle.stop(); } } } async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); const worktreeName = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); const seedMode = opts.seedMode ?? "minimal"; if (!isWorktreeSeedMode(seedMode)) { throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); } const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); const paths = resolveWorktreeLocalPaths({ cwd, homeDir: resolveWorktreeHome(opts.home), instanceId, }); const branding = { name: worktreeName, color: generateWorktreeColor(), }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) { throw new Error( `Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`, ); } if (opts.force) { rmSync(paths.repoConfigDir, { recursive: true, force: true }); rmSync(paths.instanceRoot, { recursive: true, force: true }); } const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); const serverPort = await findAvailablePort(preferredServerPort); const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); const targetConfig = buildWorktreeConfig({ sourceConfig, paths, serverPort, databasePort, }); writeConfig(targetConfig, paths.configPath); const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); const existingAgentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); mergePaperclipEnvEntries( { ...buildWorktreeEnvEntries(paths, branding), ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), }, paths.envPath, ); ensureAgentJwtSecret(paths.configPath); loadPaperclipEnvFile(paths.configPath); const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); let seedSummary: string | null = null; let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; if (opts.seed !== false) { if (!sourceConfig) { throw new Error( `Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`, ); } const spinner = p.spinner(); spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); try { const seeded = await seedWorktreeDatabase({ sourceConfigPath, sourceConfig, targetConfig, targetPaths: paths, instanceId, seedMode, }); seedSummary = seeded.backupSummary; reboundWorkspaceSummary = seeded.reboundWorkspaces; spinner.stop(`Seeded isolated worktree database (${seedMode}).`); } catch (error) { spinner.stop(pc.red("Failed to seed worktree database.")); throw error; } } p.log.message(pc.dim(`Repo config: ${paths.configPath}`)); p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); if (copiedGitHooks?.copied) { p.log.message( pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`), ); } if (seedSummary) { p.log.message(pc.dim(`Seed mode: ${seedMode}`)); p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); for (const rebound of reboundWorkspaceSummary) { p.log.message( pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), ); } } p.outro( pc.green( `Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`, ), ); } export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); await runWorktreeInit(opts); } export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); const name = resolveWorktreeMakeName(nameArg); const startPoint = resolveWorktreeStartPoint(opts.startPoint); const sourceCwd = process.cwd(); const sourceConfigPath = resolveSourceConfigPath(opts); const targetPath = resolveWorktreeMakeTargetPath(name); if (existsSync(targetPath)) { throw new Error(`Target path already exists: ${targetPath}`); } mkdirSync(path.dirname(targetPath), { recursive: true }); if (startPoint) { const [remote] = startPoint.split("/", 1); try { execFileSync("git", ["fetch", remote], { cwd: sourceCwd, stdio: ["ignore", "pipe", "pipe"], }); } catch (error) { throw new Error( `Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, ); } } const worktreeArgs = resolveGitWorktreeAddArgs({ branchName: name, targetPath, branchExists: !startPoint && localBranchExists(sourceCwd, name), startPoint, }); 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 installSpinner = p.spinner(); installSpinner.start("Installing dependencies..."); try { execFileSync("pnpm", ["install"], { cwd: targetPath, stdio: ["ignore", "pipe", "pipe"], }); installSpinner.stop("Installed dependencies."); } catch (error) { installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway).")); p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); } const originalCwd = process.cwd(); try { process.chdir(targetPath); await runWorktreeInit({ ...opts, name, sourceConfigPathOverride: sourceConfigPath, }); } catch (error) { throw error; } finally { process.chdir(originalCwd); } } type WorktreeCleanupOptions = { instance?: string; home?: string; force?: boolean; }; type GitWorktreeListEntry = { worktree: string; branch: string | null; bare: boolean; detached: boolean; }; function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); const entries: GitWorktreeListEntry[] = []; let current: Partial = {}; for (const line of raw.split("\n")) { if (line.startsWith("worktree ")) { current = { worktree: line.slice("worktree ".length) }; } else if (line.startsWith("branch ")) { current.branch = line.slice("branch ".length); } else if (line === "bare") { current.bare = true; } else if (line === "detached") { current.detached = true; } else if (line === "" && current.worktree) { entries.push({ worktree: current.worktree, branch: current.branch ?? null, bare: current.bare ?? false, detached: current.detached ?? false, }); current = {}; } } if (current.worktree) { entries.push({ worktree: current.worktree, branch: current.branch ?? null, bare: current.bare ?? false, detached: current.detached ?? false, }); } return entries; } function branchHasUniqueCommits(cwd: string, branchName: string): boolean { try { const output = execFileSync( "git", ["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ).trim(); return output.length > 0; } catch { return false; } } function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean { try { const output = execFileSync( "git", ["branch", "-r", "--list", `*/${branchName}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ).trim(); return output.length > 0; } catch { return false; } } function worktreePathHasUncommittedChanges(worktreePath: string): boolean { try { const output = execFileSync( "git", ["status", "--porcelain"], { cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ).trim(); return output.length > 0; } catch { return false; } } export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup "))); const name = resolveWorktreeMakeName(nameArg); const sourceCwd = process.cwd(); const targetPath = resolveWorktreeMakeTargetPath(name); const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home))); const instanceRoot = path.resolve(homeDir, "instances", instanceId); // ── 1. Assess current state ────────────────────────────────────────── const hasBranch = localBranchExists(sourceCwd, name); const hasTargetDir = existsSync(targetPath); const hasInstanceData = existsSync(instanceRoot); const worktrees = parseGitWorktreeList(sourceCwd); const linkedWorktree = worktrees.find( (wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath), ); if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) { p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found."); p.outro(pc.green("Already clean.")); return; } // ── 2. Safety checks ──────────────────────────────────────────────── const problems: string[] = []; if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) { const onRemote = branchExistsOnAnyRemote(sourceCwd, name); if (onRemote) { p.log.info( `Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`, ); } else { problems.push( `Branch "${name}" has commits not found on any other branch or remote. ` + `Deleting it will lose work. Push it first, or use --force.`, ); } } if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) { problems.push( `Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`, ); } if (problems.length > 0 && !opts.force) { for (const problem of problems) { p.log.error(problem); } throw new Error("Safety checks failed. Resolve the issues above or re-run with --force."); } if (problems.length > 0 && opts.force) { for (const problem of problems) { p.log.warning(`Overridden by --force: ${problem}`); } } // ── 3. Clean up (idempotent steps) ────────────────────────────────── // 3a. Remove the git worktree registration if (linkedWorktree) { const worktreeDirExists = existsSync(linkedWorktree.worktree); const spinner = p.spinner(); if (worktreeDirExists) { spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`); try { const removeArgs = ["worktree", "remove", linkedWorktree.worktree]; if (opts.force) removeArgs.push("--force"); execFileSync("git", removeArgs, { cwd: sourceCwd, stdio: ["ignore", "pipe", "pipe"], }); spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`); } catch (error) { spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`)); p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); } } else { spinner.start("Pruning stale worktree entry..."); execFileSync("git", ["worktree", "prune"], { cwd: sourceCwd, stdio: ["ignore", "pipe", "pipe"], }); spinner.stop("Pruned stale worktree entry."); } } else { // Even without a linked worktree, prune to clean up any orphaned entries execFileSync("git", ["worktree", "prune"], { cwd: sourceCwd, stdio: ["ignore", "pipe", "pipe"], }); } // 3b. Remove the worktree directory if it still exists (e.g. partial creation) if (existsSync(targetPath)) { const spinner = p.spinner(); spinner.start(`Removing worktree directory ${targetPath}...`); rmSync(targetPath, { recursive: true, force: true }); spinner.stop(`Removed worktree directory ${targetPath}.`); } // 3c. Delete the local branch (now safe — worktree is gone) if (localBranchExists(sourceCwd, name)) { const spinner = p.spinner(); spinner.start(`Deleting local branch "${name}"...`); try { const deleteFlag = opts.force ? "-D" : "-d"; execFileSync("git", ["branch", deleteFlag, name], { cwd: sourceCwd, stdio: ["ignore", "pipe", "pipe"], }); spinner.stop(`Deleted local branch "${name}".`); } catch (error) { spinner.stop(pc.yellow(`Could not delete branch "${name}".`)); p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); } } // 3d. Remove instance data if (existsSync(instanceRoot)) { const spinner = p.spinner(); spinner.start(`Removing instance data at ${instanceRoot}...`); rmSync(instanceRoot, { recursive: true, force: true }); spinner.stop(`Removed instance data at ${instanceRoot}.`); } p.outro(pc.green("Cleanup complete.")); } export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { const configPath = resolveConfigPath(opts.config); const envPath = resolvePaperclipEnvFile(configPath); const envEntries = readPaperclipEnvEntries(envPath); const out = { PAPERCLIP_CONFIG: configPath, ...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}), ...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}), ...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}), ...envEntries, }; if (opts.json) { console.log(JSON.stringify(out, null, 2)); return; } console.log(formatShellExports(out)); } 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("", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)") .option("--start-point ", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)") .option("--instance ", "Explicit isolated instance id") .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config", "default") .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-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") .option("--name ", "Display name used to derive the instance id") .option("--instance ", "Explicit isolated instance id") .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config", "default") .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-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(worktreeInitCommand); worktree .command("env") .description("Print shell exports for the current worktree-local Paperclip instance") .option("-c, --config ", "Path to config file") .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); program .command("worktree:cleanup") .description("Safely remove a worktree, its branch, and its isolated instance data") .argument("", "Worktree name — auto-prefixed with paperclip- if needed") .option("--instance ", "Explicit instance id (if different from the worktree name)") .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) .option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false) .action(worktreeCleanupCommand); }