filegrc 0.12.2 → 0.12.4

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/git.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { rm } from "node:fs/promises";
5
6
  import { devNull } from "node:os";
@@ -15,20 +16,47 @@ import { loadWorkspace } from "./workspace.js";
15
16
  const lastSuccessfulSynchronizations = new Map();
16
17
  const workspaceHistoryCache = new Map();
17
18
  const dataRecordHistoryIndexCache = new Map();
19
+ let dataRecordHistoryIndexCacheBytes = 0;
18
20
  const historicalFileCache = new Map();
19
- const reachableDataAncestryCache = new Map();
20
21
  const dataHistoryContextCache = new WeakMap();
21
22
  const backgroundSynchronizations = new Map();
22
23
  const browserRemotePrefetches = new Map();
23
24
  const browserRemotePrefetchPromises = new Map();
24
25
  const repositorySnapshotPromises = new Map();
26
+ const gitCommandCaches = new AsyncLocalStorage();
27
+ const gitCommandDeadlines = new AsyncLocalStorage();
28
+ const gitCommandCacheBytes = new WeakMap();
25
29
  let gitCommandInterceptor = null;
30
+ let gitSubprocessObserver = null;
31
+ let historicalBatchInterceptor = null;
32
+ let historicalRevisionReadObserver = null;
26
33
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
27
34
  const GIT_DEFAULT_TIMEOUT_MS = 10_000;
28
35
  const GIT_REMOTE_TIMEOUT_MS = 30_000;
29
36
  const GIT_MAX_OUTPUT_BYTES = 20_000_000;
37
+ const DATA_HISTORY_MAX_SOURCE_BYTES = 16 * 1024 * 1024;
38
+ const DATA_HISTORY_MAX_SOURCE_REQUESTS = 20_000;
39
+ const DATA_HISTORY_MAX_COMMITS = 5_000;
40
+ const DATA_HISTORY_MAX_CHANGES = 20_000;
41
+ const DATA_HISTORY_MAX_ANCESTRY_COMMITS = 20_000;
42
+ const DATA_HISTORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
43
+ const DATA_HISTORY_BUILD_TIMEOUT_MS = 10_000;
44
+ const DATA_HISTORY_FAILURE_CACHE_MS = 2_000;
45
+ const GIT_COMMAND_CACHE_MAX_ENTRIES = 128;
46
+ const GIT_COMMAND_CACHE_MAX_BYTES = 4 * 1024 * 1024;
47
+ const GIT_COMMAND_CACHE_MAX_ENTRY_BYTES = 512 * 1024;
30
48
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
31
49
 
50
+ export function withGitCommandCache(cache, callback) {
51
+ if (!(cache instanceof Map)) throw new TypeError("The Git command cache must be a Map.");
52
+ return gitCommandCaches.run(cache, callback);
53
+ }
54
+
55
+ export function withGitCommandDeadline(deadlineAt, callback) {
56
+ if (!Number.isFinite(deadlineAt)) throw new TypeError("The Git command deadline must be finite.");
57
+ return gitCommandDeadlines.run(deadlineAt, callback);
58
+ }
59
+
32
60
  function gitEnvironment(overrides = {}) {
33
61
  return {
34
62
  ...Object.fromEntries(
@@ -102,6 +130,7 @@ export function getGitSummary(input = process.cwd()) {
102
130
  lastCommit: last
103
131
  };
104
132
  } catch (error) {
133
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
105
134
  return {
106
135
  available: false,
107
136
  clean: null,
@@ -114,6 +143,8 @@ export function getGitSummary(input = process.cwd()) {
114
143
  export function getFileHistory(input, relativePath, limit = 50) {
115
144
  const root = resolveWorkspaceRoot(input);
116
145
  if (!isSafeDataGitPath(relativePath)) return null;
146
+ const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
147
+ if (indexed) return limitedHistory(indexed, limit);
117
148
  try {
118
149
  const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
119
150
  ? []
@@ -128,7 +159,8 @@ export function getFileHistory(input, relativePath, limit = 50) {
128
159
  ]);
129
160
  if (!output) return [];
130
161
  return output.split("\n").map(parseLogLine);
131
- } catch {
162
+ } catch (error) {
163
+ rethrowGitDeadline(error);
132
164
  return null;
133
165
  }
134
166
  }
@@ -136,6 +168,8 @@ export function getFileHistory(input, relativePath, limit = 50) {
136
168
  export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
137
169
  const root = resolveWorkspaceRoot(input);
138
170
  if (!isSafeDataGitPath(relativePath)) return null;
171
+ const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
172
+ if (indexed) return limitedHistory(indexed, limit);
139
173
  try {
140
174
  const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
141
175
  ? []
@@ -177,7 +211,8 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
177
211
  }
178
212
  }
179
213
  return history;
180
- } catch {
214
+ } catch (error) {
215
+ rethrowGitDeadline(error);
181
216
  return null;
182
217
  }
183
218
  }
@@ -185,6 +220,11 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
185
220
  export function getFilePathAtRevision(input, relativePath, revision) {
186
221
  const root = resolveWorkspaceRoot(input);
187
222
  if (!isSafeDataGitPath(relativePath) || !/^[a-f0-9]{40}$/i.test(String(revision || ""))) return null;
223
+ const index = dataRecordHistoryIndexCache.get(root);
224
+ const indexed = indexedPathHistory(index, relativePath);
225
+ if (indexed) {
226
+ return indexed.find(({ commit }) => indexedAncestor(index, commit, revision))?.path || null;
227
+ }
188
228
  const history = getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
189
229
  const summary = history.find(({ commit }) => commit === revision)
190
230
  || history.find(({ commit }) => isDataHistoryAncestor(root, commit, revision));
@@ -195,7 +235,8 @@ export function getDataCommitHistory(input) {
195
235
  const root = resolveWorkspaceRoot(input);
196
236
  try {
197
237
  return lines(git(root, ["log", "--reverse", "--format=%H", "--", "data"]));
198
- } catch {
238
+ } catch (error) {
239
+ rethrowGitDeadline(error);
199
240
  return [];
200
241
  }
201
242
  }
@@ -220,7 +261,8 @@ export function getChangedDataJsonFilesAtRevision(input, revision) {
220
261
  paths.push(workspacePrefix ? repositoryPath.slice(workspacePrefix.length + 1) : repositoryPath);
221
262
  }
222
263
  return [...new Set(paths)];
223
- } catch {
264
+ } catch (error) {
265
+ rethrowGitDeadline(error);
224
266
  return [];
225
267
  }
226
268
  }
@@ -234,53 +276,177 @@ export function getRecordIdentityHistories(input, ids) {
234
276
  return new Map([...new Set(ids)].map((id) => [id, index.historiesById.get(id) || []]));
235
277
  }
236
278
 
237
- export function getDataRecordHistoryIndex(input) {
279
+ export function getDataRecordHistoryIndex(input, options = {}) {
238
280
  const root = resolveWorkspaceRoot(input);
239
- const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
240
281
  const cached = dataRecordHistoryIndexCache.get(root);
241
- if (cached?.head === head) return cached;
282
+ const suppliedHead = /^[a-f0-9]{40}$/i.test(String(options.head)) ? String(options.head) : undefined;
283
+ if (
284
+ suppliedHead
285
+ && cached?.head === suppliedHead
286
+ && !cached.available
287
+ && cached.failureExpiresAt > performance.now()
288
+ ) return cached;
289
+ if (suppliedHead && cached?.head === suppliedHead && cached.available) return cached;
290
+ const hasDeadline = options.deadline !== undefined;
291
+ const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
292
+ if (!Number.isFinite(deadline) || deadline <= performance.now()) {
293
+ const error = new Error("Git data history exceeded its cumulative time limit.");
294
+ error.code = "FILEGRC_HISTORY_DEADLINE";
295
+ throw error;
296
+ }
297
+ const remainingTime = () => {
298
+ const remainingMs = Math.floor(deadline - performance.now());
299
+ if (remainingMs <= 0) {
300
+ const error = new Error("Git data history exceeded its cumulative time limit.");
301
+ error.code = "FILEGRC_HISTORY_DEADLINE";
302
+ throw error;
303
+ }
304
+ return remainingMs;
305
+ };
306
+ const historyGit = (args) => git(root, args, { timeoutMs: remainingTime() });
307
+ const historyGitRaw = (args) => gitRaw(root, args, { timeoutMs: remainingTime() });
308
+ let head = null;
309
+ let discoveryError = null;
310
+ if (suppliedHead) {
311
+ head = suppliedHead;
312
+ } else {
313
+ try {
314
+ head = historyGit(["rev-parse", "HEAD"]) || null;
315
+ } catch (cause) {
316
+ rethrowGitDeadline(cause);
317
+ discoveryError = cause;
318
+ }
319
+ }
320
+ if (cached?.head === head && cached.available) return cached;
321
+ if (cached) {
322
+ dataRecordHistoryIndexCache.delete(root);
323
+ dataRecordHistoryIndexCacheBytes -= cached.cacheBytes || 0;
324
+ }
242
325
  const changes = [];
243
- const shallow = head && tryGit(root, ["rev-parse", "--is-shallow-repository"]) === "true";
326
+ const sourceChanges = [];
327
+ const changesByCommit = new Map();
328
+ let shallow = false;
329
+ if (head) {
330
+ try {
331
+ shallow = historyGit(["rev-parse", "--is-shallow-repository"]) === "true";
332
+ } catch (cause) {
333
+ rethrowGitDeadline(cause);
334
+ discoveryError = cause;
335
+ }
336
+ }
244
337
  let available = Boolean(head) && !shallow;
245
- let error = !head
338
+ let error = discoveryError || (!head
246
339
  ? new Error("Git history is unavailable because the workspace has no committed HEAD.")
247
- : shallow ? new Error("Git history is shallow.") : null;
340
+ : shallow ? new Error("Git history is shallow.") : null);
341
+ if (discoveryError) available = false;
248
342
  try {
249
343
  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"
344
+ const output = historyGitRaw([
345
+ "log", "--topo-order", "--reverse", "--diff-merges=first-parent", "-z", "--relative",
346
+ "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", head, "--", "data"
253
347
  ]);
254
- changes.push(...parseDataRecordHistory(output));
348
+ const parsed = parseDataRecordHistory(output);
349
+ if (parsed.commitCount > DATA_HISTORY_MAX_COMMITS || parsed.changes.length > DATA_HISTORY_MAX_CHANGES) {
350
+ throw dataHistoryLimitError("Git data history is too large to reconcile safely.");
351
+ }
352
+ changes.push(...parsed.recordChanges);
353
+ sourceChanges.push(...parsed.sourceChanges);
354
+ for (const change of parsed.changes) {
355
+ if (!changesByCommit.has(change.summary.commit)) changesByCommit.set(change.summary.commit, []);
356
+ changesByCommit.get(change.summary.commit).push(change);
357
+ }
255
358
  }
256
359
  } catch (cause) {
360
+ rethrowGitDeadline(cause);
257
361
  available = false;
258
362
  error = cause;
259
363
  }
260
- const sources = available
261
- ? getFilesAtRevisions(
262
- root,
263
- changes.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
264
- { batchSize: 512 }
265
- )
266
- : [];
364
+ let sources = [];
365
+ if (available) {
366
+ try {
367
+ sources = getFilesAtRevisions(
368
+ root,
369
+ sourceChanges.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
370
+ {
371
+ batchSize: 512,
372
+ maxRequests: DATA_HISTORY_MAX_SOURCE_REQUESTS,
373
+ maxTotalBytes: DATA_HISTORY_MAX_SOURCE_BYTES,
374
+ deadline
375
+ }
376
+ );
377
+ } catch (cause) {
378
+ rethrowGitDeadline(cause);
379
+ available = false;
380
+ error = cause;
381
+ }
382
+ }
267
383
  if (head && sources.some((source) => source === null)) {
268
384
  available = false;
269
385
  error ||= new Error("Git could not read every historical data record.");
270
386
  }
271
387
  const recordsByCommit = new Map();
272
388
  const historiesById = new Map();
389
+ const fileChangesByCommit = new Map();
390
+ const historicalRecordPaths = new Set();
391
+ const sourceByCommitAndPath = new Map();
392
+ sourceChanges.forEach(({ summary, path }, index) => {
393
+ sourceByCommitAndPath.set(`${summary.commit}\0${path}`, sources[index]);
394
+ });
395
+ for (const [commit, commitChanges] of changesByCommit) {
396
+ const fileChanges = new Map();
397
+ for (const change of commitChanges) {
398
+ if (change.beforePath?.match(/\.(?:json|md)$/) && change.beforePath !== change.afterPath) {
399
+ if (change.beforePath.endsWith(".json")) historicalRecordPaths.add(change.beforePath);
400
+ fileChanges.set(change.beforePath, null);
401
+ }
402
+ if (change.afterPath?.match(/\.(?:json|md)$/)) {
403
+ if (change.afterPath.endsWith(".json")) historicalRecordPaths.add(change.afterPath);
404
+ fileChanges.set(change.afterPath, sourceByCommitAndPath.get(`${commit}\0${change.afterPath}`) ?? null);
405
+ }
406
+ }
407
+ fileChangesByCommit.set(commit, fileChanges);
408
+ }
273
409
  for (let index = 0; index < changes.length; index += 1) {
274
410
  const { summary, path } = changes[index];
275
411
  try {
276
- const record = JSON.parse(sources[index]);
277
- if (!record?.id) continue;
412
+ const record = JSON.parse(sourceByCommitAndPath.get(`${summary.commit}\0${path}`));
413
+ if (!record || Array.isArray(record) || typeof record.id !== "string" || typeof record.type !== "string") {
414
+ throw new Error("Historical data records require string IDs and types.");
415
+ }
278
416
  if (!recordsByCommit.has(summary.commit)) recordsByCommit.set(summary.commit, new Map());
417
+ if (recordsByCommit.get(summary.commit).has(record.id)) {
418
+ throw new Error(`Historical data records reuse ID "${record.id}" in one commit.`);
419
+ }
279
420
  recordsByCommit.get(summary.commit).set(record.id, { record, path });
280
421
  if (!historiesById.has(record.id)) historiesById.set(record.id, []);
281
422
  historiesById.get(record.id).push({ ...summary, path });
282
- } catch {
283
- // Ignore malformed historical files.
423
+ } catch (cause) {
424
+ rethrowGitDeadline(cause);
425
+ available = false;
426
+ error = new Error(`Git history contains an unreadable data record at ${summary.commit.slice(0, 12)}:${path}.`, { cause });
427
+ break;
428
+ }
429
+ }
430
+ const parentsByCommit = new Map();
431
+ if (available) {
432
+ try {
433
+ for (const line of lines(historyGit(["rev-list", "--parents", head]))) {
434
+ const [commit, ...parents] = line.split(" ");
435
+ if (!/^[a-f0-9]{40}$/i.test(commit) || parents.some((parent) => !/^[a-f0-9]{40}$/i.test(parent))) {
436
+ throw new Error("Git returned invalid commit ancestry.");
437
+ }
438
+ parentsByCommit.set(commit, parents);
439
+ if (parentsByCommit.size > DATA_HISTORY_MAX_ANCESTRY_COMMITS) {
440
+ throw dataHistoryLimitError("Git ancestry is too large to reconcile safely.");
441
+ }
442
+ }
443
+ if (!parentsByCommit.has(head) || [...changesByCommit.keys()].some((commit) => !parentsByCommit.has(commit))) {
444
+ throw new Error("Git returned incomplete commit ancestry.");
445
+ }
446
+ } catch (cause) {
447
+ rethrowGitDeadline(cause);
448
+ available = false;
449
+ error = cause;
284
450
  }
285
451
  }
286
452
  for (const [id, history] of historiesById) {
@@ -297,13 +463,38 @@ export function getDataRecordHistoryIndex(input) {
297
463
  head,
298
464
  available,
299
465
  error,
300
- commits: [...new Set(changes.map(({ summary }) => summary.commit))],
301
- recordsByCommit,
302
- historiesById
466
+ commits: available ? [...changesByCommit.keys()] : [],
467
+ changesByCommit: available ? changesByCommit : new Map(),
468
+ parentsByCommit: available ? parentsByCommit : new Map(),
469
+ fileChangesByCommit: available ? fileChangesByCommit : new Map(),
470
+ historicalRecordPaths: available ? historicalRecordPaths : new Set(),
471
+ recordsByCommit: available ? recordsByCommit : new Map(),
472
+ historiesById: available ? historiesById : new Map(),
473
+ sourceBytes: available
474
+ ? sources.reduce((total, source) => total + (typeof source === "string" ? Buffer.byteLength(source, "utf8") : 0), 0)
475
+ : 0,
476
+ ancestryCache: new Map(),
477
+ indexedFileCache: new Map()
303
478
  };
304
- if (available) {
479
+ result.failureExpiresAt = available ? null : performance.now() + DATA_HISTORY_FAILURE_CACHE_MS;
480
+ result.estimatedBytes = available
481
+ ? result.sourceBytes * 3
482
+ + [...result.changesByCommit.values()].reduce((total, commitChanges) => total + commitChanges.length * 512, 0)
483
+ + result.parentsByCommit.size * 256
484
+ + [...result.historiesById.values()].reduce((total, history) => total + history.length * 128, 0)
485
+ : 512 + Buffer.byteLength(error?.message || "", "utf8");
486
+ result.cacheBytes = result.estimatedBytes;
487
+ if (result.estimatedBytes <= DATA_HISTORY_CACHE_MAX_BYTES) {
305
488
  dataRecordHistoryIndexCache.set(root, result);
306
- while (dataRecordHistoryIndexCache.size > 16) dataRecordHistoryIndexCache.delete(dataRecordHistoryIndexCache.keys().next().value);
489
+ dataRecordHistoryIndexCacheBytes += result.cacheBytes;
490
+ }
491
+ if (dataRecordHistoryIndexCache.has(root)) {
492
+ while (dataRecordHistoryIndexCache.size > 4 || dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES) {
493
+ const oldestRoot = dataRecordHistoryIndexCache.keys().next().value;
494
+ const oldest = dataRecordHistoryIndexCache.get(oldestRoot);
495
+ dataRecordHistoryIndexCache.delete(oldestRoot);
496
+ dataRecordHistoryIndexCacheBytes -= oldest?.cacheBytes || 0;
497
+ }
307
498
  }
308
499
  return result;
309
500
  }
@@ -311,6 +502,9 @@ export function getDataRecordHistoryIndex(input) {
311
502
  function parseDataRecordHistory(output) {
312
503
  const fields = output.split("\0");
313
504
  const changes = [];
505
+ const recordChanges = [];
506
+ const sourceChanges = [];
507
+ const commits = new Set();
314
508
  let index = 0;
315
509
  while (index < fields.length) {
316
510
  while (fields[index] === "") index += 1;
@@ -324,6 +518,8 @@ function parseDataRecordHistory(output) {
324
518
  throw new Error("Git returned an incomplete data-history header.");
325
519
  }
326
520
  const summary = { commit, shortCommit: commit.slice(0, 8), timestamp, author, subject };
521
+ if (commits.has(commit)) throw new Error("Git returned duplicate data-history commit output.");
522
+ commits.add(commit);
327
523
  while (index < fields.length) {
328
524
  while (fields[index] === "") index += 1;
329
525
  const rawStatus = fields[index];
@@ -337,29 +533,95 @@ function parseDataRecordHistory(output) {
337
533
  const renamed = status.startsWith("R") || status.startsWith("C");
338
534
  const second = renamed ? fields[index++] : null;
339
535
  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")) {
536
+ const beforePath = status === "A" ? null : first;
537
+ const afterPath = status === "D" ? null : renamed ? second : first;
538
+ for (const path of [beforePath, afterPath].filter(Boolean)) {
343
539
  if (!isSafeDataGitPath(path)) throw new Error("Git returned an unsafe data-history path.");
344
- changes.push({ summary, path });
345
540
  }
541
+ const change = { summary, status, beforePath, afterPath };
542
+ changes.push(change);
543
+ if (afterPath?.endsWith(".json")) {
544
+ recordChanges.push({ summary, path: afterPath });
545
+ }
546
+ if (afterPath?.endsWith(".json") || afterPath?.endsWith(".md")) {
547
+ sourceChanges.push({ summary, path: afterPath });
548
+ }
549
+ }
550
+ }
551
+ return { changes, recordChanges, sourceChanges, commitCount: commits.size };
552
+ }
553
+
554
+ function indexedPathHistory(index, relativePath) {
555
+ if (!index?.available) return null;
556
+ for (const history of index.historiesById.values()) {
557
+ if (history[0]?.path === relativePath) return history;
558
+ }
559
+ return null;
560
+ }
561
+
562
+ function limitedHistory(history, limit) {
563
+ if (Number(limit) >= Number.MAX_SAFE_INTEGER) return history;
564
+ return history.slice(0, Math.max(1, Math.min(Number(limit) || 50, 200)));
565
+ }
566
+
567
+ function indexedDataFile(index, revision, relativePath) {
568
+ if (
569
+ !index?.available
570
+ || !relativePath?.match(/\.(?:json|md)$/)
571
+ || !index.parentsByCommit.has(revision)
572
+ ) return undefined;
573
+ const key = `${revision}\0${relativePath}`;
574
+ if (index.indexedFileCache.has(key)) return index.indexedFileCache.get(key);
575
+ let commit = revision;
576
+ let source = null;
577
+ while (commit) {
578
+ const changes = index.fileChangesByCommit.get(commit);
579
+ if (changes?.has(relativePath)) {
580
+ source = changes.get(relativePath);
581
+ break;
346
582
  }
583
+ commit = index.parentsByCommit.get(commit)?.[0] || null;
584
+ }
585
+ index.indexedFileCache.set(key, source);
586
+ while (index.indexedFileCache.size > 20_000) index.indexedFileCache.delete(index.indexedFileCache.keys().next().value);
587
+ return source;
588
+ }
589
+
590
+ function indexedAncestor(index, ancestor, descendant) {
591
+ if (ancestor === descendant) return true;
592
+ const key = `${ancestor}\0${descendant}`;
593
+ if (index.ancestryCache.has(key)) return index.ancestryCache.get(key);
594
+ const pending = [descendant];
595
+ const visited = new Set();
596
+ let result = false;
597
+ while (pending.length) {
598
+ const commit = pending.pop();
599
+ if (commit === ancestor) {
600
+ result = true;
601
+ break;
602
+ }
603
+ if (visited.has(commit)) continue;
604
+ visited.add(commit);
605
+ pending.push(...(index.parentsByCommit.get(commit) || []));
347
606
  }
348
- return changes;
607
+ index.ancestryCache.set(key, result);
608
+ while (index.ancestryCache.size > 20_000) index.ancestryCache.delete(index.ancestryCache.keys().next().value);
609
+ return result;
349
610
  }
350
611
 
351
612
  export function isGitAncestor(input, ancestor, descendant) {
352
613
  if (!/^[a-f0-9]{40}$/i.test(String(ancestor)) || !/^[a-f0-9]{40}$/i.test(String(descendant))) return false;
353
614
  const root = resolveWorkspaceRoot(input);
354
615
  try {
355
- execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
616
+ observedExecFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
356
617
  cwd: root,
357
618
  stdio: "ignore",
358
619
  timeout: 10_000,
359
620
  env: gitEnvironment()
360
621
  });
361
622
  return true;
362
- } catch {
623
+ } catch (error) {
624
+ rethrowGitDeadline(error);
363
625
  return false;
364
626
  }
365
627
  }
@@ -372,18 +634,10 @@ export function isDataHistoryAncestor(input, ancestor, descendant) {
372
634
  index = getDataRecordHistoryIndex(root);
373
635
  if (context) dataHistoryContextCache.set(context, index);
374
636
  }
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);
637
+ if (!index.available || !index.parentsByCommit.has(descendant)) {
638
+ return isGitAncestor(root, ancestor, descendant);
385
639
  }
386
- return result;
640
+ return indexedAncestor(index, ancestor, descendant);
387
641
  }
388
642
 
389
643
  export function getFileBufferAtRevision(input, revision, relativePath) {
@@ -394,14 +648,15 @@ export function getFileBufferAtRevision(input, revision, relativePath) {
394
648
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
395
649
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
396
650
  const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
397
- return execFileSync("git", ["show", `${revision}:${repositoryPath}`], {
651
+ return observedExecFileSync("git", ["show", `${revision}:${repositoryPath}`], {
398
652
  cwd: root,
399
653
  stdio: ["ignore", "pipe", "ignore"],
400
654
  timeout: 10_000,
401
655
  maxBuffer: 20_000_000,
402
656
  env: gitEnvironment()
403
657
  });
404
- } catch {
658
+ } catch (error) {
659
+ rethrowGitDeadline(error);
405
660
  return null;
406
661
  }
407
662
  }
@@ -416,7 +671,8 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
416
671
  const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
417
672
  const objectId = git(root, ["rev-parse", `${revision}:${repositoryPath}`]);
418
673
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
419
- } catch {
674
+ } catch (error) {
675
+ rethrowGitDeadline(error);
420
676
  return null;
421
677
  }
422
678
  }
@@ -427,7 +683,8 @@ export function getWorkingFileObjectId(input, relativePath) {
427
683
  try {
428
684
  const objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
429
685
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
430
- } catch {
686
+ } catch (error) {
687
+ rethrowGitDeadline(error);
431
688
  return null;
432
689
  }
433
690
  }
@@ -437,11 +694,28 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
437
694
  const wanted = new Set(relativePaths);
438
695
  const histories = new Map([...wanted].map((path) => [path, []]));
439
696
  if (!wanted.size) return histories;
440
- const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
697
+ const remainingTime = () => {
698
+ if (options.deadlineAt === undefined) return undefined;
699
+ const remaining = Math.ceil(options.deadlineAt - performance.now());
700
+ if (remaining > 0) return remaining;
701
+ const error = new Error("The Git history deadline expired before another subprocess could start.");
702
+ error.code = "FILEGRC_GIT_DEADLINE";
703
+ throw error;
704
+ };
705
+ const withinDeadline = (task) => options.deadlineAt === undefined
706
+ ? task()
707
+ : withGitCommandDeadline(options.deadlineAt, task);
708
+ let head = null;
709
+ try {
710
+ head = withinDeadline(() => git(root, ["rev-parse", "HEAD"], { timeoutMs: remainingTime() })) || null;
711
+ } catch (error) {
712
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
713
+ head = null;
714
+ }
441
715
  const cached = workspaceHistoryCache.get(root);
442
716
  if (cached?.head === head && cached.limitPerFile === limitPerFile) {
443
717
  if (options.strict === true && cached.available === false) {
444
- throw new Error("Git history is unavailable for the requested workspace files.");
718
+ throw gitHistoryUnavailableError();
445
719
  }
446
720
  for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
447
721
  return histories;
@@ -449,7 +723,9 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
449
723
  const allHistories = new Map();
450
724
  let available = true;
451
725
  try {
452
- const output = git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"]);
726
+ const output = withinDeadline(() => git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"], {
727
+ timeoutMs: remainingTime()
728
+ }));
453
729
  for (const block of output.split("\x1e")) {
454
730
  const lines = block.trim().split("\n").filter(Boolean);
455
731
  if (lines.length < 2) continue;
@@ -460,30 +736,64 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
460
736
  if (history.length < limitPerFile) history.push(commit);
461
737
  }
462
738
  }
463
- } catch {
739
+ } catch (error) {
740
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
464
741
  available = false;
465
742
  if (options.strict === true) {
466
- throw new Error("Git history is unavailable for the requested workspace files.");
743
+ throw gitHistoryUnavailableError(error);
467
744
  }
468
745
  // Browser and workflow views tolerate an uncommitted workspace with no history yet.
469
746
  }
470
- workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
747
+ if (available) workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
471
748
  for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
472
749
  return histories;
473
750
  }
474
751
 
752
+ function gitHistoryUnavailableError(cause) {
753
+ const error = new Error("Git history is unavailable for the requested workspace files.", { cause });
754
+ error.code = "FILEGRC_GIT_HISTORY_UNAVAILABLE";
755
+ return error;
756
+ }
757
+
475
758
  export function getFileAtRevision(input, revision, relativePath) {
476
- return getFilesAtRevisions(input, [{ revision, relativePath }])[0];
759
+ const root = resolveWorkspaceRoot(input);
760
+ historicalRevisionReadObserver?.({ root, revision, relativePath });
761
+ const indexed = indexedDataFile(dataRecordHistoryIndexCache.get(root), revision, relativePath);
762
+ return indexed === undefined
763
+ ? getFilesAtRevisions(root, [{ revision, relativePath }])[0]
764
+ : indexed;
477
765
  }
478
766
 
479
767
  export function getFilesAtRevisions(input, requests, options = {}) {
480
768
  const root = resolveWorkspaceRoot(input);
769
+ const hasDeadline = options.deadline !== undefined;
770
+ const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
771
+ if (!Number.isFinite(deadline) || deadline <= performance.now()) {
772
+ const error = new Error("Git historical file export exceeded its cumulative deadline.");
773
+ error.code = "FILEGRC_HISTORY_DEADLINE";
774
+ throw error;
775
+ }
776
+ const remainingTime = () => {
777
+ const remainingMs = Math.floor(deadline - performance.now());
778
+ if (remainingMs <= 0) {
779
+ const error = new Error("Git historical file export exceeded its cumulative deadline.");
780
+ error.code = "FILEGRC_HISTORY_DEADLINE";
781
+ throw error;
782
+ }
783
+ return remainingMs;
784
+ };
481
785
  const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
482
786
  !/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
483
787
  ));
484
788
  if (!Array.isArray(requests) || invalid) {
485
789
  throw new Error("Historical file exports require a Git commit and a data/ path.");
486
790
  }
791
+ const maxRequests = Number(options.maxRequests) > 0 ? Math.floor(Number(options.maxRequests)) : null;
792
+ if (maxRequests && requests.length > maxRequests) {
793
+ const error = new Error(`Git historical file exports exceed the ${maxRequests}-request safety limit.`);
794
+ error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
795
+ throw error;
796
+ }
487
797
  if (!requests.length) return [];
488
798
  const results = new Array(requests.length);
489
799
  const missing = [];
@@ -497,31 +807,49 @@ export function getFilesAtRevisions(input, requests, options = {}) {
497
807
  missing.push({ ...request, index, key });
498
808
  }
499
809
  });
810
+ const maxTotalBytes = Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
811
+ const cachedBytes = maxTotalBytes
812
+ ? results.reduce((total, value) => total + (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0), 0)
813
+ : 0;
814
+ if (maxTotalBytes && cachedBytes > maxTotalBytes) throw historicalExportLimitError(maxTotalBytes);
500
815
  if (!missing.length) return results;
501
816
  try {
502
- const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
817
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"], { timeoutMs: remainingTime() });
503
818
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
504
819
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
820
+ const missingSpecifications = missing.map(({ revision, relativePath }) => {
821
+ const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
822
+ return `${revision}:${repositoryPath}`;
823
+ });
824
+ if (maxTotalBytes) {
825
+ assertHistoricalExportSize(root, missingSpecifications, maxTotalBytes - cachedBytes, maxTotalBytes, remainingTime());
826
+ }
505
827
  const batchSize = Math.max(1, Math.min(Number(options.batchSize) || 4, 512));
506
828
  for (let offset = 0; offset < missing.length; offset += batchSize) {
507
829
  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
- });
830
+ const specifications = missingSpecifications.slice(offset, offset + batchSize);
512
831
  let values;
513
832
  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
- }));
833
+ const run = () => {
834
+ const remainingMs = remainingTime();
835
+ return observedExecFileSync("git", ["cat-file", "--batch"], {
836
+ cwd: root,
837
+ input: `${specifications.join("\n")}\n`,
838
+ stdio: ["pipe", "pipe", "ignore"],
839
+ timeout: remainingMs,
840
+ maxBuffer: 80_000_000,
841
+ env: gitEnvironment()
842
+ });
843
+ };
844
+ const output = measureTimingSync("git-history-export", () => (
845
+ historicalBatchInterceptor ? historicalBatchInterceptor({ root, specifications, run }) : run()
846
+ ));
522
847
  values = parseBatchObjects(output, batch.length);
523
- } catch {
524
- values = specifications.map((specification) => readHistoricalFile(root, specification));
848
+ } catch (cause) {
849
+ rethrowGitDeadline(cause);
850
+ const error = new Error("Git could not read the historical file batch safely.", { cause });
851
+ error.code = "FILEGRC_HISTORY_BATCH_FAILED";
852
+ throw error;
525
853
  }
526
854
  batch.forEach(({ index, key }, batchIndex) => {
527
855
  const value = values[batchIndex];
@@ -533,14 +861,79 @@ export function getFilesAtRevisions(input, requests, options = {}) {
533
861
  }
534
862
  }
535
863
  return results;
536
- } catch {
864
+ } catch (error) {
865
+ if (["FILEGRC_HISTORY_EXPORT_LIMIT", "FILEGRC_HISTORY_BATCH_FAILED", "FILEGRC_HISTORY_DEADLINE", "FILEGRC_GIT_DEADLINE"].includes(error?.code)) throw error;
537
866
  return results.map((value) => value ?? null);
538
867
  }
539
868
  }
540
869
 
870
+ export function setHistoricalBatchInterceptorForTests(interceptor) {
871
+ const previous = historicalBatchInterceptor;
872
+ historicalBatchInterceptor = interceptor;
873
+ return () => {
874
+ historicalBatchInterceptor = previous;
875
+ };
876
+ }
877
+
878
+ function assertHistoricalExportSize(root, specifications, remainingBytes, maxTotalBytes, timeoutMs = GIT_DEFAULT_TIMEOUT_MS) {
879
+ const output = measureTimingSync("git-history-size", () => observedExecFileSync("git", [
880
+ "cat-file",
881
+ "--batch-check=%(objectname) %(objecttype) %(objectsize)"
882
+ ], {
883
+ cwd: root,
884
+ input: `${specifications.join("\n")}\n`,
885
+ encoding: "utf8",
886
+ stdio: ["pipe", "pipe", "ignore"],
887
+ timeout: timeoutMs,
888
+ maxBuffer: 20_000_000,
889
+ env: gitEnvironment()
890
+ }));
891
+ let total = 0;
892
+ for (const line of output.trim().split("\n")) {
893
+ if (!line || line.endsWith(" missing")) continue;
894
+ const match = line.match(/^[a-f0-9]+ blob (\d+)$/i);
895
+ if (!match) throw new Error("Git returned invalid historical object metadata.");
896
+ total += Number(match[1]);
897
+ if (!Number.isSafeInteger(total) || total > remainingBytes) throw historicalExportLimitError(maxTotalBytes);
898
+ }
899
+ }
900
+
901
+ function historicalExportLimitError(maxTotalBytes) {
902
+ const megabytes = maxTotalBytes / (1024 * 1024);
903
+ const limit = Number.isInteger(megabytes) && megabytes >= 1
904
+ ? `${megabytes} MB`
905
+ : `${maxTotalBytes} byte`;
906
+ const error = new Error(`Git historical file exports exceed the ${limit} safety limit.`);
907
+ error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
908
+ return error;
909
+ }
910
+
911
+ function dataHistoryLimitError(message) {
912
+ const error = new Error(message);
913
+ error.code = "FILEGRC_HISTORY_INDEX_LIMIT";
914
+ return error;
915
+ }
916
+
541
917
  export function getDataFilesAtRevision(input, revision) {
542
918
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return [];
543
919
  const root = resolveWorkspaceRoot(input);
920
+ const index = dataRecordHistoryIndexCache.get(root);
921
+ if (index?.available && index.parentsByCommit.has(revision)) {
922
+ const lineage = [];
923
+ let commit = revision;
924
+ while (commit) {
925
+ lineage.push(commit);
926
+ commit = index.parentsByCommit.get(commit)?.[0] || null;
927
+ }
928
+ const files = new Set();
929
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
930
+ for (const [path, source] of index.fileChangesByCommit.get(lineage[position]) || []) {
931
+ if (source === null) files.delete(path);
932
+ else files.add(path);
933
+ }
934
+ }
935
+ return [...files].filter((path) => path.endsWith(".json"));
936
+ }
544
937
  try {
545
938
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
546
939
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
@@ -549,14 +942,15 @@ export function getDataFilesAtRevision(input, revision) {
549
942
  return lines(git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]))
550
943
  .filter((path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"))
551
944
  .map((path) => workspacePrefix ? path.slice(workspacePrefix.length + 1) : path);
552
- } catch {
945
+ } catch (error) {
946
+ rethrowGitDeadline(error);
553
947
  return [];
554
948
  }
555
949
  }
556
950
 
557
951
  function readHistoricalFile(root, specification) {
558
952
  try {
559
- return measureTimingSync("git-history-export", () => execFileSync("git", ["show", specification], {
953
+ return measureTimingSync("git-history-export", () => observedExecFileSync("git", ["show", specification], {
560
954
  cwd: root,
561
955
  encoding: "utf8",
562
956
  stdio: ["ignore", "pipe", "ignore"],
@@ -564,7 +958,8 @@ function readHistoricalFile(root, specification) {
564
958
  maxBuffer: 20_000_000,
565
959
  env: gitEnvironment()
566
960
  }));
567
- } catch {
961
+ } catch (error) {
962
+ rethrowGitDeadline(error);
568
963
  return null;
569
964
  }
570
965
  }
@@ -607,7 +1002,8 @@ export function getChangedDataPathsSinceRevision(input, revision) {
607
1002
  ...lines(git(root, ["diff", "--name-only", "--relative", revision, "--", "data"])),
608
1003
  ...lines(git(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
609
1004
  ])].filter((path) => path.startsWith("data/"));
610
- } catch {
1005
+ } catch (error) {
1006
+ rethrowGitDeadline(error);
611
1007
  return null;
612
1008
  }
613
1009
  }
@@ -615,10 +1011,13 @@ export function getChangedDataPathsSinceRevision(input, revision) {
615
1011
  export function hasGitRevision(input, revision) {
616
1012
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return false;
617
1013
  const root = resolveWorkspaceRoot(input);
1014
+ const index = dataRecordHistoryIndexCache.get(root);
1015
+ if (index?.available && index.parentsByCommit.has(revision)) return true;
618
1016
  try {
619
1017
  git(root, ["cat-file", "-e", `${revision}^{commit}`]);
620
1018
  return true;
621
- } catch {
1019
+ } catch (error) {
1020
+ rethrowGitDeadline(error);
622
1021
  return false;
623
1022
  }
624
1023
  }
@@ -652,6 +1051,66 @@ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
652
1051
  return snapshot;
653
1052
  }
654
1053
 
1054
+ export async function getRepositoryStateSignature(input = process.cwd(), options = {}) {
1055
+ const root = resolveWorkspaceRoot(input);
1056
+ const timeout = { timeoutMs: options.timeoutMs };
1057
+ let status;
1058
+ let gitDirectory;
1059
+ let refs;
1060
+ let remotes;
1061
+ try {
1062
+ [status, gitDirectory, refs, remotes] = await Promise.all([
1063
+ runGitCommand(root, [
1064
+ "status",
1065
+ "--porcelain=v2",
1066
+ "--branch",
1067
+ "-z",
1068
+ "--untracked-files=all"
1069
+ ], { ...timeout, operation: "verify repository state" }),
1070
+ runGitCommand(root, ["rev-parse", "--absolute-git-dir"], {
1071
+ ...timeout,
1072
+ operation: "locate repository state"
1073
+ }),
1074
+ runGitCommand(root, ["rev-parse", "--verify", "@{upstream}"], {
1075
+ ...timeout,
1076
+ operation: "verify the upstream repository revision"
1077
+ }).catch((error) => {
1078
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1079
+ return "";
1080
+ }),
1081
+ runGitCommand(root, ["remote", "-v"], { ...timeout, operation: "verify repository remotes" })
1082
+ ]);
1083
+ } catch (error) {
1084
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1085
+ status = "git-unavailable";
1086
+ gitDirectory = "";
1087
+ refs = "";
1088
+ remotes = "";
1089
+ }
1090
+ const background = backgroundSynchronizations.get(root);
1091
+ const backgroundState = background ? {
1092
+ status: background.status,
1093
+ commit: background.commit,
1094
+ startedAt: background.startedAt ?? null,
1095
+ finishedAt: background.finishedAt ?? null,
1096
+ remotePushed: background.remotePushed === true,
1097
+ error: background.error ?? null
1098
+ } : null;
1099
+ return createHash("sha256")
1100
+ .update(status)
1101
+ .update("\0")
1102
+ .update(refs)
1103
+ .update("\0")
1104
+ .update(remotes)
1105
+ .update("\0")
1106
+ .update(repositoryOperationFromDirectory(gitDirectory.trim()) || "")
1107
+ .update("\0")
1108
+ .update(JSON.stringify(backgroundState))
1109
+ .update("\0")
1110
+ .update(lastSuccessfulSynchronizations.get(root) || "")
1111
+ .digest("hex");
1112
+ }
1113
+
655
1114
  export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
656
1115
  const root = resolveWorkspaceRoot(input);
657
1116
  try {
@@ -676,6 +1135,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
676
1135
  workspaceChangePaths: parsed.changePaths
677
1136
  };
678
1137
  } catch (error) {
1138
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
679
1139
  return unavailableSnapshot(error, { workspaceChangePaths: [] });
680
1140
  }
681
1141
  }
@@ -689,6 +1149,7 @@ async function buildRepositorySnapshot(root) {
689
1149
  { operation: "locate the repository" }
690
1150
  ));
691
1151
  } catch (error) {
1152
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
692
1153
  return unavailableSnapshot(error);
693
1154
  }
694
1155
  const [topLevel, gitDirectory] = repositoryPaths.split("\n");
@@ -754,6 +1215,7 @@ async function buildRepositorySnapshot(root) {
754
1215
  invocationCount: 3 + (parsed.commit ? 1 : 0) + (parsed.upstream ? 1 : 0) + (parsed.ahead > 0 ? 1 : 0)
755
1216
  };
756
1217
  } catch (error) {
1218
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
757
1219
  return unavailableSnapshot(error, { root: topLevel, gitDirectory });
758
1220
  }
759
1221
  }
@@ -1855,7 +2317,7 @@ function assertNoHiddenIndexEntries(root) {
1855
2317
  }
1856
2318
 
1857
2319
  function hashWorkspaceBytes(root, bytes, write = false) {
1858
- return execFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
2320
+ return observedExecFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
1859
2321
  cwd: root,
1860
2322
  input: bytes,
1861
2323
  encoding: "utf8",
@@ -2141,6 +2603,24 @@ export function setGitCommandInterceptorForTests(interceptor) {
2141
2603
  return () => { gitCommandInterceptor = previous; };
2142
2604
  }
2143
2605
 
2606
+ export function setGitSubprocessObserverForTests(observer) {
2607
+ if (observer !== null && typeof observer !== "function") {
2608
+ throw new TypeError("The Git subprocess observer must be a function or null.");
2609
+ }
2610
+ const previous = gitSubprocessObserver;
2611
+ gitSubprocessObserver = observer;
2612
+ return () => { gitSubprocessObserver = previous; };
2613
+ }
2614
+
2615
+ export function setHistoricalRevisionReadObserverForTests(observer) {
2616
+ if (observer !== null && typeof observer !== "function") {
2617
+ throw new TypeError("The historical revision-read observer must be a function or null.");
2618
+ }
2619
+ const previous = historicalRevisionReadObserver;
2620
+ historicalRevisionReadObserver = observer;
2621
+ return () => { historicalRevisionReadObserver = previous; };
2622
+ }
2623
+
2144
2624
  export function runGitCommand(cwd, args, options = {}) {
2145
2625
  if (gitCommandInterceptor) {
2146
2626
  return Promise.resolve().then(() => gitCommandInterceptor({
@@ -2153,8 +2633,8 @@ export function runGitCommand(cwd, args, options = {}) {
2153
2633
  return runGitCommandNative(cwd, args, options);
2154
2634
  }
2155
2635
 
2156
- export function runGitCommandSync(cwd, args) {
2157
- return git(resolveWorkspaceRoot(cwd), args);
2636
+ export function runGitCommandSync(cwd, args, options = {}) {
2637
+ return git(resolveWorkspaceRoot(cwd), args, options);
2158
2638
  }
2159
2639
 
2160
2640
  function runGitCommandNative(cwd, args, options = {}) {
@@ -2168,8 +2648,9 @@ function runGitCommandNative(cwd, args, options = {}) {
2168
2648
  if (options.expectedNoOperation) assertNoGitOperationInProgress(cwd);
2169
2649
  const operation = options.operation || "run a Git command";
2170
2650
  const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
2171
- const timeoutMs = Math.max(1, Number(configuredTimeout) || GIT_DEFAULT_TIMEOUT_MS);
2651
+ const { timeoutMs, deadlineLimited } = gitTimeoutPlan(configuredTimeout, GIT_DEFAULT_TIMEOUT_MS);
2172
2652
  const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
2653
+ gitSubprocessObserver?.({ kind: "async", cwd, args: [...args], options: { ...options } });
2173
2654
  return new Promise((resolveCommand, rejectCommand) => {
2174
2655
  const child = spawn("git", args, {
2175
2656
  cwd,
@@ -2248,7 +2729,9 @@ function runGitCommandNative(cwd, args, options = {}) {
2248
2729
  : /not a git repository|outside repository/i.test(errorOutput)
2249
2730
  ? "invalid-repository"
2250
2731
  : "command-failure";
2251
- rejectCommand(new GitOperationError(kind, operation, detail));
2732
+ rejectCommand(new GitOperationError(kind, operation, detail, {
2733
+ code: timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined
2734
+ }));
2252
2735
  });
2253
2736
  });
2254
2737
  }
@@ -2261,21 +2744,24 @@ async function tryGitAsync(cwd, args, operation) {
2261
2744
  }
2262
2745
  }
2263
2746
 
2264
- function git(cwd, args) {
2265
- return measureTimingSync("git-command-sync", () => execFileSync("git", args, {
2266
- cwd,
2267
- encoding: "utf8",
2268
- stdio: ["ignore", "pipe", "ignore"],
2269
- timeout: 10_000,
2270
- maxBuffer: 20_000_000,
2271
- env: gitEnvironment()
2272
- }).trim());
2747
+ function git(cwd, args, options = {}) {
2748
+ return cachedGitCommand("text", cwd, args, options, () => {
2749
+ return measureTimingSync("git-command-sync", () => observedExecFileSync("git", args, {
2750
+ cwd,
2751
+ encoding: "utf8",
2752
+ stdio: ["ignore", "pipe", "ignore"],
2753
+ timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2754
+ maxBuffer: 20_000_000,
2755
+ env: gitEnvironment()
2756
+ }).trim());
2757
+ });
2273
2758
  }
2274
2759
 
2275
2760
  function tryGit(cwd, args) {
2276
2761
  try {
2277
2762
  return git(cwd, args);
2278
- } catch {
2763
+ } catch (error) {
2764
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
2279
2765
  return "";
2280
2766
  }
2281
2767
  }
@@ -2292,22 +2778,107 @@ function gitOptionalMatch(cwd, args) {
2292
2778
  function tryGitRaw(cwd, args) {
2293
2779
  try {
2294
2780
  return gitRaw(cwd, args);
2295
- } catch {
2781
+ } catch (error) {
2782
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
2296
2783
  return "";
2297
2784
  }
2298
2785
  }
2299
2786
 
2300
2787
  function gitRaw(cwd, args, options = {}) {
2301
- return execFileSync("git", args, {
2302
- cwd,
2303
- encoding: "utf8",
2304
- stdio: ["ignore", "pipe", "ignore"],
2305
- timeout: 10_000,
2306
- maxBuffer: 20_000_000,
2307
- env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
2788
+ return cachedGitCommand("raw", cwd, args, options, () => {
2789
+ return observedExecFileSync("git", args, {
2790
+ cwd,
2791
+ encoding: "utf8",
2792
+ stdio: ["ignore", "pipe", "ignore"],
2793
+ timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2794
+ maxBuffer: 20_000_000,
2795
+ env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
2796
+ });
2308
2797
  });
2309
2798
  }
2310
2799
 
2800
+ function cachedGitCommand(outputKind, cwd, args, options, run) {
2801
+ const cache = gitCommandCaches.getStore();
2802
+ if (!cache) return run();
2803
+ const key = JSON.stringify([
2804
+ outputKind,
2805
+ resolve(cwd),
2806
+ args,
2807
+ options.gitIndexFile || null
2808
+ ]);
2809
+ const cached = cache.get(key);
2810
+ if (cached) {
2811
+ cache.delete(key);
2812
+ cache.set(key, cached);
2813
+ return cached.value;
2814
+ }
2815
+ const value = run();
2816
+ const size = Buffer.byteLength(value);
2817
+ if (size <= GIT_COMMAND_CACHE_MAX_ENTRY_BYTES) {
2818
+ let totalBytes = gitCommandCacheBytes.get(cache) || 0;
2819
+ while (cache.size >= GIT_COMMAND_CACHE_MAX_ENTRIES || totalBytes + size > GIT_COMMAND_CACHE_MAX_BYTES) {
2820
+ const oldestKey = cache.keys().next().value;
2821
+ if (oldestKey === undefined) break;
2822
+ totalBytes -= cache.get(oldestKey)?.size || 0;
2823
+ cache.delete(oldestKey);
2824
+ }
2825
+ cache.set(key, { value, size });
2826
+ gitCommandCacheBytes.set(cache, totalBytes + size);
2827
+ }
2828
+ return value;
2829
+ }
2830
+
2831
+ function observedExecFileSync(executable, args, options = {}) {
2832
+ const timeoutPlan = executable === "git"
2833
+ ? gitTimeoutPlan(options.timeout, GIT_DEFAULT_TIMEOUT_MS)
2834
+ : null;
2835
+ const boundedOptions = timeoutPlan
2836
+ ? { ...options, timeout: timeoutPlan.timeoutMs }
2837
+ : options;
2838
+ try {
2839
+ if (executable === "git") {
2840
+ gitSubprocessObserver?.({
2841
+ kind: "sync",
2842
+ cwd: boundedOptions.cwd,
2843
+ args: [...args],
2844
+ options: { ...boundedOptions, input: boundedOptions.input === undefined ? undefined : "[redacted]" }
2845
+ });
2846
+ }
2847
+ return execFileSync(executable, args, boundedOptions);
2848
+ } catch (error) {
2849
+ if (timeoutPlan?.deadlineLimited && (error?.code === "ETIMEDOUT" || error?.signal)) {
2850
+ const deadlineError = new Error("The Git command exceeded the shared request deadline.", { cause: error });
2851
+ deadlineError.code = "FILEGRC_GIT_DEADLINE";
2852
+ throw deadlineError;
2853
+ }
2854
+ throw error;
2855
+ }
2856
+ }
2857
+
2858
+ function boundedGitTimeout(configured, fallback) {
2859
+ return gitTimeoutPlan(configured, fallback).timeoutMs;
2860
+ }
2861
+
2862
+ function gitTimeoutPlan(configured, fallback) {
2863
+ const requested = Math.max(1, Number(configured) || fallback);
2864
+ const deadlineAt = gitCommandDeadlines.getStore();
2865
+ if (deadlineAt === undefined) return { timeoutMs: requested, deadlineLimited: false };
2866
+ const remaining = Math.ceil(deadlineAt - performance.now());
2867
+ if (remaining <= 0) {
2868
+ const error = new Error("The Git command deadline expired before another subprocess could start.");
2869
+ error.code = "FILEGRC_GIT_DEADLINE";
2870
+ throw error;
2871
+ }
2872
+ return {
2873
+ timeoutMs: Math.min(requested, remaining),
2874
+ deadlineLimited: remaining <= requested
2875
+ };
2876
+ }
2877
+
2878
+ function rethrowGitDeadline(error) {
2879
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
2880
+ }
2881
+
2311
2882
  function nulFields(source) {
2312
2883
  return source ? source.split("\0").filter(Boolean) : [];
2313
2884
  }
@@ -2315,7 +2886,7 @@ function nulFields(source) {
2315
2886
  function gitForWrite(cwd, args, action = "create the commit", options = {}) {
2316
2887
  try {
2317
2888
  assertWorkspaceInsideGitWorktree(cwd);
2318
- return execFileSync("git", args, {
2889
+ return observedExecFileSync("git", args, {
2319
2890
  cwd,
2320
2891
  encoding: "utf8",
2321
2892
  stdio: ["ignore", "pipe", "pipe"],