Add worktree source discovery commands
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -87,6 +87,10 @@ type WorktreeEnvOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorktreeListOptions = {
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type WorktreeMergeHistoryOptions = {
|
type WorktreeMergeHistoryOptions = {
|
||||||
company?: string;
|
company?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
@@ -860,6 +864,14 @@ type GitWorktreeListEntry = {
|
|||||||
detached: boolean;
|
detached: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MergeSourceChoice = {
|
||||||
|
worktree: string;
|
||||||
|
branch: string | null;
|
||||||
|
branchLabel: string;
|
||||||
|
hasPaperclipConfig: boolean;
|
||||||
|
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,
|
||||||
@@ -898,6 +910,21 @@ function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
|||||||
return entries;
|
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 {
|
function branchHasUniqueCommits(cwd: string, branchName: string): boolean {
|
||||||
try {
|
try {
|
||||||
const output = execFileSync(
|
const output = execFileSync(
|
||||||
@@ -1108,14 +1135,6 @@ type ResolvedMergeCompany = {
|
|||||||
issuePrefix: string;
|
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<void> {
|
async function closeDb(db: ClosableDb): Promise<void> {
|
||||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -1420,6 +1439,64 @@ async function promptForProjectMappings(input: {
|
|||||||
return mappings;
|
return mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function worktreeListCommand(opts: WorktreeListOptions): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string>({
|
||||||
|
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: {
|
async function applyMergePlan(input: {
|
||||||
targetDb: ClosableDb;
|
targetDb: ClosableDb;
|
||||||
company: ResolvedMergeCompany;
|
company: ResolvedMergeCompany;
|
||||||
@@ -1537,12 +1614,12 @@ async function applyMergePlan(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeMergeHistoryCommand(sourceArg: string, opts: WorktreeMergeHistoryOptions): Promise<void> {
|
export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, opts: WorktreeMergeHistoryOptions): Promise<void> {
|
||||||
if (opts.apply && opts.dry) {
|
if (opts.apply && opts.dry) {
|
||||||
throw new Error("Use either --apply or --dry, not both.");
|
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");
|
const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json");
|
||||||
if (!existsSync(sourceConfigPath)) {
|
if (!existsSync(sourceConfigPath)) {
|
||||||
throw new Error(`Source worktree config not found at ${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")
|
.option("--json", "Print JSON instead of shell exports")
|
||||||
.action(worktreeEnvCommand);
|
.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
|
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>", "Path to the source worktree root")
|
.argument("[source]", "Optional source worktree path, directory name, or branch name")
|
||||||
.option("--company <id-or-prefix>", "Company id or issue prefix to import")
|
.option("--company <id-or-prefix>", "Company id or issue prefix to import")
|
||||||
.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)
|
||||||
|
|||||||
Reference in New Issue
Block a user