Skip to content

Commit 8f86f9a

Browse files
committed
more improvements
1 parent 470ab91 commit 8f86f9a

38 files changed

+3083
-356
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"typecheck": "turbo typecheck",
1818
"lint": "biome check --write --unsafe",
1919
"format": "biome format --write",
20-
"test": "pnpm -r test",
21-
"test:bun": "pnpm --filter @array/core --filter @array/cli test",
20+
"test": "turbo test",
21+
"test:bun": "turbo test --filter=@array/core --filter=@array/cli",
2222
"test:vitest": "pnpm --filter array --filter @posthog/electron-trpc test",
2323
"clean": "pnpm -r clean",
2424
"knip": "knip",

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
1111
"dev": "bun run ./bin/arr.ts",
1212
"typecheck": "tsc --noEmit",
13-
"test": "bun test tests/unit tests/e2e/cli.test.ts",
13+
"test": "bun test --concurrent tests/unit tests/e2e/cli.test.ts",
1414
"test:pty": "vitest run tests/e2e/pty.test.ts"
1515
},
1616
"devDependencies": {

packages/cli/src/cli.ts

Lines changed: 120 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import { JJ } from "@array/core";
1+
import {
2+
CATEGORY_LABELS,
3+
CATEGORY_ORDER,
4+
type CommandInfo,
5+
getCommandsByCategory,
6+
getCoreCommands,
7+
JJ,
8+
} from "@array/core";
29
import { abandon } from "./commands/abandon";
310
import { auth } from "./commands/auth";
411
import { bottom } from "./commands/bottom";
512
import { checkout } from "./commands/checkout";
613
import { config } from "./commands/config";
714
import { conflicts } from "./commands/conflicts";
15+
import { continueCommand } from "./commands/continue";
816
import { create } from "./commands/create";
17+
import { deleteChange } from "./commands/delete";
918
import { disable } from "./commands/disable";
1019
import { down } from "./commands/down";
1120
import { edit } from "./commands/edit";
@@ -14,8 +23,10 @@ import { exit } from "./commands/exit";
1423
import { init } from "./commands/init";
1524
import { list } from "./commands/list";
1625
import { log } from "./commands/log";
26+
import { merge } from "./commands/merge";
1727
import { modify } from "./commands/modify";
1828
import { restack } from "./commands/restack";
29+
import { stacks } from "./commands/stacks";
1930
import { status } from "./commands/status";
2031
import { submit } from "./commands/submit";
2132
import { swap } from "./commands/swap";
@@ -37,60 +48,97 @@ import {
3748
resolveCommandAlias,
3849
} from "./utils/parser";
3950

51+
const CMD_WIDTH = 22;
52+
53+
const TAGLINE = `Array (arr) is a CLI for stacked PRs using Jujutsu (jj).
54+
It enables stacking changes on top of each other to keep you unblocked
55+
and your changes small, focused, and reviewable.`;
56+
57+
const USAGE = `${bold("USAGE")}
58+
$ arr <command> [flags]`;
59+
60+
const TERMS = `${bold("TERMS")}
61+
stack: A sequence of changes, each building off of its parent.
62+
ex: main <- "add API" <- "update frontend" <- "docs"
63+
trunk: The branch that stacks are merged into (e.g., main).
64+
change: A jj commit/revision. Unlike git, jj tracks the working
65+
copy as a change automatically.`;
66+
67+
const GLOBAL_OPTIONS = `${bold("GLOBAL OPTIONS")}
68+
--help Show help for a command.
69+
--help --all Show full command reference.
70+
--version Show arr version number.`;
71+
72+
const JJ_PASSTHROUGH = `${bold("JJ PASSTHROUGH")}
73+
Unrecognized commands are passed directly to jj (e.g., arr diff, arr show).`;
74+
75+
const DOCS = `${bold("DOCS")}
76+
Get started: https://github.com/posthog/array`;
77+
78+
function formatCommand(cmd: CommandInfo): string {
79+
const full = cmd.args ? `${cmd.name} ${cmd.args}` : cmd.name;
80+
const aliasStr = cmd.aliases?.length
81+
? ` ${dim(`[aliases: ${cmd.aliases.join(", ")}]`)}`
82+
: "";
83+
return ` ${cyan(full.padEnd(CMD_WIDTH))}${cmd.description}.${aliasStr}`;
84+
}
85+
86+
function formatCoreCommand(cmd: CommandInfo): string {
87+
const full = cmd.args ? `${cmd.name} ${cmd.args}` : cmd.name;
88+
return ` ${cyan(full.padEnd(CMD_WIDTH))}${cmd.description}.`;
89+
}
90+
4091
function printHelp(): void {
41-
console.log(`
42-
${bold("arr")} - Stacked PRs and changeset management with jj
43-
44-
${bold("USAGE:")}
45-
arr <command> [options]
46-
47-
${bold("STACKING COMMANDS:")}
48-
${cyan("create")} <desc> Create a new stacked change
49-
${cyan("create")} -a <desc> Commit all changes, start new change
50-
${cyan("modify")} Save current changes (auto with jj)
51-
${cyan("checkout")} <id> Switch to any change
52-
${cyan("up")} Move up the stack (to child)
53-
${cyan("down")} Move down the stack (to parent)
54-
${cyan("top")} Jump to top of stack
55-
${cyan("bottom")} Jump to bottom of stack
56-
${cyan("log")} Show visual stack overview
57-
${cyan("submit")} Create PR for current change
58-
${cyan("submit")} --stack Submit entire stack as linked PRs
59-
${cyan("submit")} --draft Create draft PR(s)
60-
${cyan("sync")} Fetch, rebase stack, cleanup merged
61-
${cyan("restack")} Rebase stack onto main
62-
63-
${bold("CHANGESET COMMANDS:")}
64-
${cyan("status")} Show current preview state
65-
${cyan("list")} List all changesets
66-
${cyan("enable")} <id...> Enable changeset(s) in preview
67-
${cyan("disable")} <id...> Disable changeset(s) from preview
68-
${cyan("swap")} <id> Enable changeset, disable conflicts
69-
${cyan("edit")} <id> Edit a changeset directly
70-
${cyan("edit")} --done Exit editing, return to preview
71-
${cyan("abandon")} <id> Delete a changeset
72-
${cyan("conflicts")} [id...] Check for file conflicts
73-
${cyan("undo")} Undo last operation
74-
75-
${bold("SETUP:")}
76-
${cyan("init")} Initialize Array in this repo
77-
${cyan("auth")} Authenticate with GitHub
78-
${cyan("config")} Configure preferences
79-
80-
${bold("OTHER:")}
81-
${cyan("exit")} Exit Array, return to normal git
82-
${cyan("help")} Show this help message
83-
${cyan("version")} Show version
84-
85-
${bold("ALIASES:")}
86-
${cyan("c")} = create, ${cyan("s")} = submit, ${cyan("m")} = modify, ${cyan("l")} = log
87-
${cyan("co")} = checkout, ${cyan("e")} = enable, ${cyan("d")} = disable, ${cyan("st")} = status
88-
89-
${bold("JJ PASSTHROUGH:")}
90-
Any unrecognized command is passed to jj directly.
91-
Example: arr diff, arr rebase
92-
93-
${dim("For more info: https://github.com/posthog/array")}
92+
const coreCommands = getCoreCommands();
93+
94+
console.log(`${TAGLINE}
95+
96+
${USAGE}
97+
98+
${TERMS}
99+
100+
${bold("CORE COMMANDS")}
101+
${coreCommands.map(formatCoreCommand).join("\n")}
102+
103+
Run ${cyan("arr --help --all")} for a full command reference.
104+
Pass --help to any command for details (e.g., ${cyan("arr submit --help")})
105+
106+
${bold("CORE WORKFLOW")}
107+
1. ${cyan('arr create "add user auth"')} Create a new change
108+
2. ${dim("(make edits - jj tracks automatically)")}
109+
3. ${cyan('arr create "add tests"')} Stack another change
110+
4. ${cyan("arr submit --stack")} Create PRs for the stack
111+
5. ${cyan("arr sync")} Fetch & rebase after reviews
112+
113+
${bold("LEARN MORE")}
114+
Documentation: https://github.com/posthog/array
115+
jj documentation: https://www.jj-vcs.dev/latest/
116+
`);
117+
}
118+
119+
function printHelpAll(): void {
120+
const hidden = new Set(["help", "version", "config"]);
121+
const sections = CATEGORY_ORDER.map((category) => {
122+
const commands = getCommandsByCategory(category).filter(
123+
(c) => !hidden.has(c.name),
124+
);
125+
if (commands.length === 0) return "";
126+
return `${bold(CATEGORY_LABELS[category])}\n${commands.map(formatCommand).join("\n")}`;
127+
}).filter(Boolean);
128+
129+
console.log(`${TAGLINE}
130+
131+
${USAGE}
132+
133+
${TERMS}
134+
135+
${sections.join("\n\n")}
136+
137+
${GLOBAL_OPTIONS}
138+
139+
${JJ_PASSTHROUGH}
140+
141+
${DOCS}
94142
`);
95143
}
96144

@@ -161,7 +209,11 @@ export async function main(): Promise<void> {
161209
const command = resolveCommandAlias(parsed.name);
162210

163211
if (parsed.flags.help || parsed.flags.h) {
164-
printHelp();
212+
if (parsed.flags.all) {
213+
printHelpAll();
214+
} else {
215+
printHelp();
216+
}
165217
return;
166218
}
167219

@@ -193,7 +245,7 @@ export async function main(): Promise<void> {
193245
break;
194246
}
195247
case "init":
196-
await init();
248+
await init(parsed.flags);
197249
break;
198250
case "auth":
199251
await auth();
@@ -258,6 +310,18 @@ export async function main(): Promise<void> {
258310
case "checkout":
259311
await checkout(parsed.args[0]);
260312
break;
313+
case "delete":
314+
await deleteChange(parsed.args[0]);
315+
break;
316+
case "continue":
317+
await continueCommand(parsed.flags);
318+
break;
319+
case "merge":
320+
await merge(parsed.flags);
321+
break;
322+
case "stacks":
323+
await stacks();
324+
break;
261325
case "undo":
262326
await undo();
263327
break;

packages/cli/src/commands/checkout.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
changeLabel,
3-
datePrefixedLabel,
4-
filterRootChanges,
5-
JJ,
6-
} from "@array/core";
1+
import { changeLabel, filterRootChanges, JJ } from "@array/core";
72
import { cyan, dim, formatError, formatSuccess } from "../utils/output";
83

94
export async function checkout(id: string): Promise<void> {
@@ -25,26 +20,37 @@ export async function checkout(id: string): Promise<void> {
2520
return;
2621
}
2722

28-
const listResult = await jj.list({ revset: "all()" });
23+
// Use jj revset for searching - description substring (case-insensitive)
24+
const escaped = id.replace(/"/g, '\\"');
25+
const revset = `description(substring-i:"${escaped}")`;
26+
27+
const listResult = await jj.list({ revset });
2928
if (!listResult.ok) {
30-
console.error(formatError(listResult.error.message));
29+
console.error(formatError(`No changes matching: ${id}`));
3130
process.exit(1);
3231
}
3332

34-
const changesets = filterRootChanges(listResult.value);
33+
const matches = filterRootChanges(listResult.value);
3534

36-
// Match by change ID prefix or by date-prefixed label
37-
const changeset = changesets.find((cs) => {
38-
if (cs.changeId.startsWith(id)) return true;
39-
const label = datePrefixedLabel(cs.description, cs.timestamp);
40-
return label === id || label.startsWith(id);
41-
});
35+
if (matches.length === 0) {
36+
console.error(formatError(`No changes matching: ${id}`));
37+
process.exit(1);
38+
}
4239

43-
if (!changeset) {
44-
console.error(formatError(`Unknown changeset: ${id}`));
40+
if (matches.length > 1) {
41+
console.log(`Multiple matches for "${id}":\n`);
42+
for (const cs of matches) {
43+
const label = changeLabel(cs.description, cs.changeId);
44+
console.log(
45+
` ${cyan(label)}: ${cs.description || dim("(no description)")}`,
46+
);
47+
}
48+
console.log(dim("\nUse a more specific query or the full change ID."));
4549
process.exit(1);
4650
}
4751

52+
const changeset = matches[0];
53+
4854
const result = await jj.edit(changeset.changeId);
4955
if (!result.ok) {
5056
console.error(formatError(result.error.message));
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { JJ } from "@array/core";
2+
import { cyan, dim, formatError, formatSuccess, yellow } from "../utils/output";
3+
4+
interface ContinueFlags {
5+
resolve?: boolean;
6+
}
7+
8+
export async function continueCommand(
9+
flags: ContinueFlags = {},
10+
): Promise<void> {
11+
const jj = new JJ({ cwd: process.cwd() });
12+
13+
const status = await jj.status();
14+
if (!status.ok) {
15+
console.error(formatError(status.error.message));
16+
process.exit(1);
17+
}
18+
19+
const { workingCopy, conflicts } = status.value;
20+
21+
if (!workingCopy.hasConflicts && conflicts.length === 0) {
22+
console.log(formatSuccess("No conflicts to resolve"));
23+
return;
24+
}
25+
26+
if (flags.resolve) {
27+
const result = await jj.raw(["resolve"]);
28+
if (!result.ok) {
29+
console.error(formatError(result.error.message));
30+
process.exit(1);
31+
}
32+
33+
const newStatus = await jj.status();
34+
if (newStatus.ok && !newStatus.value.workingCopy.hasConflicts) {
35+
console.log(formatSuccess("All conflicts resolved"));
36+
} else {
37+
console.log(yellow("Some conflicts remain"));
38+
if (newStatus.ok) {
39+
for (const conflict of newStatus.value.conflicts) {
40+
console.log(dim(` ${conflict.path}`));
41+
}
42+
}
43+
}
44+
return;
45+
}
46+
47+
console.log(yellow(`${conflicts.length} conflicted file(s):`));
48+
for (const conflict of conflicts) {
49+
console.log(dim(` ${conflict.path}`));
50+
}
51+
console.log();
52+
console.log(
53+
dim("Resolve conflicts by editing the files, then run any arr command."),
54+
);
55+
console.log(
56+
dim(`Or run ${cyan("arr continue --resolve")} to use a merge tool.`),
57+
);
58+
}

0 commit comments

Comments
 (0)