filegrc 0.12.3 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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";
@@ -22,8 +23,13 @@ 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;
26
31
  let historicalBatchInterceptor = null;
32
+ let historicalRevisionReadObserver = null;
27
33
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
28
34
  const GIT_DEFAULT_TIMEOUT_MS = 10_000;
29
35
  const GIT_REMOTE_TIMEOUT_MS = 30_000;
@@ -36,8 +42,21 @@ const DATA_HISTORY_MAX_ANCESTRY_COMMITS = 20_000;
36
42
  const DATA_HISTORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
37
43
  const DATA_HISTORY_BUILD_TIMEOUT_MS = 10_000;
38
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;
39
48
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
40
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
+
41
60
  function gitEnvironment(overrides = {}) {
42
61
  return {
43
62
  ...Object.fromEntries(
@@ -111,6 +130,7 @@ export function getGitSummary(input = process.cwd()) {
111
130
  lastCommit: last
112
131
  };
113
132
  } catch (error) {
133
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
114
134
  return {
115
135
  available: false,
116
136
  clean: null,
@@ -139,7 +159,8 @@ export function getFileHistory(input, relativePath, limit = 50) {
139
159
  ]);
140
160
  if (!output) return [];
141
161
  return output.split("\n").map(parseLogLine);
142
- } catch {
162
+ } catch (error) {
163
+ rethrowGitDeadline(error);
143
164
  return null;
144
165
  }
145
166
  }
@@ -190,7 +211,8 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
190
211
  }
191
212
  }
192
213
  return history;
193
- } catch {
214
+ } catch (error) {
215
+ rethrowGitDeadline(error);
194
216
  return null;
195
217
  }
196
218
  }
@@ -213,7 +235,8 @@ export function getDataCommitHistory(input) {
213
235
  const root = resolveWorkspaceRoot(input);
214
236
  try {
215
237
  return lines(git(root, ["log", "--reverse", "--format=%H", "--", "data"]));
216
- } catch {
238
+ } catch (error) {
239
+ rethrowGitDeadline(error);
217
240
  return [];
218
241
  }
219
242
  }
@@ -238,7 +261,8 @@ export function getChangedDataJsonFilesAtRevision(input, revision) {
238
261
  paths.push(workspacePrefix ? repositoryPath.slice(workspacePrefix.length + 1) : repositoryPath);
239
262
  }
240
263
  return [...new Set(paths)];
241
- } catch {
264
+ } catch (error) {
265
+ rethrowGitDeadline(error);
242
266
  return [];
243
267
  }
244
268
  }
@@ -289,6 +313,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
289
313
  try {
290
314
  head = historyGit(["rev-parse", "HEAD"]) || null;
291
315
  } catch (cause) {
316
+ rethrowGitDeadline(cause);
292
317
  discoveryError = cause;
293
318
  }
294
319
  }
@@ -305,6 +330,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
305
330
  try {
306
331
  shallow = historyGit(["rev-parse", "--is-shallow-repository"]) === "true";
307
332
  } catch (cause) {
333
+ rethrowGitDeadline(cause);
308
334
  discoveryError = cause;
309
335
  }
310
336
  }
@@ -331,6 +357,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
331
357
  }
332
358
  }
333
359
  } catch (cause) {
360
+ rethrowGitDeadline(cause);
334
361
  available = false;
335
362
  error = cause;
336
363
  }
@@ -348,6 +375,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
348
375
  }
349
376
  );
350
377
  } catch (cause) {
378
+ rethrowGitDeadline(cause);
351
379
  available = false;
352
380
  error = cause;
353
381
  }
@@ -393,6 +421,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
393
421
  if (!historiesById.has(record.id)) historiesById.set(record.id, []);
394
422
  historiesById.get(record.id).push({ ...summary, path });
395
423
  } catch (cause) {
424
+ rethrowGitDeadline(cause);
396
425
  available = false;
397
426
  error = new Error(`Git history contains an unreadable data record at ${summary.commit.slice(0, 12)}:${path}.`, { cause });
398
427
  break;
@@ -415,6 +444,7 @@ export function getDataRecordHistoryIndex(input, options = {}) {
415
444
  throw new Error("Git returned incomplete commit ancestry.");
416
445
  }
417
446
  } catch (cause) {
447
+ rethrowGitDeadline(cause);
418
448
  available = false;
419
449
  error = cause;
420
450
  }
@@ -583,14 +613,15 @@ export function isGitAncestor(input, ancestor, descendant) {
583
613
  if (!/^[a-f0-9]{40}$/i.test(String(ancestor)) || !/^[a-f0-9]{40}$/i.test(String(descendant))) return false;
584
614
  const root = resolveWorkspaceRoot(input);
585
615
  try {
586
- execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
616
+ observedExecFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
587
617
  cwd: root,
588
618
  stdio: "ignore",
589
619
  timeout: 10_000,
590
620
  env: gitEnvironment()
591
621
  });
592
622
  return true;
593
- } catch {
623
+ } catch (error) {
624
+ rethrowGitDeadline(error);
594
625
  return false;
595
626
  }
596
627
  }
@@ -617,14 +648,15 @@ export function getFileBufferAtRevision(input, revision, relativePath) {
617
648
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
618
649
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
619
650
  const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
620
- return execFileSync("git", ["show", `${revision}:${repositoryPath}`], {
651
+ return observedExecFileSync("git", ["show", `${revision}:${repositoryPath}`], {
621
652
  cwd: root,
622
653
  stdio: ["ignore", "pipe", "ignore"],
623
654
  timeout: 10_000,
624
655
  maxBuffer: 20_000_000,
625
656
  env: gitEnvironment()
626
657
  });
627
- } catch {
658
+ } catch (error) {
659
+ rethrowGitDeadline(error);
628
660
  return null;
629
661
  }
630
662
  }
@@ -639,7 +671,8 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
639
671
  const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
640
672
  const objectId = git(root, ["rev-parse", `${revision}:${repositoryPath}`]);
641
673
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
642
- } catch {
674
+ } catch (error) {
675
+ rethrowGitDeadline(error);
643
676
  return null;
644
677
  }
645
678
  }
@@ -650,7 +683,8 @@ export function getWorkingFileObjectId(input, relativePath) {
650
683
  try {
651
684
  const objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
652
685
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
653
- } catch {
686
+ } catch (error) {
687
+ rethrowGitDeadline(error);
654
688
  return null;
655
689
  }
656
690
  }
@@ -660,11 +694,28 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
660
694
  const wanted = new Set(relativePaths);
661
695
  const histories = new Map([...wanted].map((path) => [path, []]));
662
696
  if (!wanted.size) return histories;
663
- 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
+ }
664
715
  const cached = workspaceHistoryCache.get(root);
665
716
  if (cached?.head === head && cached.limitPerFile === limitPerFile) {
666
717
  if (options.strict === true && cached.available === false) {
667
- throw new Error("Git history is unavailable for the requested workspace files.");
718
+ throw gitHistoryUnavailableError();
668
719
  }
669
720
  for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
670
721
  return histories;
@@ -672,7 +723,9 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
672
723
  const allHistories = new Map();
673
724
  let available = true;
674
725
  try {
675
- 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
+ }));
676
729
  for (const block of output.split("\x1e")) {
677
730
  const lines = block.trim().split("\n").filter(Boolean);
678
731
  if (lines.length < 2) continue;
@@ -683,20 +736,28 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
683
736
  if (history.length < limitPerFile) history.push(commit);
684
737
  }
685
738
  }
686
- } catch {
739
+ } catch (error) {
740
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
687
741
  available = false;
688
742
  if (options.strict === true) {
689
- throw new Error("Git history is unavailable for the requested workspace files.");
743
+ throw gitHistoryUnavailableError(error);
690
744
  }
691
745
  // Browser and workflow views tolerate an uncommitted workspace with no history yet.
692
746
  }
693
- workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
747
+ if (available) workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
694
748
  for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
695
749
  return histories;
696
750
  }
697
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
+
698
758
  export function getFileAtRevision(input, revision, relativePath) {
699
759
  const root = resolveWorkspaceRoot(input);
760
+ historicalRevisionReadObserver?.({ root, revision, relativePath });
700
761
  const indexed = indexedDataFile(dataRecordHistoryIndexCache.get(root), revision, relativePath);
701
762
  return indexed === undefined
702
763
  ? getFilesAtRevisions(root, [{ revision, relativePath }])[0]
@@ -771,7 +832,7 @@ export function getFilesAtRevisions(input, requests, options = {}) {
771
832
  try {
772
833
  const run = () => {
773
834
  const remainingMs = remainingTime();
774
- return execFileSync("git", ["cat-file", "--batch"], {
835
+ return observedExecFileSync("git", ["cat-file", "--batch"], {
775
836
  cwd: root,
776
837
  input: `${specifications.join("\n")}\n`,
777
838
  stdio: ["pipe", "pipe", "ignore"],
@@ -785,6 +846,7 @@ export function getFilesAtRevisions(input, requests, options = {}) {
785
846
  ));
786
847
  values = parseBatchObjects(output, batch.length);
787
848
  } catch (cause) {
849
+ rethrowGitDeadline(cause);
788
850
  const error = new Error("Git could not read the historical file batch safely.", { cause });
789
851
  error.code = "FILEGRC_HISTORY_BATCH_FAILED";
790
852
  throw error;
@@ -800,7 +862,7 @@ export function getFilesAtRevisions(input, requests, options = {}) {
800
862
  }
801
863
  return results;
802
864
  } catch (error) {
803
- if (["FILEGRC_HISTORY_EXPORT_LIMIT", "FILEGRC_HISTORY_BATCH_FAILED", "FILEGRC_HISTORY_DEADLINE"].includes(error?.code)) throw error;
865
+ if (["FILEGRC_HISTORY_EXPORT_LIMIT", "FILEGRC_HISTORY_BATCH_FAILED", "FILEGRC_HISTORY_DEADLINE", "FILEGRC_GIT_DEADLINE"].includes(error?.code)) throw error;
804
866
  return results.map((value) => value ?? null);
805
867
  }
806
868
  }
@@ -814,7 +876,7 @@ export function setHistoricalBatchInterceptorForTests(interceptor) {
814
876
  }
815
877
 
816
878
  function assertHistoricalExportSize(root, specifications, remainingBytes, maxTotalBytes, timeoutMs = GIT_DEFAULT_TIMEOUT_MS) {
817
- const output = measureTimingSync("git-history-size", () => execFileSync("git", [
879
+ const output = measureTimingSync("git-history-size", () => observedExecFileSync("git", [
818
880
  "cat-file",
819
881
  "--batch-check=%(objectname) %(objecttype) %(objectsize)"
820
882
  ], {
@@ -880,14 +942,15 @@ export function getDataFilesAtRevision(input, revision) {
880
942
  return lines(git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]))
881
943
  .filter((path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"))
882
944
  .map((path) => workspacePrefix ? path.slice(workspacePrefix.length + 1) : path);
883
- } catch {
945
+ } catch (error) {
946
+ rethrowGitDeadline(error);
884
947
  return [];
885
948
  }
886
949
  }
887
950
 
888
951
  function readHistoricalFile(root, specification) {
889
952
  try {
890
- return measureTimingSync("git-history-export", () => execFileSync("git", ["show", specification], {
953
+ return measureTimingSync("git-history-export", () => observedExecFileSync("git", ["show", specification], {
891
954
  cwd: root,
892
955
  encoding: "utf8",
893
956
  stdio: ["ignore", "pipe", "ignore"],
@@ -895,7 +958,8 @@ function readHistoricalFile(root, specification) {
895
958
  maxBuffer: 20_000_000,
896
959
  env: gitEnvironment()
897
960
  }));
898
- } catch {
961
+ } catch (error) {
962
+ rethrowGitDeadline(error);
899
963
  return null;
900
964
  }
901
965
  }
@@ -938,7 +1002,8 @@ export function getChangedDataPathsSinceRevision(input, revision) {
938
1002
  ...lines(git(root, ["diff", "--name-only", "--relative", revision, "--", "data"])),
939
1003
  ...lines(git(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
940
1004
  ])].filter((path) => path.startsWith("data/"));
941
- } catch {
1005
+ } catch (error) {
1006
+ rethrowGitDeadline(error);
942
1007
  return null;
943
1008
  }
944
1009
  }
@@ -951,7 +1016,8 @@ export function hasGitRevision(input, revision) {
951
1016
  try {
952
1017
  git(root, ["cat-file", "-e", `${revision}^{commit}`]);
953
1018
  return true;
954
- } catch {
1019
+ } catch (error) {
1020
+ rethrowGitDeadline(error);
955
1021
  return false;
956
1022
  }
957
1023
  }
@@ -985,6 +1051,66 @@ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
985
1051
  return snapshot;
986
1052
  }
987
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
+
988
1114
  export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
989
1115
  const root = resolveWorkspaceRoot(input);
990
1116
  try {
@@ -1009,6 +1135,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1009
1135
  workspaceChangePaths: parsed.changePaths
1010
1136
  };
1011
1137
  } catch (error) {
1138
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1012
1139
  return unavailableSnapshot(error, { workspaceChangePaths: [] });
1013
1140
  }
1014
1141
  }
@@ -1022,6 +1149,7 @@ async function buildRepositorySnapshot(root) {
1022
1149
  { operation: "locate the repository" }
1023
1150
  ));
1024
1151
  } catch (error) {
1152
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1025
1153
  return unavailableSnapshot(error);
1026
1154
  }
1027
1155
  const [topLevel, gitDirectory] = repositoryPaths.split("\n");
@@ -1087,6 +1215,7 @@ async function buildRepositorySnapshot(root) {
1087
1215
  invocationCount: 3 + (parsed.commit ? 1 : 0) + (parsed.upstream ? 1 : 0) + (parsed.ahead > 0 ? 1 : 0)
1088
1216
  };
1089
1217
  } catch (error) {
1218
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1090
1219
  return unavailableSnapshot(error, { root: topLevel, gitDirectory });
1091
1220
  }
1092
1221
  }
@@ -2188,7 +2317,7 @@ function assertNoHiddenIndexEntries(root) {
2188
2317
  }
2189
2318
 
2190
2319
  function hashWorkspaceBytes(root, bytes, write = false) {
2191
- return execFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
2320
+ return observedExecFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
2192
2321
  cwd: root,
2193
2322
  input: bytes,
2194
2323
  encoding: "utf8",
@@ -2474,6 +2603,24 @@ export function setGitCommandInterceptorForTests(interceptor) {
2474
2603
  return () => { gitCommandInterceptor = previous; };
2475
2604
  }
2476
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
+
2477
2624
  export function runGitCommand(cwd, args, options = {}) {
2478
2625
  if (gitCommandInterceptor) {
2479
2626
  return Promise.resolve().then(() => gitCommandInterceptor({
@@ -2501,8 +2648,9 @@ function runGitCommandNative(cwd, args, options = {}) {
2501
2648
  if (options.expectedNoOperation) assertNoGitOperationInProgress(cwd);
2502
2649
  const operation = options.operation || "run a Git command";
2503
2650
  const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
2504
- const timeoutMs = Math.max(1, Number(configuredTimeout) || GIT_DEFAULT_TIMEOUT_MS);
2651
+ const { timeoutMs, deadlineLimited } = gitTimeoutPlan(configuredTimeout, GIT_DEFAULT_TIMEOUT_MS);
2505
2652
  const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
2653
+ gitSubprocessObserver?.({ kind: "async", cwd, args: [...args], options: { ...options } });
2506
2654
  return new Promise((resolveCommand, rejectCommand) => {
2507
2655
  const child = spawn("git", args, {
2508
2656
  cwd,
@@ -2581,7 +2729,9 @@ function runGitCommandNative(cwd, args, options = {}) {
2581
2729
  : /not a git repository|outside repository/i.test(errorOutput)
2582
2730
  ? "invalid-repository"
2583
2731
  : "command-failure";
2584
- rejectCommand(new GitOperationError(kind, operation, detail));
2732
+ rejectCommand(new GitOperationError(kind, operation, detail, {
2733
+ code: timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined
2734
+ }));
2585
2735
  });
2586
2736
  });
2587
2737
  }
@@ -2595,20 +2745,23 @@ async function tryGitAsync(cwd, args, operation) {
2595
2745
  }
2596
2746
 
2597
2747
  function git(cwd, args, options = {}) {
2598
- return measureTimingSync("git-command-sync", () => execFileSync("git", args, {
2599
- cwd,
2600
- encoding: "utf8",
2601
- stdio: ["ignore", "pipe", "ignore"],
2602
- timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2603
- maxBuffer: 20_000_000,
2604
- env: gitEnvironment()
2605
- }).trim());
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
+ });
2606
2758
  }
2607
2759
 
2608
2760
  function tryGit(cwd, args) {
2609
2761
  try {
2610
2762
  return git(cwd, args);
2611
- } catch {
2763
+ } catch (error) {
2764
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
2612
2765
  return "";
2613
2766
  }
2614
2767
  }
@@ -2625,22 +2778,107 @@ function gitOptionalMatch(cwd, args) {
2625
2778
  function tryGitRaw(cwd, args) {
2626
2779
  try {
2627
2780
  return gitRaw(cwd, args);
2628
- } catch {
2781
+ } catch (error) {
2782
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
2629
2783
  return "";
2630
2784
  }
2631
2785
  }
2632
2786
 
2633
2787
  function gitRaw(cwd, args, options = {}) {
2634
- return execFileSync("git", args, {
2635
- cwd,
2636
- encoding: "utf8",
2637
- stdio: ["ignore", "pipe", "ignore"],
2638
- timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2639
- maxBuffer: 20_000_000,
2640
- 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
+ });
2641
2797
  });
2642
2798
  }
2643
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
+
2644
2882
  function nulFields(source) {
2645
2883
  return source ? source.split("\0").filter(Boolean) : [];
2646
2884
  }
@@ -2648,7 +2886,7 @@ function nulFields(source) {
2648
2886
  function gitForWrite(cwd, args, action = "create the commit", options = {}) {
2649
2887
  try {
2650
2888
  assertWorkspaceInsideGitWorktree(cwd);
2651
- return execFileSync("git", args, {
2889
+ return observedExecFileSync("git", args, {
2652
2890
  cwd,
2653
2891
  encoding: "utf8",
2654
2892
  stdio: ["ignore", "pipe", "pipe"],
@@ -172,13 +172,14 @@ export async function assessProgramAmendmentReadiness(loaded) {
172
172
  const commitments = loaded.resources.filter((record) => (
173
173
  record.type === "commitment" && !["superseded", "retired"].includes(record.status)
174
174
  ));
175
- const sourceIds = new Set(commitments.flatMap((record) => record.sourceResourceIds || []));
175
+ const supplementalCommitments = commitments.filter((record) => (record.sourceResourceIds || []).length > 0);
176
+ const sourceIds = new Set(supplementalCommitments.flatMap((record) => record.sourceResourceIds || []));
176
177
  for (const record of loaded.resources) {
177
178
  if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
178
179
  sourceIds.add(record.id);
179
180
  }
180
181
  }
181
- const sourceRecords = [...new Set([...sourceIds, ...commitments.map(({ id }) => id)])]
182
+ const sourceRecords = [...new Set([...sourceIds, ...supplementalCommitments.map(({ id }) => id)])]
182
183
  .map((id) => byId.get(id))
183
184
  .filter((record) => record && SOURCE_TYPES.has(record.type));
184
185
  const plans = await Promise.all(sourceRecords.map((record) => (