1600 lines
53 KiB
TypeScript
1600 lines
53 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 { and, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
applyPendingMigrations,
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
ensurePostgresDatabase,
|
|
formatDatabaseBackupResult,
|
|
goals,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
issues,
|
|
projectWorkspaces,
|
|
projects,
|
|
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";
|
|
import {
|
|
buildWorktreeMergePlan,
|
|
parseWorktreeMergeScopes,
|
|
type PlannedCommentInsert,
|
|
type PlannedIssueInsert,
|
|
} from "./worktree-merge-history-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 WorktreeMergeHistoryOptions = {
|
|
company?: string;
|
|
scope?: string;
|
|
apply?: boolean;
|
|
dry?: boolean;
|
|
yes?: 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));
|
|
}
|
|
|
|
type ClosableDb = ReturnType<typeof createDb> & {
|
|
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
|
|
};
|
|
|
|
type OpenDbHandle = {
|
|
db: ClosableDb;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
type ResolvedMergeCompany = {
|
|
id: string;
|
|
name: string;
|
|
issuePrefix: string;
|
|
};
|
|
|
|
function requirePathArgument(name: string, value: string | undefined): string {
|
|
const trimmed = nonEmpty(value);
|
|
if (!trimmed) {
|
|
throw new Error(`${name} is required.`);
|
|
}
|
|
return path.resolve(trimmed);
|
|
}
|
|
|
|
async function closeDb(db: ClosableDb): Promise<void> {
|
|
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
}
|
|
|
|
async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> {
|
|
const config = readConfig(configPath);
|
|
if (!config) {
|
|
throw new Error(`Config not found at ${configPath}.`);
|
|
}
|
|
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
|
|
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
|
|
|
try {
|
|
if (config.database.mode === "embedded-postgres") {
|
|
embeddedHandle = await ensureEmbeddedPostgres(
|
|
config.database.embeddedPostgresDataDir,
|
|
config.database.embeddedPostgresPort,
|
|
);
|
|
}
|
|
const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port);
|
|
const db = createDb(connectionString) as ClosableDb;
|
|
return {
|
|
db,
|
|
stop: async () => {
|
|
await closeDb(db);
|
|
if (embeddedHandle?.startedByThisProcess) {
|
|
await embeddedHandle.stop();
|
|
}
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (embeddedHandle?.startedByThisProcess) {
|
|
await embeddedHandle.stop().catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function resolveMergeCompany(input: {
|
|
sourceDb: ClosableDb;
|
|
targetDb: ClosableDb;
|
|
selector?: string;
|
|
}): Promise<ResolvedMergeCompany> {
|
|
const [sourceCompanies, targetCompanies] = await Promise.all([
|
|
input.sourceDb
|
|
.select({
|
|
id: companies.id,
|
|
name: companies.name,
|
|
issuePrefix: companies.issuePrefix,
|
|
})
|
|
.from(companies),
|
|
input.targetDb
|
|
.select({
|
|
id: companies.id,
|
|
name: companies.name,
|
|
issuePrefix: companies.issuePrefix,
|
|
})
|
|
.from(companies),
|
|
]);
|
|
|
|
const targetById = new Map(targetCompanies.map((company) => [company.id, company]));
|
|
const shared = sourceCompanies.filter((company) => targetById.has(company.id));
|
|
const selector = nonEmpty(input.selector);
|
|
if (selector) {
|
|
const matched = shared.find(
|
|
(company) => company.id === selector || company.issuePrefix.toLowerCase() === selector.toLowerCase(),
|
|
);
|
|
if (!matched) {
|
|
throw new Error(`Could not resolve company "${selector}" in both source and target databases.`);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
if (shared.length === 1) {
|
|
return shared[0];
|
|
}
|
|
|
|
if (shared.length === 0) {
|
|
throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match.");
|
|
}
|
|
|
|
const options = shared
|
|
.map((company) => `${company.issuePrefix} (${company.name})`)
|
|
.join(", ");
|
|
throw new Error(`Multiple shared companies found. Re-run with --company <id-or-prefix>. Options: ${options}`);
|
|
}
|
|
|
|
function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"], extras: {
|
|
sourcePath: string;
|
|
unsupportedRunCount: number;
|
|
unsupportedDocumentCount: number;
|
|
}): string {
|
|
const lines = [
|
|
`Mode: preview`,
|
|
`Source: ${extras.sourcePath}`,
|
|
`Company: ${plan.companyName} (${plan.issuePrefix})`,
|
|
"",
|
|
"Issues",
|
|
`- insert: ${plan.counts.issuesToInsert}`,
|
|
`- already present: ${plan.counts.issuesExisting}`,
|
|
`- shared/imported issues with drift: ${plan.counts.issueDrift}`,
|
|
];
|
|
|
|
const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert");
|
|
if (issueInserts.length > 0) {
|
|
lines.push("");
|
|
lines.push("Planned issue imports");
|
|
for (const issue of issueInserts) {
|
|
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
|
|
lines.push(
|
|
`- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus})${adjustments}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (plan.scopes.includes("comments")) {
|
|
lines.push("");
|
|
lines.push("Comments");
|
|
lines.push(`- insert: ${plan.counts.commentsToInsert}`);
|
|
lines.push(`- already present: ${plan.counts.commentsExisting}`);
|
|
lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push("Adjustments");
|
|
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
|
|
lines.push(`- cleared projects: ${plan.adjustments.clear_project}`);
|
|
lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`);
|
|
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
|
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
|
|
lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`);
|
|
|
|
lines.push("");
|
|
lines.push("Not imported in this phase");
|
|
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
|
|
lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`);
|
|
lines.push("");
|
|
lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
async function collectMergePlan(input: {
|
|
sourceDb: ClosableDb;
|
|
targetDb: ClosableDb;
|
|
company: ResolvedMergeCompany;
|
|
scopes: ReturnType<typeof parseWorktreeMergeScopes>;
|
|
}) {
|
|
const companyId = input.company.id;
|
|
const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, targetAgentsRows, targetProjectsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([
|
|
input.targetDb
|
|
.select({
|
|
issueCounter: companies.issueCounter,
|
|
})
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.then((rows) => rows[0] ?? null),
|
|
input.sourceDb
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId)),
|
|
input.targetDb
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.companyId, companyId)),
|
|
input.scopes.includes("comments")
|
|
? input.sourceDb
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.companyId, companyId))
|
|
: Promise.resolve([]),
|
|
input.scopes.includes("comments")
|
|
? input.targetDb
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.companyId, companyId))
|
|
: Promise.resolve([]),
|
|
input.targetDb
|
|
.select()
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
input.targetDb
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.companyId, companyId)),
|
|
input.targetDb
|
|
.select()
|
|
.from(projectWorkspaces)
|
|
.where(eq(projectWorkspaces.companyId, companyId)),
|
|
input.targetDb
|
|
.select()
|
|
.from(goals)
|
|
.where(eq(goals.companyId, companyId)),
|
|
input.sourceDb
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.companyId, companyId)),
|
|
input.sourceDb
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(issueDocuments)
|
|
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
|
.where(eq(issues.companyId, companyId)),
|
|
]);
|
|
|
|
if (!targetCompanyRow) {
|
|
throw new Error(`Target company ${companyId} was not found.`);
|
|
}
|
|
|
|
const plan = buildWorktreeMergePlan({
|
|
companyId,
|
|
companyName: input.company.name,
|
|
issuePrefix: input.company.issuePrefix,
|
|
previewIssueCounterStart: targetCompanyRow.issueCounter,
|
|
scopes: input.scopes,
|
|
sourceIssues: sourceIssuesRows,
|
|
targetIssues: targetIssuesRows,
|
|
sourceComments: sourceCommentsRows,
|
|
targetComments: targetCommentsRows,
|
|
targetAgents: targetAgentsRows,
|
|
targetProjects: targetProjectsRows,
|
|
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
|
targetGoals: targetGoalsRows,
|
|
});
|
|
|
|
return {
|
|
plan,
|
|
unsupportedRunCount: runCountRows[0]?.count ?? 0,
|
|
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
|
|
};
|
|
}
|
|
|
|
async function applyMergePlan(input: {
|
|
targetDb: ClosableDb;
|
|
company: ResolvedMergeCompany;
|
|
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
|
}) {
|
|
const companyId = input.company.id;
|
|
|
|
return await input.targetDb.transaction(async (tx) => {
|
|
const issueCandidates = input.plan.issuePlans.filter(
|
|
(plan): plan is PlannedIssueInsert => plan.action === "insert",
|
|
);
|
|
const issueCandidateIds = issueCandidates.map((issue) => issue.source.id);
|
|
const existingIssueIds = issueCandidateIds.length > 0
|
|
? new Set(
|
|
(await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(inArray(issues.id, issueCandidateIds)))
|
|
.map((row) => row.id),
|
|
)
|
|
: new Set<string>();
|
|
const issueInserts = issueCandidates.filter((issue) => !existingIssueIds.has(issue.source.id));
|
|
|
|
let nextIssueNumber = 0;
|
|
if (issueInserts.length > 0) {
|
|
const [companyRow] = await tx
|
|
.update(companies)
|
|
.set({ issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}` })
|
|
.where(eq(companies.id, companyId))
|
|
.returning({ issueCounter: companies.issueCounter });
|
|
nextIssueNumber = companyRow.issueCounter - issueInserts.length + 1;
|
|
}
|
|
|
|
const insertedIssueIdentifiers = new Map<string, string>();
|
|
for (const issue of issueInserts) {
|
|
const issueNumber = nextIssueNumber;
|
|
nextIssueNumber += 1;
|
|
const identifier = `${input.company.issuePrefix}-${issueNumber}`;
|
|
insertedIssueIdentifiers.set(issue.source.id, identifier);
|
|
await tx.insert(issues).values({
|
|
id: issue.source.id,
|
|
companyId,
|
|
projectId: issue.targetProjectId,
|
|
projectWorkspaceId: issue.targetProjectWorkspaceId,
|
|
goalId: issue.targetGoalId,
|
|
parentId: issue.source.parentId,
|
|
title: issue.source.title,
|
|
description: issue.source.description,
|
|
status: issue.targetStatus,
|
|
priority: issue.source.priority,
|
|
assigneeAgentId: issue.targetAssigneeAgentId,
|
|
assigneeUserId: issue.source.assigneeUserId,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
createdByAgentId: issue.targetCreatedByAgentId,
|
|
createdByUserId: issue.source.createdByUserId,
|
|
issueNumber,
|
|
identifier,
|
|
requestDepth: issue.source.requestDepth,
|
|
billingCode: issue.source.billingCode,
|
|
assigneeAdapterOverrides: issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
startedAt: issue.source.startedAt,
|
|
completedAt: issue.source.completedAt,
|
|
cancelledAt: issue.source.cancelledAt,
|
|
hiddenAt: issue.source.hiddenAt,
|
|
createdAt: issue.source.createdAt,
|
|
updatedAt: issue.source.updatedAt,
|
|
});
|
|
}
|
|
|
|
const commentCandidates = input.plan.commentPlans.filter(
|
|
(plan): plan is PlannedCommentInsert => plan.action === "insert",
|
|
);
|
|
const commentCandidateIds = commentCandidates.map((comment) => comment.source.id);
|
|
const existingCommentIds = commentCandidateIds.length > 0
|
|
? new Set(
|
|
(await tx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(inArray(issueComments.id, commentCandidateIds)))
|
|
.map((row) => row.id),
|
|
)
|
|
: new Set<string>();
|
|
|
|
for (const comment of commentCandidates) {
|
|
if (existingCommentIds.has(comment.source.id)) continue;
|
|
const parentExists = await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, comment.source.issueId), eq(issues.companyId, companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!parentExists) continue;
|
|
await tx.insert(issueComments).values({
|
|
id: comment.source.id,
|
|
companyId,
|
|
issueId: comment.source.issueId,
|
|
authorAgentId: comment.targetAuthorAgentId,
|
|
authorUserId: comment.source.authorUserId,
|
|
body: comment.source.body,
|
|
createdAt: comment.source.createdAt,
|
|
updatedAt: comment.source.updatedAt,
|
|
});
|
|
}
|
|
|
|
return {
|
|
insertedIssues: issueInserts.length,
|
|
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
|
|
insertedIssueIdentifiers,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function worktreeMergeHistoryCommand(sourceArg: string, opts: WorktreeMergeHistoryOptions): Promise<void> {
|
|
if (opts.apply && opts.dry) {
|
|
throw new Error("Use either --apply or --dry, not both.");
|
|
}
|
|
|
|
const sourceRoot = requirePathArgument("Source worktree path", sourceArg);
|
|
const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json");
|
|
if (!existsSync(sourceConfigPath)) {
|
|
throw new Error(`Source worktree config not found at ${sourceConfigPath}.`);
|
|
}
|
|
|
|
const targetConfigPath = resolveConfigPath();
|
|
if (path.resolve(sourceConfigPath) === path.resolve(targetConfigPath)) {
|
|
throw new Error("Source and target Paperclip configs are the same. Point --source at a different worktree.");
|
|
}
|
|
|
|
const scopes = parseWorktreeMergeScopes(opts.scope);
|
|
const sourceHandle = await openConfiguredDb(sourceConfigPath);
|
|
const targetHandle = await openConfiguredDb(targetConfigPath);
|
|
|
|
try {
|
|
const company = await resolveMergeCompany({
|
|
sourceDb: sourceHandle.db,
|
|
targetDb: targetHandle.db,
|
|
selector: opts.company,
|
|
});
|
|
const collected = await collectMergePlan({
|
|
sourceDb: sourceHandle.db,
|
|
targetDb: targetHandle.db,
|
|
company,
|
|
scopes,
|
|
});
|
|
|
|
console.log(renderMergePlan(collected.plan, {
|
|
sourcePath: sourceRoot,
|
|
unsupportedRunCount: collected.unsupportedRunCount,
|
|
unsupportedDocumentCount: collected.unsupportedDocumentCount,
|
|
}));
|
|
|
|
if (!opts.apply) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = opts.yes
|
|
? true
|
|
: await p.confirm({
|
|
message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${path.basename(sourceRoot)}?`,
|
|
initialValue: false,
|
|
});
|
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
p.log.warn("Import cancelled.");
|
|
return;
|
|
}
|
|
|
|
const applied = await applyMergePlan({
|
|
targetDb: targetHandle.db,
|
|
company,
|
|
plan: collected.plan,
|
|
});
|
|
p.outro(
|
|
pc.green(
|
|
`Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`,
|
|
),
|
|
);
|
|
} finally {
|
|
await targetHandle.stop();
|
|
await sourceHandle.stop();
|
|
}
|
|
}
|
|
|
|
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:merge-history")
|
|
.description("Preview or import issue/comment history from another worktree into the current instance")
|
|
.argument("<source>", "Path to the source worktree root")
|
|
.option("--company <id-or-prefix>", "Company id or issue prefix to import")
|
|
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
|
|
.option("--apply", "Apply the import after previewing the plan", false)
|
|
.option("--dry", "Preview only and do not import anything", false)
|
|
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
|
.action(worktreeMergeHistoryCommand);
|
|
|
|
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);
|
|
}
|