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/package.json +1 -1
- package/src/git.js +677 -106
- package/src/reconciliation.js +616 -154
- package/src/server.js +356 -52
- package/src/state.js +18 -10
- package/src/validate.js +112 -15
- 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/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";
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
251
|
-
"log", "--reverse", "-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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(
|
|
277
|
-
if (!record
|
|
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
|
-
|
|
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: [...
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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
|
|
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 =
|
|
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
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
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
|
|
2889
|
+
return observedExecFileSync("git", args, {
|
|
2319
2890
|
cwd,
|
|
2320
2891
|
encoding: "utf8",
|
|
2321
2892
|
stdio: ["ignore", "pipe", "pipe"],
|