Skip to content

Commit 13de630

Browse files
committed
add rev tracking engine
1 parent 9e88746 commit 13de630

39 files changed

Lines changed: 1542 additions & 316 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646

4747
### Avoid Barrel Files
4848

49+
- Do not make use of index.ts
50+
4951
Barrel files:
5052

5153
- Break tree-shaking

apps/cli/src/cli.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
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";
15
import {
26
CATEGORY_LABELS,
37
CATEGORY_ORDER,
@@ -159,17 +163,46 @@ export async function main(): Promise<void> {
159163
return;
160164
}
161165

166+
// Hidden commands
167+
if (command === "__refresh-pr-info") {
168+
await refreshPRInfo();
169+
return;
170+
}
171+
if (command === "__dump-refs") {
172+
await dumpRefs();
173+
return;
174+
}
175+
162176
const handler = HANDLERS[command];
163177
if (handler) {
164178
const requiredLevel = getRequiredContext(command);
165-
if (requiredLevel !== "none") {
166-
const context = await checkContext();
167-
if (!isContextValid(context, requiredLevel)) {
168-
printContextError(context, requiredLevel);
169-
process.exit(1);
170-
}
179+
180+
// Commands that don't need context (auth, help, etc.)
181+
if (requiredLevel === "none") {
182+
await handler(parsed, null);
183+
return;
184+
}
185+
186+
// Check prerequisites (git, jj, arr initialized)
187+
const prereqs = await checkContext();
188+
if (!isContextValid(prereqs, requiredLevel)) {
189+
printContextError(prereqs, requiredLevel);
190+
process.exit(1);
191+
}
192+
193+
// Initialize context with engine
194+
let context: ArrContext | null = null;
195+
try {
196+
context = await initContext();
197+
198+
// Trigger background PR refresh (rate-limited)
199+
triggerBackgroundRefresh(context.cwd);
200+
201+
await handler(parsed, context);
202+
} finally {
203+
// Auto-persist engine changes
204+
context?.engine.persist();
171205
}
172-
await handler(parsed);
173206
return;
174207
}
175208

apps/cli/src/commands/checkout.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
11
import { checkout as checkoutCmd } from "@array/core/commands/checkout";
22
import { changeLabel } from "@array/core/slugify";
33
import { cyan, dim, formatSuccess, message } from "../utils/output";
4-
import { findChange, requireArg, unwrap } from "../utils/run";
4+
import { requireArg, unwrap } from "../utils/run";
55

66
export async function checkout(id: string): Promise<void> {
77
requireArg(id, "Usage: arr checkout <id>");
88

9+
const result = unwrap(await checkoutCmd(id));
10+
911
// Handle trunk checkout - creates new empty change on main
1012
if (id === "main" || id === "master" || id === "trunk") {
11-
unwrap(await checkoutCmd(id));
1213
message(formatSuccess(`Switched to ${cyan(id)}`));
1314
return;
1415
}
1516

16-
// For other targets, resolve via findChange first
17-
const change = await findChange(id, { includeBookmarks: true });
18-
unwrap(await checkoutCmd(change.changeId));
19-
20-
const label = changeLabel(change.description, change.changeId);
17+
const label = changeLabel(result.change.description, result.change.changeId);
2118
message(
2219
formatSuccess(
23-
`Switched to ${cyan(label)}: ${change.description || dim("(no description)")}`,
20+
`Switched to ${cyan(label)}: ${result.change.description || dim("(no description)")}`,
2421
),
2522
);
2623
}

apps/cli/src/commands/create.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { create as createCmd } from "@array/core/commands/create";
2+
import type { ArrContext } from "@array/core/engine";
23
import { COMMANDS } from "../registry";
34
import {
45
arr,
@@ -11,13 +12,18 @@ import {
1112
import { requireArg, unwrap } from "../utils/run";
1213
import { showTip } from "../utils/tips";
1314

14-
export async function create(msg: string): Promise<void> {
15+
export async function create(msg: string, ctx: ArrContext): Promise<void> {
1516
requireArg(
1617
msg,
1718
"Usage: arr create <description>\n Creates a change with current file modifications",
1819
);
1920

20-
const result = unwrap(await createCmd(msg));
21+
const result = unwrap(
22+
await createCmd({
23+
message: msg,
24+
engine: ctx.engine,
25+
}),
26+
);
2127

2228
message(formatSuccess(`Created ${cyan(result.bookmarkName)}`));
2329
indent(

apps/cli/src/commands/delete.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { deleteChange as deleteCmd } from "@array/core/commands/delete";
2+
import type { ArrContext } from "@array/core/engine";
23
import { changeLabel } from "@array/core/slugify";
34
import {
45
cyan,
@@ -10,20 +11,20 @@ import {
1011
yellow,
1112
} from "../utils/output";
1213
import { confirm } from "../utils/prompt";
13-
import { findChange, requireArg, unwrap } from "../utils/run";
14+
import { requireArg, unwrap } from "../utils/run";
1415

1516
export async function deleteChange(
1617
id: string,
18+
ctx: ArrContext,
1719
options?: { yes?: boolean },
1820
): Promise<void> {
1921
requireArg(id, "Usage: arr delete <id>");
2022

21-
const change = await findChange(id);
22-
const label = changeLabel(change.description, change.changeId);
23-
24-
// Confirm deletion since work will be lost
23+
// Note: We call deleteCmd which resolves the change internally.
24+
// For confirmation, we show the raw id since we can't resolve beforehand without duplicating logic.
25+
// The actual label will be shown in the success message.
2526
const confirmed = await confirm(
26-
`Delete ${cyan(label)}? ${red("Work will be permanently lost.")}`,
27+
`Delete ${cyan(id)}? ${red("Work will be permanently lost.")}`,
2728
{ autoYes: options?.yes, default: false },
2829
);
2930

@@ -32,8 +33,9 @@ export async function deleteChange(
3233
return;
3334
}
3435

35-
const result = unwrap(await deleteCmd(change.changeId));
36+
const result = unwrap(await deleteCmd({ id, engine: ctx.engine }));
3637

38+
const label = changeLabel(result.change.description, result.change.changeId);
3739
message(formatSuccess(`Deleted change ${cyan(label)}`));
3840

3941
if (result.movedTo) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { $ } from "bun";
2+
3+
/**
4+
* Hidden debug command to dump all arr refs metadata.
5+
* Usage: arr __dump-refs
6+
*
7+
* Shows the contents of all refs/arr/* blobs, which store
8+
* metadata about changes (PR info, etc.).
9+
*/
10+
export async function dumpRefs(): Promise<void> {
11+
const result =
12+
await $`git for-each-ref refs/arr --format='%(refname:short)'`.quiet();
13+
const refs = result.stdout.toString().trim().split("\n").filter(Boolean);
14+
15+
if (refs.length === 0) {
16+
console.log("No arr refs found.");
17+
return;
18+
}
19+
20+
for (const ref of refs) {
21+
console.log(`=== ${ref} ===`);
22+
const blob = await $`git cat-file blob refs/${ref}`.quiet();
23+
const content = blob.stdout.toString().trim();
24+
try {
25+
console.log(JSON.stringify(JSON.parse(content), null, 2));
26+
} catch {
27+
console.log(content);
28+
}
29+
console.log();
30+
}
31+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { syncPRInfo } from "@array/core/commands/sync-pr-info";
2+
import { initContext } from "@array/core/engine";
3+
4+
/**
5+
* Background PR info refresh command.
6+
* Called by triggerBackgroundRefresh() as a detached process.
7+
* Silently syncs PR info and exits.
8+
*/
9+
export async function refreshPRInfo(): Promise<void> {
10+
try {
11+
const context = await initContext();
12+
await syncPRInfo({ engine: context.engine });
13+
context.engine.persist();
14+
} catch {
15+
// Silent failure - background task shouldn't crash
16+
}
17+
}

apps/cli/src/commands/log.ts

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { log as logCmd } from "@array/core/commands/log";
2+
import type { ArrContext } from "@array/core/engine";
23
import type { LogGraphData, PRInfo } from "@array/core/log-graph";
34
import { COMMANDS } from "../registry";
45
import {
@@ -7,24 +8,32 @@ import {
78
dim,
89
formatChangeId,
910
formatCommitId,
10-
formatDiffStats,
1111
green,
12+
hint,
1213
magenta,
1314
message,
1415
red,
1516
yellow,
1617
} from "../utils/output";
1718
import { unwrap } from "../utils/run";
1819

19-
export async function log(): Promise<void> {
20-
const data = unwrap(await logCmd());
20+
export async function log(ctx: ArrContext): Promise<void> {
21+
const data = unwrap(
22+
await logCmd({
23+
engine: ctx.engine,
24+
trunk: ctx.trunk,
25+
}),
26+
);
2127

28+
// isEmpty means: on trunk with empty working copy and no tracked branches
29+
// In this case, show a simplified view
2230
if (data.isEmpty) {
23-
message(dim("No changes in stack"));
31+
message(`${green("◉")} ${ctx.trunk} ${dim("(current)")}`);
32+
hint(`Run ${cyan("arr create")} to start a new stack`);
2433
return;
2534
}
2635

27-
const output = renderLogGraph(data);
36+
const output = renderLogGraph(data, ctx.trunk);
2837
message(output);
2938

3039
if (data.modifiedCount > 0) {
@@ -35,7 +44,7 @@ export async function log(): Promise<void> {
3544
}
3645
}
3746

38-
function renderLogGraph(data: LogGraphData): string {
47+
function renderLogGraph(data: LogGraphData, trunk: string): string {
3948
const output = data.rawOutput;
4049

4150
// Process each line to handle placeholders
@@ -45,7 +54,8 @@ function renderLogGraph(data: LogGraphData): string {
4554
for (const line of lines) {
4655
let processed = line;
4756

48-
// {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks|added|removed|files}}
57+
// {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}}
58+
// Note: jj outputs the graph marker (@, ○, ◆), we just output the label content
4959
processed = processed.replace(/\{\{LABEL:([^}]+)\}\}/g, (_, content) => {
5060
const parts = content.split("|");
5161
const [
@@ -54,20 +64,15 @@ function renderLogGraph(data: LogGraphData): string {
5464
timestamp,
5565
description,
5666
conflict,
57-
wc,
67+
_wc,
5868
empty,
59-
immutable,
69+
_immutable,
6070
localBookmarks,
6171
_remoteBookmarks,
62-
added,
63-
removed,
64-
files,
6572
] = parts;
6673

67-
const isWorkingCopy = wc === "1";
6874
const hasConflicts = conflict === "1";
6975
const isEmpty = empty === "1";
70-
const isImmutable = immutable === "1";
7176
const bookmarks = localBookmarks
7277
? localBookmarks.split(",").filter(Boolean)
7378
: [];
@@ -103,19 +108,7 @@ function renderLogGraph(data: LogGraphData): string {
103108
const badgeStr =
104109
badges.length > 0 ? ` ${dim("(")}${badges.join(", ")}${dim(")")}` : "";
105110

106-
// Diff stats
107-
const filesChanged = Number(files) || 0;
108-
const insertions = Number(added) || 0;
109-
const deletions = Number(removed) || 0;
110-
const statsStr =
111-
filesChanged > 0
112-
? ` ${formatDiffStats({ filesChanged, insertions, deletions })}`
113-
: "";
114-
115-
// Marker
116-
const marker = isWorkingCopy ? green("◉") : isImmutable ? "◆" : "○";
117-
118-
return `${marker} ${label} ${shortId}${statsStr}${badgeStr}`;
111+
return `${label} ${shortId}${badgeStr}`;
119112
});
120113

121114
// {{TIME:timestamp}}
@@ -126,7 +119,7 @@ function renderLogGraph(data: LogGraphData): string {
126119

127120
// {{HINT_EMPTY}}
128121
processed = processed.replace(/\{\{HINT_EMPTY\}\}/g, () => {
129-
return `${dim("No changes yet")}\n${dim("Edit files, then:")}\n ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}\n ${arr(COMMANDS.down)} ${dim("to edit the change below")}`;
122+
return `${dim("Run")} ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as a change")}`;
130123
});
131124

132125
// {{HINT_UNCOMMITTED}}
@@ -175,10 +168,40 @@ function renderLogGraph(data: LogGraphData): string {
175168
},
176169
);
177170

171+
// {{TRUNK:bookmark}} - trunk label (prefer actual trunk name if present)
172+
processed = processed.replace(
173+
/\{\{TRUNK:([^}]*)\}\}/g,
174+
(_, bookmarksStr) => {
175+
const bookmarks = bookmarksStr.split(",").filter(Boolean);
176+
// Prefer the actual trunk name if this commit has multiple bookmarks
177+
if (bookmarks.includes(trunk)) {
178+
return trunk;
179+
}
180+
return bookmarks[0] || "trunk";
181+
},
182+
);
183+
178184
processedLines.push(processed);
179185
}
180186

181-
return processedLines.join("\n");
187+
let result = processedLines.join("\n");
188+
189+
// Replace jj's graph markers with styled versions
190+
// @ = working copy (green ◉)
191+
// ○ = mutable commit (◯)
192+
// ◆ = immutable commit (◯ - same as mutable, we don't distinguish)
193+
// × = conflict (red ×)
194+
// ~ = elided (│)
195+
result = result.replace(/^(@)(\s+)/gm, `${green("◉")}$2`);
196+
result = result.replace(/^()(\s+)/gm, "◯$2");
197+
result = result.replace(/^()(\s+)/gm, "◯$2");
198+
result = result.replace(/^(×)(\s+)/gm, `${red("×")}$2`);
199+
result = result.replace(/^(~)(\s+)/gm, "│$2");
200+
201+
// Remove trailing newlines
202+
result = result.trimEnd();
203+
204+
return result;
182205
}
183206

184207
function formatPRLine(prInfo: PRInfo, description: string): string {

0 commit comments

Comments
 (0)