Skip to content

Commit 523858d

Browse files
committed
feat: add @array/cli app
1 parent ac67cf8 commit 523858d

42 files changed

Lines changed: 3160 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
> [!IMPORTANT] > `arr` is still in development and not production-ready. Interested? Email jonathan@posthog.com
2+
3+
# arr
4+
5+
arr is CLI for stacked PR management using Jujutsu (`jj`).
6+
7+
Split your work into small changes, push them as a PR stack, and keep everything in sync.
8+
9+
## Install
10+
11+
Requires [Bun](https://bun.sh).
12+
13+
```
14+
git clone https://github.com/posthog/array
15+
cd array
16+
pnpm install
17+
pnpm --filter @array/core build
18+
```
19+
20+
Then install the `arr` command (symlinked to `~/bin/arr`):
21+
22+
```
23+
./apps/cli/arr.sh install
24+
```
25+
26+
## Usage
27+
28+
```
29+
arr init # set up arr in a git repo
30+
arr create "message" # new change on stack
31+
arr submit # push stack, create PRs
32+
arr merge # merge stack of PRs
33+
arr sync # fetch, rebase, cleanup merged
34+
arr up / arr down # navigate stack
35+
arr log # show stack
36+
arr exit # back to git
37+
arr help --all # show all commands
38+
```
39+
40+
## Example
41+
42+
```
43+
$ echo "user model" >> user_model.ts
44+
$ arr create "Add user model"
45+
✓ Created add-user-model-qtrsqm
46+
47+
$ echo "user api" >> user_api.ts
48+
$ arr create "Add user API"
49+
✓ Created add-user-api-nnmzrt
50+
51+
$ arr log
52+
◉ (working copy)
53+
│ Empty
54+
○ 12-23-add-user-api nnmzrtzz (+1, 1 file)
55+
│ Not submitted
56+
○ 12-23-add-user-model qtrsqmmy (+1, 1 file)
57+
│ Not submitted
58+
○ main
59+
60+
$ arr submit
61+
Created PR #8: 12-23-add-user-model
62+
https://github.com/username/your-repo/pull/8
63+
Created PR #9: 12-23-add-user-api
64+
https://github.com/username/your-repo/pull/9
65+
66+
$ arr merge
67+
...
68+
69+
$ arr sync
70+
```
71+
72+
Each change becomes a PR.
73+
Stacked PRs are explained through a generated comments so reviewers see the dependency.
74+
75+
## FAQ
76+
77+
**Can I use this with an existing `git` repo?**
78+
79+
Yes, do so by using `arr init` in any `git` repo. `jj` works alongside `git`.
80+
81+
**Do my teammates need to use `arr` or `jj`?**
82+
83+
No, your PRs are normal GitHub PRs. Teammates review and merge them as usual. `jj` has full support for `git`.
84+
85+
**What if I want to stop using `arr`?**
86+
87+
Run `arr exit` to switch back to `git`. Your repo, branches, and PRs stay exactly as they are.
88+
89+
## Learn more
90+
91+
- [`jj` documentation](https://jj-vcs.github.io/jj/latest/) - full `jj` reference
92+
- [`jj` tutorial](https://jj-vcs.github.io/jj/latest/tutorial/) - getting started with `jj`
93+
- `arr help`

apps/cli/arr.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# Wrapper script to run arr CLI via bun.
3+
SOURCE="${BASH_SOURCE[0]}"
4+
while [ -L "$SOURCE" ]; do
5+
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
6+
SOURCE="$(readlink "$SOURCE")"
7+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
8+
done
9+
SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
10+
11+
# Self-install: ./arr.sh install
12+
if [ "$1" = "install" ]; then
13+
mkdir -p ~/bin
14+
ln -sf "$SCRIPT_DIR/arr.sh" ~/bin/arr
15+
echo "Installed: ~/bin/arr -> $SCRIPT_DIR/arr.sh"
16+
echo "Make sure ~/bin is in your PATH"
17+
exit 0
18+
fi
19+
20+
exec bun run "$SCRIPT_DIR/bin/arr.ts" "$@"

apps/cli/bin/arr.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bun
2+
3+
import { main } from "../src/cli";
4+
5+
main()
6+
.then(() => process.exit(0))
7+
.catch((error) => {
8+
console.error(error);
9+
process.exit(1);
10+
});

apps/cli/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@array/cli",
3+
"version": "0.0.1",
4+
"description": "CLI for changeset management with jj",
5+
"bin": {
6+
"arr": "./bin/arr.ts"
7+
},
8+
"type": "module",
9+
"scripts": {
10+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
11+
"dev": "bun run ./bin/arr.ts",
12+
"typecheck": "tsc --noEmit",
13+
"test": "bun test --concurrent tests/unit tests/e2e/cli.test.ts",
14+
"test:pty": "vitest run tests/e2e/pty.test.ts"
15+
},
16+
"devDependencies": {
17+
"@types/bun": "latest",
18+
"@types/node": "^25.0.3",
19+
"typescript": "^5.5.0",
20+
"vitest": "^4.0.16"
21+
},
22+
"dependencies": {
23+
"@array/core": "workspace:*"
24+
}
25+
}

apps/cli/src/cli.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { triggerBackgroundRefresh } from "@array/core/background-refresh";
2+
import { type ArrContext, initContext } from "@array/core/engine";
3+
import { dumpRefs } from "./commands/hidden/dump-refs";
4+
import { refreshPRInfo } from "./commands/hidden/refresh-pr-info";
5+
import {
6+
CATEGORY_LABELS,
7+
CATEGORY_ORDER,
8+
COMMANDS as COMMAND_INFO,
9+
type CommandInfo,
10+
getCommandsByCategory,
11+
getCoreCommands,
12+
getRequiredContext,
13+
HANDLERS,
14+
resolveCommandAlias,
15+
} from "./registry";
16+
import { parseArgs } from "./utils/args";
17+
import {
18+
checkContext,
19+
isContextValid,
20+
printContextError,
21+
} from "./utils/context";
22+
import {
23+
arr,
24+
bold,
25+
cyan,
26+
dim,
27+
formatError,
28+
hint,
29+
message,
30+
} from "./utils/output";
31+
32+
const CLI_NAME = "arr";
33+
const CLI_VERSION = "0.0.1";
34+
const CMD_WIDTH = 22;
35+
36+
const TAGLINE = `arr is a CLI for stacked PRs using jj.
37+
It enables stacking changes on top of each other to keep you unblocked
38+
and your changes small, focused, and reviewable.`;
39+
40+
const USAGE = `${bold("USAGE")}
41+
$ arr <command> [flags]`;
42+
43+
const TERMS = `${bold("TERMS")}
44+
stack: A sequence of changes, each building off of its parent.
45+
ex: main <- "add API" <- "update frontend" <- "docs"
46+
trunk: The branch that stacks are merged into (e.g., main).
47+
change: A jj commit/revision. Unlike git, jj tracks the working
48+
copy as a change automatically.`;
49+
50+
const GLOBAL_OPTIONS = `${bold("GLOBAL OPTIONS")}
51+
--help Show help for a command.
52+
--help --all Show full command reference.
53+
--version Show arr version number.`;
54+
55+
const DOCS = `${bold("DOCS")}
56+
Get started: https://github.com/posthog/array`;
57+
58+
function formatCommand(
59+
c: CommandInfo,
60+
showAliases = true,
61+
showFlags = false,
62+
): string {
63+
const full = c.args ? `${c.name} ${c.args}` : c.name;
64+
const aliasStr =
65+
showAliases && c.aliases?.length
66+
? ` ${dim(`[aliases: ${c.aliases.join(", ")}]`)}`
67+
: "";
68+
let result = ` ${cyan(full.padEnd(CMD_WIDTH))}${c.description}.${aliasStr}`;
69+
70+
if (showFlags && c.flags?.length) {
71+
for (const flag of c.flags) {
72+
const flagName = flag.short
73+
? `-${flag.short}, --${flag.name}`
74+
: `--${flag.name}`;
75+
result += `\n ${dim(flagName.padEnd(CMD_WIDTH - 2))}${dim(flag.description)}`;
76+
}
77+
}
78+
79+
return result;
80+
}
81+
82+
function printHelp(): void {
83+
const coreCommands = getCoreCommands();
84+
85+
console.log(`${TAGLINE}
86+
87+
${USAGE}
88+
89+
${TERMS}
90+
91+
${bold("CORE COMMANDS")}
92+
${coreCommands.map((c) => formatCommand(c, false)).join("\n")}
93+
94+
Run ${arr(COMMAND_INFO.help, "--all")} for a full command reference.
95+
96+
${bold("CORE WORKFLOW")}
97+
1. ${dim("(make edits)")}\t\t\tno need to stage, jj tracks automatically
98+
2. ${arr(COMMAND_INFO.create, '"add user model"')}\tSave as a change
99+
3. ${dim("(make more edits)")}\t\t\tStack more work
100+
4. ${arr(COMMAND_INFO.create, '"add user api"')}\t\tSave as another change
101+
5. ${arr(COMMAND_INFO.submit)}\t\t\t\tCreate PRs for the stack
102+
6. ${arr(COMMAND_INFO.merge)}\t\t\t\tMerge PRs from the CLI
103+
7. ${arr(COMMAND_INFO.sync)}\t\t\t\tFetch & rebase after reviews
104+
105+
${bold("ESCAPE HATCH")}
106+
${arr(COMMAND_INFO.exit)}\t\t\t\tSwitch back to plain git if you need it.
107+
\t\t\t\t\tYour jj changes are preserved and you can return anytime.
108+
109+
${bold("LEARN MORE")}
110+
Documentation\t\t\thttps://github.com/posthog/array
111+
jj documentation\t\thttps://www.jj-vcs.dev/latest/
112+
`);
113+
}
114+
115+
function printHelpAll(): void {
116+
const hidden = new Set(["help", "version", "config"]);
117+
const sections = CATEGORY_ORDER.map((category) => {
118+
const commands = getCommandsByCategory(category).filter(
119+
(c) => !hidden.has(c.name),
120+
);
121+
if (commands.length === 0) return "";
122+
return `${bold(CATEGORY_LABELS[category])}\n${commands.map((c) => formatCommand(c, true, true)).join("\n")}`;
123+
}).filter(Boolean);
124+
125+
console.log(`${TAGLINE}
126+
127+
${USAGE}
128+
129+
${TERMS}
130+
131+
${sections.join("\n\n")}
132+
133+
${GLOBAL_OPTIONS}
134+
135+
${DOCS}
136+
`);
137+
}
138+
139+
function printVersion(): void {
140+
console.log(`${CLI_NAME} ${CLI_VERSION}`);
141+
}
142+
143+
export async function main(): Promise<void> {
144+
const parsed = parseArgs(Bun.argv);
145+
const command = resolveCommandAlias(parsed.name);
146+
147+
if (parsed.name && parsed.name !== command) {
148+
message(dim(`(${parsed.name}${command})`));
149+
}
150+
151+
if (parsed.flags.help || parsed.flags.h) {
152+
if (parsed.flags.all) {
153+
printHelpAll();
154+
} else {
155+
printHelp();
156+
}
157+
return;
158+
}
159+
160+
if (parsed.flags.version || parsed.flags.v) {
161+
printVersion();
162+
return;
163+
}
164+
165+
// No command provided - show help
166+
if (command === "__guided") {
167+
printHelp();
168+
return;
169+
}
170+
171+
// Built-in commands
172+
if (command === "help") {
173+
parsed.flags.all ? printHelpAll() : printHelp();
174+
return;
175+
}
176+
if (command === "version") {
177+
printVersion();
178+
return;
179+
}
180+
181+
// Hidden commands
182+
if (command === "__refresh-pr-info") {
183+
await refreshPRInfo();
184+
return;
185+
}
186+
if (command === "__dump-refs") {
187+
await dumpRefs();
188+
return;
189+
}
190+
191+
const handler = HANDLERS[command];
192+
if (handler) {
193+
const requiredLevel = getRequiredContext(command);
194+
195+
// Commands that don't need context (auth, help, etc.)
196+
if (requiredLevel === "none") {
197+
await handler(parsed, null);
198+
return;
199+
}
200+
201+
// Check prerequisites (git, jj, arr initialized)
202+
const debug = !!parsed.flags.debug;
203+
let t0 = Date.now();
204+
const prereqs = await checkContext();
205+
if (debug) console.log(` checkContext: ${Date.now() - t0}ms`);
206+
if (!isContextValid(prereqs, requiredLevel)) {
207+
printContextError(prereqs, requiredLevel);
208+
process.exit(1);
209+
}
210+
211+
// Initialize context with engine
212+
let context: ArrContext | null = null;
213+
try {
214+
t0 = Date.now();
215+
context = await initContext();
216+
if (debug) console.log(` initContext: ${Date.now() - t0}ms`);
217+
218+
// Trigger background PR refresh (rate-limited)
219+
triggerBackgroundRefresh(context.cwd);
220+
221+
t0 = Date.now();
222+
await handler(parsed, context);
223+
if (debug) console.log(` handler: ${Date.now() - t0}ms`);
224+
} finally {
225+
// Auto-persist engine changes
226+
context?.engine.persist();
227+
}
228+
return;
229+
}
230+
231+
console.error(formatError(`Unknown command: ${command}`));
232+
hint(`Run '${arr(COMMAND_INFO.help)}' to see available commands.`);
233+
process.exit(1);
234+
}

0 commit comments

Comments
 (0)