claude-code-runrate 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
@@ -25,12 +25,18 @@
25
25
  "scripts/launch.sh",
26
26
  "sidecar",
27
27
  "README.md",
28
+ "CHANGELOG.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "scripts": {
31
32
  "test": "node --test",
32
33
  "typecheck": "tsc --noEmit -p jsconfig.json",
33
- "lint": "oxlint"
34
+ "lint": "oxlint",
35
+ "release-gate": "node scripts/release-gate.js",
36
+ "install-hooks": "node scripts/install-hooks.js",
37
+ "probe:wt": "node scripts/probe-wt.js",
38
+ "scan-package": "node scripts/scan-package.js",
39
+ "prepublishOnly": "npm run release-gate && npm run scan-package && npm run lint && npm run typecheck && npm test"
34
40
  },
35
41
  "keywords": [
36
42
  "claude-code",
package/scripts/launch.sh CHANGED
@@ -42,7 +42,14 @@ chmod +x "$REPO/sidecar/ccr-statusline" 2>/dev/null || true
42
42
 
43
43
  # Prefer the newest nvm-installed node; `sort -V` is a GNU-ism, so suppress its
44
44
  # error on BSD/macOS and fall back to PATH node below.
45
- NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1)"
45
+ #
46
+ # The `|| true` is load-bearing, not defensive habit: with `set -e` and
47
+ # `pipefail`, `ls` failing on a machine with no ~/.nvm makes the whole pipeline
48
+ # non-zero, and a failing command substitution in an assignment aborts the
49
+ # script. That killed the launcher outright — exit 2, no message, no sidebar —
50
+ # for exactly the user who has plain Claude Code and no nvm, who then never
51
+ # reaches the PATH fallback two lines below. Reproduced 2026-08-04.
52
+ NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1 || true)"
46
53
  [ -x "$NODE" ] || NODE="$(command -v node || true)"
47
54
  [ -n "$NODE" ] || { echo "ccr: node not found" >&2; exit 1; }
48
55
  command -v tmux >/dev/null 2>&1 || { echo "ccr: tmux not found (required for the sidebar)" >&2; exit 1; }
@@ -55,13 +62,15 @@ if [ -n "$PROFILE" ]; then
55
62
  exit 1
56
63
  fi
57
64
  CC_CMD="ccs $PROFILE"
58
- SESSION="${CCR_SESSION:-ccr-$PROFILE}"
59
- STATE="${CCR_STATE_DIR:-$HOME/.ccr/$PROFILE}"
60
65
  else
61
66
  CC_CMD="${CC_BIN:-claude}"
62
- SESSION="${CCR_SESSION:-ccr}"
63
- STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
64
67
  fi
68
+ # The launcher normally arrives with CCR_SESSION/CCR_STATE_DIR already set by
69
+ # the slot allocator (bin/ccr.js — every launch slots, profiled or bare). The
70
+ # fallbacks cover only a direct invocation of this script, and they point at
71
+ # slot 1's member dir: ~/.ccr itself is a container now, never a state dir.
72
+ SESSION="${CCR_SESSION:-ccr}"
73
+ STATE="${CCR_STATE_DIR:-$HOME/.ccr/instances/1}"
65
74
 
66
75
  # Every instance gets its OWN tmux server, on a socket named after the session
67
76
  # (-L puts it under /tmp/tmux-$UID/). On a shared server, one server death —
@@ -76,6 +85,17 @@ SOCKET="$SESSION"
76
85
  mkdir -p "$STATE"
77
86
  chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
78
87
  rm -f "$STATE/exited"
88
+ # The directory ccr was launched in — the tab's stable identity for the git
89
+ # pane (src/state-dir.js: recordLaunchDir does the same for the other two
90
+ # launchers). Best-effort: a tab that cannot record it falls back to naming
91
+ # only the repo the session is in.
92
+ # The rm is load-bearing, not tidiness: `>` on a planted FIFO blocks until a
93
+ # reader appears and hangs the launcher here, before Claude is ever spawned, and
94
+ # on a planted symlink it overwrites the link's target. Removing first turns both
95
+ # into an ordinary create. src/state-dir.js does the same for the other two
96
+ # launchers, and both sibling writers in <stateDir> already guard this way.
97
+ rm -f "$STATE/launch-cwd" 2>/dev/null || true
98
+ printf '%s\n' "$PWD" > "$STATE/launch-cwd" 2>/dev/null || true
79
99
 
80
100
  SETTINGS='{"statusLine":{"type":"command","command":"'"$REPO/sidecar/ccr-statusline"'"}}'
81
101
 
@@ -105,6 +125,15 @@ CLAUDE_PANE="$(tmux -L "$SOCKET" new-session -d -P -F '#{pane_id}' -s "$SESSION"
105
125
  "$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE_Q/exited'; sleep 2; tmux -L '$SOCKET_Q' kill-session -t '$SESSION_Q' 2>/dev/null")"
106
126
  tmux -L "$SOCKET" set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
107
127
 
128
+ # The tab's ADDRESS: composed once by the launcher (CCR_TITLE = "[profile / ]name",
129
+ # both halves allow-listed), never retitled mid-session. set-titles-string is a
130
+ # literal — deliberately NOT a tmux format that would follow the session; the
131
+ # pane and the status line are the surfaces honest about movement.
132
+ if [ -n "${CCR_TITLE:-}" ]; then
133
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles on
134
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles-string "$CCR_TITLE"
135
+ fi
136
+
108
137
  # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
109
138
  SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
110
139
  "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
@@ -33,7 +33,8 @@ const { parseResetsAt } = require('./burn');
33
33
  const { modelScope } = require('./rate-limits');
34
34
 
35
35
  const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; bound parse/disk
36
- const MAX_PROFILES = 32; // sanity cap on how many siblings we scan
36
+ const MAX_PROFILES = 32; // sanity cap on how many siblings we merge
37
+ const MAX_SCAN_ENTRIES = 512; // sanity cap on how many dir entries we inspect
37
38
 
38
39
  /**
39
40
  * Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
@@ -127,12 +128,14 @@ function readSiblingRateLimits(file) {
127
128
  * reconcile the local meters against them. Best-effort — returns `localRl` on any
128
129
  * problem so it can wrap the render path without a guard at the call site.
129
130
  *
130
- * Engages ONLY for the launcher's profile layout (`~/.ccr/<profile>`): the state
131
- * dir's parent must be `~/.ccr`. For ad-hoc `~/.ccr` or a custom CCR_STATE_DIR we
132
- * have no sibling set to trust, so we behave exactly as before (no merge).
131
+ * Engages ONLY for the launcher's layout: instances under `~/.ccr/instances/`
132
+ * (the 0.4.0 container/member split src/instance-slot.js). A custom
133
+ * CCR_STATE_DIR elsewhere has no sibling set to trust, so it behaves as
134
+ * before (no merge). There is no slot-1 special case any more: slot 1 is an
135
+ * ordinary member of instances/, which is exactly why the layout changed.
133
136
  *
134
137
  * @param {any} localRl the local snapshot's `rate_limits`
135
- * @param {string} stateDir the local profile's state dir (CCR_STATE_DIR)
138
+ * @param {string} stateDir the local instance's state dir (CCR_STATE_DIR)
136
139
  * @param {{ home?: string }} [opts]
137
140
  * @returns {any}
138
141
  */
@@ -140,21 +143,26 @@ function freshenAccountLimits(localRl, stateDir, opts = {}) {
140
143
  try {
141
144
  if (!localRl || typeof localRl !== 'object') return localRl;
142
145
  const home = opts.home || os.homedir();
143
- const root = path.dirname(path.resolve(stateDir));
144
- if (root !== path.resolve(path.join(home, '.ccr'))) return localRl; // not a profile layout
146
+ const root = path.resolve(path.join(home, '.ccr', 'instances'));
147
+ const self = path.resolve(stateDir);
148
+ if (path.dirname(self) !== root) return localRl; // not the launcher's layout
145
149
  const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
146
150
 
147
151
  /** @type {any[]} */
148
152
  const siblings = [];
153
+ // Bound the WALK, not just the harvest: MAX_PROFILES alone counts collected
154
+ // siblings, so entries that yield nothing — slot dirs with no snapshot yet,
155
+ // junk — would all be stat'd on every tick. This runs inside the sidecar's
156
+ // ~1s draw loop, so the scan stays capped by entries seen even though
157
+ // instances/ holds only instance dirs under the 0.4.0 layout.
158
+ let seen = 0;
149
159
  for (const name of fs.readdirSync(root)) {
150
- if (siblings.length >= MAX_PROFILES) break;
160
+ if (siblings.length >= MAX_PROFILES || ++seen > MAX_SCAN_ENTRIES) break;
151
161
  const p = path.join(root, name);
152
162
  let st; try { st = fs.statSync(p); } catch { continue; }
153
- // A sibling profile dir (~/.ccr/<name>/last-status.json) or the ad-hoc
154
- // ~/.ccr/last-status.json file itself.
155
- const file = st.isDirectory() ? path.join(p, 'last-status.json')
156
- : (name === 'last-status.json' ? p : null);
157
- if (!file || path.resolve(file) === selfFile) continue;
163
+ if (!st.isDirectory()) continue;
164
+ const file = path.join(p, 'last-status.json');
165
+ if (path.resolve(file) === selfFile) continue;
158
166
  const rl = readSiblingRateLimits(file);
159
167
  if (rl) siblings.push(rl);
160
168
  }
package/src/doctor.js CHANGED
@@ -9,6 +9,7 @@ const path = require('node:path');
9
9
  const os = require('node:os');
10
10
  const { spawnSync } = require('node:child_process');
11
11
  const { stripControl } = require('./sanitize');
12
+ const { loadPaneConfig } = require('./pane-config');
12
13
 
13
14
  const ok = (/** @type {string} */ s) => `\x1b[32m✓\x1b[0m ${s}`;
14
15
  const bad = (/** @type {string} */ s) => `\x1b[31m✗\x1b[0m ${s}`;
@@ -36,7 +37,8 @@ function isExec(/** @type {string} */ f) {
36
37
 
37
38
  /**
38
39
  * @param {{ platform?: string, has?: (cmd: string) => (string|null),
39
- * homedir?: string, repo?: string, write?: (s: string) => void }} [opts]
40
+ * homedir?: string, repo?: string, write?: (s: string) => void,
41
+ * env?: Record<string, string|undefined> }} [opts]
40
42
  * side effects are injectable for testing; defaults hit the real environment
41
43
  * @returns {number} exit code (0 = healthy)
42
44
  */
@@ -91,8 +93,30 @@ function run(opts = {}) {
91
93
  out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
92
94
  }
93
95
 
94
- // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
95
- // lives under the user's home now, never world-shared /tmp).
96
+ // Pane wiring. This command exists to diagnose "nothing happens", and a pane
97
+ // config the user wrote and got wrong is exactly that — the sidecar has room
98
+ // for a one-line marker and no more. Here there is room for the path, the
99
+ // reason, and what ccr actually read out of the file, which is the question
100
+ // someone whose pane never appeared is really asking.
101
+ const cfg = loadPaneConfig({ env: opts.env, home: homedir });
102
+ if (cfg.error) {
103
+ out.push(bad(`pane config: ${cfg.error} — ${stripControl(cfg.configPath)}`));
104
+ problems++;
105
+ } else if (cfg.panes.length) {
106
+ out.push(ok(`pane config: ${cfg.panes.length} pane(s) (${stripControl(cfg.configPath)})`));
107
+ // The path as ccr resolved it, not as written: a tilde that did not expand
108
+ // is invisible in the source string and obvious in the resolved one.
109
+ for (const pane of cfg.panes) out.push(dim(` · ${stripControl(pane.path)}`));
110
+ } else {
111
+ out.push(dim(`· no panes configured (optional — ${stripControl(cfg.configPath)})`));
112
+ }
113
+
114
+ // newest captured snapshot across the container. Instances live TWO levels
115
+ // down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) — the
116
+ // one-level scan alone would report "no status captured" while instances run
117
+ // fine (features/instance-lifecycle.feature: "doctor finds a live instance's
118
+ // captured status"). The root and one-level entries are still scanned so a
119
+ // pre-migration home keeps diagnosing.
96
120
  const ccrDir = path.join(homedir, '.ccr');
97
121
  const dirs = [ccrDir];
98
122
  try {
@@ -101,6 +125,13 @@ function run(opts = {}) {
101
125
  try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
102
126
  }
103
127
  } catch { /* none */ }
128
+ try {
129
+ const inst = path.join(ccrDir, 'instances');
130
+ for (const d of fs.readdirSync(inst)) {
131
+ const sub = path.join(inst, d);
132
+ try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
133
+ }
134
+ } catch { /* none */ }
104
135
  let newest = null;
105
136
  for (const d of dirs) {
106
137
  try { const m = fs.statSync(path.join(d, 'last-status.json')).mtimeMs; if (!newest || m > newest.m) newest = { d, m }; } catch { /* none */ }
@@ -0,0 +1,273 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/git-history.js — recent commits with their branch structure, the model
4
+ // behind the pane's graph section (features/git-commit-graph.feature).
5
+ //
6
+ // THE WALK STARTS AT EVERY LOCAL BRANCH, not just HEAD. The visionary chose a
7
+ // multi-lane graph to match IDE git-graph habits, and those graphs answer
8
+ // "what lines of work exist here", which a HEAD-ancestry walk cannot — a
9
+ // repository with three topic branches would draw one lane and call itself
10
+ // done. Tips are ordered newest-first; when there are more tips than lanes
11
+ // (laneBudget in the renderer), the newest keep their lanes and the rest are
12
+ // COUNTED, never silently dropped — the lane-overflow scenario exists because
13
+ // the visionary was warned a graph that dropped branches would be worse than
14
+ // the flat list it replaced.
15
+ //
16
+ // LANE ASSIGNMENT is the classic newest-first sweep: each lane holds the
17
+ // commit id it expects next; a commit takes the leftmost lane expecting it,
18
+ // closes every other lane that expected it (a fork, seen from below), and
19
+ // hands its first parent that lane — extra parents open lanes beside it (the
20
+ // merge's second line). This is a simplification of git log --graph's painter
21
+ // and its contract is exactly what the scenarios pin: lane COUNT, join at the
22
+ // merge, newest first.
23
+ //
24
+ // Commit metadata is display data from an untrusted repository: subjects are
25
+ // stripped at THIS boundary (the choke-point rule of src/git-repo.js), and
26
+ // every read is bounded — the walk has a hard commit cap, and object reads
27
+ // inherit src/git-objects.js's own caps.
28
+
29
+ const path = require('node:path');
30
+ const fs = require('node:fs');
31
+ const { readObject, resolveHead, resolveRef } = require('./git-objects');
32
+ const { readTextCapped } = require('./safe-read');
33
+ const { stripControl } = require('./sanitize');
34
+
35
+ // More commits than a sidebar can show, fewer than a pathological repository
36
+ // could make us read. The renderer slices further by its row budget.
37
+ const MAX_COMMITS = 64;
38
+
39
+ // Branch tips considered, before the renderer's lane budget cuts further.
40
+ const MAX_TIPS = 128;
41
+
42
+ // A subject longer than this cannot survive any pane layout; cap at the read
43
+ // boundary so layout budgets around a value, not a megabyte.
44
+ const SUBJECT_MAX = 200;
45
+
46
+ /**
47
+ * @typedef {object} CommitRow
48
+ * @property {string} oid
49
+ * @property {string} shortHash 7 hex chars, git's default abbreviation floor.
50
+ * @property {string} subject First message line, control-stripped, capped.
51
+ * @property {number} when Committer time, seconds.
52
+ * @property {number} lane 0-based lane of this commit's node.
53
+ * @property {boolean[]} activeMask Which lanes are live on this row (the
54
+ * renderer's `│` columns).
55
+ * @property {number[]} joinLanes Lanes this merge's extra parents run in —
56
+ * the renderer's join glyph, the merge scenario's evidence.
57
+ * @property {boolean} closes Another lane also expected this commit (a
58
+ * fork seen from below) and was folded into this one.
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} History
63
+ * @property {'ok'|'empty'|'unavailable'} state 'empty' = no commits anywhere.
64
+ * @property {CommitRow[]} rows Newest first.
65
+ * @property {number} laneCount Widest simultaneous lane use.
66
+ * @property {number} droppedBranches Tips beyond the lane budget, counted.
67
+ */
68
+
69
+ /**
70
+ * Parse the header of a commit object: tree, parents, committer time, subject.
71
+ * @param {Buffer} data
72
+ * @returns {{ parents: string[], when: number, subject: string }|null}
73
+ */
74
+ function parseCommit(data) {
75
+ const text = data.toString('utf8');
76
+ const headerEnd = text.indexOf('\n\n');
77
+ const header = headerEnd === -1 ? text : text.slice(0, headerEnd);
78
+ if (!/^tree [0-9a-f]{40}|^tree [0-9a-f]{64}/m.test(header)) return null;
79
+ const parents = [...header.matchAll(/^parent ([0-9a-f]{40}|[0-9a-f]{64})$/gm)].map((m) => m[1]);
80
+ const committer = /^committer [^\n]* (\d{1,12}) [+-]\d{4}$/m.exec(header);
81
+ const when = committer ? Number(committer[1]) : 0;
82
+ const body = headerEnd === -1 ? '' : text.slice(headerEnd + 2);
83
+ const firstLine = body.split('\n')[0] || '';
84
+ const clean = stripControl(firstLine).trim();
85
+ const cps = [...clean];
86
+ const subject = cps.length <= SUBJECT_MAX ? clean : cps.slice(0, SUBJECT_MAX - 1).join('') + '…';
87
+ return { parents, when, subject };
88
+ }
89
+
90
+ /**
91
+ * Every local branch tip: loose refs under refs/heads plus packed-refs
92
+ * entries, deduplicated, HEAD's target included even when detached.
93
+ * @param {string} gitDir
94
+ * @returns {string[]} Tip oids, unordered.
95
+ */
96
+ function branchTips(gitDir) {
97
+ /** @type {Set<string>} */
98
+ const tips = new Set();
99
+ const headsDir = path.join(gitDir, 'refs', 'heads');
100
+ /** @type {Array<{ dir: string, ref: string }>} */
101
+ const stack = [{ dir: headsDir, ref: 'refs/heads' }];
102
+ let visited = 0;
103
+ while (stack.length > 0 && tips.size < MAX_TIPS) {
104
+ const top = /** @type {{ dir: string, ref: string }} */ (stack.pop());
105
+ /** @type {fs.Dirent[]} */
106
+ let dirents = [];
107
+ try { dirents = fs.readdirSync(top.dir, { withFileTypes: true }); } catch { continue; }
108
+ for (const d of dirents) {
109
+ visited += 1;
110
+ if (visited > MAX_TIPS * 4) break;
111
+ if (d.isDirectory()) stack.push({ dir: path.join(top.dir, d.name), ref: top.ref + '/' + d.name });
112
+ else if (d.isFile()) {
113
+ const oid = resolveRef(gitDir, top.ref + '/' + d.name);
114
+ if (oid) tips.add(oid);
115
+ }
116
+ }
117
+ }
118
+ const packed = readTextCapped(path.join(gitDir, 'packed-refs'), 4 * 1024 * 1024);
119
+ if (packed) {
120
+ for (const line of packed.split('\n')) {
121
+ if (tips.size >= MAX_TIPS) break;
122
+ const m = /^([0-9a-f]{40}|[0-9a-f]{64}) refs\/heads\/\S+$/.exec(line.trim());
123
+ // A loose ref shadows its packed entry; resolveRef already prefers it,
124
+ // and adding the packed oid too would resurrect a stale tip. Only tips
125
+ // whose ref has no loose file get taken from here — approximated by the
126
+ // Set: a shadowed packed oid that differs would add a phantom tip, so
127
+ // resolve the ref properly instead.
128
+ if (m) {
129
+ const ref = line.trim().slice(line.trim().indexOf(' ') + 1);
130
+ const oid = resolveRef(gitDir, ref);
131
+ if (oid) tips.add(oid);
132
+ }
133
+ }
134
+ }
135
+ const head = resolveHead(gitDir);
136
+ if (head) tips.add(head);
137
+ return [...tips];
138
+ }
139
+
140
+ /**
141
+ * Read recent history: tips, walk, lanes.
142
+ *
143
+ * @param {string} gitDir
144
+ * @param {{ maxLanes?: number, maxRows?: number }} [opts]
145
+ * @returns {History}
146
+ */
147
+ function readHistory(gitDir, opts = {}) {
148
+ const maxLanes = opts.maxLanes && opts.maxLanes > 0 ? opts.maxLanes : 6;
149
+ const maxRows = opts.maxRows && opts.maxRows > 0 ? Math.min(opts.maxRows, MAX_COMMITS) : MAX_COMMITS;
150
+
151
+ const tips = branchTips(gitDir);
152
+ if (tips.length === 0) {
153
+ // No refs anywhere. An unborn HEAD (fresh init) is the EMPTY state the
154
+ // no-commits scenario names; an unreadable HEAD is not.
155
+ const raw = readTextCapped(path.join(gitDir, 'HEAD'), 4096);
156
+ const unborn = raw !== null && /^ref:[ \t]*refs\//.test(raw.split('\n')[0].trim());
157
+ return { state: unborn ? 'empty' : 'unavailable', rows: [], laneCount: 0, droppedBranches: 0 };
158
+ }
159
+
160
+ // Load every tip commit; tips that no longer resolve to a commit degrade the
161
+ // whole section (never guess at history).
162
+ /** @type {Map<string, { parents: string[], when: number, subject: string }>} */
163
+ const loaded = new Map();
164
+ const load = (/** @type {string} */ oid) => {
165
+ if (loaded.has(oid)) return loaded.get(oid) || null;
166
+ const obj = readObject(gitDir, oid);
167
+ if (obj === null || obj.type !== 'commit') return null;
168
+ const parsed = parseCommit(obj.data);
169
+ if (parsed === null) return null;
170
+ loaded.set(oid, parsed);
171
+ return parsed;
172
+ };
173
+
174
+ /** @type {Array<{ oid: string, when: number }>} */
175
+ const tipList = [];
176
+ for (const oid of tips) {
177
+ const c = load(oid);
178
+ if (c === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches: 0 };
179
+ tipList.push({ oid, when: c.when });
180
+ }
181
+ tipList.sort((a, b) => b.when - a.when || (a.oid < b.oid ? -1 : 1));
182
+ const taken = tipList.slice(0, maxLanes);
183
+ // Tips sharing history with a taken tip still count as their own branch —
184
+ // the scenario counts BRANCHES, and each tip is one.
185
+ const droppedBranches = tipList.length - taken.length;
186
+
187
+ // Date-ordered walk from the taken tips: a max-heap by committer time,
188
+ // approximated with a sorted array (sizes here are tens, not thousands).
189
+ /** @type {Array<{ oid: string, when: number }>} */
190
+ const frontier = [...taken];
191
+ /** @type {Set<string>} */
192
+ const emitted = new Set();
193
+ /** Lanes: the commit id each lane expects next (null = closed). */
194
+ /** @type {Array<string|null>} */
195
+ const lanes = [];
196
+ /** @type {CommitRow[]} */
197
+ const rows = [];
198
+ let laneCount = 0;
199
+
200
+ while (frontier.length > 0 && rows.length < maxRows) {
201
+ frontier.sort((a, b) => b.when - a.when || (a.oid < b.oid ? -1 : 1));
202
+ const next = /** @type {{ oid: string, when: number }} */ (frontier.shift());
203
+ if (emitted.has(next.oid)) continue;
204
+ const c = load(next.oid);
205
+ if (c === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches };
206
+ emitted.add(next.oid);
207
+
208
+ // The leftmost lane expecting this commit; none → a new tip opens a lane.
209
+ let lane = lanes.findIndex((l) => l === next.oid);
210
+ let closes = false;
211
+ if (lane === -1) {
212
+ lane = lanes.findIndex((l) => l === null);
213
+ if (lane === -1) { lanes.push(null); lane = lanes.length - 1; }
214
+ } else {
215
+ // Every OTHER lane expecting it folds into this one — a fork, viewed
216
+ // from below.
217
+ for (let i = 0; i < lanes.length; i += 1) {
218
+ if (i !== lane && lanes[i] === next.oid) { lanes[i] = null; closes = true; }
219
+ }
220
+ }
221
+ const first = c.parents[0] || null;
222
+ lanes[lane] = first && !emitted.has(first) ? first : null;
223
+ /** @type {number[]} */
224
+ const joinLanes = [];
225
+ for (const p of c.parents.slice(1)) {
226
+ if (emitted.has(p)) continue;
227
+ const existing = lanes.indexOf(p);
228
+ if (existing !== -1) {
229
+ // The merge's other line already runs in a lane; the join points there.
230
+ joinLanes.push(existing);
231
+ continue;
232
+ }
233
+ // Open the nearest free lane for it.
234
+ let free = lanes.findIndex((l) => l === null);
235
+ if (free === -1) {
236
+ if (lanes.length >= maxLanes) continue;
237
+ lanes.push(p);
238
+ free = lanes.length - 1;
239
+ } else {
240
+ lanes[free] = p;
241
+ }
242
+ joinLanes.push(free);
243
+ }
244
+ while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop();
245
+ const activeMask = lanes.map((l) => l !== null);
246
+ while (activeMask.length < lane + 1) activeMask.push(false);
247
+ activeMask[lane] = true; // the node's own column is always drawn
248
+ const active = activeMask.filter(Boolean).length;
249
+ laneCount = Math.max(laneCount, Math.max(active, lane + 1));
250
+
251
+ rows.push({
252
+ oid: next.oid,
253
+ shortHash: next.oid.slice(0, 7),
254
+ subject: c.subject,
255
+ when: c.when,
256
+ lane,
257
+ activeMask,
258
+ joinLanes,
259
+ closes,
260
+ });
261
+ for (const p of c.parents) {
262
+ if (!emitted.has(p)) {
263
+ const pc = load(p);
264
+ if (pc === null) return { state: 'unavailable', rows: [], laneCount: 0, droppedBranches };
265
+ frontier.push({ oid: p, when: pc.when });
266
+ }
267
+ }
268
+ }
269
+
270
+ return { state: 'ok', rows, laneCount: Math.min(laneCount, maxLanes), droppedBranches };
271
+ }
272
+
273
+ module.exports = { readHistory, branchTips, parseCommit, MAX_COMMITS };
@@ -0,0 +1,118 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/git-ignore.js — enough of gitignore matching for the untracked walk.
4
+ //
5
+ // The untracked list is the one part of the working-tree section that reads
6
+ // the WORLD rather than `.git`, and without ignore rules it would lead with
7
+ // node_modules — a thousand-line lie of omission about what the user actually
8
+ // created. So the walk honors the two per-repo sources: `.git/info/exclude`
9
+ // and every `.gitignore` on the path down.
10
+ //
11
+ // WHAT IS DELIBERATELY OUT: the user's global excludesFile (a config lookup
12
+ // away, but its patterns describe the USER's machine, and the far-side oracle
13
+ // pins this reader against `git status` run with that config disabled), and
14
+ // the escape subtleties (`\#`, trailing backslash-space). Both are recorded in
15
+ // features/design/git-untracked-walk.feature rather than silently absent.
16
+ //
17
+ // Precedence is git's: within one file the LAST matching pattern wins; a
18
+ // deeper .gitignore beats a shallower one; exclude is the weakest. A directory
19
+ // that is ignored is never descended into, which also reproduces git's "cannot
20
+ // re-include below an excluded directory" rule for free.
21
+
22
+ /**
23
+ * @typedef {object} IgnoreRule
24
+ * @property {boolean} neg `!pattern` — re-includes.
25
+ * @property {boolean} dirOnly Trailing slash — matches directories only.
26
+ * @property {RegExp} re Compiled against the path RELATIVE TO the rule's base.
27
+ */
28
+
29
+ /**
30
+ * Compile one gitignore pattern line, or null for blanks and comments.
31
+ * @param {string} line
32
+ * @returns {IgnoreRule|null}
33
+ */
34
+ function compilePattern(line) {
35
+ let p = line.replace(/\r$/, '');
36
+ if (!p || p.startsWith('#')) return null;
37
+ let neg = false;
38
+ if (p.startsWith('!')) { neg = true; p = p.slice(1); }
39
+ p = p.replace(/(?<!\\)\s+$/, ''); // unescaped trailing spaces are trimmed
40
+ if (!p) return null;
41
+ let dirOnly = false;
42
+ if (p.endsWith('/')) { dirOnly = true; p = p.slice(0, -1); }
43
+ // A slash anywhere (now that a trailing one is gone) anchors the pattern to
44
+ // the rule's own directory; without one it matches at any depth.
45
+ const anchored = p.includes('/');
46
+ if (p.startsWith('/')) p = p.slice(1);
47
+
48
+ let re = '';
49
+ for (let i = 0; i < p.length; i += 1) {
50
+ const c = p[i];
51
+ if (c === '*') {
52
+ if (p[i + 1] === '*') {
53
+ // `**` spans directories: leading `**/` any prefix, trailing `/**`
54
+ // everything below, `a**b` collapses to any run.
55
+ i += 1;
56
+ if (p[i + 1] === '/') { i += 1; re += '(?:[^/]+/)*'; } else re += '.*';
57
+ } else re += '[^/]*';
58
+ } else if (c === '?') {
59
+ re += '[^/]';
60
+ } else if (c === '[') {
61
+ const close = p.indexOf(']', i + 2);
62
+ if (close === -1) { re += '\\['; continue; }
63
+ let cls = p.slice(i + 1, close);
64
+ if (cls.startsWith('!')) cls = '^' + cls.slice(1);
65
+ re += '[' + cls.replace(/\\/g, '\\\\') + ']';
66
+ i = close;
67
+ } else if (c === '\\' && i + 1 < p.length) {
68
+ i += 1;
69
+ re += p[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ } else {
71
+ re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ }
73
+ }
74
+ const body = anchored ? re : '(?:[^/]+/)*' + re;
75
+ let compiled;
76
+ try {
77
+ compiled = new RegExp('^' + body + '$');
78
+ } catch {
79
+ return null; // a pattern this reader cannot compile ignores nothing
80
+ }
81
+ return { neg, dirOnly, re: compiled };
82
+ }
83
+
84
+ /**
85
+ * Parse a whole ignore file's text into rules, in order.
86
+ * @param {string|null} text
87
+ * @returns {IgnoreRule[]}
88
+ */
89
+ function parseIgnore(text) {
90
+ if (!text) return [];
91
+ /** @type {IgnoreRule[]} */
92
+ const out = [];
93
+ for (const line of text.split('\n')) {
94
+ const rule = compilePattern(line);
95
+ if (rule) out.push(rule);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Is `rel` (POSIX path relative to the rules' base) ignored by these rules?
102
+ * Returns the last matching rule's verdict, or null when nothing matched.
103
+ * @param {IgnoreRule[]} rules
104
+ * @param {string} rel
105
+ * @param {boolean} isDir
106
+ * @returns {boolean|null}
107
+ */
108
+ function matchRules(rules, rel, isDir) {
109
+ /** @type {boolean|null} */
110
+ let verdict = null;
111
+ for (const r of rules) {
112
+ if (r.dirOnly && !isDir) continue;
113
+ if (r.re.test(rel)) verdict = !r.neg;
114
+ }
115
+ return verdict;
116
+ }
117
+
118
+ module.exports = { compilePattern, parseIgnore, matchRules };