filegrc 0.12.3 → 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/package.json +1 -1
- package/src/git.js +284 -46
- package/src/reconciliation.js +9 -4
- package/src/server.js +356 -52
- package/src/state.js +18 -10
- package/src/validate.js +73 -8
- package/src/web.js +75 -9
- package/src/workflow-history-integrity.js +19 -9
- package/src/workflow.js +7 -2
- package/src/workspace.js +3 -0
package/package.json
CHANGED
package/src/git.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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", () =>
|
|
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", () =>
|
|
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
|
|
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 =
|
|
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
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
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
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
|
2889
|
+
return observedExecFileSync("git", args, {
|
|
2652
2890
|
cwd,
|
|
2653
2891
|
encoding: "utf8",
|
|
2654
2892
|
stdio: ["ignore", "pipe", "pipe"],
|