2026-03-10 14:55:35 -05:00
import {
chmodSync ,
copyFileSync ,
existsSync ,
mkdirSync ,
2026-03-20 15:59:41 -05:00
promises as fsPromises ,
2026-03-10 14:55:35 -05:00
readdirSync ,
readFileSync ,
readlinkSync ,
rmSync ,
statSync ,
symlinkSync ,
writeFileSync ,
} from "node:fs" ;
2026-03-10 10:08:13 -05:00
import os from "node:os" ;
import path from "node:path" ;
import { execFileSync } from "node:child_process" ;
import { createServer } from "node:net" ;
2026-03-20 15:59:41 -05:00
import { Readable } from "node:stream" ;
2026-03-10 10:08:13 -05:00
import * as p from "@clack/prompts" ;
import pc from "picocolors" ;
2026-03-20 15:02:24 -05:00
import { and , eq , inArray , sql } from "drizzle-orm" ;
2026-03-10 10:08:13 -05:00
import {
2026-03-10 10:08:58 -05:00
applyPendingMigrations ,
2026-03-20 15:02:24 -05:00
agents ,
2026-03-20 15:59:41 -05:00
assets ,
2026-03-20 15:02:24 -05:00
companies ,
2026-03-10 13:50:29 -05:00
createDb ,
2026-03-20 15:59:41 -05:00
documentRevisions ,
documents ,
2026-03-10 10:08:13 -05:00
ensurePostgresDatabase ,
formatDatabaseBackupResult ,
2026-03-20 15:02:24 -05:00
goals ,
heartbeatRuns ,
2026-03-20 17:23:45 -05:00
inspectMigrations ,
2026-03-20 15:59:41 -05:00
issueAttachments ,
2026-03-20 15:02:24 -05:00
issueComments ,
issueDocuments ,
issues ,
2026-03-10 13:50:29 -05:00
projectWorkspaces ,
2026-03-20 15:02:24 -05:00
projects ,
2026-03-10 10:08:13 -05:00
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" ;
2026-03-10 12:57:53 -05:00
import { resolveRuntimeLikePath } from "../utils/path-resolver.js" ;
2026-03-10 10:08:13 -05:00
import {
buildWorktreeConfig ,
buildWorktreeEnvEntries ,
DEFAULT_WORKTREE_HOME ,
formatShellExports ,
2026-03-13 11:12:43 -05:00
generateWorktreeColor ,
2026-03-10 07:41:01 -05:00
isWorktreeSeedMode ,
2026-03-10 10:08:13 -05:00
resolveSuggestedWorktreeName ,
2026-03-10 07:41:01 -05:00
resolveWorktreeSeedPlan ,
2026-03-10 10:08:13 -05:00
resolveWorktreeLocalPaths ,
sanitizeWorktreeInstanceId ,
2026-03-10 07:41:01 -05:00
type WorktreeSeedMode ,
2026-03-10 10:08:13 -05:00
type WorktreeLocalPaths ,
} from "./worktree-lib.js" ;
2026-03-20 15:02:24 -05:00
import {
buildWorktreeMergePlan ,
parseWorktreeMergeScopes ,
2026-03-20 15:59:41 -05:00
type IssueAttachmentRow ,
type IssueDocumentRow ,
type DocumentRevisionRow ,
type PlannedAttachmentInsert ,
2026-03-20 15:02:24 -05:00
type PlannedCommentInsert ,
2026-03-20 15:59:41 -05:00
type PlannedIssueDocumentInsert ,
type PlannedIssueDocumentMerge ,
2026-03-20 15:02:24 -05:00
type PlannedIssueInsert ,
} from "./worktree-merge-history-lib.js" ;
2026-03-10 10:08:13 -05:00
type WorktreeInitOptions = {
name? : string ;
instance? : string ;
home? : string ;
fromConfig? : string ;
fromDataDir? : string ;
fromInstance? : string ;
2026-03-13 14:24:06 -05:00
sourceConfigPathOverride? : string ;
2026-03-10 10:08:13 -05:00
serverPort? : number ;
dbPort? : number ;
seed? : boolean ;
2026-03-10 07:41:01 -05:00
seedMode? : string ;
2026-03-10 10:08:13 -05:00
force? : boolean ;
} ;
2026-03-11 09:15:27 -05:00
type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint? : string ;
} ;
2026-03-10 16:52:26 -05:00
2026-03-10 10:08:13 -05:00
type WorktreeEnvOptions = {
config? : string ;
json? : boolean ;
} ;
2026-03-20 15:17:51 -05:00
type WorktreeListOptions = {
json? : boolean ;
} ;
2026-03-20 15:02:24 -05:00
type WorktreeMergeHistoryOptions = {
2026-03-20 15:39:02 -05:00
from ? : string ;
to? : string ;
2026-03-20 15:02:24 -05:00
company? : string ;
scope? : string ;
apply? : boolean ;
dry? : boolean ;
yes? : boolean ;
} ;
2026-03-10 10:08:13 -05:00
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 ;
2026-03-13 09:25:04 -05:00
initdbFlags? : string [ ] ;
2026-03-10 10:08:13 -05:00
onLog ? : ( message : unknown ) = > void ;
onError ? : ( message : unknown ) = > void ;
} ) = > EmbeddedPostgresInstance ;
type EmbeddedPostgresHandle = {
port : number ;
startedByThisProcess : boolean ;
stop : ( ) = > Promise < void > ;
} ;
2026-03-10 13:50:29 -05:00
type GitWorkspaceInfo = {
root : string ;
commonDir : string ;
2026-03-10 14:55:35 -05:00
gitDir : string ;
hooksPath : string ;
} ;
type CopiedGitHooksResult = {
sourceHooksPath : string ;
targetHooksPath : string ;
copied : boolean ;
2026-03-10 13:50:29 -05:00
} ;
type SeedWorktreeDatabaseResult = {
backupSummary : string ;
reboundWorkspaces : Array < {
name : string ;
fromCwd : string ;
toCwd : string ;
} > ;
} ;
2026-03-10 10:08:13 -05:00
function nonEmpty ( value : string | null | undefined ) : string | null {
return typeof value === "string" && value . trim ( ) . length > 0 ? value . trim ( ) : null ;
}
2026-03-11 16:38:31 -05:00
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 ) ;
}
2026-03-13 07:24:39 -05:00
const WORKTREE_NAME_PREFIX = "paperclip-" ;
2026-03-10 16:52:26 -05:00
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." ,
) ;
}
2026-03-13 07:24:39 -05:00
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 ;
2026-03-10 16:52:26 -05:00
}
2026-03-20 15:59:41 -05:00
type ConfiguredStorage = {
getObject ( companyId : string , objectKey : string ) : Promise < Buffer > ;
putObject ( companyId : string , objectKey : string , body : Buffer , contentType : string ) : Promise < void > ;
} ;
function assertStorageCompanyPrefix ( companyId : string , objectKey : string ) : void {
if ( ! objectKey . startsWith ( ` ${ companyId } / ` ) || objectKey . includes ( ".." ) ) {
throw new Error ( ` Invalid object key for company ${ companyId } . ` ) ;
}
}
function normalizeStorageObjectKey ( objectKey : string ) : string {
const normalized = objectKey . replace ( /\\/g , "/" ) . trim ( ) ;
if ( ! normalized || normalized . startsWith ( "/" ) ) {
throw new Error ( "Invalid object key." ) ;
}
const parts = normalized . split ( "/" ) . filter ( ( part ) = > part . length > 0 ) ;
if ( parts . length === 0 || parts . some ( ( part ) = > part === "." || part === ".." ) ) {
throw new Error ( "Invalid object key." ) ;
}
return parts . join ( "/" ) ;
}
function resolveLocalStoragePath ( baseDir : string , objectKey : string ) : string {
const resolved = path . resolve ( baseDir , normalizeStorageObjectKey ( objectKey ) ) ;
const root = path . resolve ( baseDir ) ;
if ( resolved !== root && ! resolved . startsWith ( ` ${ root } ${ path . sep } ` ) ) {
throw new Error ( "Invalid object key path." ) ;
}
return resolved ;
}
async function s3BodyToBuffer ( body : unknown ) : Promise < Buffer > {
if ( ! body ) {
throw new Error ( "Object not found." ) ;
}
if ( Buffer . isBuffer ( body ) ) {
return body ;
}
if ( body instanceof Readable ) {
return await streamToBuffer ( body ) ;
}
const candidate = body as {
transformToWebStream ? : ( ) = > ReadableStream < Uint8Array > ;
arrayBuffer ? : ( ) = > Promise < ArrayBuffer > ;
} ;
if ( typeof candidate . transformToWebStream === "function" ) {
const webStream = candidate . transformToWebStream ( ) ;
const reader = webStream . getReader ( ) ;
const chunks : Uint8Array [ ] = [ ] ;
while ( true ) {
const { done , value } = await reader . read ( ) ;
if ( done ) break ;
if ( value ) chunks . push ( value ) ;
}
return Buffer . concat ( chunks . map ( ( chunk ) = > Buffer . from ( chunk ) ) ) ;
}
if ( typeof candidate . arrayBuffer === "function" ) {
return Buffer . from ( await candidate . arrayBuffer ( ) ) ;
}
throw new Error ( "Unsupported storage response body." ) ;
}
function normalizeS3Prefix ( prefix : string | undefined ) : string {
if ( ! prefix ) return "" ;
return prefix . trim ( ) . replace ( /^\/+/ , "" ) . replace ( /\/+$/ , "" ) ;
}
function buildS3ObjectKey ( prefix : string , objectKey : string ) : string {
return prefix ? ` ${ prefix } / ${ objectKey } ` : objectKey ;
}
const dynamicImport = new Function ( "specifier" , "return import(specifier);" ) as ( specifier : string ) = > Promise < any > ;
function createConfiguredStorageFromPaperclipConfig ( config : PaperclipConfig ) : ConfiguredStorage {
if ( config . storage . provider === "local_disk" ) {
const baseDir = expandHomePrefix ( config . storage . localDisk . baseDir ) ;
return {
async getObject ( companyId : string , objectKey : string ) {
assertStorageCompanyPrefix ( companyId , objectKey ) ;
return await fsPromises . readFile ( resolveLocalStoragePath ( baseDir , objectKey ) ) ;
} ,
async putObject ( companyId : string , objectKey : string , body : Buffer ) {
assertStorageCompanyPrefix ( companyId , objectKey ) ;
const filePath = resolveLocalStoragePath ( baseDir , objectKey ) ;
await fsPromises . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
await fsPromises . writeFile ( filePath , body ) ;
} ,
} ;
}
const prefix = normalizeS3Prefix ( config . storage . s3 . prefix ) ;
let s3ClientPromise : Promise < any > | null = null ;
async function getS3Client() {
if ( ! s3ClientPromise ) {
s3ClientPromise = ( async ( ) = > {
const sdk = await dynamicImport ( "@aws-sdk/client-s3" ) ;
return {
sdk ,
client : new sdk . S3Client ( {
region : config.storage.s3.region ,
endpoint : config.storage.s3.endpoint ,
forcePathStyle : config.storage.s3.forcePathStyle ,
} ) ,
} ;
} ) ( ) ;
}
return await s3ClientPromise ;
}
const bucket = config . storage . s3 . bucket ;
return {
async getObject ( companyId : string , objectKey : string ) {
assertStorageCompanyPrefix ( companyId , objectKey ) ;
const { sdk , client } = await getS3Client ( ) ;
const response = await client . send (
new sdk . GetObjectCommand ( {
Bucket : bucket ,
Key : buildS3ObjectKey ( prefix , objectKey ) ,
} ) ,
) ;
return await s3BodyToBuffer ( response . Body ) ;
} ,
async putObject ( companyId : string , objectKey : string , body : Buffer , contentType : string ) {
assertStorageCompanyPrefix ( companyId , objectKey ) ;
const { sdk , client } = await getS3Client ( ) ;
await client . send (
new sdk . PutObjectCommand ( {
Bucket : bucket ,
Key : buildS3ObjectKey ( prefix , objectKey ) ,
Body : body ,
ContentType : contentType ,
ContentLength : body.length ,
} ) ,
) ;
} ,
} ;
}
function openConfiguredStorage ( configPath : string ) : ConfiguredStorage {
const config = readConfig ( configPath ) ;
if ( ! config ) {
throw new Error ( ` Config not found at ${ configPath } . ` ) ;
}
return createConfiguredStorageFromPaperclipConfig ( config ) ;
}
async function streamToBuffer ( stream : NodeJS.ReadableStream ) : Promise < Buffer > {
const chunks : Buffer [ ] = [ ] ;
for await ( const chunk of stream ) {
chunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer.from ( chunk ) ) ;
}
return Buffer . concat ( chunks ) ;
}
2026-03-20 16:06:41 -05:00
export function isMissingStorageObjectError ( error : unknown ) : boolean {
if ( ! error || typeof error !== "object" ) return false ;
const candidate = error as { code? : unknown ; status? : unknown ; name? : unknown ; message? : unknown } ;
return candidate . code === "ENOENT"
|| candidate . status === 404
|| candidate . name === "NoSuchKey"
|| candidate . name === "NotFound"
|| candidate . message === "Object not found." ;
}
export async function readSourceAttachmentBody (
2026-03-20 16:12:10 -05:00
sourceStorages : Array < Pick < ConfiguredStorage , "getObject" > > ,
2026-03-20 16:06:41 -05:00
companyId : string ,
objectKey : string ,
) : Promise < Buffer | null > {
2026-03-20 16:12:10 -05:00
for ( const sourceStorage of sourceStorages ) {
try {
return await sourceStorage . getObject ( companyId , objectKey ) ;
} catch ( error ) {
if ( isMissingStorageObjectError ( error ) ) {
continue ;
}
throw error ;
2026-03-20 16:06:41 -05:00
}
}
2026-03-20 16:12:10 -05:00
return null ;
2026-03-20 16:06:41 -05:00
}
2026-03-10 16:52:26 -05:00
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 ;
2026-03-11 09:15:27 -05:00
startPoint? : string ;
2026-03-10 16:52:26 -05:00
} ) : string [ ] {
2026-03-11 09:15:27 -05:00
if ( input . branchExists && ! input . startPoint ) {
2026-03-10 16:52:26 -05:00
return [ "worktree" , "add" , input . targetPath , input . branchName ] ;
}
2026-03-11 09:15:27 -05:00
const commitish = input . startPoint ? ? "HEAD" ;
return [ "worktree" , "add" , "-b" , input . branchName , input . targetPath , commitish ] ;
2026-03-10 16:52:26 -05:00
}
2026-03-10 10:08:13 -05:00
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 ;
}
}
2026-03-10 13:50:29 -05:00
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 ( ) ;
2026-03-10 14:55:35 -05:00
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 ( ) ;
2026-03-10 13:50:29 -05:00
return {
root : path.resolve ( root ) ,
commonDir : path.resolve ( root , commonDirRaw ) ,
2026-03-10 14:55:35 -05:00
gitDir : path.resolve ( root , gitDirRaw ) ,
hooksPath : path.resolve ( root , hooksPathRaw ) ,
2026-03-10 13:50:29 -05:00
} ;
} catch {
return null ;
}
}
2026-03-10 14:55:35 -05:00
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 & 0 o777 ) ;
} 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 ) ,
} ;
}
2026-03-10 13:50:29 -05:00
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 ) ;
}
}
2026-03-13 14:24:06 -05:00
export function resolveSourceConfigPath ( opts : WorktreeInitOptions ) : string {
if ( opts . sourceConfigPathOverride ) return path . resolve ( opts . sourceConfigPathOverride ) ;
2026-03-10 10:08:13 -05:00
if ( opts . fromConfig ) return path . resolve ( opts . fromConfig ) ;
2026-03-13 14:24:06 -05:00
if ( ! opts . fromDataDir && ! opts . fromInstance ) {
return resolveConfigPath ( ) ;
}
2026-03-10 10:08:13 -05:00
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 ` ;
}
2026-03-10 12:57:53 -05:00
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 } ) ;
2026-03-11 16:38:31 -05:00
const allowProcessEnvFallback = isCurrentSourceConfigPath ( input . sourceConfigPath ) ;
2026-03-10 12:57:53 -05:00
const sourceInlineMasterKey =
nonEmpty ( input . sourceEnvEntries . PAPERCLIP_SECRETS_MASTER_KEY ) ? ?
2026-03-11 16:38:31 -05:00
( allowProcessEnvFallback ? nonEmpty ( process . env . PAPERCLIP_SECRETS_MASTER_KEY ) : null ) ;
2026-03-10 12:57:53 -05:00
if ( sourceInlineMasterKey ) {
writeFileSync ( input . targetKeyFilePath , sourceInlineMasterKey , {
encoding : "utf8" ,
mode : 0o600 ,
} ) ;
try {
chmodSync ( input . targetKeyFilePath , 0 o600 ) ;
} catch {
// best effort
}
return ;
}
const sourceKeyFileOverride =
nonEmpty ( input . sourceEnvEntries . PAPERCLIP_SECRETS_MASTER_KEY_FILE ) ? ?
2026-03-11 16:38:31 -05:00
( allowProcessEnvFallback ? nonEmpty ( process . env . PAPERCLIP_SECRETS_MASTER_KEY_FILE ) : null ) ;
2026-03-10 12:57:53 -05:00
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 , 0 o600 ) ;
} catch {
// best effort
}
}
2026-03-10 10:08:13 -05:00
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 ,
2026-03-11 21:59:02 +01:00
initdbFlags : [ "--encoding=UTF8" , "--locale=C" ] ,
2026-03-10 07:41:01 -05:00
onLog : ( ) = > { } ,
onError : ( ) = > { } ,
2026-03-10 10:08:13 -05:00
} ) ;
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 ;
2026-03-10 07:41:01 -05:00
seedMode : WorktreeSeedMode ;
2026-03-10 13:50:29 -05:00
} ) : Promise < SeedWorktreeDatabaseResult > {
2026-03-10 07:41:01 -05:00
const seedPlan = resolveWorktreeSeedPlan ( input . seedMode ) ;
2026-03-10 10:08:13 -05:00
const sourceEnvFile = resolvePaperclipEnvFile ( input . sourceConfigPath ) ;
const sourceEnvEntries = readPaperclipEnvEntries ( sourceEnvFile ) ;
2026-03-10 12:57:53 -05:00
copySeededSecretsKey ( {
sourceConfigPath : input.sourceConfigPath ,
sourceConfig : input.sourceConfig ,
sourceEnvEntries ,
targetKeyFilePath : input.targetPaths.secretsKeyFilePath ,
} ) ;
2026-03-10 10:08:13 -05:00
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 ,
2026-03-10 07:41:01 -05:00
excludeTables : seedPlan.excludedTables ,
nullifyColumns : seedPlan.nullifyColumns ,
2026-03-10 10:08:13 -05:00
} ) ;
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 ,
} ) ;
2026-03-10 10:08:58 -05:00
await applyPendingMigrations ( targetConnectionString ) ;
2026-03-10 13:50:29 -05:00
const reboundWorkspaces = await rebindSeededProjectWorkspaces ( {
targetConnectionString ,
currentCwd : input.targetPaths.cwd ,
} ) ;
2026-03-10 10:08:13 -05:00
2026-03-10 13:50:29 -05:00
return {
backupSummary : formatDatabaseBackupResult ( backup ) ,
reboundWorkspaces ,
} ;
2026-03-10 10:08:13 -05:00
} finally {
if ( targetHandle ? . startedByThisProcess ) {
await targetHandle . stop ( ) ;
}
if ( sourceHandle ? . startedByThisProcess ) {
await sourceHandle . stop ( ) ;
}
}
}
2026-03-10 16:52:26 -05:00
async function runWorktreeInit ( opts : WorktreeInitOptions ) : Promise < void > {
2026-03-10 10:08:13 -05:00
const cwd = process . cwd ( ) ;
2026-03-13 11:12:43 -05:00
const worktreeName = resolveSuggestedWorktreeName (
2026-03-10 10:08:13 -05:00
cwd ,
opts . name ? ? detectGitBranchName ( cwd ) ? ? undefined ,
) ;
2026-03-10 07:41:01 -05:00
const seedMode = opts . seedMode ? ? "minimal" ;
if ( ! isWorktreeSeedMode ( seedMode ) ) {
throw new Error ( ` Unsupported seed mode " ${ seedMode } ". Expected one of: minimal, full. ` ) ;
}
2026-03-13 11:12:43 -05:00
const instanceId = sanitizeWorktreeInstanceId ( opts . instance ? ? worktreeName ) ;
2026-03-10 10:08:13 -05:00
const paths = resolveWorktreeLocalPaths ( {
cwd ,
2026-03-13 07:24:39 -05:00
homeDir : resolveWorktreeHome ( opts . home ) ,
2026-03-10 10:08:13 -05:00
instanceId ,
} ) ;
2026-03-13 11:12:43 -05:00
const branding = {
name : worktreeName ,
color : generateWorktreeColor ( ) ,
} ;
2026-03-10 10:08:13 -05:00
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 ) ;
2026-03-11 16:38:16 -05:00
const sourceEnvEntries = readPaperclipEnvEntries ( resolvePaperclipEnvFile ( sourceConfigPath ) ) ;
const existingAgentJwtSecret =
nonEmpty ( sourceEnvEntries . PAPERCLIP_AGENT_JWT_SECRET ) ? ?
nonEmpty ( process . env . PAPERCLIP_AGENT_JWT_SECRET ) ;
mergePaperclipEnvEntries (
{
2026-03-13 11:12:43 -05:00
. . . buildWorktreeEnvEntries ( paths , branding ) ,
2026-03-11 16:38:16 -05:00
. . . ( existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET : existingAgentJwtSecret } : { } ) ,
} ,
paths . envPath ,
) ;
2026-03-10 10:08:13 -05:00
ensureAgentJwtSecret ( paths . configPath ) ;
loadPaperclipEnvFile ( paths . configPath ) ;
2026-03-10 14:55:35 -05:00
const copiedGitHooks = copyGitHooksToWorktreeGitDir ( cwd ) ;
2026-03-10 10:08:13 -05:00
let seedSummary : string | null = null ;
2026-03-10 13:50:29 -05:00
let reboundWorkspaceSummary : SeedWorktreeDatabaseResult [ "reboundWorkspaces" ] = [ ] ;
2026-03-10 10:08:13 -05:00
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 ( ) ;
2026-03-10 07:41:01 -05:00
spinner . start ( ` Seeding isolated worktree database from source instance ( ${ seedMode } )... ` ) ;
2026-03-10 10:08:13 -05:00
try {
2026-03-10 13:50:29 -05:00
const seeded = await seedWorktreeDatabase ( {
2026-03-10 10:08:13 -05:00
sourceConfigPath ,
sourceConfig ,
targetConfig ,
targetPaths : paths ,
instanceId ,
2026-03-10 07:41:01 -05:00
seedMode ,
2026-03-10 10:08:13 -05:00
} ) ;
2026-03-10 13:50:29 -05:00
seedSummary = seeded . backupSummary ;
reboundWorkspaceSummary = seeded . reboundWorkspaces ;
2026-03-10 07:41:01 -05:00
spinner . stop ( ` Seeded isolated worktree database ( ${ seedMode } ). ` ) ;
2026-03-10 10:08:13 -05:00
} 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 } ` ) ) ;
2026-03-13 11:12:43 -05:00
p . log . message ( pc . dim ( ` Worktree badge: ${ branding . name } ( ${ branding . color } ) ` ) ) ;
2026-03-10 10:08:13 -05:00
p . log . message ( pc . dim ( ` Server port: ${ serverPort } | DB port: ${ databasePort } ` ) ) ;
2026-03-10 14:55:35 -05:00
if ( copiedGitHooks ? . copied ) {
p . log . message (
pc . dim ( ` Mirrored git hooks: ${ copiedGitHooks . sourceHooksPath } -> ${ copiedGitHooks . targetHooksPath } ` ) ,
) ;
}
2026-03-10 10:08:13 -05:00
if ( seedSummary ) {
2026-03-10 07:41:01 -05:00
p . log . message ( pc . dim ( ` Seed mode: ${ seedMode } ` ) ) ;
2026-03-10 10:08:13 -05:00
p . log . message ( pc . dim ( ` Seed snapshot: ${ seedSummary } ` ) ) ;
2026-03-10 13:50:29 -05:00
for ( const rebound of reboundWorkspaceSummary ) {
p . log . message (
pc . dim ( ` Rebound workspace ${ rebound . name } : ${ rebound . fromCwd } -> ${ rebound . toCwd } ` ) ,
) ;
}
2026-03-10 10:08:13 -05:00
}
p . outro (
pc . green (
` Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${ paths . instanceId } automatically. ` ,
) ,
) ;
}
2026-03-10 16:52:26 -05:00
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 ) ;
2026-03-13 07:24:39 -05:00
const startPoint = resolveWorktreeStartPoint ( opts . startPoint ) ;
2026-03-10 16:52:26 -05:00
const sourceCwd = process . cwd ( ) ;
2026-03-13 14:24:06 -05:00
const sourceConfigPath = resolveSourceConfigPath ( opts ) ;
2026-03-10 16:52:26 -05:00
const targetPath = resolveWorktreeMakeTargetPath ( name ) ;
if ( existsSync ( targetPath ) ) {
throw new Error ( ` Target path already exists: ${ targetPath } ` ) ;
}
mkdirSync ( path . dirname ( targetPath ) , { recursive : true } ) ;
2026-03-13 07:24:39 -05:00
if ( startPoint ) {
const [ remote ] = startPoint . split ( "/" , 1 ) ;
2026-03-11 09:15:27 -05:00
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 ) } ` ,
) ;
}
}
2026-03-10 16:52:26 -05:00
const worktreeArgs = resolveGitWorktreeAddArgs ( {
branchName : name ,
targetPath ,
2026-03-13 07:24:39 -05:00
branchExists : ! startPoint && localBranchExists ( sourceCwd , name ) ,
startPoint ,
2026-03-10 16:52:26 -05:00
} ) ;
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 ) ) ;
}
2026-03-11 09:31:41 -05:00
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 ) ) ;
}
2026-03-10 16:52:26 -05:00
const originalCwd = process . cwd ( ) ;
try {
process . chdir ( targetPath ) ;
await runWorktreeInit ( {
. . . opts ,
name ,
2026-03-13 14:24:06 -05:00
sourceConfigPathOverride : sourceConfigPath ,
2026-03-10 16:52:26 -05:00
} ) ;
} catch ( error ) {
throw error ;
} finally {
process . chdir ( originalCwd ) ;
}
}
2026-03-13 07:24:39 -05:00
type WorktreeCleanupOptions = {
instance? : string ;
home? : string ;
force? : boolean ;
} ;
type GitWorktreeListEntry = {
worktree : string ;
branch : string | null ;
bare : boolean ;
detached : boolean ;
} ;
2026-03-20 15:17:51 -05:00
type MergeSourceChoice = {
worktree : string ;
branch : string | null ;
branchLabel : string ;
hasPaperclipConfig : boolean ;
isCurrent : boolean ;
} ;
2026-03-20 15:39:02 -05:00
type ResolvedWorktreeEndpoint = {
rootPath : string ;
configPath : string ;
label : string ;
isCurrent : boolean ;
} ;
2026-03-13 07:24:39 -05:00
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 ;
}
2026-03-20 15:17:51 -05:00
function toMergeSourceChoices ( cwd : string ) : MergeSourceChoice [ ] {
const currentCwd = path . resolve ( cwd ) ;
return parseGitWorktreeList ( cwd ) . map ( ( entry ) = > {
const branchLabel = entry . branch ? . replace ( /^refs\/heads\// , "" ) ? ? "(detached)" ;
const worktreePath = path . resolve ( entry . worktree ) ;
return {
worktree : worktreePath ,
branch : entry.branch ,
branchLabel ,
hasPaperclipConfig : existsSync ( path . resolve ( worktreePath , ".paperclip" , "config.json" ) ) ,
isCurrent : worktreePath === currentCwd ,
} ;
} ) ;
}
2026-03-13 07:24:39 -05:00
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." ) ) ;
}
2026-03-10 10:08:13 -05:00
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 ) ) ;
}
2026-03-20 15:02:24 -05:00
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 ;
} ;
async function closeDb ( db : ClosableDb ) : Promise < void > {
await db . $client ? . end ? . ( { timeout : 5 } ) . catch ( ( ) = > undefined ) ;
}
2026-03-20 15:39:02 -05:00
function resolveCurrentEndpoint ( ) : ResolvedWorktreeEndpoint {
return {
rootPath : path.resolve ( process . cwd ( ) ) ,
configPath : resolveConfigPath ( ) ,
label : "current" ,
isCurrent : true ,
} ;
}
2026-03-20 16:12:10 -05:00
function resolveAttachmentLookupStorages ( input : {
sourceEndpoint : ResolvedWorktreeEndpoint ;
targetEndpoint : ResolvedWorktreeEndpoint ;
} ) : ConfiguredStorage [ ] {
const orderedConfigPaths = [
input . sourceEndpoint . configPath ,
resolveCurrentEndpoint ( ) . configPath ,
input . targetEndpoint . configPath ,
. . . toMergeSourceChoices ( process . cwd ( ) )
. filter ( ( choice ) = > choice . hasPaperclipConfig )
. map ( ( choice ) = > path . resolve ( choice . worktree , ".paperclip" , "config.json" ) ) ,
] ;
const seen = new Set < string > ( ) ;
const storages : ConfiguredStorage [ ] = [ ] ;
for ( const configPath of orderedConfigPaths ) {
const resolved = path . resolve ( configPath ) ;
if ( seen . has ( resolved ) || ! existsSync ( resolved ) ) continue ;
seen . add ( resolved ) ;
storages . push ( openConfiguredStorage ( resolved ) ) ;
}
return storages ;
}
2026-03-20 15:02:24 -05:00
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 ) ;
2026-03-20 17:23:45 -05:00
const migrationState = await inspectMigrations ( connectionString ) ;
if ( migrationState . status !== "upToDate" ) {
const pending =
migrationState . reason === "pending-migrations"
? ` Pending migrations: ${ migrationState . pendingMigrations . join ( ", " ) } . `
: "" ;
throw new Error (
` Database for ${ configPath } is not up to date. ${ pending } Run \` pnpm db:migrate \` (or start Paperclip once) before using worktree merge history. ` ,
) ;
}
2026-03-20 15:02:24 -05:00
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 ;
2026-03-20 15:39:02 -05:00
targetPath : string ;
2026-03-20 15:02:24 -05:00
unsupportedRunCount : number ;
} ) : string {
2026-03-20 15:44:22 -05:00
const terminalWidth = Math . max ( 60 , process . stdout . columns ? ? 100 ) ;
const oneLine = ( value : string ) = > value . replace ( /\s+/g , " " ) . trim ( ) ;
const truncateToWidth = ( value : string , maxWidth : number ) = > {
if ( maxWidth <= 1 ) return "" ;
if ( value . length <= maxWidth ) return value ;
return ` ${ value . slice ( 0 , Math . max ( 0 , maxWidth - 1 ) ) . trimEnd ( ) } … ` ;
} ;
2026-03-20 15:02:24 -05:00
const lines = [
` Mode: preview ` ,
` Source: ${ extras . sourcePath } ` ,
2026-03-20 15:39:02 -05:00
` Target: ${ extras . targetPath } ` ,
2026-03-20 15:02:24 -05:00
` 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 ) {
2026-03-20 15:13:35 -05:00
const projectNote =
issue . projectResolution === "mapped" && issue . mappedProjectName
? ` project-> ${ issue . mappedProjectName } `
: "" ;
2026-03-20 15:02:24 -05:00
const adjustments = issue . adjustments . length > 0 ? ` [ ${ issue . adjustments . join ( ", " ) } ] ` : "" ;
2026-03-20 15:44:22 -05:00
const prefix = ` - ${ issue . source . identifier ? ? issue . source . id } -> ${ issue . previewIdentifier } ( ${ issue . targetStatus } ${ projectNote } ) ` ;
const title = oneLine ( issue . source . title ) ;
const suffix = ` ${ adjustments } ${ title ? ` ${ title } ` : "" } ` ;
2026-03-20 15:02:24 -05:00
lines . push (
2026-03-20 15:44:22 -05:00
` ${ prefix } ${ truncateToWidth ( suffix , Math . max ( 8 , terminalWidth - prefix . length ) ) } ` ,
2026-03-20 15:02:24 -05:00
) ;
}
}
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 } ` ) ;
}
2026-03-20 15:59:41 -05:00
lines . push ( "" ) ;
lines . push ( "Documents" ) ;
lines . push ( ` - insert: ${ plan . counts . documentsToInsert } ` ) ;
lines . push ( ` - merge existing: ${ plan . counts . documentsToMerge } ` ) ;
lines . push ( ` - already present: ${ plan . counts . documentsExisting } ` ) ;
lines . push ( ` - skipped (conflicting key): ${ plan . counts . documentsConflictingKey } ` ) ;
lines . push ( ` - skipped (missing parent): ${ plan . counts . documentsMissingParent } ` ) ;
lines . push ( ` - revisions insert: ${ plan . counts . documentRevisionsToInsert } ` ) ;
lines . push ( "" ) ;
lines . push ( "Attachments" ) ;
lines . push ( ` - insert: ${ plan . counts . attachmentsToInsert } ` ) ;
lines . push ( ` - already present: ${ plan . counts . attachmentsExisting } ` ) ;
lines . push ( ` - skipped (missing parent): ${ plan . counts . attachmentsMissingParent } ` ) ;
2026-03-20 15:02:24 -05:00
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 } ` ) ;
2026-03-20 15:59:41 -05:00
lines . push ( ` - cleared document agents: ${ plan . adjustments . clear_document_agent } ` ) ;
lines . push ( ` - cleared document revision agents: ${ plan . adjustments . clear_document_revision_agent } ` ) ;
lines . push ( ` - cleared attachment author agents: ${ plan . adjustments . clear_attachment_agent } ` ) ;
2026-03-20 15:02:24 -05:00
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 ( "" ) ;
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 > ;
2026-03-20 15:13:35 -05:00
projectIdOverrides? : Record < string , string | null | undefined > ;
2026-03-20 15:02:24 -05:00
} ) {
const companyId = input . company . id ;
2026-03-20 15:59:41 -05:00
const [
targetCompanyRow ,
sourceIssuesRows ,
targetIssuesRows ,
sourceCommentsRows ,
targetCommentsRows ,
sourceIssueDocumentsRows ,
targetIssueDocumentsRows ,
sourceDocumentRevisionRows ,
targetDocumentRevisionRows ,
sourceAttachmentRows ,
targetAttachmentRows ,
sourceProjectsRows ,
targetProjectsRows ,
targetAgentsRows ,
targetProjectWorkspaceRows ,
targetGoalsRows ,
runCountRows ,
] = await Promise . all ( [
2026-03-20 15:02:24 -05:00
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 ( [ ] ) ,
2026-03-20 15:59:41 -05:00
input . targetDb
. select ( )
. from ( issueComments )
. where ( eq ( issueComments . companyId , companyId ) ) ,
input . sourceDb
. select ( {
id : issueDocuments.id ,
companyId : issueDocuments.companyId ,
issueId : issueDocuments.issueId ,
documentId : issueDocuments.documentId ,
key : issueDocuments.key ,
linkCreatedAt : issueDocuments.createdAt ,
linkUpdatedAt : issueDocuments.updatedAt ,
title : documents.title ,
format : documents.format ,
latestBody : documents.latestBody ,
latestRevisionId : documents.latestRevisionId ,
latestRevisionNumber : documents.latestRevisionNumber ,
createdByAgentId : documents.createdByAgentId ,
createdByUserId : documents.createdByUserId ,
updatedByAgentId : documents.updatedByAgentId ,
updatedByUserId : documents.updatedByUserId ,
documentCreatedAt : documents.createdAt ,
documentUpdatedAt : documents.updatedAt ,
} )
. from ( issueDocuments )
. innerJoin ( documents , eq ( issueDocuments . documentId , documents . id ) )
. innerJoin ( issues , eq ( issueDocuments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
input . targetDb
. select ( {
id : issueDocuments.id ,
companyId : issueDocuments.companyId ,
issueId : issueDocuments.issueId ,
documentId : issueDocuments.documentId ,
key : issueDocuments.key ,
linkCreatedAt : issueDocuments.createdAt ,
linkUpdatedAt : issueDocuments.updatedAt ,
title : documents.title ,
format : documents.format ,
latestBody : documents.latestBody ,
latestRevisionId : documents.latestRevisionId ,
latestRevisionNumber : documents.latestRevisionNumber ,
createdByAgentId : documents.createdByAgentId ,
createdByUserId : documents.createdByUserId ,
updatedByAgentId : documents.updatedByAgentId ,
updatedByUserId : documents.updatedByUserId ,
documentCreatedAt : documents.createdAt ,
documentUpdatedAt : documents.updatedAt ,
} )
. from ( issueDocuments )
. innerJoin ( documents , eq ( issueDocuments . documentId , documents . id ) )
. innerJoin ( issues , eq ( issueDocuments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
input . sourceDb
. select ( {
id : documentRevisions.id ,
companyId : documentRevisions.companyId ,
documentId : documentRevisions.documentId ,
revisionNumber : documentRevisions.revisionNumber ,
body : documentRevisions.body ,
changeSummary : documentRevisions.changeSummary ,
createdByAgentId : documentRevisions.createdByAgentId ,
createdByUserId : documentRevisions.createdByUserId ,
createdAt : documentRevisions.createdAt ,
} )
. from ( documentRevisions )
. innerJoin ( issueDocuments , eq ( documentRevisions . documentId , issueDocuments . documentId ) )
. innerJoin ( issues , eq ( issueDocuments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
input . targetDb
. select ( {
id : documentRevisions.id ,
companyId : documentRevisions.companyId ,
documentId : documentRevisions.documentId ,
revisionNumber : documentRevisions.revisionNumber ,
body : documentRevisions.body ,
changeSummary : documentRevisions.changeSummary ,
createdByAgentId : documentRevisions.createdByAgentId ,
createdByUserId : documentRevisions.createdByUserId ,
createdAt : documentRevisions.createdAt ,
} )
. from ( documentRevisions )
. innerJoin ( issueDocuments , eq ( documentRevisions . documentId , issueDocuments . documentId ) )
. innerJoin ( issues , eq ( issueDocuments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
input . sourceDb
. select ( {
id : issueAttachments.id ,
companyId : issueAttachments.companyId ,
issueId : issueAttachments.issueId ,
issueCommentId : issueAttachments.issueCommentId ,
assetId : issueAttachments.assetId ,
provider : assets.provider ,
objectKey : assets.objectKey ,
contentType : assets.contentType ,
byteSize : assets.byteSize ,
sha256 : assets.sha256 ,
originalFilename : assets.originalFilename ,
createdByAgentId : assets.createdByAgentId ,
createdByUserId : assets.createdByUserId ,
assetCreatedAt : assets.createdAt ,
assetUpdatedAt : assets.updatedAt ,
attachmentCreatedAt : issueAttachments.createdAt ,
attachmentUpdatedAt : issueAttachments.updatedAt ,
} )
. from ( issueAttachments )
. innerJoin ( assets , eq ( issueAttachments . assetId , assets . id ) )
. innerJoin ( issues , eq ( issueAttachments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
input . targetDb
. select ( {
id : issueAttachments.id ,
companyId : issueAttachments.companyId ,
issueId : issueAttachments.issueId ,
issueCommentId : issueAttachments.issueCommentId ,
assetId : issueAttachments.assetId ,
provider : assets.provider ,
objectKey : assets.objectKey ,
contentType : assets.contentType ,
byteSize : assets.byteSize ,
sha256 : assets.sha256 ,
originalFilename : assets.originalFilename ,
createdByAgentId : assets.createdByAgentId ,
createdByUserId : assets.createdByUserId ,
assetCreatedAt : assets.createdAt ,
assetUpdatedAt : assets.updatedAt ,
attachmentCreatedAt : issueAttachments.createdAt ,
attachmentUpdatedAt : issueAttachments.updatedAt ,
} )
. from ( issueAttachments )
. innerJoin ( assets , eq ( issueAttachments . assetId , assets . id ) )
. innerJoin ( issues , eq ( issueAttachments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
2026-03-20 15:13:35 -05:00
input . sourceDb
2026-03-20 15:02:24 -05:00
. select ( )
2026-03-20 15:13:35 -05:00
. from ( projects )
. where ( eq ( projects . companyId , companyId ) ) ,
2026-03-20 15:02:24 -05:00
input . targetDb
. select ( )
. from ( projects )
. where ( eq ( projects . companyId , companyId ) ) ,
2026-03-20 15:13:35 -05:00
input . targetDb
. select ( )
. from ( agents )
. where ( eq ( agents . companyId , companyId ) ) ,
2026-03-20 15:02:24 -05:00
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 ) ) ,
] ) ;
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 ,
2026-03-20 15:59:41 -05:00
sourceDocuments : sourceIssueDocumentsRows as IssueDocumentRow [ ] ,
targetDocuments : targetIssueDocumentsRows as IssueDocumentRow [ ] ,
sourceDocumentRevisions : sourceDocumentRevisionRows as DocumentRevisionRow [ ] ,
targetDocumentRevisions : targetDocumentRevisionRows as DocumentRevisionRow [ ] ,
sourceAttachments : sourceAttachmentRows as IssueAttachmentRow [ ] ,
targetAttachments : targetAttachmentRows as IssueAttachmentRow [ ] ,
2026-03-20 15:02:24 -05:00
targetAgents : targetAgentsRows ,
targetProjects : targetProjectsRows ,
targetProjectWorkspaces : targetProjectWorkspaceRows ,
targetGoals : targetGoalsRows ,
2026-03-20 15:13:35 -05:00
projectIdOverrides : input.projectIdOverrides ,
2026-03-20 15:02:24 -05:00
} ) ;
return {
plan ,
2026-03-20 15:13:35 -05:00
sourceProjects : sourceProjectsRows ,
targetProjects : targetProjectsRows ,
2026-03-20 15:02:24 -05:00
unsupportedRunCount : runCountRows [ 0 ] ? . count ? ? 0 ,
} ;
}
2026-03-20 15:13:35 -05:00
async function promptForProjectMappings ( input : {
plan : Awaited < ReturnType < typeof collectMergePlan > > [ "plan" ] ;
sourceProjects : Awaited < ReturnType < typeof collectMergePlan > > [ "sourceProjects" ] ;
targetProjects : Awaited < ReturnType < typeof collectMergePlan > > [ "targetProjects" ] ;
} ) : Promise < Record < string , string | null > > {
const missingProjectIds = [
. . . new Set (
input . plan . issuePlans
. filter ( ( plan ) : plan is PlannedIssueInsert = > plan . action === "insert" )
. filter ( ( plan ) = > ! ! plan . source . projectId && plan . projectResolution === "cleared" )
. map ( ( plan ) = > plan . source . projectId as string ) ,
) ,
] ;
if ( missingProjectIds . length === 0 || input . targetProjects . length === 0 ) {
return { } ;
}
const sourceProjectsById = new Map ( input . sourceProjects . map ( ( project ) = > [ project . id , project ] ) ) ;
const targetChoices = [ . . . input . targetProjects ]
. sort ( ( left , right ) = > left . name . localeCompare ( right . name ) )
. map ( ( project ) = > ( {
value : project.id ,
label : project.name ,
hint : project.status ,
} ) ) ;
const mappings : Record < string , string | null > = { } ;
for ( const sourceProjectId of missingProjectIds ) {
const sourceProject = sourceProjectsById . get ( sourceProjectId ) ;
if ( ! sourceProject ) continue ;
const nameMatch = input . targetProjects . find (
( project ) = > project . name . trim ( ) . toLowerCase ( ) === sourceProject . name . trim ( ) . toLowerCase ( ) ,
) ;
const selection = await p . select < string | null > ( {
message : ` Project " ${ sourceProject . name } " is missing in target. How should ${ input . plan . issuePrefix } imports handle it? ` ,
options : [
. . . ( nameMatch
? [ {
value : nameMatch.id ,
label : ` Map to ${ nameMatch . name } ` ,
hint : "Recommended: exact name match" ,
} ]
: [ ] ) ,
{
value : null ,
label : "Leave unset" ,
hint : "Keep imported issues without a project" ,
} ,
. . . targetChoices . filter ( ( choice ) = > choice . value !== nameMatch ? . id ) ,
] ,
initialValue : nameMatch?.id ? ? null ,
} ) ;
if ( p . isCancel ( selection ) ) {
throw new Error ( "Project mapping cancelled." ) ;
}
mappings [ sourceProjectId ] = selection ;
}
return mappings ;
}
2026-03-20 15:17:51 -05:00
export async function worktreeListCommand ( opts : WorktreeListOptions ) : Promise < void > {
const choices = toMergeSourceChoices ( process . cwd ( ) ) ;
if ( opts . json ) {
console . log ( JSON . stringify ( choices , null , 2 ) ) ;
return ;
}
for ( const choice of choices ) {
const flags = [
choice . isCurrent ? "current" : null ,
choice . hasPaperclipConfig ? "paperclip" : "no-paperclip-config" ,
] . filter ( ( value ) : value is string = > value !== null ) ;
p . log . message ( ` ${ choice . branchLabel } ${ choice . worktree } [ ${ flags . join ( ", " ) } ] ` ) ;
}
}
2026-03-20 15:39:02 -05:00
function resolveEndpointFromChoice ( choice : MergeSourceChoice ) : ResolvedWorktreeEndpoint {
if ( choice . isCurrent ) {
return resolveCurrentEndpoint ( ) ;
}
return {
rootPath : choice.worktree ,
configPath : path.resolve ( choice . worktree , ".paperclip" , "config.json" ) ,
label : choice.branchLabel ,
isCurrent : false ,
} ;
}
2026-03-20 15:17:51 -05:00
2026-03-20 15:39:02 -05:00
function resolveWorktreeEndpointFromSelector (
selector : string ,
opts ? : { allowCurrent? : boolean } ,
) : ResolvedWorktreeEndpoint {
const trimmed = selector . trim ( ) ;
const allowCurrent = opts ? . allowCurrent !== false ;
if ( trimmed . length === 0 ) {
throw new Error ( "Worktree selector cannot be empty." ) ;
}
2026-03-20 15:17:51 -05:00
2026-03-20 15:39:02 -05:00
const currentEndpoint = resolveCurrentEndpoint ( ) ;
if ( allowCurrent && trimmed === "current" ) {
return currentEndpoint ;
}
const choices = toMergeSourceChoices ( process . cwd ( ) ) ;
const directPath = path . resolve ( trimmed ) ;
if ( existsSync ( directPath ) ) {
if ( allowCurrent && directPath === currentEndpoint . rootPath ) {
return currentEndpoint ;
}
const configPath = path . resolve ( directPath , ".paperclip" , "config.json" ) ;
if ( ! existsSync ( configPath ) ) {
throw new Error ( ` Resolved worktree path ${ directPath } does not contain .paperclip/config.json. ` ) ;
2026-03-20 15:17:51 -05:00
}
2026-03-20 15:39:02 -05:00
return {
rootPath : directPath ,
configPath ,
label : path.basename ( directPath ) ,
isCurrent : false ,
} ;
}
2026-03-20 15:17:51 -05:00
2026-03-20 15:39:02 -05:00
const matched = choices . find ( ( choice ) = >
( allowCurrent || ! choice . isCurrent )
&& ( choice . worktree === directPath
|| path . basename ( choice . worktree ) === trimmed
|| choice . branchLabel === trimmed ) ,
) ;
if ( ! matched ) {
2026-03-20 15:17:51 -05:00
throw new Error (
2026-03-20 15:39:02 -05:00
` Could not resolve worktree " ${ selector } ". Use a path, a listed worktree directory name, branch name, or "current". ` ,
2026-03-20 15:17:51 -05:00
) ;
}
2026-03-20 15:39:02 -05:00
if ( ! matched . hasPaperclipConfig && ! matched . isCurrent ) {
throw new Error ( ` Resolved worktree " ${ selector } " does not look like a Paperclip worktree. ` ) ;
2026-03-20 15:17:51 -05:00
}
2026-03-20 15:39:02 -05:00
return resolveEndpointFromChoice ( matched ) ;
}
2026-03-20 15:17:51 -05:00
2026-03-20 15:39:02 -05:00
async function promptForSourceEndpoint ( excludeWorktreePath? : string ) : Promise < ResolvedWorktreeEndpoint > {
const excluded = excludeWorktreePath ? path . resolve ( excludeWorktreePath ) : null ;
const currentEndpoint = resolveCurrentEndpoint ( ) ;
const choices = toMergeSourceChoices ( process . cwd ( ) )
. filter ( ( choice ) = > choice . hasPaperclipConfig || choice . isCurrent )
. filter ( ( choice ) = > path . resolve ( choice . worktree ) !== excluded )
. map ( ( choice ) = > ( {
value : choice.isCurrent ? "__current__" : choice . worktree ,
label : choice.branchLabel ,
hint : ` ${ choice . worktree } ${ choice . isCurrent ? " (current)" : "" } ` ,
} ) ) ;
if ( choices . length === 0 ) {
throw new Error ( "No Paperclip worktrees were found. Run `paperclipai worktree:list` to inspect the repo worktrees." ) ;
}
2026-03-20 15:17:51 -05:00
const selection = await p . select < string > ( {
message : "Choose the source worktree to import from" ,
2026-03-20 15:39:02 -05:00
options : choices ,
2026-03-20 15:17:51 -05:00
} ) ;
if ( p . isCancel ( selection ) ) {
throw new Error ( "Source worktree selection cancelled." ) ;
}
2026-03-20 15:39:02 -05:00
if ( selection === "__current__" ) {
return currentEndpoint ;
}
return resolveWorktreeEndpointFromSelector ( selection , { allowCurrent : true } ) ;
2026-03-20 15:17:51 -05:00
}
2026-03-20 15:02:24 -05:00
async function applyMergePlan ( input : {
2026-03-20 16:12:10 -05:00
sourceStorages : ConfiguredStorage [ ] ;
2026-03-20 15:59:41 -05:00
targetStorage : ConfiguredStorage ;
2026-03-20 15:02:24 -05:00
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 > ( ) ;
2026-03-20 15:59:41 -05:00
let insertedIssues = 0 ;
2026-03-20 15:02:24 -05:00
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 ,
} ) ;
2026-03-20 15:59:41 -05:00
insertedIssues += 1 ;
2026-03-20 15:02:24 -05:00
}
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 > ( ) ;
2026-03-20 15:59:41 -05:00
let insertedComments = 0 ;
2026-03-20 15:02:24 -05:00
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 ,
} ) ;
2026-03-20 15:59:41 -05:00
insertedComments += 1 ;
}
const documentCandidates = input . plan . documentPlans . filter (
( plan ) : plan is PlannedIssueDocumentInsert | PlannedIssueDocumentMerge = >
plan . action === "insert" || plan . action === "merge_existing" ,
) ;
let insertedDocuments = 0 ;
let mergedDocuments = 0 ;
let insertedDocumentRevisions = 0 ;
for ( const documentPlan of documentCandidates ) {
const parentExists = await tx
. select ( { id : issues.id } )
. from ( issues )
. where ( and ( eq ( issues . id , documentPlan . source . issueId ) , eq ( issues . companyId , companyId ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! parentExists ) continue ;
const conflictingKeyDocument = await tx
. select ( { documentId : issueDocuments.documentId } )
. from ( issueDocuments )
. where ( and ( eq ( issueDocuments . issueId , documentPlan . source . issueId ) , eq ( issueDocuments . key , documentPlan . source . key ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if (
conflictingKeyDocument
&& conflictingKeyDocument . documentId !== documentPlan . source . documentId
) {
continue ;
}
const existingDocument = await tx
. select ( { id : documents.id } )
. from ( documents )
. where ( eq ( documents . id , documentPlan . source . documentId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! existingDocument ) {
await tx . insert ( documents ) . values ( {
id : documentPlan.source.documentId ,
companyId ,
title : documentPlan.source.title ,
format : documentPlan.source.format ,
latestBody : documentPlan.source.latestBody ,
latestRevisionId : documentPlan.latestRevisionId ,
latestRevisionNumber : documentPlan.latestRevisionNumber ,
createdByAgentId : documentPlan.targetCreatedByAgentId ,
createdByUserId : documentPlan.source.createdByUserId ,
updatedByAgentId : documentPlan.targetUpdatedByAgentId ,
updatedByUserId : documentPlan.source.updatedByUserId ,
createdAt : documentPlan.source.documentCreatedAt ,
updatedAt : documentPlan.source.documentUpdatedAt ,
} ) ;
await tx . insert ( issueDocuments ) . values ( {
id : documentPlan.source.id ,
companyId ,
issueId : documentPlan.source.issueId ,
documentId : documentPlan.source.documentId ,
key : documentPlan.source.key ,
createdAt : documentPlan.source.linkCreatedAt ,
updatedAt : documentPlan.source.linkUpdatedAt ,
} ) ;
insertedDocuments += 1 ;
} else {
const existingLink = await tx
. select ( { id : issueDocuments.id } )
. from ( issueDocuments )
. where ( eq ( issueDocuments . documentId , documentPlan . source . documentId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! existingLink ) {
await tx . insert ( issueDocuments ) . values ( {
id : documentPlan.source.id ,
companyId ,
issueId : documentPlan.source.issueId ,
documentId : documentPlan.source.documentId ,
key : documentPlan.source.key ,
createdAt : documentPlan.source.linkCreatedAt ,
updatedAt : documentPlan.source.linkUpdatedAt ,
} ) ;
} else {
await tx
. update ( issueDocuments )
. set ( {
issueId : documentPlan.source.issueId ,
key : documentPlan.source.key ,
updatedAt : documentPlan.source.linkUpdatedAt ,
} )
. where ( eq ( issueDocuments . documentId , documentPlan . source . documentId ) ) ;
}
await tx
. update ( documents )
. set ( {
title : documentPlan.source.title ,
format : documentPlan.source.format ,
latestBody : documentPlan.source.latestBody ,
latestRevisionId : documentPlan.latestRevisionId ,
latestRevisionNumber : documentPlan.latestRevisionNumber ,
updatedByAgentId : documentPlan.targetUpdatedByAgentId ,
updatedByUserId : documentPlan.source.updatedByUserId ,
updatedAt : documentPlan.source.documentUpdatedAt ,
} )
. where ( eq ( documents . id , documentPlan . source . documentId ) ) ;
mergedDocuments += 1 ;
}
const existingRevisionIds = new Set (
(
await tx
. select ( { id : documentRevisions.id } )
. from ( documentRevisions )
. where ( eq ( documentRevisions . documentId , documentPlan . source . documentId ) )
) . map ( ( row ) = > row . id ) ,
) ;
for ( const revisionPlan of documentPlan . revisionsToInsert ) {
if ( existingRevisionIds . has ( revisionPlan . source . id ) ) continue ;
await tx . insert ( documentRevisions ) . values ( {
id : revisionPlan.source.id ,
companyId ,
documentId : documentPlan.source.documentId ,
revisionNumber : revisionPlan.targetRevisionNumber ,
body : revisionPlan.source.body ,
changeSummary : revisionPlan.source.changeSummary ,
createdByAgentId : revisionPlan.targetCreatedByAgentId ,
createdByUserId : revisionPlan.source.createdByUserId ,
createdAt : revisionPlan.source.createdAt ,
} ) ;
insertedDocumentRevisions += 1 ;
}
}
const attachmentCandidates = input . plan . attachmentPlans . filter (
( plan ) : plan is PlannedAttachmentInsert = > plan . action === "insert" ,
) ;
const existingAttachmentIds = new Set (
(
await tx
. select ( { id : issueAttachments.id } )
. from ( issueAttachments )
. where ( eq ( issueAttachments . companyId , companyId ) )
) . map ( ( row ) = > row . id ) ,
) ;
let insertedAttachments = 0 ;
2026-03-20 16:06:41 -05:00
let skippedMissingAttachmentObjects = 0 ;
2026-03-20 15:59:41 -05:00
for ( const attachment of attachmentCandidates ) {
if ( existingAttachmentIds . has ( attachment . source . id ) ) continue ;
const parentExists = await tx
. select ( { id : issues.id } )
. from ( issues )
. where ( and ( eq ( issues . id , attachment . source . issueId ) , eq ( issues . companyId , companyId ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! parentExists ) continue ;
2026-03-20 16:06:41 -05:00
const body = await readSourceAttachmentBody (
2026-03-20 16:12:10 -05:00
input . sourceStorages ,
2026-03-20 16:06:41 -05:00
companyId ,
attachment . source . objectKey ,
) ;
if ( ! body ) {
skippedMissingAttachmentObjects += 1 ;
continue ;
}
2026-03-20 15:59:41 -05:00
await input . targetStorage . putObject (
companyId ,
attachment . source . objectKey ,
body ,
attachment . source . contentType ,
) ;
await tx . insert ( assets ) . values ( {
id : attachment.source.assetId ,
companyId ,
provider : attachment.source.provider ,
objectKey : attachment.source.objectKey ,
contentType : attachment.source.contentType ,
byteSize : attachment.source.byteSize ,
sha256 : attachment.source.sha256 ,
originalFilename : attachment.source.originalFilename ,
createdByAgentId : attachment.targetCreatedByAgentId ,
createdByUserId : attachment.source.createdByUserId ,
createdAt : attachment.source.assetCreatedAt ,
updatedAt : attachment.source.assetUpdatedAt ,
} ) ;
await tx . insert ( issueAttachments ) . values ( {
id : attachment.source.id ,
companyId ,
issueId : attachment.source.issueId ,
assetId : attachment.source.assetId ,
issueCommentId : attachment.targetIssueCommentId ,
createdAt : attachment.source.attachmentCreatedAt ,
updatedAt : attachment.source.attachmentUpdatedAt ,
} ) ;
insertedAttachments += 1 ;
2026-03-20 15:02:24 -05:00
}
return {
2026-03-20 15:59:41 -05:00
insertedIssues ,
insertedComments ,
insertedDocuments ,
mergedDocuments ,
insertedDocumentRevisions ,
insertedAttachments ,
2026-03-20 16:06:41 -05:00
skippedMissingAttachmentObjects ,
2026-03-20 15:02:24 -05:00
insertedIssueIdentifiers ,
} ;
} ) ;
}
2026-03-20 15:17:51 -05:00
export async function worktreeMergeHistoryCommand ( sourceArg : string | undefined , opts : WorktreeMergeHistoryOptions ) : Promise < void > {
2026-03-20 15:02:24 -05:00
if ( opts . apply && opts . dry ) {
throw new Error ( "Use either --apply or --dry, not both." ) ;
}
2026-03-20 15:39:02 -05:00
if ( sourceArg && opts . from ) {
throw new Error ( "Use either the positional source argument or --from, not both." ) ;
2026-03-20 15:02:24 -05:00
}
2026-03-20 15:39:02 -05:00
const targetEndpoint = opts . to
? resolveWorktreeEndpointFromSelector ( opts . to , { allowCurrent : true } )
: resolveCurrentEndpoint ( ) ;
const sourceEndpoint = opts . from
? resolveWorktreeEndpointFromSelector ( opts . from , { allowCurrent : true } )
: sourceArg
? resolveWorktreeEndpointFromSelector ( sourceArg , { allowCurrent : true } )
: await promptForSourceEndpoint ( targetEndpoint . rootPath ) ;
if ( path . resolve ( sourceEndpoint . configPath ) === path . resolve ( targetEndpoint . configPath ) ) {
throw new Error ( "Source and target Paperclip configs are the same. Choose different --from/--to worktrees." ) ;
2026-03-20 15:02:24 -05:00
}
const scopes = parseWorktreeMergeScopes ( opts . scope ) ;
2026-03-20 15:39:02 -05:00
const sourceHandle = await openConfiguredDb ( sourceEndpoint . configPath ) ;
const targetHandle = await openConfiguredDb ( targetEndpoint . configPath ) ;
2026-03-20 16:12:10 -05:00
const sourceStorages = resolveAttachmentLookupStorages ( {
sourceEndpoint ,
targetEndpoint ,
} ) ;
2026-03-20 15:59:41 -05:00
const targetStorage = openConfiguredStorage ( targetEndpoint . configPath ) ;
2026-03-20 15:02:24 -05:00
try {
const company = await resolveMergeCompany ( {
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
selector : opts.company ,
} ) ;
2026-03-20 15:13:35 -05:00
let collected = await collectMergePlan ( {
2026-03-20 15:02:24 -05:00
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
company ,
scopes ,
} ) ;
2026-03-20 15:13:35 -05:00
if ( ! opts . yes ) {
const projectIdOverrides = await promptForProjectMappings ( {
plan : collected.plan ,
sourceProjects : collected.sourceProjects ,
targetProjects : collected.targetProjects ,
} ) ;
if ( Object . keys ( projectIdOverrides ) . length > 0 ) {
collected = await collectMergePlan ( {
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
company ,
scopes ,
projectIdOverrides ,
} ) ;
}
}
2026-03-20 15:02:24 -05:00
console . log ( renderMergePlan ( collected . plan , {
2026-03-20 15:39:02 -05:00
sourcePath : ` ${ sourceEndpoint . label } ( ${ sourceEndpoint . rootPath } ) ` ,
targetPath : ` ${ targetEndpoint . label } ( ${ targetEndpoint . rootPath } ) ` ,
2026-03-20 15:02:24 -05:00
unsupportedRunCount : collected.unsupportedRunCount ,
} ) ) ;
if ( ! opts . apply ) {
return ;
}
const confirmed = opts . yes
? true
: await p . confirm ( {
2026-03-20 15:39:02 -05:00
message : ` Import ${ collected . plan . counts . issuesToInsert } issues and ${ collected . plan . counts . commentsToInsert } comments from ${ sourceEndpoint . label } into ${ targetEndpoint . label } ? ` ,
2026-03-20 15:02:24 -05:00
initialValue : false ,
} ) ;
if ( p . isCancel ( confirmed ) || ! confirmed ) {
p . log . warn ( "Import cancelled." ) ;
return ;
}
const applied = await applyMergePlan ( {
2026-03-20 16:12:10 -05:00
sourceStorages ,
2026-03-20 15:59:41 -05:00
targetStorage ,
2026-03-20 15:02:24 -05:00
targetDb : targetHandle.db ,
company ,
plan : collected.plan ,
} ) ;
2026-03-20 16:06:41 -05:00
if ( applied . skippedMissingAttachmentObjects > 0 ) {
p . log . warn (
` Skipped ${ applied . skippedMissingAttachmentObjects } attachments whose source files were missing from storage. ` ,
) ;
}
2026-03-20 15:02:24 -05:00
p . outro (
pc . green (
2026-03-20 15:59:41 -05:00
` Imported ${ applied . insertedIssues } issues, ${ applied . insertedComments } comments, ${ applied . insertedDocuments } documents ( ${ applied . insertedDocumentRevisions } revisions, ${ applied . mergedDocuments } merged), and ${ applied . insertedAttachments } attachments into ${ company . issuePrefix } . ` ,
2026-03-20 15:02:24 -05:00
) ,
) ;
} finally {
await targetHandle . stop ( ) ;
await sourceHandle . stop ( ) ;
}
}
2026-03-10 10:08:13 -05:00
export function registerWorktreeCommands ( program : Command ) : void {
const worktree = program . command ( "worktree" ) . description ( "Worktree-local Paperclip instance helpers" ) ;
2026-03-10 16:52:26 -05:00
program
. command ( "worktree:make" )
. description ( "Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it" )
2026-03-13 07:24:39 -05:00
. 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)" )
2026-03-10 16:52:26 -05:00
. option ( "--instance <id>" , "Explicit isolated instance id" )
2026-03-13 07:24:39 -05:00
. option ( "--home <path>" , ` Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${ DEFAULT_WORKTREE_HOME } ) ` )
2026-03-10 16:52:26 -05:00
. 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 ) ;
2026-03-10 10:08:13 -05:00
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" )
2026-03-13 07:24:39 -05:00
. option ( "--home <path>" , ` Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${ DEFAULT_WORKTREE_HOME } ) ` )
2026-03-10 10:08:13 -05:00
. 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 ) )
2026-03-10 07:41:01 -05:00
. option ( "--seed-mode <mode>" , "Seed profile: minimal or full (default: minimal)" , "minimal" )
2026-03-10 10:08:13 -05:00
. 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 ) ;
2026-03-13 07:24:39 -05:00
2026-03-20 15:17:51 -05:00
program
. command ( "worktree:list" )
. description ( "List git worktrees visible from this repo and whether they look like Paperclip worktrees" )
. option ( "--json" , "Print JSON instead of text output" )
. action ( worktreeListCommand ) ;
2026-03-20 15:02:24 -05:00
program
. command ( "worktree:merge-history" )
. description ( "Preview or import issue/comment history from another worktree into the current instance" )
2026-03-20 15:39:02 -05:00
. argument ( "[source]" , "Optional source worktree path, directory name, or branch name (back-compat alias for --from)" )
. option ( "--from <worktree>" , "Source worktree path, directory name, branch name, or current" )
. option ( "--to <worktree>" , "Target worktree path, directory name, branch name, or current (defaults to current)" )
. option ( "--company <id-or-prefix>" , "Shared company id or issue prefix inside the chosen source/target instances" )
2026-03-20 15:02:24 -05:00
. 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 ) ;
2026-03-13 07:24:39 -05:00
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 ) ;
2026-03-10 10:08:13 -05:00
}