Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/cli/src/commands/split.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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)}`);
}
9 changes: 9 additions & 0 deletions apps/cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -134,6 +137,12 @@ export const HANDLERS: Record<string, CommandHandler> = {
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(),
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/src/utils/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,31 @@ export async function confirm(
return result === "yes";
}

export async function textInput(message: string): Promise<string | null> {
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<T extends string>(
message: string,
options: { label: string; value: T; hint?: string }[],
Expand Down
218 changes: 218 additions & 0 deletions packages/core/src/commands/split.ts
Original file line number Diff line number Diff line change
@@ -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 "<description>" <paths...>` under the hood.
*/
export async function split(
options: SplitOptions,
): Promise<Result<SplitResult>> {
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<string, string> = {
M: "modified",
A: "added",
D: "deleted",
R: "renamed",
};
return { path, status: statusMap[statusChar] ?? statusChar };
});

return ok(files);
}

export const splitCommand: Command<SplitResult, [SplitOptions]> = {
meta: {
name: "split",
args: "<paths...>",
description:
"Split files from the parent change into a new change below it",
aliases: ["sp"],
category: "management",
},
run: split,
};
Loading