Clarify worktree import source and target flags

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 15:39:02 -05:00
parent 28a5f858b7
commit ad011fbf1e

View File

@@ -92,6 +92,8 @@ type WorktreeListOptions = {
}; };
type WorktreeMergeHistoryOptions = { type WorktreeMergeHistoryOptions = {
from?: string;
to?: string;
company?: string; company?: string;
scope?: string; scope?: string;
apply?: boolean; apply?: boolean;
@@ -872,6 +874,13 @@ type MergeSourceChoice = {
isCurrent: boolean; isCurrent: boolean;
}; };
type ResolvedWorktreeEndpoint = {
rootPath: string;
configPath: string;
label: string;
isCurrent: boolean;
};
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd, cwd,
@@ -1139,6 +1148,15 @@ async function closeDb(db: ClosableDb): Promise<void> {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
} }
function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint {
return {
rootPath: path.resolve(process.cwd()),
configPath: resolveConfigPath(),
label: "current",
isCurrent: true,
};
}
async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> { async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> {
const config = readConfig(configPath); const config = readConfig(configPath);
if (!config) { if (!config) {
@@ -1224,12 +1242,14 @@ async function resolveMergeCompany(input: {
function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"], extras: { function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"], extras: {
sourcePath: string; sourcePath: string;
targetPath: string;
unsupportedRunCount: number; unsupportedRunCount: number;
unsupportedDocumentCount: number; unsupportedDocumentCount: number;
}): string { }): string {
const lines = [ const lines = [
`Mode: preview`, `Mode: preview`,
`Source: ${extras.sourcePath}`, `Source: ${extras.sourcePath}`,
`Target: ${extras.targetPath}`,
`Company: ${plan.companyName} (${plan.issuePrefix})`, `Company: ${plan.companyName} (${plan.issuePrefix})`,
"", "",
"Issues", "Issues",
@@ -1455,46 +1475,93 @@ export async function worktreeListCommand(opts: WorktreeListOptions): Promise<vo
} }
} }
async function resolveMergeSourceRoot(sourceArg: string | undefined): Promise<string> { function resolveEndpointFromChoice(choice: MergeSourceChoice): ResolvedWorktreeEndpoint {
if (choice.isCurrent) {
return resolveCurrentEndpoint();
}
return {
rootPath: choice.worktree,
configPath: path.resolve(choice.worktree, ".paperclip", "config.json"),
label: choice.branchLabel,
isCurrent: false,
};
}
function resolveWorktreeEndpointFromSelector(
selector: string,
opts?: { allowCurrent?: boolean },
): ResolvedWorktreeEndpoint {
const trimmed = selector.trim();
const allowCurrent = opts?.allowCurrent !== false;
if (trimmed.length === 0) {
throw new Error("Worktree selector cannot be empty.");
}
const currentEndpoint = resolveCurrentEndpoint();
if (allowCurrent && trimmed === "current") {
return currentEndpoint;
}
const choices = toMergeSourceChoices(process.cwd()); const choices = toMergeSourceChoices(process.cwd());
const candidates = choices.filter((choice) => !choice.isCurrent && choice.hasPaperclipConfig); const directPath = path.resolve(trimmed);
if (sourceArg && sourceArg.trim().length > 0) {
const directPath = path.resolve(sourceArg);
if (existsSync(directPath)) { if (existsSync(directPath)) {
return directPath; if (allowCurrent && directPath === currentEndpoint.rootPath) {
return currentEndpoint;
}
const configPath = path.resolve(directPath, ".paperclip", "config.json");
if (!existsSync(configPath)) {
throw new Error(`Resolved worktree path ${directPath} does not contain .paperclip/config.json.`);
}
return {
rootPath: directPath,
configPath,
label: path.basename(directPath),
isCurrent: false,
};
} }
const matched = candidates.find((choice) => const matched = choices.find((choice) =>
choice.worktree === path.resolve(sourceArg) (allowCurrent || !choice.isCurrent)
|| path.basename(choice.worktree) === sourceArg && (choice.worktree === directPath
|| choice.branchLabel === sourceArg, || path.basename(choice.worktree) === trimmed
|| choice.branchLabel === trimmed),
); );
if (matched) { if (!matched) {
return matched.worktree;
}
throw new Error( throw new Error(
`Could not resolve source worktree "${sourceArg}". Use a path, a listed worktree directory name, or a listed branch name.`, `Could not resolve worktree "${selector}". Use a path, a listed worktree directory name, branch name, or "current".`,
); );
} }
if (!matched.hasPaperclipConfig && !matched.isCurrent) {
if (candidates.length === 0) { throw new Error(`Resolved worktree "${selector}" does not look like a Paperclip worktree.`);
throw new Error("No other Paperclip worktrees were found. Run `paperclipai worktree:list` to inspect the repo worktrees.");
} }
return resolveEndpointFromChoice(matched);
}
async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise<ResolvedWorktreeEndpoint> {
const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null;
const currentEndpoint = resolveCurrentEndpoint();
const choices = toMergeSourceChoices(process.cwd())
.filter((choice) => choice.hasPaperclipConfig || choice.isCurrent)
.filter((choice) => path.resolve(choice.worktree) !== excluded)
.map((choice) => ({
value: choice.isCurrent ? "__current__" : choice.worktree,
label: choice.branchLabel,
hint: `${choice.worktree}${choice.isCurrent ? " (current)" : ""}`,
}));
if (choices.length === 0) {
throw new Error("No Paperclip worktrees were found. Run `paperclipai worktree:list` to inspect the repo worktrees.");
}
const selection = await p.select<string>({ const selection = await p.select<string>({
message: "Choose the source worktree to import from", message: "Choose the source worktree to import from",
options: candidates.map((choice) => ({ options: choices,
value: choice.worktree,
label: choice.branchLabel,
hint: choice.worktree,
})),
}); });
if (p.isCancel(selection)) { if (p.isCancel(selection)) {
throw new Error("Source worktree selection cancelled."); throw new Error("Source worktree selection cancelled.");
} }
return selection; if (selection === "__current__") {
return currentEndpoint;
}
return resolveWorktreeEndpointFromSelector(selection, { allowCurrent: true });
} }
async function applyMergePlan(input: { async function applyMergePlan(input: {
@@ -1619,20 +1686,26 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
throw new Error("Use either --apply or --dry, not both."); throw new Error("Use either --apply or --dry, not both.");
} }
const sourceRoot = await resolveMergeSourceRoot(sourceArg); if (sourceArg && opts.from) {
const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json"); throw new Error("Use either the positional source argument or --from, not both.");
if (!existsSync(sourceConfigPath)) {
throw new Error(`Source worktree config not found at ${sourceConfigPath}.`);
} }
const targetConfigPath = resolveConfigPath(); const targetEndpoint = opts.to
if (path.resolve(sourceConfigPath) === path.resolve(targetConfigPath)) { ? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
throw new Error("Source and target Paperclip configs are the same. Point --source at a different worktree."); : resolveCurrentEndpoint();
const sourceEndpoint = opts.from
? resolveWorktreeEndpointFromSelector(opts.from, { allowCurrent: true })
: sourceArg
? resolveWorktreeEndpointFromSelector(sourceArg, { allowCurrent: true })
: await promptForSourceEndpoint(targetEndpoint.rootPath);
if (path.resolve(sourceEndpoint.configPath) === path.resolve(targetEndpoint.configPath)) {
throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to worktrees.");
} }
const scopes = parseWorktreeMergeScopes(opts.scope); const scopes = parseWorktreeMergeScopes(opts.scope);
const sourceHandle = await openConfiguredDb(sourceConfigPath); const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
const targetHandle = await openConfiguredDb(targetConfigPath); const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
try { try {
const company = await resolveMergeCompany({ const company = await resolveMergeCompany({
@@ -1664,7 +1737,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
} }
console.log(renderMergePlan(collected.plan, { console.log(renderMergePlan(collected.plan, {
sourcePath: sourceRoot, sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`,
targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`,
unsupportedRunCount: collected.unsupportedRunCount, unsupportedRunCount: collected.unsupportedRunCount,
unsupportedDocumentCount: collected.unsupportedDocumentCount, unsupportedDocumentCount: collected.unsupportedDocumentCount,
})); }));
@@ -1676,7 +1750,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
const confirmed = opts.yes const confirmed = opts.yes
? true ? true
: await p.confirm({ : await p.confirm({
message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${path.basename(sourceRoot)}?`, message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${sourceEndpoint.label} into ${targetEndpoint.label}?`,
initialValue: false, initialValue: false,
}); });
if (p.isCancel(confirmed) || !confirmed) { if (p.isCancel(confirmed) || !confirmed) {
@@ -1752,8 +1826,10 @@ export function registerWorktreeCommands(program: Command): void {
program program
.command("worktree:merge-history") .command("worktree:merge-history")
.description("Preview or import issue/comment history from another worktree into the current instance") .description("Preview or import issue/comment history from another worktree into the current instance")
.argument("[source]", "Optional source worktree path, directory name, or branch name") .argument("[source]", "Optional source worktree path, directory name, or branch name (back-compat alias for --from)")
.option("--company <id-or-prefix>", "Company id or issue prefix to import") .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("--company <id-or-prefix>", "Shared company id or issue prefix inside the chosen source/target instances")
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments") .option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
.option("--apply", "Apply the import after previewing the plan", false) .option("--apply", "Apply the import after previewing the plan", false)
.option("--dry", "Preview only and do not import anything", false) .option("--dry", "Preview only and do not import anything", false)