filegrc 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/git.js CHANGED
@@ -15,18 +15,27 @@ import { loadWorkspace } from "./workspace.js";
15
15
  const lastSuccessfulSynchronizations = new Map();
16
16
  const workspaceHistoryCache = new Map();
17
17
  const dataRecordHistoryIndexCache = new Map();
18
+ let dataRecordHistoryIndexCacheBytes = 0;
18
19
  const historicalFileCache = new Map();
19
- const reachableDataAncestryCache = new Map();
20
20
  const dataHistoryContextCache = new WeakMap();
21
21
  const backgroundSynchronizations = new Map();
22
22
  const browserRemotePrefetches = new Map();
23
23
  const browserRemotePrefetchPromises = new Map();
24
24
  const repositorySnapshotPromises = new Map();
25
25
  let gitCommandInterceptor = null;
26
+ let historicalBatchInterceptor = null;
26
27
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
27
28
  const GIT_DEFAULT_TIMEOUT_MS = 10_000;
28
29
  const GIT_REMOTE_TIMEOUT_MS = 30_000;
29
30
  const GIT_MAX_OUTPUT_BYTES = 20_000_000;
31
+ const DATA_HISTORY_MAX_SOURCE_BYTES = 16 * 1024 * 1024;
32
+ const DATA_HISTORY_MAX_SOURCE_REQUESTS = 20_000;
33
+ const DATA_HISTORY_MAX_COMMITS = 5_000;
34
+ const DATA_HISTORY_MAX_CHANGES = 20_000;
35
+ const DATA_HISTORY_MAX_ANCESTRY_COMMITS = 20_000;
36
+ const DATA_HISTORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
37
+ const DATA_HISTORY_BUILD_TIMEOUT_MS = 10_000;
38
+ const DATA_HISTORY_FAILURE_CACHE_MS = 2_000;
30
39
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
31
40
 
32
41
  function gitEnvironment(overrides = {}) {
@@ -114,6 +123,8 @@ export function getGitSummary(input = process.cwd()) {
114
123
  export function getFileHistory(input, relativePath, limit = 50) {
115
124
  const root = resolveWorkspaceRoot(input);
116
125
  if (!isSafeDataGitPath(relativePath)) return null;
126
+ const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
127
+ if (indexed) return limitedHistory(indexed, limit);
117
128
  try {
118
129
  const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
119
130
  ? []
@@ -136,6 +147,8 @@ export function getFileHistory(input, relativePath, limit = 50) {
136
147
  export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
137
148
  const root = resolveWorkspaceRoot(input);
138
149
  if (!isSafeDataGitPath(relativePath)) return null;
150
+ const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
151
+ if (indexed) return limitedHistory(indexed, limit);
139
152
  try {
140
153
  const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
141
154
  ? []
@@ -185,6 +198,11 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
185
198
  export function getFilePathAtRevision(input, relativePath, revision) {
186
199
  const root = resolveWorkspaceRoot(input);
187
200
  if (!isSafeDataGitPath(relativePath) || !/^[a-f0-9]{40}$/i.test(String(revision || ""))) return null;
201
+ const index = dataRecordHistoryIndexCache.get(root);
202
+ const indexed = indexedPathHistory(index, relativePath);
203
+ if (indexed) {
204
+ return indexed.find(({ commit }) => indexedAncestor(index, commit, revision))?.path || null;
205
+ }
188
206
  const history = getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
189
207
  const summary = history.find(({ commit }) => commit === revision)
190
208
  || history.find(({ commit }) => isDataHistoryAncestor(root, commit, revision));
@@ -234,53 +252,171 @@ export function getRecordIdentityHistories(input, ids) {
234
252
  return new Map([...new Set(ids)].map((id) => [id, index.historiesById.get(id) || []]));
235
253
  }
236
254
 
237
- export function getDataRecordHistoryIndex(input) {
255
+ export function getDataRecordHistoryIndex(input, options = {}) {
238
256
  const root = resolveWorkspaceRoot(input);
239
- const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
240
257
  const cached = dataRecordHistoryIndexCache.get(root);
241
- if (cached?.head === head) return cached;
258
+ const suppliedHead = /^[a-f0-9]{40}$/i.test(String(options.head)) ? String(options.head) : undefined;
259
+ if (
260
+ suppliedHead
261
+ && cached?.head === suppliedHead
262
+ && !cached.available
263
+ && cached.failureExpiresAt > performance.now()
264
+ ) return cached;
265
+ if (suppliedHead && cached?.head === suppliedHead && cached.available) return cached;
266
+ const hasDeadline = options.deadline !== undefined;
267
+ const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
268
+ if (!Number.isFinite(deadline) || deadline <= performance.now()) {
269
+ const error = new Error("Git data history exceeded its cumulative time limit.");
270
+ error.code = "FILEGRC_HISTORY_DEADLINE";
271
+ throw error;
272
+ }
273
+ const remainingTime = () => {
274
+ const remainingMs = Math.floor(deadline - performance.now());
275
+ if (remainingMs <= 0) {
276
+ const error = new Error("Git data history exceeded its cumulative time limit.");
277
+ error.code = "FILEGRC_HISTORY_DEADLINE";
278
+ throw error;
279
+ }
280
+ return remainingMs;
281
+ };
282
+ const historyGit = (args) => git(root, args, { timeoutMs: remainingTime() });
283
+ const historyGitRaw = (args) => gitRaw(root, args, { timeoutMs: remainingTime() });
284
+ let head = null;
285
+ let discoveryError = null;
286
+ if (suppliedHead) {
287
+ head = suppliedHead;
288
+ } else {
289
+ try {
290
+ head = historyGit(["rev-parse", "HEAD"]) || null;
291
+ } catch (cause) {
292
+ discoveryError = cause;
293
+ }
294
+ }
295
+ if (cached?.head === head && cached.available) return cached;
296
+ if (cached) {
297
+ dataRecordHistoryIndexCache.delete(root);
298
+ dataRecordHistoryIndexCacheBytes -= cached.cacheBytes || 0;
299
+ }
242
300
  const changes = [];
243
- const shallow = head && tryGit(root, ["rev-parse", "--is-shallow-repository"]) === "true";
301
+ const sourceChanges = [];
302
+ const changesByCommit = new Map();
303
+ let shallow = false;
304
+ if (head) {
305
+ try {
306
+ shallow = historyGit(["rev-parse", "--is-shallow-repository"]) === "true";
307
+ } catch (cause) {
308
+ discoveryError = cause;
309
+ }
310
+ }
244
311
  let available = Boolean(head) && !shallow;
245
- let error = !head
312
+ let error = discoveryError || (!head
246
313
  ? new Error("Git history is unavailable because the workspace has no committed HEAD.")
247
- : shallow ? new Error("Git history is shallow.") : null;
314
+ : shallow ? new Error("Git history is shallow.") : null);
315
+ if (discoveryError) available = false;
248
316
  try {
249
317
  if (available) {
250
- const output = gitRaw(root, [
251
- "log", "--reverse", "-m", "-z", "--relative",
252
- "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", "--", "data"
318
+ const output = historyGitRaw([
319
+ "log", "--topo-order", "--reverse", "--diff-merges=first-parent", "-z", "--relative",
320
+ "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", head, "--", "data"
253
321
  ]);
254
- changes.push(...parseDataRecordHistory(output));
322
+ const parsed = parseDataRecordHistory(output);
323
+ if (parsed.commitCount > DATA_HISTORY_MAX_COMMITS || parsed.changes.length > DATA_HISTORY_MAX_CHANGES) {
324
+ throw dataHistoryLimitError("Git data history is too large to reconcile safely.");
325
+ }
326
+ changes.push(...parsed.recordChanges);
327
+ sourceChanges.push(...parsed.sourceChanges);
328
+ for (const change of parsed.changes) {
329
+ if (!changesByCommit.has(change.summary.commit)) changesByCommit.set(change.summary.commit, []);
330
+ changesByCommit.get(change.summary.commit).push(change);
331
+ }
255
332
  }
256
333
  } catch (cause) {
257
334
  available = false;
258
335
  error = cause;
259
336
  }
260
- const sources = available
261
- ? getFilesAtRevisions(
262
- root,
263
- changes.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
264
- { batchSize: 512 }
265
- )
266
- : [];
337
+ let sources = [];
338
+ if (available) {
339
+ try {
340
+ sources = getFilesAtRevisions(
341
+ root,
342
+ sourceChanges.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
343
+ {
344
+ batchSize: 512,
345
+ maxRequests: DATA_HISTORY_MAX_SOURCE_REQUESTS,
346
+ maxTotalBytes: DATA_HISTORY_MAX_SOURCE_BYTES,
347
+ deadline
348
+ }
349
+ );
350
+ } catch (cause) {
351
+ available = false;
352
+ error = cause;
353
+ }
354
+ }
267
355
  if (head && sources.some((source) => source === null)) {
268
356
  available = false;
269
357
  error ||= new Error("Git could not read every historical data record.");
270
358
  }
271
359
  const recordsByCommit = new Map();
272
360
  const historiesById = new Map();
361
+ const fileChangesByCommit = new Map();
362
+ const historicalRecordPaths = new Set();
363
+ const sourceByCommitAndPath = new Map();
364
+ sourceChanges.forEach(({ summary, path }, index) => {
365
+ sourceByCommitAndPath.set(`${summary.commit}\0${path}`, sources[index]);
366
+ });
367
+ for (const [commit, commitChanges] of changesByCommit) {
368
+ const fileChanges = new Map();
369
+ for (const change of commitChanges) {
370
+ if (change.beforePath?.match(/\.(?:json|md)$/) && change.beforePath !== change.afterPath) {
371
+ if (change.beforePath.endsWith(".json")) historicalRecordPaths.add(change.beforePath);
372
+ fileChanges.set(change.beforePath, null);
373
+ }
374
+ if (change.afterPath?.match(/\.(?:json|md)$/)) {
375
+ if (change.afterPath.endsWith(".json")) historicalRecordPaths.add(change.afterPath);
376
+ fileChanges.set(change.afterPath, sourceByCommitAndPath.get(`${commit}\0${change.afterPath}`) ?? null);
377
+ }
378
+ }
379
+ fileChangesByCommit.set(commit, fileChanges);
380
+ }
273
381
  for (let index = 0; index < changes.length; index += 1) {
274
382
  const { summary, path } = changes[index];
275
383
  try {
276
- const record = JSON.parse(sources[index]);
277
- if (!record?.id) continue;
384
+ const record = JSON.parse(sourceByCommitAndPath.get(`${summary.commit}\0${path}`));
385
+ if (!record || Array.isArray(record) || typeof record.id !== "string" || typeof record.type !== "string") {
386
+ throw new Error("Historical data records require string IDs and types.");
387
+ }
278
388
  if (!recordsByCommit.has(summary.commit)) recordsByCommit.set(summary.commit, new Map());
389
+ if (recordsByCommit.get(summary.commit).has(record.id)) {
390
+ throw new Error(`Historical data records reuse ID "${record.id}" in one commit.`);
391
+ }
279
392
  recordsByCommit.get(summary.commit).set(record.id, { record, path });
280
393
  if (!historiesById.has(record.id)) historiesById.set(record.id, []);
281
394
  historiesById.get(record.id).push({ ...summary, path });
282
- } catch {
283
- // Ignore malformed historical files.
395
+ } catch (cause) {
396
+ available = false;
397
+ error = new Error(`Git history contains an unreadable data record at ${summary.commit.slice(0, 12)}:${path}.`, { cause });
398
+ break;
399
+ }
400
+ }
401
+ const parentsByCommit = new Map();
402
+ if (available) {
403
+ try {
404
+ for (const line of lines(historyGit(["rev-list", "--parents", head]))) {
405
+ const [commit, ...parents] = line.split(" ");
406
+ if (!/^[a-f0-9]{40}$/i.test(commit) || parents.some((parent) => !/^[a-f0-9]{40}$/i.test(parent))) {
407
+ throw new Error("Git returned invalid commit ancestry.");
408
+ }
409
+ parentsByCommit.set(commit, parents);
410
+ if (parentsByCommit.size > DATA_HISTORY_MAX_ANCESTRY_COMMITS) {
411
+ throw dataHistoryLimitError("Git ancestry is too large to reconcile safely.");
412
+ }
413
+ }
414
+ if (!parentsByCommit.has(head) || [...changesByCommit.keys()].some((commit) => !parentsByCommit.has(commit))) {
415
+ throw new Error("Git returned incomplete commit ancestry.");
416
+ }
417
+ } catch (cause) {
418
+ available = false;
419
+ error = cause;
284
420
  }
285
421
  }
286
422
  for (const [id, history] of historiesById) {
@@ -297,13 +433,38 @@ export function getDataRecordHistoryIndex(input) {
297
433
  head,
298
434
  available,
299
435
  error,
300
- commits: [...new Set(changes.map(({ summary }) => summary.commit))],
301
- recordsByCommit,
302
- historiesById
436
+ commits: available ? [...changesByCommit.keys()] : [],
437
+ changesByCommit: available ? changesByCommit : new Map(),
438
+ parentsByCommit: available ? parentsByCommit : new Map(),
439
+ fileChangesByCommit: available ? fileChangesByCommit : new Map(),
440
+ historicalRecordPaths: available ? historicalRecordPaths : new Set(),
441
+ recordsByCommit: available ? recordsByCommit : new Map(),
442
+ historiesById: available ? historiesById : new Map(),
443
+ sourceBytes: available
444
+ ? sources.reduce((total, source) => total + (typeof source === "string" ? Buffer.byteLength(source, "utf8") : 0), 0)
445
+ : 0,
446
+ ancestryCache: new Map(),
447
+ indexedFileCache: new Map()
303
448
  };
304
- if (available) {
449
+ result.failureExpiresAt = available ? null : performance.now() + DATA_HISTORY_FAILURE_CACHE_MS;
450
+ result.estimatedBytes = available
451
+ ? result.sourceBytes * 3
452
+ + [...result.changesByCommit.values()].reduce((total, commitChanges) => total + commitChanges.length * 512, 0)
453
+ + result.parentsByCommit.size * 256
454
+ + [...result.historiesById.values()].reduce((total, history) => total + history.length * 128, 0)
455
+ : 512 + Buffer.byteLength(error?.message || "", "utf8");
456
+ result.cacheBytes = result.estimatedBytes;
457
+ if (result.estimatedBytes <= DATA_HISTORY_CACHE_MAX_BYTES) {
305
458
  dataRecordHistoryIndexCache.set(root, result);
306
- while (dataRecordHistoryIndexCache.size > 16) dataRecordHistoryIndexCache.delete(dataRecordHistoryIndexCache.keys().next().value);
459
+ dataRecordHistoryIndexCacheBytes += result.cacheBytes;
460
+ }
461
+ if (dataRecordHistoryIndexCache.has(root)) {
462
+ while (dataRecordHistoryIndexCache.size > 4 || dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES) {
463
+ const oldestRoot = dataRecordHistoryIndexCache.keys().next().value;
464
+ const oldest = dataRecordHistoryIndexCache.get(oldestRoot);
465
+ dataRecordHistoryIndexCache.delete(oldestRoot);
466
+ dataRecordHistoryIndexCacheBytes -= oldest?.cacheBytes || 0;
467
+ }
307
468
  }
308
469
  return result;
309
470
  }
@@ -311,6 +472,9 @@ export function getDataRecordHistoryIndex(input) {
311
472
  function parseDataRecordHistory(output) {
312
473
  const fields = output.split("\0");
313
474
  const changes = [];
475
+ const recordChanges = [];
476
+ const sourceChanges = [];
477
+ const commits = new Set();
314
478
  let index = 0;
315
479
  while (index < fields.length) {
316
480
  while (fields[index] === "") index += 1;
@@ -324,6 +488,8 @@ function parseDataRecordHistory(output) {
324
488
  throw new Error("Git returned an incomplete data-history header.");
325
489
  }
326
490
  const summary = { commit, shortCommit: commit.slice(0, 8), timestamp, author, subject };
491
+ if (commits.has(commit)) throw new Error("Git returned duplicate data-history commit output.");
492
+ commits.add(commit);
327
493
  while (index < fields.length) {
328
494
  while (fields[index] === "") index += 1;
329
495
  const rawStatus = fields[index];
@@ -337,15 +503,80 @@ function parseDataRecordHistory(output) {
337
503
  const renamed = status.startsWith("R") || status.startsWith("C");
338
504
  const second = renamed ? fields[index++] : null;
339
505
  if (!first || (renamed && !second)) throw new Error("Git returned an incomplete data-history path.");
340
- if (status === "D") continue;
341
- const path = renamed ? second : first;
342
- if (path.startsWith("data/") && path.endsWith(".json")) {
506
+ const beforePath = status === "A" ? null : first;
507
+ const afterPath = status === "D" ? null : renamed ? second : first;
508
+ for (const path of [beforePath, afterPath].filter(Boolean)) {
343
509
  if (!isSafeDataGitPath(path)) throw new Error("Git returned an unsafe data-history path.");
344
- changes.push({ summary, path });
345
510
  }
511
+ const change = { summary, status, beforePath, afterPath };
512
+ changes.push(change);
513
+ if (afterPath?.endsWith(".json")) {
514
+ recordChanges.push({ summary, path: afterPath });
515
+ }
516
+ if (afterPath?.endsWith(".json") || afterPath?.endsWith(".md")) {
517
+ sourceChanges.push({ summary, path: afterPath });
518
+ }
519
+ }
520
+ }
521
+ return { changes, recordChanges, sourceChanges, commitCount: commits.size };
522
+ }
523
+
524
+ function indexedPathHistory(index, relativePath) {
525
+ if (!index?.available) return null;
526
+ for (const history of index.historiesById.values()) {
527
+ if (history[0]?.path === relativePath) return history;
528
+ }
529
+ return null;
530
+ }
531
+
532
+ function limitedHistory(history, limit) {
533
+ if (Number(limit) >= Number.MAX_SAFE_INTEGER) return history;
534
+ return history.slice(0, Math.max(1, Math.min(Number(limit) || 50, 200)));
535
+ }
536
+
537
+ function indexedDataFile(index, revision, relativePath) {
538
+ if (
539
+ !index?.available
540
+ || !relativePath?.match(/\.(?:json|md)$/)
541
+ || !index.parentsByCommit.has(revision)
542
+ ) return undefined;
543
+ const key = `${revision}\0${relativePath}`;
544
+ if (index.indexedFileCache.has(key)) return index.indexedFileCache.get(key);
545
+ let commit = revision;
546
+ let source = null;
547
+ while (commit) {
548
+ const changes = index.fileChangesByCommit.get(commit);
549
+ if (changes?.has(relativePath)) {
550
+ source = changes.get(relativePath);
551
+ break;
552
+ }
553
+ commit = index.parentsByCommit.get(commit)?.[0] || null;
554
+ }
555
+ index.indexedFileCache.set(key, source);
556
+ while (index.indexedFileCache.size > 20_000) index.indexedFileCache.delete(index.indexedFileCache.keys().next().value);
557
+ return source;
558
+ }
559
+
560
+ function indexedAncestor(index, ancestor, descendant) {
561
+ if (ancestor === descendant) return true;
562
+ const key = `${ancestor}\0${descendant}`;
563
+ if (index.ancestryCache.has(key)) return index.ancestryCache.get(key);
564
+ const pending = [descendant];
565
+ const visited = new Set();
566
+ let result = false;
567
+ while (pending.length) {
568
+ const commit = pending.pop();
569
+ if (commit === ancestor) {
570
+ result = true;
571
+ break;
346
572
  }
573
+ if (visited.has(commit)) continue;
574
+ visited.add(commit);
575
+ pending.push(...(index.parentsByCommit.get(commit) || []));
347
576
  }
348
- return changes;
577
+ index.ancestryCache.set(key, result);
578
+ while (index.ancestryCache.size > 20_000) index.ancestryCache.delete(index.ancestryCache.keys().next().value);
579
+ return result;
349
580
  }
350
581
 
351
582
  export function isGitAncestor(input, ancestor, descendant) {
@@ -372,18 +603,10 @@ export function isDataHistoryAncestor(input, ancestor, descendant) {
372
603
  index = getDataRecordHistoryIndex(root);
373
604
  if (context) dataHistoryContextCache.set(context, index);
374
605
  }
375
- if (
376
- !index.available
377
- || (descendant !== index.head && !index.commits.includes(descendant))
378
- ) return isGitAncestor(root, ancestor, descendant);
379
- const key = `${root}\0${ancestor}\0${descendant}`;
380
- if (reachableDataAncestryCache.has(key)) return reachableDataAncestryCache.get(key);
381
- const result = isGitAncestor(root, ancestor, descendant);
382
- reachableDataAncestryCache.set(key, result);
383
- while (reachableDataAncestryCache.size > 20_000) {
384
- reachableDataAncestryCache.delete(reachableDataAncestryCache.keys().next().value);
606
+ if (!index.available || !index.parentsByCommit.has(descendant)) {
607
+ return isGitAncestor(root, ancestor, descendant);
385
608
  }
386
- return result;
609
+ return indexedAncestor(index, ancestor, descendant);
387
610
  }
388
611
 
389
612
  export function getFileBufferAtRevision(input, revision, relativePath) {
@@ -473,17 +696,43 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
473
696
  }
474
697
 
475
698
  export function getFileAtRevision(input, revision, relativePath) {
476
- return getFilesAtRevisions(input, [{ revision, relativePath }])[0];
699
+ const root = resolveWorkspaceRoot(input);
700
+ const indexed = indexedDataFile(dataRecordHistoryIndexCache.get(root), revision, relativePath);
701
+ return indexed === undefined
702
+ ? getFilesAtRevisions(root, [{ revision, relativePath }])[0]
703
+ : indexed;
477
704
  }
478
705
 
479
706
  export function getFilesAtRevisions(input, requests, options = {}) {
480
707
  const root = resolveWorkspaceRoot(input);
708
+ const hasDeadline = options.deadline !== undefined;
709
+ const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
710
+ if (!Number.isFinite(deadline) || deadline <= performance.now()) {
711
+ const error = new Error("Git historical file export exceeded its cumulative deadline.");
712
+ error.code = "FILEGRC_HISTORY_DEADLINE";
713
+ throw error;
714
+ }
715
+ const remainingTime = () => {
716
+ const remainingMs = Math.floor(deadline - performance.now());
717
+ if (remainingMs <= 0) {
718
+ const error = new Error("Git historical file export exceeded its cumulative deadline.");
719
+ error.code = "FILEGRC_HISTORY_DEADLINE";
720
+ throw error;
721
+ }
722
+ return remainingMs;
723
+ };
481
724
  const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
482
725
  !/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
483
726
  ));
484
727
  if (!Array.isArray(requests) || invalid) {
485
728
  throw new Error("Historical file exports require a Git commit and a data/ path.");
486
729
  }
730
+ const maxRequests = Number(options.maxRequests) > 0 ? Math.floor(Number(options.maxRequests)) : null;
731
+ if (maxRequests && requests.length > maxRequests) {
732
+ const error = new Error(`Git historical file exports exceed the ${maxRequests}-request safety limit.`);
733
+ error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
734
+ throw error;
735
+ }
487
736
  if (!requests.length) return [];
488
737
  const results = new Array(requests.length);
489
738
  const missing = [];
@@ -497,31 +746,48 @@ export function getFilesAtRevisions(input, requests, options = {}) {
497
746
  missing.push({ ...request, index, key });
498
747
  }
499
748
  });
749
+ const maxTotalBytes = Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
750
+ const cachedBytes = maxTotalBytes
751
+ ? results.reduce((total, value) => total + (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0), 0)
752
+ : 0;
753
+ if (maxTotalBytes && cachedBytes > maxTotalBytes) throw historicalExportLimitError(maxTotalBytes);
500
754
  if (!missing.length) return results;
501
755
  try {
502
- const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
756
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"], { timeoutMs: remainingTime() });
503
757
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
504
758
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
759
+ const missingSpecifications = missing.map(({ revision, relativePath }) => {
760
+ const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
761
+ return `${revision}:${repositoryPath}`;
762
+ });
763
+ if (maxTotalBytes) {
764
+ assertHistoricalExportSize(root, missingSpecifications, maxTotalBytes - cachedBytes, maxTotalBytes, remainingTime());
765
+ }
505
766
  const batchSize = Math.max(1, Math.min(Number(options.batchSize) || 4, 512));
506
767
  for (let offset = 0; offset < missing.length; offset += batchSize) {
507
768
  const batch = missing.slice(offset, offset + batchSize);
508
- const specifications = batch.map(({ revision, relativePath }) => {
509
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
510
- return `${revision}:${repositoryPath}`;
511
- });
769
+ const specifications = missingSpecifications.slice(offset, offset + batchSize);
512
770
  let values;
513
771
  try {
514
- const output = measureTimingSync("git-history-export", () => execFileSync("git", ["cat-file", "--batch"], {
515
- cwd: root,
516
- input: `${specifications.join("\n")}\n`,
517
- stdio: ["pipe", "pipe", "ignore"],
518
- timeout: 10_000,
519
- maxBuffer: 80_000_000,
520
- env: gitEnvironment()
521
- }));
772
+ const run = () => {
773
+ const remainingMs = remainingTime();
774
+ return execFileSync("git", ["cat-file", "--batch"], {
775
+ cwd: root,
776
+ input: `${specifications.join("\n")}\n`,
777
+ stdio: ["pipe", "pipe", "ignore"],
778
+ timeout: remainingMs,
779
+ maxBuffer: 80_000_000,
780
+ env: gitEnvironment()
781
+ });
782
+ };
783
+ const output = measureTimingSync("git-history-export", () => (
784
+ historicalBatchInterceptor ? historicalBatchInterceptor({ root, specifications, run }) : run()
785
+ ));
522
786
  values = parseBatchObjects(output, batch.length);
523
- } catch {
524
- values = specifications.map((specification) => readHistoricalFile(root, specification));
787
+ } catch (cause) {
788
+ const error = new Error("Git could not read the historical file batch safely.", { cause });
789
+ error.code = "FILEGRC_HISTORY_BATCH_FAILED";
790
+ throw error;
525
791
  }
526
792
  batch.forEach(({ index, key }, batchIndex) => {
527
793
  const value = values[batchIndex];
@@ -533,14 +799,79 @@ export function getFilesAtRevisions(input, requests, options = {}) {
533
799
  }
534
800
  }
535
801
  return results;
536
- } catch {
802
+ } catch (error) {
803
+ if (["FILEGRC_HISTORY_EXPORT_LIMIT", "FILEGRC_HISTORY_BATCH_FAILED", "FILEGRC_HISTORY_DEADLINE"].includes(error?.code)) throw error;
537
804
  return results.map((value) => value ?? null);
538
805
  }
539
806
  }
540
807
 
808
+ export function setHistoricalBatchInterceptorForTests(interceptor) {
809
+ const previous = historicalBatchInterceptor;
810
+ historicalBatchInterceptor = interceptor;
811
+ return () => {
812
+ historicalBatchInterceptor = previous;
813
+ };
814
+ }
815
+
816
+ function assertHistoricalExportSize(root, specifications, remainingBytes, maxTotalBytes, timeoutMs = GIT_DEFAULT_TIMEOUT_MS) {
817
+ const output = measureTimingSync("git-history-size", () => execFileSync("git", [
818
+ "cat-file",
819
+ "--batch-check=%(objectname) %(objecttype) %(objectsize)"
820
+ ], {
821
+ cwd: root,
822
+ input: `${specifications.join("\n")}\n`,
823
+ encoding: "utf8",
824
+ stdio: ["pipe", "pipe", "ignore"],
825
+ timeout: timeoutMs,
826
+ maxBuffer: 20_000_000,
827
+ env: gitEnvironment()
828
+ }));
829
+ let total = 0;
830
+ for (const line of output.trim().split("\n")) {
831
+ if (!line || line.endsWith(" missing")) continue;
832
+ const match = line.match(/^[a-f0-9]+ blob (\d+)$/i);
833
+ if (!match) throw new Error("Git returned invalid historical object metadata.");
834
+ total += Number(match[1]);
835
+ if (!Number.isSafeInteger(total) || total > remainingBytes) throw historicalExportLimitError(maxTotalBytes);
836
+ }
837
+ }
838
+
839
+ function historicalExportLimitError(maxTotalBytes) {
840
+ const megabytes = maxTotalBytes / (1024 * 1024);
841
+ const limit = Number.isInteger(megabytes) && megabytes >= 1
842
+ ? `${megabytes} MB`
843
+ : `${maxTotalBytes} byte`;
844
+ const error = new Error(`Git historical file exports exceed the ${limit} safety limit.`);
845
+ error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
846
+ return error;
847
+ }
848
+
849
+ function dataHistoryLimitError(message) {
850
+ const error = new Error(message);
851
+ error.code = "FILEGRC_HISTORY_INDEX_LIMIT";
852
+ return error;
853
+ }
854
+
541
855
  export function getDataFilesAtRevision(input, revision) {
542
856
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return [];
543
857
  const root = resolveWorkspaceRoot(input);
858
+ const index = dataRecordHistoryIndexCache.get(root);
859
+ if (index?.available && index.parentsByCommit.has(revision)) {
860
+ const lineage = [];
861
+ let commit = revision;
862
+ while (commit) {
863
+ lineage.push(commit);
864
+ commit = index.parentsByCommit.get(commit)?.[0] || null;
865
+ }
866
+ const files = new Set();
867
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
868
+ for (const [path, source] of index.fileChangesByCommit.get(lineage[position]) || []) {
869
+ if (source === null) files.delete(path);
870
+ else files.add(path);
871
+ }
872
+ }
873
+ return [...files].filter((path) => path.endsWith(".json"));
874
+ }
544
875
  try {
545
876
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
546
877
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
@@ -615,6 +946,8 @@ export function getChangedDataPathsSinceRevision(input, revision) {
615
946
  export function hasGitRevision(input, revision) {
616
947
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return false;
617
948
  const root = resolveWorkspaceRoot(input);
949
+ const index = dataRecordHistoryIndexCache.get(root);
950
+ if (index?.available && index.parentsByCommit.has(revision)) return true;
618
951
  try {
619
952
  git(root, ["cat-file", "-e", `${revision}^{commit}`]);
620
953
  return true;
@@ -2153,8 +2486,8 @@ export function runGitCommand(cwd, args, options = {}) {
2153
2486
  return runGitCommandNative(cwd, args, options);
2154
2487
  }
2155
2488
 
2156
- export function runGitCommandSync(cwd, args) {
2157
- return git(resolveWorkspaceRoot(cwd), args);
2489
+ export function runGitCommandSync(cwd, args, options = {}) {
2490
+ return git(resolveWorkspaceRoot(cwd), args, options);
2158
2491
  }
2159
2492
 
2160
2493
  function runGitCommandNative(cwd, args, options = {}) {
@@ -2261,12 +2594,12 @@ async function tryGitAsync(cwd, args, operation) {
2261
2594
  }
2262
2595
  }
2263
2596
 
2264
- function git(cwd, args) {
2597
+ function git(cwd, args, options = {}) {
2265
2598
  return measureTimingSync("git-command-sync", () => execFileSync("git", args, {
2266
2599
  cwd,
2267
2600
  encoding: "utf8",
2268
2601
  stdio: ["ignore", "pipe", "ignore"],
2269
- timeout: 10_000,
2602
+ timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2270
2603
  maxBuffer: 20_000_000,
2271
2604
  env: gitEnvironment()
2272
2605
  }).trim());
@@ -2302,7 +2635,7 @@ function gitRaw(cwd, args, options = {}) {
2302
2635
  cwd,
2303
2636
  encoding: "utf8",
2304
2637
  stdio: ["ignore", "pipe", "ignore"],
2305
- timeout: 10_000,
2638
+ timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2306
2639
  maxBuffer: 20_000_000,
2307
2640
  env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
2308
2641
  });