diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 400923b5..717c2235 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -87,6 +87,10 @@ type WorktreeEnvOptions = { json?: boolean; }; +type WorktreeListOptions = { + json?: boolean; +}; + type WorktreeMergeHistoryOptions = { company?: string; scope?: string; @@ -860,6 +864,14 @@ type GitWorktreeListEntry = { detached: boolean; }; +type MergeSourceChoice = { + worktree: string; + branch: string | null; + branchLabel: string; + hasPaperclipConfig: boolean; + isCurrent: boolean; +}; + function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd, @@ -898,6 +910,21 @@ function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { return entries; } +function toMergeSourceChoices(cwd: string): MergeSourceChoice[] { + const currentCwd = path.resolve(cwd); + return parseGitWorktreeList(cwd).map((entry) => { + const branchLabel = entry.branch?.replace(/^refs\/heads\//, "") ?? "(detached)"; + const worktreePath = path.resolve(entry.worktree); + return { + worktree: worktreePath, + branch: entry.branch, + branchLabel, + hasPaperclipConfig: existsSync(path.resolve(worktreePath, ".paperclip", "config.json")), + isCurrent: worktreePath === currentCwd, + }; + }); +} + function branchHasUniqueCommits(cwd: string, branchName: string): boolean { try { const output = execFileSync( @@ -1108,14 +1135,6 @@ type ResolvedMergeCompany = { issuePrefix: string; }; -function requirePathArgument(name: string, value: string | undefined): string { - const trimmed = nonEmpty(value); - if (!trimmed) { - throw new Error(`${name} is required.`); - } - return path.resolve(trimmed); -} - async function closeDb(db: ClosableDb): Promise { await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); } @@ -1420,6 +1439,64 @@ async function promptForProjectMappings(input: { return mappings; } +export async function worktreeListCommand(opts: WorktreeListOptions): Promise { + const choices = toMergeSourceChoices(process.cwd()); + if (opts.json) { + console.log(JSON.stringify(choices, null, 2)); + return; + } + + for (const choice of choices) { + const flags = [ + choice.isCurrent ? "current" : null, + choice.hasPaperclipConfig ? "paperclip" : "no-paperclip-config", + ].filter((value): value is string => value !== null); + p.log.message(`${choice.branchLabel} ${choice.worktree} [${flags.join(", ")}]`); + } +} + +async function resolveMergeSourceRoot(sourceArg: string | undefined): Promise { + const choices = toMergeSourceChoices(process.cwd()); + const candidates = choices.filter((choice) => !choice.isCurrent && choice.hasPaperclipConfig); + + if (sourceArg && sourceArg.trim().length > 0) { + const directPath = path.resolve(sourceArg); + if (existsSync(directPath)) { + return directPath; + } + + const matched = candidates.find((choice) => + choice.worktree === path.resolve(sourceArg) + || path.basename(choice.worktree) === sourceArg + || choice.branchLabel === sourceArg, + ); + if (matched) { + return matched.worktree; + } + + throw new Error( + `Could not resolve source worktree "${sourceArg}". Use a path, a listed worktree directory name, or a listed branch name.`, + ); + } + + if (candidates.length === 0) { + throw new Error("No other Paperclip worktrees were found. Run `paperclipai worktree:list` to inspect the repo worktrees."); + } + + const selection = await p.select({ + message: "Choose the source worktree to import from", + options: candidates.map((choice) => ({ + value: choice.worktree, + label: choice.branchLabel, + hint: choice.worktree, + })), + }); + if (p.isCancel(selection)) { + throw new Error("Source worktree selection cancelled."); + } + return selection; +} + async function applyMergePlan(input: { targetDb: ClosableDb; company: ResolvedMergeCompany; @@ -1537,12 +1614,12 @@ async function applyMergePlan(input: { }); } -export async function worktreeMergeHistoryCommand(sourceArg: string, opts: WorktreeMergeHistoryOptions): Promise { +export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, opts: WorktreeMergeHistoryOptions): Promise { if (opts.apply && opts.dry) { throw new Error("Use either --apply or --dry, not both."); } - const sourceRoot = requirePathArgument("Source worktree path", sourceArg); + const sourceRoot = await resolveMergeSourceRoot(sourceArg); const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json"); if (!existsSync(sourceConfigPath)) { throw new Error(`Source worktree config not found at ${sourceConfigPath}.`); @@ -1666,10 +1743,16 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + program + .command("worktree:list") + .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") + .option("--json", "Print JSON instead of text output") + .action(worktreeListCommand); + program .command("worktree:merge-history") .description("Preview or import issue/comment history from another worktree into the current instance") - .argument("", "Path to the source worktree root") + .argument("[source]", "Optional source worktree path, directory name, or branch name") .option("--company ", "Company id or issue prefix to import") .option("--scope ", "Comma-separated scopes to import (issues, comments)", "issues,comments") .option("--apply", "Apply the import after previewing the plan", false)