Clarify worktree import source and target flags
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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 (existsSync(directPath)) {
|
||||||
if (sourceArg && sourceArg.trim().length > 0) {
|
if (allowCurrent && directPath === currentEndpoint.rootPath) {
|
||||||
const directPath = path.resolve(sourceArg);
|
return currentEndpoint;
|
||||||
if (existsSync(directPath)) {
|
|
||||||
return directPath;
|
|
||||||
}
|
}
|
||||||
|
const configPath = path.resolve(directPath, ".paperclip", "config.json");
|
||||||
const matched = candidates.find((choice) =>
|
if (!existsSync(configPath)) {
|
||||||
choice.worktree === path.resolve(sourceArg)
|
throw new Error(`Resolved worktree path ${directPath} does not contain .paperclip/config.json.`);
|
||||||
|| path.basename(choice.worktree) === sourceArg
|
|
||||||
|| choice.branchLabel === sourceArg,
|
|
||||||
);
|
|
||||||
if (matched) {
|
|
||||||
return matched.worktree;
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
rootPath: directPath,
|
||||||
|
configPath,
|
||||||
|
label: path.basename(directPath),
|
||||||
|
isCurrent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = choices.find((choice) =>
|
||||||
|
(allowCurrent || !choice.isCurrent)
|
||||||
|
&& (choice.worktree === directPath
|
||||||
|
|| path.basename(choice.worktree) === trimmed
|
||||||
|
|| choice.branchLabel === trimmed),
|
||||||
|
);
|
||||||
|
if (!matched) {
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user