From 34589ad457cddfe31fe33553d197dbec73edce63 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 7 Apr 2026 17:02:34 -0500 Subject: [PATCH] Add worktree reseed command --- cli/src/__tests__/worktree.test.ts | 105 ++++++++++++++++++++++++ cli/src/commands/worktree.ts | 124 ++++++++++++++++++++++++++++- doc/DEVELOPING.md | 20 +++++ 3 files changed, 247 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 6f6af963..8250ab92 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -13,6 +13,7 @@ import { resolveWorktreeMakeTargetPath, worktreeInitCommand, worktreeMakeCommand, + worktreeReseedCommand, } from "../commands/worktree.js"; import { buildWorktreeConfig, @@ -481,6 +482,110 @@ 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; + + 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.", + ); + } 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 }); + } + }); + + it("reseed preserves the current worktree ports, instance id, and branding", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceRoot = path.join(tempRoot, "source"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const currentInstanceId = "existing-worktree"; + const currentPaths = resolveWorktreeLocalPaths({ + cwd: repoRoot, + homeDir, + instanceId: currentInstanceId, + }); + const sourcePaths = resolveWorktreeLocalPaths({ + cwd: sourceRoot, + homeDir: path.join(tempRoot, ".paperclip-source"), + instanceId: "default", + }); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const currentConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: currentPaths, + serverPort: 3114, + databasePort: 54341, + }); + const sourceConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: sourcePaths, + serverPort: 3200, + databasePort: 54400, + }); + fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync( + currentPaths.envPath, + [ + `PAPERCLIP_HOME=${homeDir}`, + `PAPERCLIP_INSTANCE_ID=${currentInstanceId}`, + "PAPERCLIP_WORKTREE_NAME=existing-name", + "PAPERCLIP_WORKTREE_COLOR=\"#112233\"", + ].join("\n"), + "utf8", + ); + + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + seed: false, + yes: true, + }); + + const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8"); + + expect(rewrittenConfig.server.port).toBe(3114); + expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341); + expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir); + expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name"); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\""); + } 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 }); + } + }); + it("rebinds same-repo workspace paths onto the current worktree root", () => { expect( rebindWorkspaceCwd({ diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 65e74849..5693b8d1 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -80,6 +80,7 @@ import { type WorktreeInitOptions = { name?: string; + color?: string; instance?: string; home?: string; fromConfig?: string; @@ -97,6 +98,16 @@ type WorktreeMakeOptions = WorktreeInitOptions & { startPoint?: string; }; +type WorktreeReseedOptions = { + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + home?: string; + seedMode?: string; + yes?: boolean; + seed?: boolean; +}; + type WorktreeEnvOptions = { config?: string; json?: boolean; @@ -942,8 +953,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { instanceId, }); const branding = { - name: worktreeName, - color: generateWorktreeColor(), + name: opts.name ?? worktreeName, + color: opts.color ?? generateWorktreeColor(), }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; @@ -1051,6 +1062,104 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed "))); + + if (!hasExplicitSourceSelection(opts)) { + throw new Error( + "Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).", + ); + } + + const target = resolveCurrentWorktreeReseedState({ home: opts.home }); + const sourceConfigPath = resolveSourceConfigPath(opts); + if (path.resolve(sourceConfigPath) === target.currentConfigPath) { + throw new Error( + "Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.", + ); + } + + const seedMode = opts.seedMode ?? "minimal"; + if (!isWorktreeSeedMode(seedMode)) { + throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + } + + const confirmed = opts.yes + ? true + : await p.confirm({ + message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.log.warn("Reseed cancelled."); + return; + } + + await runWorktreeInit({ + name: target.worktreeName, + color: target.worktreeColor, + instance: target.instanceId, + home: target.homeDir, + fromConfig: opts.fromConfig, + fromDataDir: opts.fromDataDir, + fromInstance: opts.fromInstance, + sourceConfigPathOverride: sourceConfigPath, + serverPort: target.serverPort, + dbPort: target.dbPort, + seed: opts.seed ?? true, + seedMode, + force: true, + }); +} + export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); @@ -2632,6 +2741,17 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + worktree + .command("reseed") + .description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id") + .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("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--yes", "Skip the destructive confirmation prompt", false) + .action(worktreeReseedCommand); + program .command("worktree:list") .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7aa171f5..6aa30237 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -232,6 +232,15 @@ 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: + +```sh +cd /path/to/existing/worktree +pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full +``` + +`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. + **`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. | Option | Description | @@ -258,6 +267,17 @@ 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 |