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.
@@ -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 };
@@ -0,0 +1,448 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/git-objects.js — read commits and trees out of `.git/objects`, without git.
4
+ //
5
+ // Third stop of the build fork (src/git-repo.js header): the pane parses `.git`
6
+ // itself, and the working-tree and history sections both need real objects —
7
+ // staged-ness is "does the index entry differ from HEAD's tree", which cannot
8
+ // be answered without reading that tree. The byte-level contract lives in
9
+ // features/design/git-object-store.feature.
10
+ //
11
+ // ONLY COMMITS AND TREES ARE EVER REQUESTED. The modified-check hashes worktree
12
+ // files and compares AGAINST index oids, so blob CONTENT is never read — which
13
+ // is why the size cap can be modest. A repository whose history the pane wants
14
+ // is made of objects a few KB each; a cap that would refuse a giant vendored
15
+ // blob refuses nothing the pane asks for.
16
+ //
17
+ // PACKFILES ARE NOT OPTIONAL. Every clone and every gc leaves most objects
18
+ // packed; a loose-only reader would work in a demo repo and degrade in every
19
+ // repository anyone actually uses. Pack index v2 and both delta forms
20
+ // (ofs-delta, ref-delta) are supported; v1 pack indexes (pre-2008) are not,
21
+ // and degrade to null like every other surprise.
22
+ //
23
+ // EVERY FAILURE IS null. The caller says "git data unavailable"
24
+ // (features/git-pane-safety.feature) — honest, and the exact opposite of
25
+ // guessing at history. All reads go through readBytesCapped: regular files
26
+ // only, size-capped, never blocking, so a fifo planted in .git/objects cannot
27
+ // wedge the draw loop (the safety feature's wedging scenarios).
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const zlib = require('node:zlib');
32
+ const { readBytesCapped, readTextCapped } = require('./safe-read');
33
+
34
+ // Caps. An object the pane reads (commit, tree) is small; the pack caps are
35
+ // roofs against corruption, not budgets the code approaches.
36
+ const OBJECT_MAX_BYTES = 8 * 1024 * 1024; // one inflated object
37
+ const PACK_IDX_MAX_BYTES = 128 * 1024 * 1024; // linux kernel idx is ~90 MB
38
+ const MAX_DELTA_DEPTH = 64; // git's own effective ceiling
39
+ const MAX_PACKS = 256;
40
+
41
+ /** @typedef {{ type: 'commit'|'tree'|'blob'|'tag', data: Buffer }} GitObject */
42
+
43
+ /**
44
+ * Inflate with a hard output cap, never throwing. The cap matters: zlib is a
45
+ * compression format, so a 100-byte planted file can claim to inflate to
46
+ * gigabytes; maxOutputLength makes that a null, not an allocation.
47
+ * @param {Buffer} buf
48
+ * @param {number} [cap]
49
+ * @returns {Buffer|null}
50
+ */
51
+ function inflateCapped(buf, cap = OBJECT_MAX_BYTES) {
52
+ try {
53
+ return zlib.inflateSync(buf, { maxOutputLength: cap });
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Read a loose object: `.git/objects/aa/bbbb…`, zlib-deflated
61
+ * `"<type> <size>\0<data>"`.
62
+ * @param {string} gitDir
63
+ * @param {string} oid
64
+ * @returns {GitObject|null}
65
+ */
66
+ function readLoose(gitDir, oid) {
67
+ const file = path.join(gitDir, 'objects', oid.slice(0, 2), oid.slice(2));
68
+ const raw = readBytesCapped(file, OBJECT_MAX_BYTES);
69
+ if (raw === null) return null;
70
+ const inflated = inflateCapped(raw);
71
+ if (inflated === null) return null;
72
+ const nul = inflated.indexOf(0);
73
+ if (nul === -1 || nul > 32) return null;
74
+ const m = /^(commit|tree|blob|tag) (\d{1,10})$/.exec(inflated.toString('latin1', 0, nul));
75
+ if (!m) return null;
76
+ const data = inflated.subarray(nul + 1);
77
+ if (data.length !== Number(m[2])) return null;
78
+ return { type: /** @type {GitObject['type']} */ (m[1]), data };
79
+ }
80
+
81
+ /**
82
+ * A pack entry's position, from its `.idx` (version 2 only).
83
+ *
84
+ * The idx layout: 8-byte magic+version, 256×4 fanout, then N hashes, N CRCs,
85
+ * N 4-byte offsets — an offset with its MSB set indexes a table of 8-byte
86
+ * offsets after it (packs over 2 GB).
87
+ *
88
+ * @param {Buffer} idx
89
+ * @param {string} oid
90
+ * @param {number} hashBytes
91
+ * @returns {number|null} Byte offset into the .pack, or null when absent.
92
+ */
93
+ function packOffset(idx, oid, hashBytes) {
94
+ if (idx.length < 8 + 256 * 4 || idx.readUInt32BE(0) !== 0xff744f63 || idx.readUInt32BE(4) !== 2) return null;
95
+ const fanout = 8;
96
+ // The caller validated the oid as lowercase hex; the first byte picks the
97
+ // fanout bucket.
98
+ const first = parseInt(oid.slice(0, 2), 16);
99
+ if (!Number.isInteger(first) || first < 0 || first > 255) return null;
100
+ const total = idx.readUInt32BE(fanout + 255 * 4);
101
+ const lo0 = first === 0 ? 0 : idx.readUInt32BE(fanout + (first - 1) * 4);
102
+ const hi0 = idx.readUInt32BE(fanout + first * 4);
103
+ const names = fanout + 256 * 4;
104
+ const target = Buffer.from(oid, 'hex');
105
+ if (target.length !== hashBytes) return null;
106
+
107
+ let lo = lo0;
108
+ let hi = hi0;
109
+ const need = names + total * hashBytes; // hashes table must fit
110
+ if (need > idx.length || hi > total || lo > hi) return null;
111
+ while (lo < hi) {
112
+ const mid = (lo + hi) >>> 1;
113
+ const at = names + mid * hashBytes;
114
+ const cmp = target.compare(idx, at, at + hashBytes);
115
+ if (cmp === 0) {
116
+ const offsets = names + total * hashBytes + total * 4; // skip CRC table
117
+ const o32at = offsets + mid * 4;
118
+ if (o32at + 4 > idx.length) return null;
119
+ const o32 = idx.readUInt32BE(o32at);
120
+ if ((o32 & 0x80000000) === 0) return o32;
121
+ const bigAt = offsets + total * 4 + (o32 & 0x7fffffff) * 8;
122
+ if (bigAt + 8 > idx.length) return null;
123
+ const big = idx.readBigUInt64BE(bigAt);
124
+ return big <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(big) : null;
125
+ }
126
+ if (cmp < 0) hi = mid; else lo = mid + 1;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Apply a git delta: a source-size varint and target-size varint, then copy
133
+ * (from base) and insert (literal) instructions until the target is built.
134
+ * @param {Buffer} base
135
+ * @param {Buffer} delta
136
+ * @returns {Buffer|null}
137
+ */
138
+ function applyDelta(base, delta) {
139
+ let off = 0;
140
+ const varint = () => {
141
+ let v = 0;
142
+ let shift = 0;
143
+ for (;;) {
144
+ if (off >= delta.length || shift > 49) return null;
145
+ const b = delta[off]; off += 1;
146
+ v += (b & 0x7f) * 2 ** shift;
147
+ if ((b & 0x80) === 0) return v;
148
+ shift += 7;
149
+ }
150
+ };
151
+ const srcSize = varint();
152
+ const outSize = varint();
153
+ if (srcSize === null || outSize === null) return null;
154
+ if (srcSize !== base.length || outSize > OBJECT_MAX_BYTES) return null;
155
+ const out = Buffer.alloc(outSize);
156
+ let at = 0;
157
+ while (off < delta.length) {
158
+ const cmd = delta[off]; off += 1;
159
+ if (cmd & 0x80) {
160
+ // Copy from base: bits 0-3 select offset bytes, 4-6 size bytes.
161
+ let cpOff = 0;
162
+ let cpSize = 0;
163
+ for (let i = 0; i < 4; i += 1) {
164
+ if (cmd & (1 << i)) { if (off >= delta.length) return null; cpOff |= delta[off] << (8 * i); off += 1; }
165
+ }
166
+ for (let i = 0; i < 3; i += 1) {
167
+ if (cmd & (0x10 << i)) { if (off >= delta.length) return null; cpSize |= delta[off] << (8 * i); off += 1; }
168
+ }
169
+ if (cpSize === 0) cpSize = 0x10000;
170
+ cpOff >>>= 0;
171
+ if (cpOff + cpSize > base.length || at + cpSize > outSize) return null;
172
+ base.copy(out, at, cpOff, cpOff + cpSize);
173
+ at += cpSize;
174
+ } else if (cmd > 0) {
175
+ // Insert literal bytes.
176
+ if (off + cmd > delta.length || at + cmd > outSize) return null;
177
+ delta.copy(out, at, off, off + cmd);
178
+ off += cmd;
179
+ at += cmd;
180
+ } else {
181
+ return null; // cmd 0 is reserved and means corruption
182
+ }
183
+ }
184
+ return at === outSize ? out : null;
185
+ }
186
+
187
+ const PACK_TYPE = /** @type {const} */ ({ 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag' });
188
+
189
+ /**
190
+ * Read one object out of a `.pack` at a known offset, resolving deltas
191
+ * recursively (bounded), against an open descriptor — packfiles can be huge,
192
+ * so unlike every other read here the file is NOT slurped; each entry reads
193
+ * only the bytes it needs.
194
+ *
195
+ * @param {{ fd: number, size: number, idx: Buffer, hashBytes: number }} pack
196
+ * @param {number} offset
197
+ * @param {number} depth
198
+ * @returns {GitObject|null}
199
+ */
200
+ function readPacked(pack, offset, depth) {
201
+ if (depth > MAX_DELTA_DEPTH || offset < 12 || offset >= pack.size) return null;
202
+ // An entry's header is a size varint with a 3-bit type; the deflated data
203
+ // follows. We read a window generously sized for headers + base offsets.
204
+ const head = Buffer.alloc(Math.min(64, pack.size - offset));
205
+ try { fs.readSync(pack.fd, head, 0, head.length, offset); } catch { return null; }
206
+ if (head.length < 2) return null;
207
+ let b = head[0];
208
+ const typeNum = (b >> 4) & 0x7;
209
+ let size = b & 0xf;
210
+ let shift = 4;
211
+ let hOff = 1;
212
+ while (b & 0x80) {
213
+ if (hOff >= head.length || shift > 53) return null;
214
+ b = head[hOff]; hOff += 1;
215
+ size += (b & 0x7f) * 2 ** shift;
216
+ shift += 7;
217
+ }
218
+ if (size > OBJECT_MAX_BYTES) return null;
219
+
220
+ /** @type {Buffer|null} */
221
+ let baseData = null;
222
+ /** @type {GitObject['type']|null} */
223
+ let baseType = null;
224
+ if (typeNum === 6) {
225
+ // ofs-delta: a varint (the +1-before-shift form) giving the DISTANCE back
226
+ // to the base entry's offset.
227
+ if (hOff >= head.length) return null;
228
+ b = head[hOff]; hOff += 1;
229
+ let dist = b & 0x7f;
230
+ let hops = 0;
231
+ while (b & 0x80) {
232
+ if (hOff >= head.length || hops > 7) return null;
233
+ b = head[hOff]; hOff += 1;
234
+ dist = ((dist + 1) * 128) + (b & 0x7f);
235
+ hops += 1;
236
+ }
237
+ const base = readPacked(pack, offset - dist, depth + 1);
238
+ if (base === null) return null;
239
+ baseData = base.data;
240
+ baseType = base.type;
241
+ } else if (typeNum === 7) {
242
+ // ref-delta: the base's full hash, then the delta.
243
+ if (hOff + pack.hashBytes > head.length) return null;
244
+ const baseOid = head.toString('hex', hOff, hOff + pack.hashBytes);
245
+ hOff += pack.hashBytes;
246
+ const at = packOffset(pack.idx, baseOid, pack.hashBytes);
247
+ // The base is almost always in the same pack; a thin pack's external base
248
+ // would need the whole store, and a null here degrades honestly.
249
+ const base = at === null ? null : readPacked(pack, at, depth + 1);
250
+ if (base === null) return null;
251
+ baseData = base.data;
252
+ baseType = base.type;
253
+ } else if (!(typeNum in PACK_TYPE)) {
254
+ return null;
255
+ }
256
+
257
+ // Inflate the entry's data. The deflated length is not recorded, so read a
258
+ // bounded window from the entry body to the end cap and let zlib stop at the
259
+ // stream's own end.
260
+ const bodyAt = offset + hOff;
261
+ const windowLen = Math.min(pack.size - bodyAt, size + 1024, OBJECT_MAX_BYTES);
262
+ if (windowLen <= 0) return null;
263
+ const body = Buffer.alloc(windowLen);
264
+ try { fs.readSync(pack.fd, body, 0, windowLen, bodyAt); } catch { return null; }
265
+ const inflated = inflateCapped(body, size);
266
+ if (inflated === null || inflated.length !== size) return null;
267
+
268
+ if (baseData !== null) {
269
+ const restored = applyDelta(baseData, inflated);
270
+ return restored === null || baseType === null ? null : { type: baseType, data: restored };
271
+ }
272
+ return { type: PACK_TYPE[/** @type {1|2|3|4} */ (typeNum)], data: inflated };
273
+ }
274
+
275
+ /**
276
+ * Read an object by id: loose first (cheap, and where fresh objects live),
277
+ * then every pack. Missing everywhere → null.
278
+ *
279
+ * @param {string} gitDir
280
+ * @param {string} oid Lowercase hex, 40 or 64 chars.
281
+ * @returns {GitObject|null}
282
+ */
283
+ function readObject(gitDir, oid) {
284
+ if (!/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(oid)) return null;
285
+ const loose = readLoose(gitDir, oid);
286
+ if (loose !== null) return loose;
287
+
288
+ const hashBytes = oid.length / 2;
289
+ const packDir = path.join(gitDir, 'objects', 'pack');
290
+ /** @type {string[]} */
291
+ let names = [];
292
+ try { names = fs.readdirSync(packDir).filter((n) => n.endsWith('.idx')).slice(0, MAX_PACKS); } catch { return null; }
293
+ for (const name of names) {
294
+ const idx = readBytesCapped(path.join(packDir, name), PACK_IDX_MAX_BYTES);
295
+ if (idx === null) continue;
296
+ const at = packOffset(idx, oid, hashBytes);
297
+ if (at === null) continue;
298
+ const packPath = path.join(packDir, name.slice(0, -4) + '.pack');
299
+ let fd = -1;
300
+ try {
301
+ let st;
302
+ try { st = fs.lstatSync(packPath); } catch { st = null; }
303
+ if (st === null || !st.isFile()) continue;
304
+ fd = fs.openSync(packPath, 'r');
305
+ const fst = fs.fstatSync(fd);
306
+ if (!fst.isFile()) continue;
307
+ const got = readPacked({ fd, size: fst.size, idx, hashBytes }, at, 0);
308
+ if (got !== null) return got;
309
+ } catch {
310
+ // fall through to the next pack
311
+ } finally {
312
+ if (fd !== -1) { try { fs.closeSync(fd); } catch { /* closed */ } }
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /**
319
+ * Resolve a ref name ("refs/heads/main") to an object id: the loose ref file
320
+ * first, then `packed-refs`. Null when the ref does not exist — which is what
321
+ * an unborn branch looks like, and is a state, not an error.
322
+ *
323
+ * @param {string} gitDir
324
+ * @param {string} ref
325
+ * @returns {string|null}
326
+ */
327
+ function resolveRef(gitDir, ref) {
328
+ if (!/^refs\/[\x21-\x7e]+$/.test(ref) || ref.includes('..')) return null;
329
+ const loose = readTextCapped(path.join(gitDir, ...ref.split('/')), 4096);
330
+ if (loose !== null) {
331
+ const line = loose.split('\n')[0].trim();
332
+ if (/^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(line)) return line.toLowerCase();
333
+ return null;
334
+ }
335
+ const packed = readTextCapped(path.join(gitDir, 'packed-refs'), 4 * 1024 * 1024);
336
+ if (packed === null) return null;
337
+ for (const line of packed.split('\n')) {
338
+ if (line.startsWith('#') || line.startsWith('^')) continue;
339
+ const sp = line.indexOf(' ');
340
+ if (sp === -1) continue;
341
+ if (line.slice(sp + 1).trim() === ref) {
342
+ const oid = line.slice(0, sp).trim().toLowerCase();
343
+ if (/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(oid)) return oid;
344
+ }
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /**
350
+ * The object id HEAD points at, following one level of symbolic ref. Null for
351
+ * an unborn branch (fresh `git init`) and for anything unreadable.
352
+ * @param {string} gitDir
353
+ * @returns {string|null}
354
+ */
355
+ function resolveHead(gitDir) {
356
+ const raw = readTextCapped(path.join(gitDir, 'HEAD'), 4096);
357
+ if (raw === null) return null;
358
+ const line = raw.split('\n')[0].trim();
359
+ const m = /^ref:[ \t]*(.+)$/.exec(line);
360
+ if (m) return resolveRef(gitDir, m[1].trim());
361
+ return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(line) ? line.toLowerCase() : null;
362
+ }
363
+
364
+ /**
365
+ * Parse a tree object's entries: `"<octal mode> <name>\0<raw hash>"` repeated.
366
+ * @param {Buffer} data
367
+ * @param {number} hashBytes
368
+ * @returns {Array<{ mode: number, name: string, oid: string }>|null}
369
+ */
370
+ function parseTree(data, hashBytes) {
371
+ const out = [];
372
+ let off = 0;
373
+ while (off < data.length) {
374
+ const sp = data.indexOf(0x20, off);
375
+ if (sp === -1 || sp - off > 7) return null;
376
+ const mode = parseInt(data.toString('latin1', off, sp), 8);
377
+ if (!Number.isInteger(mode)) return null;
378
+ const nul = data.indexOf(0, sp + 1);
379
+ if (nul === -1 || nul + hashBytes >= data.length + 1) return null;
380
+ if (nul + 1 + hashBytes > data.length) return null;
381
+ const name = data.toString('utf8', sp + 1, nul);
382
+ if (!name || name === '.' || name === '..' || name.includes('/')) return null;
383
+ out.push({ mode, name, oid: data.toString('hex', nul + 1, nul + 1 + hashBytes) });
384
+ off = nul + 1 + hashBytes;
385
+ }
386
+ return out;
387
+ }
388
+
389
+ // Flattening a tree touches one object per directory; a repository with more
390
+ // directories than this is not being drawn in a 50-row pane anyway, and the
391
+ // cap keeps a crafted deep tree from spending the draw budget.
392
+ const MAX_TREE_OBJECTS = 10_000;
393
+
394
+ /**
395
+ * Flatten HEAD's tree to `path → { oid, mode }` — the "committed" side of the
396
+ * staged comparison. Null when any needed object cannot be read (degrade,
397
+ * never guess); an EMPTY map for an unborn branch, where nothing is committed
398
+ * and every index entry really is staged.
399
+ *
400
+ * @param {string} gitDir
401
+ * @returns {Map<string, { oid: string, mode: number }>|null}
402
+ */
403
+ function readHeadTree(gitDir) {
404
+ const head = resolveHead(gitDir);
405
+ if (head === null) {
406
+ // Distinguish "no commits yet" from "HEAD unreadable": an unborn HEAD still
407
+ // has a well-formed symbolic ref line; garbage does not.
408
+ const raw = readTextCapped(path.join(gitDir, 'HEAD'), 4096);
409
+ if (raw !== null && /^ref:[ \t]*refs\//.test(raw.split('\n')[0].trim())) return new Map();
410
+ return null;
411
+ }
412
+ const commit = readObject(gitDir, head);
413
+ if (commit === null || commit.type !== 'commit') return null;
414
+ const treeLine = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(commit.data.toString('latin1', 0, Math.min(commit.data.length, 256)));
415
+ if (!treeLine) return null;
416
+
417
+ const hashBytes = treeLine[1].length / 2;
418
+ /** @type {Map<string, { oid: string, mode: number }>} */
419
+ const out = new Map();
420
+ /** @type {Array<{ oid: string, prefix: string }>} */
421
+ const queue = [{ oid: treeLine[1], prefix: '' }];
422
+ let read = 0;
423
+ while (queue.length > 0) {
424
+ const { oid, prefix } = /** @type {{ oid: string, prefix: string }} */ (queue.shift());
425
+ read += 1;
426
+ if (read > MAX_TREE_OBJECTS) return null;
427
+ const obj = readObject(gitDir, oid);
428
+ if (obj === null || obj.type !== 'tree') return null;
429
+ const entries = parseTree(obj.data, hashBytes);
430
+ if (entries === null) return null;
431
+ for (const e of entries) {
432
+ if ((e.mode & 0o170000) === 0o040000) {
433
+ queue.push({ oid: e.oid, prefix: prefix + e.name + '/' });
434
+ } else if ((e.mode & 0o170000) === 0o160000) {
435
+ // A gitlink (submodule): recorded as committed content, never recursed.
436
+ out.set(prefix + e.name, { oid: e.oid, mode: e.mode });
437
+ } else {
438
+ out.set(prefix + e.name, { oid: e.oid, mode: e.mode });
439
+ }
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+
445
+ module.exports = {
446
+ readObject, resolveRef, resolveHead, readHeadTree, parseTree, applyDelta,
447
+ OBJECT_MAX_BYTES, MAX_DELTA_DEPTH,
448
+ };