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/README.md +86 -10
- package/bin/ccr.js +223 -26
- package/package.json +4 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +13 -2
- 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/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 +40 -9
- package/src/migrate.js +155 -0
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +49 -1
- package/src/render/statusline.js +42 -4
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +145 -20
- package/src/state-dir.js +44 -1
|
@@ -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
|
+
};
|