Add worktree reseed command

This commit is contained in:
dotta
2026-04-07 17:02:34 -05:00
parent 7dd3661467
commit 34589ad457
3 changed files with 247 additions and 2 deletions

View File

@@ -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({

View File

@@ -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")