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