mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Add worktree reseed command
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<vo
|
||||
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> {
|
||||
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 <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
|
||||
.command("worktree:list")
|
||||
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
||||
|
||||
Reference in New Issue
Block a user