diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 1c8ed5e1..2089c032 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -9,6 +9,8 @@ import { readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, + resolveWorktreeReseedSource, + resolveWorktreeReseedTargetPaths, resolveGitWorktreeAddArgs, resolveWorktreeMakeTargetPath, worktreeInitCommand, @@ -482,27 +484,69 @@ describe("worktree helpers", () => { } }); - it("requires an explicit source for worktree reseed", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-source-")); - const repoRoot = path.join(tempRoot, "repo"); - const originalCwd = process.cwd(); - const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + it("requires an explicit reseed source", () => { + expect(() => resolveWorktreeReseedSource({})).toThrow( + "Pass --from or --from-config/--from-instance explicitly so the reseed source is unambiguous.", + ); + }); + + it("rejects mixed reseed source selectors", () => { + expect(() => resolveWorktreeReseedSource({ + from: "current", + fromInstance: "default", + })).toThrow( + "Use either --from or --from-config/--from-data-dir/--from-instance, not both.", + ); + }); + + it("derives worktree reseed target paths from the adjacent env file", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-")); + const worktreeRoot = path.join(tempRoot, "repo"); + const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); + const envPath = path.join(worktreeRoot, ".paperclip", ".env"); try { - fs.mkdirSync(repoRoot, { recursive: true }); - delete process.env.PAPERCLIP_CONFIG; - process.chdir(repoRoot); - - await expect(worktreeReseedCommand({ seed: false, yes: true })).rejects.toThrow( - "Reseed requires an explicit source.", + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync( + envPath, + [ + "PAPERCLIP_HOME=/tmp/paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=pap-1132-chat", + ].join("\n"), + "utf8", ); + expect( + resolveWorktreeReseedTargetPaths({ + configPath, + rootPath: worktreeRoot, + }), + ).toMatchObject({ + cwd: worktreeRoot, + homeDir: "/tmp/paperclip-worktrees", + instanceId: "pap-1132-chat", + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects reseed targets without worktree env metadata", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-")); + const worktreeRoot = path.join(tempRoot, "repo"); + const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); + + try { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8"); + + expect(() => + resolveWorktreeReseedTargetPaths({ + configPath, + rootPath: worktreeRoot, + })).toThrow("does not look like a worktree-local Paperclip instance"); } finally { - process.chdir(originalCwd); - if (originalPaperclipConfig === undefined) { - delete process.env.PAPERCLIP_CONFIG; - } else { - process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; - } fs.rmSync(tempRoot, { recursive: true, force: true }); } }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 3025e955..23eafe11 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -133,6 +133,17 @@ type WorktreeMergeHistoryOptions = { yes?: boolean; }; +type WorktreeReseedOptions = { + from?: string; + to?: string; + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + seedMode?: string; + yes?: boolean; + allowLiveTarget?: boolean; +}; + type EmbeddedPostgresInstance = { initialise(): Promise; start(): Promise; @@ -738,6 +749,65 @@ export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); } +export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource { + const fromSelector = nonEmpty(input.from); + const fromConfig = nonEmpty(input.fromConfig); + const fromDataDir = nonEmpty(input.fromDataDir); + const fromInstance = nonEmpty(input.fromInstance); + const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance); + + if (fromSelector && hasExplicitConfigSource) { + throw new Error( + "Use either --from or --from-config/--from-data-dir/--from-instance, not both.", + ); + } + + if (fromSelector) { + const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true }); + return { + configPath: endpoint.configPath, + label: endpoint.label, + }; + } + + if (hasExplicitConfigSource) { + const configPath = resolveSourceConfigPath({ + fromConfig: fromConfig ?? undefined, + fromDataDir: fromDataDir ?? undefined, + fromInstance: fromInstance ?? undefined, + }); + return { + configPath, + label: configPath, + }; + } + + throw new Error( + "Pass --from or --from-config/--from-instance explicitly so the reseed source is unambiguous.", + ); +} + +export function resolveWorktreeReseedTargetPaths(input: { + configPath: string; + rootPath: string; +}): WorktreeLocalPaths { + const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(input.configPath)); + const homeDir = nonEmpty(envEntries.PAPERCLIP_HOME); + const instanceId = nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID); + + if (!homeDir || !instanceId) { + throw new Error( + `Target config ${input.configPath} does not look like a worktree-local Paperclip instance. Expected PAPERCLIP_HOME and PAPERCLIP_INSTANCE_ID in the adjacent .env.`, + ); + } + + return resolveWorktreeLocalPaths({ + cwd: input.rootPath, + homeDir, + instanceId, + }); +} + function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record, portOverride?: number): string { if (config.database.mode === "postgres") { const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); @@ -1326,6 +1396,11 @@ type ResolvedWorktreeEndpoint = { isCurrent: boolean; }; +type ResolvedWorktreeReseedSource = { + configPath: string; + label: string; +}; + function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd, @@ -1819,6 +1894,13 @@ function renderMergePlan(plan: Awaited>["pla return lines.join("\n"); } +function resolveRunningEmbeddedPostgresPid(config: PaperclipConfig): number | null { + if (config.database.mode !== "embedded-postgres") { + return null; + } + return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid")); +} + async function collectMergePlan(input: { sourceDb: ClosableDb; targetDb: ClosableDb; @@ -2760,6 +2842,89 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, } } +export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed "))); + + const seedMode = opts.seedMode ?? "full"; + if (!isWorktreeSeedMode(seedMode)) { + throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + } + + const targetEndpoint = opts.to + ? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true }) + : resolveCurrentEndpoint(); + const source = resolveWorktreeReseedSource(opts); + + if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) { + throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to values."); + } + if (!existsSync(source.configPath)) { + throw new Error(`Source config not found at ${source.configPath}.`); + } + + const targetConfig = readConfig(targetEndpoint.configPath); + if (!targetConfig) { + throw new Error(`Target config not found at ${targetEndpoint.configPath}.`); + } + const sourceConfig = readConfig(source.configPath); + if (!sourceConfig) { + throw new Error(`Source config not found at ${source.configPath}.`); + } + + const targetPaths = resolveWorktreeReseedTargetPaths({ + configPath: targetEndpoint.configPath, + rootPath: targetEndpoint.rootPath, + }); + const runningTargetPid = resolveRunningEmbeddedPostgresPid(targetConfig); + if (runningTargetPid && !opts.allowLiveTarget) { + throw new Error( + `Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${targetEndpoint.rootPath} before reseeding, or re-run with --allow-live-target if you want to override this guard.`, + ); + } + + const confirmed = opts.yes + ? true + : await p.confirm({ + message: `Overwrite the isolated Paperclip DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.log.warn("Reseed cancelled."); + return; + } + + if (runningTargetPid && opts.allowLiveTarget) { + p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`); + } + + const spinner = p.spinner(); + spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`); + try { + const seeded = await seedWorktreeDatabase({ + sourceConfigPath: source.configPath, + sourceConfig, + targetConfig, + targetPaths, + instanceId: targetPaths.instanceId, + seedMode, + }); + spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`); + p.log.message(pc.dim(`Source: ${source.configPath}`)); + p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`)); + p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`)); + for (const rebound of seeded.reboundWorkspaces) { + p.log.message( + pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), + ); + } + p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`)); + } catch (error) { + spinner.stop(pc.red("Failed to reseed worktree database.")); + throw error; + } +} + export function registerWorktreeCommands(program: Command): void { const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers"); @@ -2833,6 +2998,19 @@ export function registerWorktreeCommands(program: Command): void { .option("--yes", "Skip the interactive confirmation prompt when applying", false) .action(worktreeMergeHistoryCommand); + worktree + .command("reseed") + .description("Re-seed an existing worktree-local instance from another Paperclip instance or worktree") + .option("--from ", "Source worktree path, directory name, branch name, or current") + .option("--to ", "Target worktree path, directory name, branch name, or current (defaults to current)") + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config") + .option("--seed-mode ", "Seed profile: minimal or full (default: full)", "full") + .option("--yes", "Skip the destructive confirmation prompt", false) + .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) + .action(worktreeReseedCommand); + program .command("worktree:cleanup") .description("Safely remove a worktree, its branch, and its isolated instance data") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6aa30237..724496e9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -232,14 +232,38 @@ pnpm paperclipai worktree init --force --seed-mode minimal \ That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances//`, and preserves the git worktree contents themselves. -For existing worktrees, prefer the dedicated reseed command instead of rebuilding the `worktree init --force` flags manually: +For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely. + +**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity. + +| Option | Description | +|---|---| +| `--from ` | Source worktree path, directory name, branch name, or `current` | +| `--to ` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source `PAPERCLIP_HOME` used when deriving the source config | +| `--from-instance ` | Source instance id when deriving the source config | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `full`) | +| `--yes` | Skip the destructive confirmation prompt | +| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | + +Examples: ```sh -cd /path/to/existing/worktree -pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full -``` +# From the main repo, reseed a worktree from the current default/master instance. +cd /path/to/paperclip +pnpm paperclipai worktree reseed \ + --from current \ + --to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \ + --seed-mode full \ + --yes -`worktree reseed` preserves the current worktree's instance id, ports, and branding while replacing only that worktree's isolated Paperclip instance data from the chosen source. +# From inside a worktree, reseed it from the default instance config. +cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat +pnpm paperclipai worktree reseed \ + --from-instance default \ + --seed-mode full +``` **`pnpm paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. @@ -267,17 +291,6 @@ pnpm paperclipai worktree:make experiment --no-seed **`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance. -**`pnpm paperclipai worktree reseed [options]`** — Replace the current worktree instance with a fresh seed from another Paperclip source while preserving the current worktree's ports and instance id. - -| Option | Description | -|---|---| -| `--from-config ` | Source config.json to seed from | -| `--from-data-dir ` | Source `PAPERCLIP_HOME` used when deriving the source config | -| `--from-instance ` | Source instance id when deriving the source config | -| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | -| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | -| `--yes` | Skip the destructive confirmation prompt | - | Option | Description | |---|---| | `-c, --config ` | Path to config file |