Skip to content

Commit 7bc52b3

Browse files
committed
feat: add arr trunk and arr exit commands
1 parent 640704e commit 7bc52b3

File tree

12 files changed

+441
-14
lines changed

12 files changed

+441
-14
lines changed

packages/cli/src/cli.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
import { auth } from "./commands/auth";
1010
import { bottom } from "./commands/bottom";
1111
import { checkout } from "./commands/checkout";
12+
import { ci } from "./commands/ci";
1213
import { config } from "./commands/config";
1314
import { continueCommand } from "./commands/continue";
1415
import { create } from "./commands/create";
1516
import { deleteChange } from "./commands/delete";
1617
import { down } from "./commands/down";
18+
import { exit } from "./commands/exit";
1719
import { init } from "./commands/init";
1820
import { log } from "./commands/log";
1921
import { merge } from "./commands/merge";
@@ -24,6 +26,7 @@ import { status } from "./commands/status";
2426
import { submit } from "./commands/submit";
2527
import { sync } from "./commands/sync";
2628
import { top } from "./commands/top";
29+
import { trunk } from "./commands/trunk";
2730
import { undo } from "./commands/undo";
2831
import { up } from "./commands/up";
2932
import {
@@ -102,6 +105,10 @@ ${bold("CORE WORKFLOW")}
102105
4. ${cyan("arr submit")} Create PRs for the stack
103106
5. ${cyan("arr sync")} Fetch & rebase after reviews
104107
108+
${bold("ESCAPE HATCH")}
109+
${cyan("arr exit")} Switch back to plain git if you need it.
110+
Your jj changes are preserved and you can return anytime.
111+
105112
${bold("LEARN MORE")}
106113
Documentation: https://github.com/posthog/array
107114
jj documentation: https://www.jj-vcs.dev/latest/
@@ -200,6 +207,11 @@ export async function main(): Promise<void> {
200207
const parsed = parseArgs(Bun.argv);
201208
const command = resolveCommandAlias(parsed.name);
202209

210+
// Show expanded command when alias was used
211+
if (parsed.name && parsed.name !== command) {
212+
console.log(dim(`(${parsed.name}${command})`));
213+
}
214+
203215
if (parsed.flags.help || parsed.flags.h) {
204216
if (parsed.flags.all) {
205217
printHelpAll();
@@ -266,6 +278,9 @@ export async function main(): Promise<void> {
266278
case "top":
267279
await top();
268280
break;
281+
case "trunk":
282+
await trunk();
283+
break;
269284
case "bottom":
270285
await bottom();
271286
break;
@@ -296,6 +311,12 @@ export async function main(): Promise<void> {
296311
case "undo":
297312
await undo();
298313
break;
314+
case "exit":
315+
await exit();
316+
break;
317+
case "ci":
318+
await ci();
319+
break;
299320
case "help":
300321
if (parsed.flags.all) {
301322
printHelpAll();

packages/cli/src/commands/ci.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
ciWorkflowExists,
3+
getBranchProtectionUrl,
4+
getRepoInfoFromRemote,
5+
setupCI,
6+
shellExecutor,
7+
} from "@array/core";
8+
import { cyan, dim, formatSuccess, yellow } from "../utils/output";
9+
10+
export async function ci(): Promise<void> {
11+
const cwd = process.cwd();
12+
13+
if (ciWorkflowExists(cwd)) {
14+
console.log(yellow("Stack check workflow already exists"));
15+
console.log(dim(" .github/workflows/array-stack-check.yml"));
16+
console.log();
17+
await printInstructions(cwd);
18+
return;
19+
}
20+
21+
const result = setupCI(cwd);
22+
23+
if (result.created) {
24+
console.log(formatSuccess("Created .github/workflows/array-stack-check.yml"));
25+
console.log();
26+
await printInstructions(cwd);
27+
}
28+
}
29+
30+
async function printInstructions(cwd: string): Promise<void> {
31+
const remoteResult = await shellExecutor.execute(
32+
"git",
33+
["config", "--get", "remote.origin.url"],
34+
{ cwd },
35+
);
36+
37+
if (remoteResult.exitCode === 0) {
38+
const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim());
39+
if (repoInfo.ok) {
40+
const url = getBranchProtectionUrl(repoInfo.value.owner, repoInfo.value.repo);
41+
console.log("To enforce stack ordering, add 'Stack Check' as a required check:");
42+
console.log(cyan(` ${url}`));
43+
console.log(dim(" → Edit rule for 'main' → Require status checks → Add 'Stack Check'"));
44+
return;
45+
}
46+
}
47+
48+
console.log("To enforce stack ordering:");
49+
console.log(dim(" 1. Go to Repo Settings → Branches → Branch protection rules"));
50+
console.log(dim(" 2. Edit rule for 'main' (or create one)"));
51+
console.log(dim(" 3. Enable 'Require status checks to pass'"));
52+
console.log(dim(" 4. Search for 'Stack Check' and add it"));
53+
}

packages/cli/src/commands/exit.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { cyan, dim, green } from "../utils/output";
2+
import { createJJ, unwrap } from "../utils/run";
3+
4+
export async function exit(): Promise<void> {
5+
const result = unwrap(await createJJ().exitToGit());
6+
7+
console.log(`${green(">")} Switched to git branch ${cyan(result.trunk)}`);
8+
console.log();
9+
console.log(dim(" You're now using plain git. Your jj changes are still safe."));
10+
console.log(dim(` To return to arr/jj, run: ${cyan("arr init")}`));
11+
}

packages/cli/src/commands/init.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {
22
checkPrerequisites,
33
configureTrunk,
44
detectTrunkBranches,
5+
getBranchProtectionUrl,
6+
getRepoInfoFromRemote,
57
hasBranch,
68
hasGitCommits,
79
hasRemote,
@@ -13,6 +15,7 @@ import {
1315
isJjInitialized,
1416
isRepoInitialized,
1517
pushBranch,
18+
setupCI,
1619
} from "@array/core";
1720
import {
1821
bold,
@@ -260,5 +263,32 @@ export async function init(
260263
// No .array config needed - fully stateless
261264
// Trunk is stored in jj's config via configureTrunk()
262265

266+
// Offer to set up CI workflow (default to Yes)
267+
console.log();
268+
const addCI = await autoConfirm("Add GitHub Action to check stack dependencies?");
269+
if (addCI) {
270+
const ciResult = setupCI(cwd);
271+
if (ciResult.created) {
272+
console.log(` ${formatSuccess("Created .github/workflows/array-stack-check.yml")}`);
273+
274+
// Show direct link if we can determine the repo
275+
if (remoteExists) {
276+
const { shellExecutor } = await import("@array/core");
277+
const remoteResult = await shellExecutor.execute(
278+
"git",
279+
["config", "--get", "remote.origin.url"],
280+
{ cwd },
281+
);
282+
if (remoteResult.exitCode === 0) {
283+
const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim());
284+
if (repoInfo.ok) {
285+
const url = getBranchProtectionUrl(repoInfo.value.owner, repoInfo.value.repo);
286+
console.log(dim(` To make it required: ${url}`));
287+
}
288+
}
289+
}
290+
}
291+
}
292+
263293
printQuickStart();
264294
}

packages/cli/src/commands/log.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { datePrefixedLabel, formatRelativeTime, GitHub } from "@array/core";
1+
import {
2+
type DiffStats,
3+
datePrefixedLabel,
4+
formatRelativeTime,
5+
GitHub,
6+
} from "@array/core";
27
import {
38
cyan,
49
dim,
@@ -11,6 +16,16 @@ import {
1116
} from "../utils/output";
1217
import { createJJ, unwrap } from "../utils/run";
1318

19+
function formatDiffStats(stats: DiffStats): string {
20+
if (stats.filesChanged === 0) return "";
21+
const parts: string[] = [];
22+
if (stats.insertions > 0) parts.push(green(`+${stats.insertions}`));
23+
if (stats.deletions > 0) parts.push(red(`-${stats.deletions}`));
24+
const filesLabel = stats.filesChanged === 1 ? "file" : "files";
25+
parts.push(dim(`${stats.filesChanged} ${filesLabel}`));
26+
return dim("(") + parts.join(dim(", ")) + dim(")");
27+
}
28+
1429
interface PRInfo {
1530
number: number;
1631
state: "open" | "merged" | "closed";
@@ -58,6 +73,21 @@ export async function log(): Promise<void> {
5873
}
5974
}
6075

76+
// Fetch diff stats for all entries in parallel
77+
// Use unpushed stats for modified changes, total stats otherwise
78+
const diffStatsMap = new Map<string, DiffStats>();
79+
const statsPromises = entries.map(async (entry) => {
80+
const bookmark = entry.change.bookmarks[0];
81+
const result =
82+
entry.isModified && bookmark
83+
? await jj.getUnpushedDiffStats(entry.change.changeId, bookmark)
84+
: await jj.getDiffStats(entry.change.changeId);
85+
if (result.ok) {
86+
diffStatsMap.set(entry.change.changeId, result.value);
87+
}
88+
});
89+
await Promise.all(statsPromises);
90+
6191
console.log();
6292

6393
// Show empty working copy if present
@@ -82,25 +112,27 @@ export async function log(): Promise<void> {
82112
entries[i];
83113
const label = datePrefixedLabel(change.description, change.timestamp);
84114

85-
// Track modified changes for summary
115+
// Track unpushed changes for summary
86116
if (isModified) modifiedCount++;
87117

88118
// Determine status badges
89119
const badges: string[] = [];
90-
if (isModified) badges.push(yellow("modified"));
120+
if (isModified) badges.push(yellow("unpushed"));
91121
if (change.hasConflicts) badges.push(yellow("conflicts"));
92122

93123
// Don't show current marker on entries when empty WC is shown above
94124
const marker = isCurrent && !hasEmptyWorkingCopy ? green("◉") : "○";
95125
const badgeStr =
96126
badges.length > 0 ? ` ${dim("(")}${badges.join(", ")}${dim(")")}` : "";
97127

98-
// Line 1: marker + branch name + change ID + badges
128+
// Line 1: marker + branch name + change ID + stats + badges
99129
const shortId = formatChangeId(
100130
change.changeId.slice(0, 8),
101131
change.changeIdPrefix,
102132
);
103-
console.log(`${prefix}${marker} ${label} ${shortId}${badgeStr}`);
133+
const stats = diffStatsMap.get(change.changeId);
134+
const statsStr = stats ? ` ${formatDiffStats(stats)}` : "";
135+
console.log(`${prefix}${marker} ${label} ${shortId}${statsStr}${badgeStr}`);
104136

105137
// Line 2: relative time
106138
console.log(`${prefix}${dim(formatRelativeTime(change.timestamp))}`);
@@ -155,11 +187,11 @@ export async function log(): Promise<void> {
155187
}
156188
console.log();
157189

158-
// Show summary/guidance when there are modified changes
190+
// Show summary/guidance when there are unpushed changes
159191
if (modifiedCount > 0) {
160-
const changeWord = modifiedCount === 1 ? "change" : "changes";
192+
const changeWord = modifiedCount === 1 ? "change has" : "changes have";
161193
console.log(
162-
`${modifiedCount} ${changeWord} modified. Run ${cyan("arr submit")} to update PRs.`,
194+
`${modifiedCount} ${changeWord} unpushed commits. Run ${cyan("arr submit")} to update PRs.`,
163195
);
164196
console.log();
165197
}

packages/cli/src/commands/status.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1+
import type { DiffStats } from "@array/core";
12
import { cyan, dim, formatChangeId, green, red, yellow } from "../utils/output";
23
import { createJJ, unwrap } from "../utils/run";
34

5+
function formatDiffStats(stats: DiffStats): string {
6+
if (stats.filesChanged === 0) return "";
7+
const parts: string[] = [];
8+
if (stats.insertions > 0) parts.push(green(`+${stats.insertions}`));
9+
if (stats.deletions > 0) parts.push(red(`-${stats.deletions}`));
10+
const filesLabel = stats.filesChanged === 1 ? "file" : "files";
11+
parts.push(dim(`${stats.filesChanged} ${filesLabel}`));
12+
return dim("(") + parts.join(dim(", ")) + dim(")");
13+
}
14+
415
export async function status(): Promise<void> {
5-
const info = unwrap(await createJJ().getStatusInfo());
16+
const jj = createJJ();
17+
const info = unwrap(await jj.getStatusInfo());
18+
19+
// Get diff stats for current change
20+
const statsResult = await jj.getDiffStats("@");
21+
const stats = statsResult.ok ? statsResult.value : null;
22+
const statsStr = stats ? ` ${formatDiffStats(stats)}` : "";
623

724
// Check if on main with no stack above (fresh start)
825
const isOnMainFresh =
@@ -16,13 +33,13 @@ export async function status(): Promise<void> {
1633
info.changeIdPrefix,
1734
);
1835
if (isOnMainFresh) {
19-
console.log(`${green("◉")} On ${cyan("main")} ${changeId}`);
36+
console.log(`${green("◉")} On ${cyan("main")} ${changeId}${statsStr}`);
2037
} else if (info.isUndescribed) {
2138
const label = info.hasChanges ? "(unsaved)" : "(empty)";
22-
console.log(`${green(label)} ${changeId}`);
39+
console.log(`${green(label)} ${changeId}${statsStr}`);
2340
console.log(dim(` ↳ ${info.stackPath.join(" → ")}`));
2441
} else {
25-
console.log(`${green(info.name)} ${changeId}`);
42+
console.log(`${green(info.name)} ${changeId}${statsStr}`);
2643
console.log(dim(` ↳ ${info.stackPath.join(" → ")}`));
2744
}
2845

packages/cli/src/commands/trunk.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { cyan, dim, green } from "../utils/output";
2+
import { createJJ, unwrap } from "../utils/run";
3+
4+
export async function trunk(): Promise<void> {
5+
unwrap(await createJJ().goToTrunk());
6+
console.log(`${green("◉")} Started fresh on ${cyan("main")}`);
7+
console.log(dim(` Run ${cyan("arr top")} to go back to your stack`));
8+
}

0 commit comments

Comments
 (0)