mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Add worktree reseed command
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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 <worktree> 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 <worktree> 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
start(): Promise<void>;
|
||||
@@ -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 <worktree> 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 <worktree> 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<string, string>, 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<ReturnType<typeof collectMergePlan>>["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<void> {
|
||||
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 <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("--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")
|
||||
.option("--seed-mode <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")
|
||||
|
||||
@@ -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/<worktree-id>/`, 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 <worktree>` | Source worktree path, directory name, branch name, or `current` |
|
||||
| `--to <worktree>` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config |
|
||||
| `--seed-mode <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 <name> [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 <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--yes` | Skip the destructive confirmation prompt |
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-c, --config <path>` | Path to config file |
|
||||
|
||||
Reference in New Issue
Block a user