diff --git a/apps/cli/src/commands/split.ts b/apps/cli/src/commands/split.ts new file mode 100644 index 00000000..9342dea5 --- /dev/null +++ b/apps/cli/src/commands/split.ts @@ -0,0 +1,75 @@ +import { + getSplittableFiles, + split as splitCmd, +} from "@array/core/commands/split"; +import type { ArrContext } from "@array/core/engine"; +import { cyan, dim, formatSuccess, hint, message, red } from "../utils/output"; +import { textInput } from "../utils/prompt"; +import { unwrap } from "../utils/run"; + +export async function split( + paths: string[], + options: { message?: string }, + ctx: ArrContext, +): Promise { + // Get splittable files for validation/preview + const filesResult = unwrap(await getSplittableFiles()); + + if (filesResult.length === 0) { + message(`${red("error:")} No files in parent change to split`); + return; + } + + if (paths.length === 0) { + message(`${red("error:")} No paths provided to split`); + hint(`Files in parent: ${filesResult.map((f) => f.path).join(", ")}`); + return; + } + + // Show preview of what will be split + const matchingFiles = filesResult.filter((f) => + paths.some((p) => f.path === p || f.path.startsWith(`${p}/`)), + ); + + if (matchingFiles.length === 0) { + message( + `${red("error:")} None of the specified paths match files in parent change`, + ); + hint(`Files in parent: ${filesResult.map((f) => f.path).join(", ")}`); + return; + } + + message( + `Splitting ${cyan(String(matchingFiles.length))} file${matchingFiles.length === 1 ? "" : "s"} into new change:`, + ); + for (const file of matchingFiles) { + console.log(` ${dim(file.status)} ${file.path}`); + } + console.log(); + + // Get description - from option or prompt + let description = options.message; + if (!description) { + const input = await textInput("Description for new change"); + if (!input) { + message(`${red("error:")} Description is required`); + return; + } + description = input; + } + + const result = unwrap( + await splitCmd({ + paths, + description, + engine: ctx.engine, + }), + ); + + message( + formatSuccess( + `Split ${cyan(String(result.fileCount))} file${result.fileCount === 1 ? "" : "s"} into "${result.description}"`, + ), + ); + hint(`Tracking: ${cyan(result.bookmarkName)}`); +} diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index d04e078f..632fb3dd 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -8,6 +8,7 @@ import { mergeCommand } from "@array/core/commands/merge"; import { modifyCommand } from "@array/core/commands/modify"; import { resolveCommand } from "@array/core/commands/resolve"; import { restackCommand } from "@array/core/commands/restack"; +import { splitCommand } from "@array/core/commands/split"; import { squashCommand } from "@array/core/commands/squash"; import { statusCommand } from "@array/core/commands/status"; import { submitCommand } from "@array/core/commands/submit"; @@ -36,6 +37,7 @@ import { merge } from "./commands/merge"; import { modify } from "./commands/modify"; import { resolve } from "./commands/resolve"; import { restack } from "./commands/restack"; +import { split } from "./commands/split"; import { squash } from "./commands/squash"; import { status } from "./commands/status"; import { submit } from "./commands/submit"; @@ -102,6 +104,7 @@ export const COMMANDS = { delete: deleteCommand.meta, modify: modifyCommand.meta, resolve: resolveCommand.meta, + split: splitCommand.meta, squash: squashCommand.meta, merge: mergeCommand.meta, undo: undoCommand.meta, @@ -134,6 +137,12 @@ export const HANDLERS: Record = { deleteChange(p.args[0], ctx!, { yes: !!p.flags.yes || !!p.flags.y }), modify: () => modify(), resolve: () => resolve(), + split: (p, ctx) => + split( + p.args, + { message: (p.flags.message ?? p.flags.m) as string | undefined }, + ctx!, + ), squash: (p, ctx) => squash(p.args[0], ctx!), merge: (p, ctx) => merge(p.flags, ctx!), undo: () => undo(), diff --git a/apps/cli/src/utils/args.ts b/apps/cli/src/utils/args.ts index 5026c4a1..a1742c0a 100644 --- a/apps/cli/src/utils/args.ts +++ b/apps/cli/src/utils/args.ts @@ -23,7 +23,14 @@ export function parseArgs(argv: string[]): ParsedCommand { flags[key] = true; } } else if (arg.startsWith("-") && arg.length === 2) { - flags[arg.slice(1)] = true; + const key = arg.slice(1); + const nextArg = allArgs[i + 1]; + if (nextArg && !nextArg.startsWith("-")) { + flags[key] = nextArg; + i++; + } else { + flags[key] = true; + } } else if (command === "__guided") { command = arg; } else { diff --git a/apps/cli/src/utils/prompt.ts b/apps/cli/src/utils/prompt.ts index 1817cc93..675c208d 100644 --- a/apps/cli/src/utils/prompt.ts +++ b/apps/cli/src/utils/prompt.ts @@ -57,6 +57,31 @@ export async function confirm( return result === "yes"; } +export async function textInput(message: string): Promise { + const { stdin, stdout } = process; + const readline = await import("node:readline"); + + if (!stdin.isTTY) { + return null; + } + + const rl = readline.createInterface({ + input: stdin, + output: stdout, + }); + + return new Promise((resolve) => { + rl.question(`${cyan("?")} ${bold(message)} ${dim("›")} `, (answer) => { + rl.close(); + if (answer.trim() === "") { + resolve(null); + } else { + resolve(answer.trim()); + } + }); + }); +} + export async function select( message: string, options: { label: string; value: T; hint?: string }[], diff --git a/packages/core/src/commands/split.ts b/packages/core/src/commands/split.ts new file mode 100644 index 00000000..c1c23ff0 --- /dev/null +++ b/packages/core/src/commands/split.ts @@ -0,0 +1,218 @@ +import { resolveBookmarkConflict } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { ensureBookmark, list, runJJ, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import type { Command } from "./types"; + +interface SplitResult { + /** Number of files that were split out */ + fileCount: number; + /** The paths that were split out */ + paths: string[]; + /** Description of the new commit (the split-out changes) */ + description: string; + /** Bookmark name for the split-out commit */ + bookmarkName: string; + /** Change ID of the split-out commit */ + changeId: string; +} + +interface SplitOptions { + /** File paths to split out into a new commit */ + paths: string[]; + /** Description for the new commit containing the split-out changes */ + description: string; + /** Engine for tracking */ + engine: Engine; +} + +/** + * Split the parent change by moving specified files into a new grandparent. + * Like `arr modify`, this targets the parent (the change you're "on"). + * + * Before: trunk -> parent (with all changes) -> WC (empty) + * After: trunk -> new (selected files) -> parent (remaining) -> WC (empty) + * + * Uses `jj split -r @- -m "" ` under the hood. + */ +export async function split( + options: SplitOptions, +): Promise> { + const { paths, description, engine } = options; + + if (paths.length === 0) { + return err(createError("INVALID_STATE", "No paths provided to split")); + } + + if (!description.trim()) { + return err( + createError("INVALID_STATE", "Description is required for split"), + ); + } + + // Get current status + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const { parents, modifiedFiles } = statusResult.value; + + // If WC has changes, tell user to create first + if (modifiedFiles.length > 0) { + return err( + createError( + "INVALID_STATE", + 'You have uncommitted changes. Run `arr create "message"` first.', + ), + ); + } + + // Get the parent (the change we're splitting) + const parent = parents[0]; + if (!parent) { + return err(createError("INVALID_STATE", "No parent change to split")); + } + + if (parent.isEmpty) { + return err(createError("INVALID_STATE", "Cannot split an empty change")); + } + + // Get the parent's modified files + const parentDiffResult = await runJJ(["diff", "-r", "@-", "--summary"]); + if (!parentDiffResult.ok) return parentDiffResult; + + const parentFiles = parentDiffResult.value.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const status = line[0]; + const path = line.slice(2).trim(); + return { status, path }; + }); + + if (parentFiles.length === 0) { + return err( + createError("INVALID_STATE", "Parent change has no files to split"), + ); + } + + // Check if any of the specified paths match parent's files + const changedPaths = new Set(parentFiles.map((f) => f.path)); + const matchingPaths: string[] = []; + + for (const path of paths) { + // Check for exact match or prefix match (for directories) + const matches = parentFiles.filter( + (f) => f.path === path || f.path.startsWith(`${path}/`), + ); + if (matches.length > 0) { + matchingPaths.push(...matches.map((m) => m.path)); + } else if (!changedPaths.has(path)) { + return err( + createError( + "INVALID_STATE", + `Path "${path}" is not in the parent change's files`, + ), + ); + } else { + matchingPaths.push(path); + } + } + + const uniquePaths = [...new Set(matchingPaths)]; + + // Generate bookmark name for the split-out commit + const timestamp = new Date(); + const initialBookmarkName = datePrefixedLabel(description, timestamp); + + // Check GitHub for name conflicts + const conflictResult = await resolveBookmarkConflict(initialBookmarkName); + if (!conflictResult.ok) return conflictResult; + + const bookmarkName = conflictResult.value.resolvedName; + + // Run jj split on the parent (-r @-) with the description and paths + const splitResult = await runJJ([ + "split", + "-r", + "@-", + "-m", + description.trim(), + ...uniquePaths, + ]); + + if (!splitResult.ok) return splitResult; + + // After split on @-, the new structure is: + // grandparent (split-out) -> parent (remaining, keeps bookmark) -> WC + // So the split-out commit is the grandparent (parent of @-) + const grandparentResult = await list({ revset: "@--" }); + if (!grandparentResult.ok) return grandparentResult; + + const splitChangeId = grandparentResult.value[0]?.changeId; + if (!splitChangeId) { + return err(createError("INVALID_STATE", "Could not find split change")); + } + + // Create bookmark on the split-out commit + const bookmarkResult = await ensureBookmark(bookmarkName, splitChangeId); + if (!bookmarkResult.ok) return bookmarkResult; + + // Export to git + const exportResult = await runJJ(["git", "export"]); + if (!exportResult.ok) return exportResult; + + // Track the new bookmark in the engine + const refreshResult = await engine.refreshFromJJ(bookmarkName); + if (!refreshResult.ok) return refreshResult; + + return ok({ + fileCount: uniquePaths.length, + paths: uniquePaths, + description: description.trim(), + bookmarkName, + changeId: splitChangeId, + }); +} + +/** + * Get the list of files in the parent change that can be split. + * Returns the parent's files (since split targets @-). + */ +export async function getSplittableFiles(): Promise< + Result<{ path: string; status: string }[]> +> { + const parentDiffResult = await runJJ(["diff", "-r", "@-", "--summary"]); + if (!parentDiffResult.ok) return parentDiffResult; + + const files = parentDiffResult.value.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const statusChar = line[0]; + const path = line.slice(2).trim(); + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + }; + return { path, status: statusMap[statusChar] ?? statusChar }; + }); + + return ok(files); +} + +export const splitCommand: Command = { + meta: { + name: "split", + args: "", + description: + "Split files from the parent change into a new change below it", + aliases: ["sp"], + category: "management", + }, + run: split, +};