Files
paperclip/cli/src/commands/worktree.ts
2026-03-13 15:07:42 -05:00

1126 lines
37 KiB
TypeScript

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<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
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<void>;
};
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<boolean> {
return await new Promise<boolean>((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<number>()): Promise<number> {
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<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
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<void> };
};
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<string, string>, 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<string, string>;
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<EmbeddedPostgresHandle> {
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<SeedWorktreeDatabaseResult> {
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<void> {
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<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 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<GitWorktreeListEntry> = {};
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<void> {
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<void> {
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("<name>", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, 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")
.option("--name <name>", "Display name used to derive the instance id")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, 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(worktreeInitCommand);
worktree
.command("env")
.description("Print shell exports for the current worktree-local Paperclip instance")
.option("-c, --config <path>", "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("<name>", "Worktree name — auto-prefixed with paperclip- if needed")
.option("--instance <id>", "Explicit instance id (if different from the worktree name)")
.option("--home <path>", `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);
}