mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add worktree reseed command
This commit is contained in:
parent
7dd3661467
commit
34589ad457
3 changed files with 247 additions and 2 deletions
|
|
@ -13,6 +13,7 @@ import {
|
||||||
resolveWorktreeMakeTargetPath,
|
resolveWorktreeMakeTargetPath,
|
||||||
worktreeInitCommand,
|
worktreeInitCommand,
|
||||||
worktreeMakeCommand,
|
worktreeMakeCommand,
|
||||||
|
worktreeReseedCommand,
|
||||||
} from "../commands/worktree.js";
|
} from "../commands/worktree.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
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", () => {
|
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||||
expect(
|
expect(
|
||||||
rebindWorkspaceCwd({
|
rebindWorkspaceCwd({
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import {
|
||||||
|
|
||||||
type WorktreeInitOptions = {
|
type WorktreeInitOptions = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
color?: string;
|
||||||
instance?: string;
|
instance?: string;
|
||||||
home?: string;
|
home?: string;
|
||||||
fromConfig?: string;
|
fromConfig?: string;
|
||||||
|
|
@ -97,6 +98,16 @@ type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||||
startPoint?: string;
|
startPoint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorktreeReseedOptions = {
|
||||||
|
fromConfig?: string;
|
||||||
|
fromDataDir?: string;
|
||||||
|
fromInstance?: string;
|
||||||
|
home?: string;
|
||||||
|
seedMode?: string;
|
||||||
|
yes?: boolean;
|
||||||
|
seed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type WorktreeEnvOptions = {
|
type WorktreeEnvOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
|
@ -942,8 +953,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||||
instanceId,
|
instanceId,
|
||||||
});
|
});
|
||||||
const branding = {
|
const branding = {
|
||||||
name: worktreeName,
|
name: opts.name ?? worktreeName,
|
||||||
color: generateWorktreeColor(),
|
color: opts.color ?? generateWorktreeColor(),
|
||||||
};
|
};
|
||||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||||
|
|
@ -1051,6 +1062,104 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||||
await runWorktreeInit(opts);
|
await runWorktreeInit(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExplicitSourceSelection(opts: {
|
||||||
|
fromConfig?: string;
|
||||||
|
fromDataDir?: string;
|
||||||
|
fromInstance?: string;
|
||||||
|
sourceConfigPathOverride?: string;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(
|
||||||
|
nonEmpty(opts.fromConfig)
|
||||||
|
|| nonEmpty(opts.fromDataDir)
|
||||||
|
|| nonEmpty(opts.fromInstance)
|
||||||
|
|| nonEmpty(opts.sourceConfigPathOverride),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentWorktreeReseedState(opts: { home?: string } = {}) {
|
||||||
|
const currentConfigPath = resolveConfigPath();
|
||||||
|
if (!existsSync(currentConfigPath)) {
|
||||||
|
throw new Error(
|
||||||
|
"Current directory does not have a Paperclip worktree config. Run `paperclipai worktree init` here first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentConfig = readConfig(currentConfigPath);
|
||||||
|
if (!currentConfig) {
|
||||||
|
throw new Error(`Could not read current worktree config at ${currentConfigPath}.`);
|
||||||
|
}
|
||||||
|
if (currentConfig.database.mode !== "embedded-postgres") {
|
||||||
|
throw new Error("Worktree reseed only supports embedded-postgres worktree instances.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(currentConfigPath));
|
||||||
|
const instanceRoot = path.dirname(currentConfig.database.embeddedPostgresDataDir);
|
||||||
|
const derivedHomeDir = path.dirname(path.dirname(instanceRoot));
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentConfigPath: path.resolve(currentConfigPath),
|
||||||
|
instanceId:
|
||||||
|
nonEmpty(currentEnvEntries.PAPERCLIP_INSTANCE_ID)
|
||||||
|
?? nonEmpty(path.basename(instanceRoot))
|
||||||
|
?? sanitizeWorktreeInstanceId(path.basename(process.cwd())),
|
||||||
|
homeDir: path.resolve(expandHomePrefix(opts.home ?? currentEnvEntries.PAPERCLIP_HOME ?? derivedHomeDir)),
|
||||||
|
serverPort: currentConfig.server.port,
|
||||||
|
dbPort: currentConfig.database.embeddedPostgresPort,
|
||||||
|
worktreeName: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_NAME) ?? undefined,
|
||||||
|
worktreeColor: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_COLOR) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||||
|
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<void> {
|
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
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")
|
.option("--json", "Print JSON instead of shell exports")
|
||||||
.action(worktreeEnvCommand);
|
.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 <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("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||||
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||||
|
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||||
|
.action(worktreeReseedCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("worktree:list")
|
.command("worktree:list")
|
||||||
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
||||||
|
|
|
||||||
|
|
@ -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/<worktree-id>/`, and preserves the git worktree contents themselves.
|
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:
|
||||||
|
|
||||||
|
```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 <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.
|
**`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.
|
||||||
|
|
||||||
| Option | Description |
|
| 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 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 |
|
| Option | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-c, --config <path>` | Path to config file |
|
| `-c, --config <path>` | Path to config file |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue