claude-code-runrate 0.3.0 → 0.4.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/src/doctor.js CHANGED
@@ -91,8 +91,12 @@ function run(opts = {}) {
91
91
  out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
92
92
  }
93
93
 
94
- // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
95
- // lives under the user's home now, never world-shared /tmp).
94
+ // newest captured snapshot across the container. Instances live TWO levels
95
+ // down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) the
96
+ // one-level scan alone would report "no status captured" while instances run
97
+ // fine (features/instance-lifecycle.feature: "doctor finds a live instance's
98
+ // captured status"). The root and one-level entries are still scanned so a
99
+ // pre-migration home keeps diagnosing.
96
100
  const ccrDir = path.join(homedir, '.ccr');
97
101
  const dirs = [ccrDir];
98
102
  try {
@@ -101,6 +105,13 @@ function run(opts = {}) {
101
105
  try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
102
106
  }
103
107
  } catch { /* none */ }
108
+ try {
109
+ const inst = path.join(ccrDir, 'instances');
110
+ for (const d of fs.readdirSync(inst)) {
111
+ const sub = path.join(inst, d);
112
+ try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
113
+ }
114
+ } catch { /* none */ }
104
115
  let newest = null;
105
116
  for (const d of dirs) {
106
117
  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 };
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/git-index.js — parse `.git/index`, the staging area, without git.
4
+ //
5
+ // Second stop of the build fork src/git-repo.js rules in its header: the pane
6
+ // reads `.git` itself, so the working-tree section needs the index in git's own
7
+ // on-disk format. The contract this parser is held to lives in
8
+ // features/design/git-index-format.feature — the design tier, because an index
9
+ // byte layout is an implementation criterion no visionary should be asked to
10
+ // review.
11
+ //
12
+ // WHAT IS PARSED, AND WHAT IS DELIBERATELY NOT. Versions 2, 3 and 4 — the three
13
+ // git writes today — including the stage bits that mark conflicts and the stat
14
+ // cache the modified-check needs. Extensions (TREE, REUC, link…) are skipped
15
+ // whole: every one is an optimization cache over the entries themselves, and a
16
+ // reader that consults none of them can never be lied to by a stale one.
17
+ //
18
+ // EVERY FAILURE IS null, NEVER A GUESS. A truncated entry, an impossible count,
19
+ // an unknown version, an over-cap file — the caller degrades to "git data
20
+ // unavailable" (features/git-pane-safety.feature), which is honest where a
21
+ // partial listing would be a quiet lie about what is staged.
22
+ //
23
+ // Hostile-input rules are src/safe-read.js's: the index is read through
24
+ // readBytesCapped (regular file only, size-capped, never blocks), and paths are
25
+ // NOT display-sanitized here — the model layer does that at its own boundary,
26
+ // because these paths are also compared against real directory listings and a
27
+ // stripped path would fail to match the file it names.
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { readBytesCapped, readTextCapped } = require('./safe-read');
32
+
33
+ // A generous roof, not a target: the linux kernel's index is ~10 MB. Past this
34
+ // the pane degrades rather than spending the draw loop's budget parsing.
35
+ const INDEX_MAX_BYTES = 32 * 1024 * 1024;
36
+
37
+ // Entries are ~70 bytes plus a path, so this cap can only trip on a file whose
38
+ // header count lies about its body — the exact corruption it exists to stop.
39
+ const MAX_ENTRIES = 200_000;
40
+
41
+ /**
42
+ * One index entry. `stage` is 0 for an ordinary staged path and 1..3 for the
43
+ * three sides of a conflict (base, ours, theirs).
44
+ *
45
+ * @typedef {object} IndexEntry
46
+ * @property {string} path Repo-relative, POSIX separators, as git stores it.
47
+ * @property {number} mode File mode (e.g. 0o100644, 0o120000 symlink).
48
+ * @property {number} stage 0 normal, 1-3 conflict stages.
49
+ * @property {string} oid Object id, lowercase hex (40 or 64 chars).
50
+ * @property {number} size Cached worktree size at add time.
51
+ * @property {number} mtimeSec Cached worktree mtime (seconds).
52
+ * @property {number} mtimeNsec Cached worktree mtime (nanoseconds part).
53
+ */
54
+
55
+ /**
56
+ * @typedef {object} GitIndex
57
+ * @property {IndexEntry[]} entries In file order (git keeps them path-sorted).
58
+ * @property {number} version
59
+ * @property {number} mtimeMs The index file's own mtime — the racy-clean
60
+ * comparison needs it (see src/git-working-tree.js).
61
+ */
62
+
63
+ /**
64
+ * Does this repository use sha-256 object names? The index does not declare its
65
+ * hash width; the repository does, in config. A config that cannot be read
66
+ * means the default format, which is what the fallback answers.
67
+ * @param {string} gitDir
68
+ * @returns {boolean}
69
+ */
70
+ function usesSha256(gitDir) {
71
+ const cfg = readTextCapped(path.join(gitDir, 'config'), 64 * 1024);
72
+ return cfg != null && /^\s*objectformat\s*=\s*sha256\s*$/im.test(cfg);
73
+ }
74
+
75
+ /**
76
+ * Parse `.git/index`. Returns the entries, or null when the file cannot be
77
+ * trusted, or `{ entries: [] }` (empty, versioned 0) when it simply does not
78
+ * exist — a freshly-initialized repository has no index yet, and "nothing is
79
+ * staged" is the true statement about it.
80
+ *
81
+ * @param {string} gitDir
82
+ * @returns {GitIndex|null}
83
+ */
84
+ function readIndex(gitDir) {
85
+ const file = path.join(gitDir, 'index');
86
+ let st = null;
87
+ try { st = fs.lstatSync(file); } catch { st = null; }
88
+ if (st === null) return { entries: [], version: 0, mtimeMs: 0 };
89
+
90
+ const buf = readBytesCapped(file, INDEX_MAX_BYTES);
91
+ if (buf === null || buf.length < 12) return null;
92
+ if (buf.toString('latin1', 0, 4) !== 'DIRC') return null;
93
+ const version = buf.readUInt32BE(4);
94
+ if (version < 2 || version > 4) return null;
95
+ const count = buf.readUInt32BE(8);
96
+ if (count > MAX_ENTRIES) return null;
97
+
98
+ const hashBytes = usesSha256(gitDir) ? 32 : 20;
99
+ /** @type {IndexEntry[]} */
100
+ const entries = [];
101
+ let off = 12;
102
+ let prevPath = '';
103
+ for (let i = 0; i < count; i += 1) {
104
+ const start = off;
105
+ // Fixed part: ctime(8) mtime(8) dev(4) ino(4) mode(4) uid(4) gid(4)
106
+ // size(4) oid(hashBytes) flags(2).
107
+ if (off + 40 + hashBytes + 2 > buf.length) return null;
108
+ const mtimeSec = buf.readUInt32BE(off + 8);
109
+ const mtimeNsec = buf.readUInt32BE(off + 12);
110
+ const mode = buf.readUInt32BE(off + 24);
111
+ const size = buf.readUInt32BE(off + 36);
112
+ const oid = buf.toString('hex', off + 40, off + 40 + hashBytes);
113
+ const flags = buf.readUInt16BE(off + 40 + hashBytes);
114
+ off += 40 + hashBytes + 2;
115
+ const stage = (flags >> 12) & 0x3;
116
+ // Version 3+ may carry an extended-flags word, marked by bit 14.
117
+ if (flags & 0x4000) {
118
+ if (version < 3 || off + 2 > buf.length) return null;
119
+ off += 2;
120
+ }
121
+
122
+ /** @type {string} */
123
+ let p;
124
+ if (version === 4) {
125
+ // v4 compresses paths: a varint N ("strip N bytes from the previous
126
+ // path"), then the NUL-terminated suffix to append.
127
+ if (off >= buf.length) return null;
128
+ let b = buf[off]; off += 1;
129
+ let strip = b & 0x7f;
130
+ let hops = 0;
131
+ while (b & 0x80) {
132
+ // Git's offset varint: the accumulated value gains 1 BEFORE each
133
+ // 7-bit shift — decode_varint in git's own varint.c.
134
+ if (off >= buf.length || hops > 6) return null;
135
+ b = buf[off]; off += 1;
136
+ strip = ((strip + 1) << 7) + (b & 0x7f);
137
+ hops += 1;
138
+ }
139
+ const nul = buf.indexOf(0, off);
140
+ if (nul === -1) return null;
141
+ const suffix = buf.toString('utf8', off, nul);
142
+ off = nul + 1;
143
+ if (strip > prevPath.length) return null;
144
+ p = prevPath.slice(0, prevPath.length - strip) + suffix;
145
+ } else {
146
+ // v2/v3: NUL-terminated path, then the whole entry padded with NULs to a
147
+ // multiple of 8 bytes from the entry's start.
148
+ const nameLen = flags & 0x0fff;
149
+ const nul = nameLen < 0x0fff && off + nameLen <= buf.length && buf[off + nameLen] === 0
150
+ ? off + nameLen
151
+ : buf.indexOf(0, off);
152
+ if (nul === -1) return null;
153
+ p = buf.toString('utf8', off, nul);
154
+ off = nul + 1;
155
+ const entryLen = off - start;
156
+ const padded = Math.ceil(entryLen / 8) * 8;
157
+ off = start + padded;
158
+ if (off > buf.length) return null;
159
+ }
160
+ if (!p) return null;
161
+ entries.push({ path: p, mode, stage, oid, size, mtimeSec, mtimeNsec });
162
+ prevPath = p;
163
+ }
164
+ return { entries, version, mtimeMs: st.mtimeMs };
165
+ }
166
+
167
+ module.exports = { readIndex, usesSha256, INDEX_MAX_BYTES, MAX_ENTRIES };