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/CHANGELOG.md +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/history-privacy.js +435 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "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
|
-
|
|
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 '")"
|
package/src/account-limits.js
CHANGED
|
@@ -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
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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.
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
//
|
|
95
|
-
//
|
|
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 };
|