Skip to content
Merged
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
93 changes: 93 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
> [!IMPORTANT] > `arr` is still in development and not production-ready. Interested? Email jonathan@posthog.com

# arr

arr is CLI for stacked PR management using Jujutsu (`jj`).

Split your work into small changes, push them as a PR stack, and keep everything in sync.

## Install

Requires [Bun](https://bun.sh).

```
git clone https://github.com/posthog/array
cd array
pnpm install
pnpm --filter @array/core build
```

Then install the `arr` command (symlinked to `~/bin/arr`):

```
./apps/cli/arr.sh install
```

## Usage

```
arr init # set up arr in a git repo
arr create "message" # new change on stack
arr submit # push stack, create PRs
arr merge # merge stack of PRs
arr sync # fetch, rebase, cleanup merged
arr up / arr down # navigate stack
arr log # show stack
arr exit # back to git
arr help --all # show all commands
```

## Example

```
$ echo "user model" >> user_model.ts
$ arr create "Add user model"
✓ Created add-user-model-qtrsqm

$ echo "user api" >> user_api.ts
$ arr create "Add user API"
✓ Created add-user-api-nnmzrt

$ arr log
◉ (working copy)
│ Empty
○ 12-23-add-user-api nnmzrtzz (+1, 1 file)
│ Not submitted
○ 12-23-add-user-model qtrsqmmy (+1, 1 file)
│ Not submitted
○ main

$ arr submit
Created PR #8: 12-23-add-user-model
https://github.com/username/your-repo/pull/8
Created PR #9: 12-23-add-user-api
https://github.com/username/your-repo/pull/9

$ arr merge
...

$ arr sync
```

Each change becomes a PR.
Stacked PRs are explained through a generated comments so reviewers see the dependency.

## FAQ

**Can I use this with an existing `git` repo?**

Yes, do so by using `arr init` in any `git` repo. `jj` works alongside `git`.

**Do my teammates need to use `arr` or `jj`?**

No, your PRs are normal GitHub PRs. Teammates review and merge them as usual. `jj` has full support for `git`.

**What if I want to stop using `arr`?**

Run `arr exit` to switch back to `git`. Your repo, branches, and PRs stay exactly as they are.

## Learn more

- [`jj` documentation](https://jj-vcs.github.io/jj/latest/) - full `jj` reference
- [`jj` tutorial](https://jj-vcs.github.io/jj/latest/tutorial/) - getting started with `jj`
- `arr help`
20 changes: 20 additions & 0 deletions apps/cli/arr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Wrapper script to run arr CLI via bun.
SOURCE="${BASH_SOURCE[0]}"
while [ -L "$SOURCE" ]; do
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"

# Self-install: ./arr.sh install
if [ "$1" = "install" ]; then
mkdir -p ~/bin
ln -sf "$SCRIPT_DIR/arr.sh" ~/bin/arr
echo "Installed: ~/bin/arr -> $SCRIPT_DIR/arr.sh"
echo "Make sure ~/bin is in your PATH"
exit 0
fi

exec bun run "$SCRIPT_DIR/bin/arr.ts" "$@"
10 changes: 10 additions & 0 deletions apps/cli/bin/arr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bun

import { main } from "../src/cli";

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
25 changes: 25 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@array/cli",
"version": "0.0.1",
"description": "CLI for changeset management with jj",
"bin": {
"arr": "./bin/arr.ts"
},
"type": "module",
"scripts": {
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
"dev": "bun run ./bin/arr.ts",
"typecheck": "tsc --noEmit",
"test": "bun test --concurrent tests/unit tests/e2e/cli.test.ts",
"test:pty": "vitest run tests/e2e/pty.test.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^25.0.3",
"typescript": "^5.5.0",
"vitest": "^4.0.16"
},
"dependencies": {
"@array/core": "workspace:*"
}
}
234 changes: 234 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { triggerBackgroundRefresh } from "@array/core/background-refresh";
import { type ArrContext, initContext } from "@array/core/engine";
import { dumpRefs } from "./commands/hidden/dump-refs";
import { refreshPRInfo } from "./commands/hidden/refresh-pr-info";
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
COMMANDS as COMMAND_INFO,
type CommandInfo,
getCommandsByCategory,
getCoreCommands,
getRequiredContext,
HANDLERS,
resolveCommandAlias,
} from "./registry";
import { parseArgs } from "./utils/args";
import {
checkContext,
isContextValid,
printContextError,
} from "./utils/context";
import {
arr,
bold,
cyan,
dim,
formatError,
hint,
message,
} from "./utils/output";

const CLI_NAME = "arr";
const CLI_VERSION = "0.0.1";
const CMD_WIDTH = 22;

const TAGLINE = `arr is a CLI for stacked PRs using jj.
It enables stacking changes on top of each other to keep you unblocked
and your changes small, focused, and reviewable.`;

const USAGE = `${bold("USAGE")}
$ arr <command> [flags]`;

const TERMS = `${bold("TERMS")}
stack: A sequence of changes, each building off of its parent.
ex: main <- "add API" <- "update frontend" <- "docs"
trunk: The branch that stacks are merged into (e.g., main).
change: A jj commit/revision. Unlike git, jj tracks the working
copy as a change automatically.`;

const GLOBAL_OPTIONS = `${bold("GLOBAL OPTIONS")}
--help Show help for a command.
--help --all Show full command reference.
--version Show arr version number.`;

const DOCS = `${bold("DOCS")}
Get started: https://github.com/posthog/array`;

function formatCommand(
c: CommandInfo,
showAliases = true,
showFlags = false,
): string {
const full = c.args ? `${c.name} ${c.args}` : c.name;
const aliasStr =
showAliases && c.aliases?.length
? ` ${dim(`[aliases: ${c.aliases.join(", ")}]`)}`
: "";
let result = ` ${cyan(full.padEnd(CMD_WIDTH))}${c.description}.${aliasStr}`;

if (showFlags && c.flags?.length) {
for (const flag of c.flags) {
const flagName = flag.short
? `-${flag.short}, --${flag.name}`
: `--${flag.name}`;
result += `\n ${dim(flagName.padEnd(CMD_WIDTH - 2))}${dim(flag.description)}`;
}
}

return result;
}

function printHelp(): void {
const coreCommands = getCoreCommands();

console.log(`${TAGLINE}

${USAGE}

${TERMS}

${bold("CORE COMMANDS")}
${coreCommands.map((c) => formatCommand(c, false)).join("\n")}

Run ${arr(COMMAND_INFO.help, "--all")} for a full command reference.

${bold("CORE WORKFLOW")}
1. ${dim("(make edits)")}\t\t\tno need to stage, jj tracks automatically
2. ${arr(COMMAND_INFO.create, '"add user model"')}\tSave as a change
3. ${dim("(make more edits)")}\t\t\tStack more work
4. ${arr(COMMAND_INFO.create, '"add user api"')}\t\tSave as another change
5. ${arr(COMMAND_INFO.submit)}\t\t\t\tCreate PRs for the stack
6. ${arr(COMMAND_INFO.merge)}\t\t\t\tMerge PRs from the CLI
7. ${arr(COMMAND_INFO.sync)}\t\t\t\tFetch & rebase after reviews

${bold("ESCAPE HATCH")}
${arr(COMMAND_INFO.exit)}\t\t\t\tSwitch back to plain git if you need it.
\t\t\t\t\tYour jj changes are preserved and you can return anytime.

${bold("LEARN MORE")}
Documentation\t\t\thttps://github.com/posthog/array
jj documentation\t\thttps://www.jj-vcs.dev/latest/
`);
}

function printHelpAll(): void {
const hidden = new Set(["help", "version", "config"]);
const sections = CATEGORY_ORDER.map((category) => {
const commands = getCommandsByCategory(category).filter(
(c) => !hidden.has(c.name),
);
if (commands.length === 0) return "";
return `${bold(CATEGORY_LABELS[category])}\n${commands.map((c) => formatCommand(c, true, true)).join("\n")}`;
}).filter(Boolean);

console.log(`${TAGLINE}

${USAGE}

${TERMS}

${sections.join("\n\n")}

${GLOBAL_OPTIONS}

${DOCS}
`);
}

function printVersion(): void {
console.log(`${CLI_NAME} ${CLI_VERSION}`);
}

export async function main(): Promise<void> {
const parsed = parseArgs(Bun.argv);
const command = resolveCommandAlias(parsed.name);

if (parsed.name && parsed.name !== command) {
message(dim(`(${parsed.name} → ${command})`));
}

if (parsed.flags.help || parsed.flags.h) {
if (parsed.flags.all) {
printHelpAll();
} else {
printHelp();
}
return;
}

if (parsed.flags.version || parsed.flags.v) {
printVersion();
return;
}

// No command provided - show help
if (command === "__guided") {
printHelp();
return;
}

// Built-in commands
if (command === "help") {
parsed.flags.all ? printHelpAll() : printHelp();
return;
}
if (command === "version") {
printVersion();
return;
}

// Hidden commands
if (command === "__refresh-pr-info") {
await refreshPRInfo();
return;
}
if (command === "__dump-refs") {
await dumpRefs();
return;
}

const handler = HANDLERS[command];
if (handler) {
const requiredLevel = getRequiredContext(command);

// Commands that don't need context (auth, help, etc.)
if (requiredLevel === "none") {
await handler(parsed, null);
return;
}

// Check prerequisites (git, jj, arr initialized)
const debug = !!parsed.flags.debug;
let t0 = Date.now();
const prereqs = await checkContext();
if (debug) console.log(` checkContext: ${Date.now() - t0}ms`);
if (!isContextValid(prereqs, requiredLevel)) {
printContextError(prereqs, requiredLevel);
process.exit(1);
}

// Initialize context with engine
let context: ArrContext | null = null;
try {
t0 = Date.now();
context = await initContext();
if (debug) console.log(` initContext: ${Date.now() - t0}ms`);

// Trigger background PR refresh (rate-limited)
triggerBackgroundRefresh(context.cwd);

t0 = Date.now();
await handler(parsed, context);
if (debug) console.log(` handler: ${Date.now() - t0}ms`);
} finally {
// Auto-persist engine changes
context?.engine.persist();
}
return;
}

console.error(formatError(`Unknown command: ${command}`));
hint(`Run '${arr(COMMAND_INFO.help)}' to see available commands.`);
process.exit(1);
}
Loading
Loading