filegrc 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.js CHANGED
@@ -1,15 +1,37 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import {
5
+ closeSync,
6
+ constants,
7
+ existsSync,
8
+ fstatSync,
9
+ fsyncSync,
10
+ lstatSync,
11
+ openSync,
12
+ readFileSync,
13
+ readSync,
14
+ realpathSync,
15
+ renameSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
5
19
  import { rm } from "node:fs/promises";
6
20
  import { devNull } from "node:os";
7
21
  import { relative, resolve, sep } from "node:path";
8
22
  import { performance } from "node:perf_hooks";
9
23
  import { isSafeGitName } from "./git-name.js";
10
- import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
24
+ import {
25
+ serializeWorkspaceMutation,
26
+ withDeferredWorkspaceValidation,
27
+ } from "./mutation.js";
11
28
  import { isCanonicalDataPath, resolveWorkspaceRoot } from "./paths.js";
12
- import { measureTiming, measureTimingSync, recordTiming, timingEnabled } from "./timing.js";
29
+ import {
30
+ measureTiming,
31
+ measureTimingSync,
32
+ recordTiming,
33
+ timingEnabled,
34
+ } from "./timing.js";
13
35
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
36
  import { loadWorkspace } from "./workspace.js";
15
37
 
@@ -26,6 +48,7 @@ const repositorySnapshotPromises = new Map();
26
48
  const repositoryObjectFormats = new Map();
27
49
  const gitCommandCaches = new AsyncLocalStorage();
28
50
  const gitCommandDeadlines = new AsyncLocalStorage();
51
+ const gitCommandAdapters = new AsyncLocalStorage();
29
52
  const gitCommandCacheBytes = new WeakMap();
30
53
  let gitCommandInterceptor = null;
31
54
  let gitSubprocessObserver = null;
@@ -50,19 +73,23 @@ const GIT_COMMAND_CACHE_MAX_ENTRY_BYTES = 512 * 1024;
50
73
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
51
74
 
52
75
  export function withGitCommandCache(cache, callback) {
53
- if (!(cache instanceof Map)) throw new TypeError("The Git command cache must be a Map.");
76
+ if (!(cache instanceof Map))
77
+ throw new TypeError("The Git command cache must be a Map.");
54
78
  return gitCommandCaches.run(cache, callback);
55
79
  }
56
80
 
57
81
  export function withGitCommandDeadline(deadlineAt, callback) {
58
- if (!Number.isFinite(deadlineAt)) throw new TypeError("The Git command deadline must be finite.");
82
+ if (!Number.isFinite(deadlineAt))
83
+ throw new TypeError("The Git command deadline must be finite.");
59
84
  return gitCommandDeadlines.run(deadlineAt, callback);
60
85
  }
61
86
 
62
87
  function gitEnvironment(overrides = {}) {
63
88
  return {
64
89
  ...Object.fromEntries(
65
- Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_"))
90
+ Object.entries(process.env).filter(
91
+ ([name]) => !name.toUpperCase().startsWith("GIT_"),
92
+ ),
66
93
  ),
67
94
  ...overrides,
68
95
  GIT_NO_REPLACE_OBJECTS: "1",
@@ -87,20 +114,24 @@ function gitEnvironment(overrides = {}) {
87
114
  GIT_CONFIG_KEY_6: "protocol.ssh.allow",
88
115
  GIT_CONFIG_VALUE_6: "always",
89
116
  GIT_CONFIG_KEY_7: "protocol.file.allow",
90
- GIT_CONFIG_VALUE_7: "always"
117
+ GIT_CONFIG_VALUE_7: "always",
91
118
  };
92
119
  }
93
120
 
94
121
  export class GitOperationError extends Error {
95
122
  constructor(kind, operation, detail, options = {}) {
96
- const prefix = kind === "missing-executable"
97
- ? "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
98
- : kind === "timeout"
99
- ? `Git timed out while trying to ${operation}.`
100
- : kind === "invalid-repository"
101
- ? `Git could not ${operation} because this workspace is not in a valid Git repository.`
102
- : `Git could not ${operation}.`;
103
- super(detail ? `${prefix} ${sanitizeGitErrorMessage(detail)}` : prefix, options);
123
+ const prefix =
124
+ kind === "missing-executable"
125
+ ? "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
126
+ : kind === "timeout"
127
+ ? `Git timed out while trying to ${operation}.`
128
+ : kind === "invalid-repository"
129
+ ? `Git could not ${operation} because this workspace is not in a valid Git repository.`
130
+ : `Git could not ${operation}.`;
131
+ super(
132
+ detail ? `${prefix} ${sanitizeGitErrorMessage(detail)}` : prefix,
133
+ options,
134
+ );
104
135
  this.name = "GitOperationError";
105
136
  this.kind = kind;
106
137
  this.operation = operation;
@@ -111,14 +142,27 @@ export class GitOperationError extends Error {
111
142
  export function getGitSummary(input = process.cwd()) {
112
143
  const root = resolveWorkspaceRoot(input);
113
144
  try {
114
- const topLevel = measureTimingSync("git-discovery", () => git(root, ["rev-parse", "--show-toplevel"]));
145
+ const topLevel = measureTimingSync("git-discovery", () =>
146
+ git(root, ["rev-parse", "--show-toplevel"]),
147
+ );
115
148
  assertNoWorkspaceContentFilters(root);
116
149
  const status = git(root, ["status", "--porcelain=v1", "--", "."]);
117
150
  const commit = tryGit(root, ["rev-parse", "HEAD"]) || null;
118
151
  const branch = tryGit(root, ["symbolic-ref", "--short", "HEAD"]) || null;
119
- const upstream = branch ? tryGit(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) || null : null;
152
+ const upstream = branch
153
+ ? tryGit(root, [
154
+ "rev-parse",
155
+ "--abbrev-ref",
156
+ "--symbolic-full-name",
157
+ "@{upstream}",
158
+ ]) || null
159
+ : null;
120
160
  const remotes = lines(tryGit(root, ["remote"]));
121
- const last = commit ? parseLogLine(tryGit(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"])) : null;
161
+ const last = commit
162
+ ? parseLogLine(
163
+ tryGit(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"]),
164
+ )
165
+ : null;
122
166
  return {
123
167
  available: true,
124
168
  root: topLevel,
@@ -129,7 +173,7 @@ export function getGitSummary(input = process.cwd()) {
129
173
  remotes,
130
174
  clean: status === "",
131
175
  changes: status ? status.split("\n") : [],
132
- lastCommit: last
176
+ lastCommit: last,
133
177
  };
134
178
  } catch (error) {
135
179
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -137,7 +181,8 @@ export function getGitSummary(input = process.cwd()) {
137
181
  available: false,
138
182
  clean: null,
139
183
  changes: [],
140
- message: "Git history is unavailable. Commit the workspace to enable audit metadata."
184
+ message:
185
+ "Git history is unavailable. Commit the workspace to enable audit metadata.",
141
186
  };
142
187
  }
143
188
  }
@@ -145,19 +190,23 @@ export function getGitSummary(input = process.cwd()) {
145
190
  export function getFileHistory(input, relativePath, limit = 50) {
146
191
  const root = resolveWorkspaceRoot(input);
147
192
  if (!isSafeDataGitPath(relativePath)) return null;
148
- const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
193
+ const indexed = indexedPathHistory(
194
+ dataRecordHistoryIndexCache.get(root),
195
+ relativePath,
196
+ );
149
197
  if (indexed) return limitedHistory(indexed, limit);
150
198
  try {
151
- const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
152
- ? []
153
- : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
199
+ const countArgs =
200
+ Number(limit) >= Number.MAX_SAFE_INTEGER
201
+ ? []
202
+ : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
154
203
  const output = git(root, [
155
204
  "log",
156
205
  "--follow",
157
206
  ...countArgs,
158
207
  "--format=%H%x1f%cI%x1f%an%x1f%s",
159
208
  "--",
160
- relativePath
209
+ relativePath,
161
210
  ]);
162
211
  if (!output) return [];
163
212
  return output.split("\n").map(parseLogLine);
@@ -170,12 +219,16 @@ export function getFileHistory(input, relativePath, limit = 50) {
170
219
  export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
171
220
  const root = resolveWorkspaceRoot(input);
172
221
  if (!isSafeDataGitPath(relativePath)) return null;
173
- const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
222
+ const indexed = indexedPathHistory(
223
+ dataRecordHistoryIndexCache.get(root),
224
+ relativePath,
225
+ );
174
226
  if (indexed) return limitedHistory(indexed, limit);
175
227
  try {
176
- const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
177
- ? []
178
- : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
228
+ const countArgs =
229
+ Number(limit) >= Number.MAX_SAFE_INTEGER
230
+ ? []
231
+ : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
179
232
  const output = gitRaw(root, [
180
233
  "log",
181
234
  "--follow",
@@ -185,7 +238,7 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
185
238
  "--name-status",
186
239
  "-M",
187
240
  "--",
188
- relativePath
241
+ relativePath,
189
242
  ]);
190
243
  if (!output) return [];
191
244
  const tokens = output.split("\0");
@@ -193,22 +246,30 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
193
246
  const history = [];
194
247
  let index = 0;
195
248
  while (index < tokens.length) {
196
- while (index < tokens.length && !/^[a-f0-9]{40}$/i.test(tokens[index].trim())) index += 1;
249
+ while (
250
+ index < tokens.length &&
251
+ !/^[a-f0-9]{40}$/i.test(tokens[index].trim())
252
+ )
253
+ index += 1;
197
254
  if (index >= tokens.length) break;
198
255
  const summary = {
199
256
  commit: tokens[index++].trim(),
200
257
  timestamp: tokens[index++] || "",
201
258
  author: tokens[index++] || "",
202
- subject: tokens[index++] || ""
259
+ subject: tokens[index++] || "",
203
260
  };
204
261
  history.push({ ...summary, path: trackedPath });
205
- while (index < tokens.length && !/^[a-f0-9]{40}$/i.test(tokens[index].trim())) {
262
+ while (
263
+ index < tokens.length &&
264
+ !/^[a-f0-9]{40}$/i.test(tokens[index].trim())
265
+ ) {
206
266
  const status = tokens[index++].trim();
207
267
  if (!status) continue;
208
268
  const oldPath = tokens[index++] || "";
209
269
  if (status.startsWith("R") || status.startsWith("C")) {
210
270
  const newPath = tokens[index++] || "";
211
- if (status.startsWith("R") && newPath === trackedPath) trackedPath = oldPath;
271
+ if (status.startsWith("R") && newPath === trackedPath)
272
+ trackedPath = oldPath;
212
273
  }
213
274
  }
214
275
  }
@@ -221,15 +282,24 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
221
282
 
222
283
  export function getFilePathAtRevision(input, relativePath, revision) {
223
284
  const root = resolveWorkspaceRoot(input);
224
- if (!isSafeDataGitPath(relativePath) || !/^[a-f0-9]{40}$/i.test(String(revision || ""))) return null;
285
+ if (
286
+ !isSafeDataGitPath(relativePath) ||
287
+ !/^[a-f0-9]{40}$/i.test(String(revision || ""))
288
+ )
289
+ return null;
225
290
  const index = dataRecordHistoryIndexCache.get(root);
226
291
  const indexed = indexedPathHistory(index, relativePath);
227
292
  if (indexed) {
228
- return indexed.find(({ commit }) => indexedAncestor(index, commit, revision))?.path || null;
293
+ return (
294
+ indexed.find(({ commit }) => indexedAncestor(index, commit, revision))
295
+ ?.path || null
296
+ );
229
297
  }
230
- const history = getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
231
- const summary = history.find(({ commit }) => commit === revision)
232
- || history.find(({ commit }) => isDataHistoryAncestor(root, commit, revision));
298
+ const history =
299
+ getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
300
+ const summary =
301
+ history.find(({ commit }) => commit === revision) ||
302
+ history.find(({ commit }) => isDataHistoryAncestor(root, commit, revision));
233
303
  return summary?.path || null;
234
304
  }
235
305
 
@@ -249,18 +319,37 @@ export function getChangedDataJsonFilesAtRevision(input, revision) {
249
319
  try {
250
320
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
251
321
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
252
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
322
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
323
+ return [];
253
324
  const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
254
325
  const output = git(root, [
255
- "diff-tree", "--root", "--no-commit-id", "--name-status", "-M", "-m", "-r", revision, "--", dataPrefix
326
+ "diff-tree",
327
+ "--root",
328
+ "--no-commit-id",
329
+ "--name-status",
330
+ "-M",
331
+ "-m",
332
+ "-r",
333
+ revision,
334
+ "--",
335
+ dataPrefix,
256
336
  ]);
257
337
  const paths = [];
258
338
  for (const line of lines(output)) {
259
339
  const [status, first, second] = line.split("\t");
260
340
  if (status === "D") continue;
261
- const repositoryPath = status?.startsWith("R") || status?.startsWith("C") ? second : first;
262
- if (!repositoryPath?.startsWith(`${dataPrefix}/`) || !repositoryPath.endsWith(".json")) continue;
263
- paths.push(workspacePrefix ? repositoryPath.slice(workspacePrefix.length + 1) : repositoryPath);
341
+ const repositoryPath =
342
+ status?.startsWith("R") || status?.startsWith("C") ? second : first;
343
+ if (
344
+ !repositoryPath?.startsWith(`${dataPrefix}/`) ||
345
+ !repositoryPath.endsWith(".json")
346
+ )
347
+ continue;
348
+ paths.push(
349
+ workspacePrefix
350
+ ? repositoryPath.slice(workspacePrefix.length + 1)
351
+ : repositoryPath,
352
+ );
264
353
  }
265
354
  return [...new Set(paths)];
266
355
  } catch (error) {
@@ -275,38 +364,51 @@ export function getRecordIdentityHistory(input, id) {
275
364
 
276
365
  export function getRecordIdentityHistories(input, ids) {
277
366
  const index = getDataRecordHistoryIndex(input);
278
- return new Map([...new Set(ids)].map((id) => [id, index.historiesById.get(id) || []]));
367
+ return new Map(
368
+ [...new Set(ids)].map((id) => [id, index.historiesById.get(id) || []]),
369
+ );
279
370
  }
280
371
 
281
372
  export function getDataRecordHistoryIndex(input, options = {}) {
282
373
  const root = resolveWorkspaceRoot(input);
283
374
  const cached = dataRecordHistoryIndexCache.get(root);
284
- const suppliedHead = /^[a-f0-9]{40}$/i.test(String(options.head)) ? String(options.head) : undefined;
375
+ const suppliedHead = /^[a-f0-9]{40}$/i.test(String(options.head))
376
+ ? String(options.head)
377
+ : undefined;
285
378
  if (
286
- suppliedHead
287
- && cached?.head === suppliedHead
288
- && !cached.available
289
- && cached.failureExpiresAt > performance.now()
290
- ) return cached;
291
- if (suppliedHead && cached?.head === suppliedHead && cached.available) return cached;
379
+ suppliedHead &&
380
+ cached?.head === suppliedHead &&
381
+ !cached.available &&
382
+ cached.failureExpiresAt > performance.now()
383
+ )
384
+ return cached;
385
+ if (suppliedHead && cached?.head === suppliedHead && cached.available)
386
+ return cached;
292
387
  const hasDeadline = options.deadline !== undefined;
293
- const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
388
+ const deadline = hasDeadline
389
+ ? Number(options.deadline)
390
+ : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
294
391
  if (!Number.isFinite(deadline) || deadline <= performance.now()) {
295
- const error = new Error("Git data history exceeded its cumulative time limit.");
392
+ const error = new Error(
393
+ "Git data history exceeded its cumulative time limit.",
394
+ );
296
395
  error.code = "FILEGRC_HISTORY_DEADLINE";
297
396
  throw error;
298
397
  }
299
398
  const remainingTime = () => {
300
399
  const remainingMs = Math.floor(deadline - performance.now());
301
400
  if (remainingMs <= 0) {
302
- const error = new Error("Git data history exceeded its cumulative time limit.");
401
+ const error = new Error(
402
+ "Git data history exceeded its cumulative time limit.",
403
+ );
303
404
  error.code = "FILEGRC_HISTORY_DEADLINE";
304
405
  throw error;
305
406
  }
306
407
  return remainingMs;
307
408
  };
308
409
  const historyGit = (args) => git(root, args, { timeoutMs: remainingTime() });
309
- const historyGitRaw = (args) => gitRaw(root, args, { timeoutMs: remainingTime() });
410
+ const historyGitRaw = (args) =>
411
+ gitRaw(root, args, { timeoutMs: remainingTime() });
310
412
  let head = null;
311
413
  let discoveryError = null;
312
414
  if (suppliedHead) {
@@ -337,24 +439,46 @@ export function getDataRecordHistoryIndex(input, options = {}) {
337
439
  }
338
440
  }
339
441
  let available = Boolean(head) && !shallow;
340
- let error = discoveryError || (!head
341
- ? new Error("Git history is unavailable because the workspace has no committed HEAD.")
342
- : shallow ? new Error("Git history is shallow.") : null);
442
+ let error =
443
+ discoveryError ||
444
+ (!head
445
+ ? new Error(
446
+ "Git history is unavailable because the workspace has no committed HEAD.",
447
+ )
448
+ : shallow
449
+ ? new Error("Git history is shallow.")
450
+ : null);
343
451
  if (discoveryError) available = false;
344
452
  try {
345
453
  if (available) {
346
454
  const output = historyGitRaw([
347
- "log", "--topo-order", "--reverse", "--diff-merges=first-parent", "-z", "--relative",
348
- "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", head, "--", "data"
455
+ "log",
456
+ "--topo-order",
457
+ "--reverse",
458
+ "--diff-merges=first-parent",
459
+ "-z",
460
+ "--relative",
461
+ "--format=%H%x00%cI%x00%an%x00%s%x00",
462
+ "--name-status",
463
+ "-M",
464
+ head,
465
+ "--",
466
+ "data",
349
467
  ]);
350
468
  const parsed = parseDataRecordHistory(output);
351
- if (parsed.commitCount > DATA_HISTORY_MAX_COMMITS || parsed.changes.length > DATA_HISTORY_MAX_CHANGES) {
352
- throw dataHistoryLimitError("Git data history is too large to reconcile safely.");
469
+ if (
470
+ parsed.commitCount > DATA_HISTORY_MAX_COMMITS ||
471
+ parsed.changes.length > DATA_HISTORY_MAX_CHANGES
472
+ ) {
473
+ throw dataHistoryLimitError(
474
+ "Git data history is too large to reconcile safely.",
475
+ );
353
476
  }
354
477
  changes.push(...parsed.recordChanges);
355
478
  sourceChanges.push(...parsed.sourceChanges);
356
479
  for (const change of parsed.changes) {
357
- if (!changesByCommit.has(change.summary.commit)) changesByCommit.set(change.summary.commit, []);
480
+ if (!changesByCommit.has(change.summary.commit))
481
+ changesByCommit.set(change.summary.commit, []);
358
482
  changesByCommit.get(change.summary.commit).push(change);
359
483
  }
360
484
  }
@@ -368,13 +492,16 @@ export function getDataRecordHistoryIndex(input, options = {}) {
368
492
  try {
369
493
  sources = getFilesAtRevisions(
370
494
  root,
371
- sourceChanges.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
495
+ sourceChanges.map(({ summary, path }) => ({
496
+ revision: summary.commit,
497
+ relativePath: path,
498
+ })),
372
499
  {
373
500
  batchSize: 512,
374
501
  maxRequests: DATA_HISTORY_MAX_SOURCE_REQUESTS,
375
502
  maxTotalBytes: DATA_HISTORY_MAX_SOURCE_BYTES,
376
- deadline
377
- }
503
+ deadline,
504
+ },
378
505
  );
379
506
  } catch (cause) {
380
507
  rethrowGitDeadline(cause);
@@ -397,13 +524,21 @@ export function getDataRecordHistoryIndex(input, options = {}) {
397
524
  for (const [commit, commitChanges] of changesByCommit) {
398
525
  const fileChanges = new Map();
399
526
  for (const change of commitChanges) {
400
- if (change.beforePath?.match(/\.(?:json|md)$/) && change.beforePath !== change.afterPath) {
401
- if (change.beforePath.endsWith(".json")) historicalRecordPaths.add(change.beforePath);
527
+ if (
528
+ change.beforePath?.match(/\.(?:json|md)$/) &&
529
+ change.beforePath !== change.afterPath
530
+ ) {
531
+ if (change.beforePath.endsWith(".json"))
532
+ historicalRecordPaths.add(change.beforePath);
402
533
  fileChanges.set(change.beforePath, null);
403
534
  }
404
535
  if (change.afterPath?.match(/\.(?:json|md)$/)) {
405
- if (change.afterPath.endsWith(".json")) historicalRecordPaths.add(change.afterPath);
406
- fileChanges.set(change.afterPath, sourceByCommitAndPath.get(`${commit}\0${change.afterPath}`) ?? null);
536
+ if (change.afterPath.endsWith(".json"))
537
+ historicalRecordPaths.add(change.afterPath);
538
+ fileChanges.set(
539
+ change.afterPath,
540
+ sourceByCommitAndPath.get(`${commit}\0${change.afterPath}`) ?? null,
541
+ );
407
542
  }
408
543
  }
409
544
  fileChangesByCommit.set(commit, fileChanges);
@@ -411,13 +546,25 @@ export function getDataRecordHistoryIndex(input, options = {}) {
411
546
  for (let index = 0; index < changes.length; index += 1) {
412
547
  const { summary, path } = changes[index];
413
548
  try {
414
- const record = JSON.parse(sourceByCommitAndPath.get(`${summary.commit}\0${path}`));
415
- if (!record || Array.isArray(record) || typeof record.id !== "string" || typeof record.type !== "string") {
416
- throw new Error("Historical data records require string IDs and types.");
549
+ const record = JSON.parse(
550
+ sourceByCommitAndPath.get(`${summary.commit}\0${path}`),
551
+ );
552
+ if (
553
+ !record ||
554
+ Array.isArray(record) ||
555
+ typeof record.id !== "string" ||
556
+ typeof record.type !== "string"
557
+ ) {
558
+ throw new Error(
559
+ "Historical data records require string IDs and types.",
560
+ );
417
561
  }
418
- if (!recordsByCommit.has(summary.commit)) recordsByCommit.set(summary.commit, new Map());
562
+ if (!recordsByCommit.has(summary.commit))
563
+ recordsByCommit.set(summary.commit, new Map());
419
564
  if (recordsByCommit.get(summary.commit).has(record.id)) {
420
- throw new Error(`Historical data records reuse ID "${record.id}" in one commit.`);
565
+ throw new Error(
566
+ `Historical data records reuse ID "${record.id}" in one commit.`,
567
+ );
421
568
  }
422
569
  recordsByCommit.get(summary.commit).set(record.id, { record, path });
423
570
  if (!historiesById.has(record.id)) historiesById.set(record.id, []);
@@ -425,7 +572,10 @@ export function getDataRecordHistoryIndex(input, options = {}) {
425
572
  } catch (cause) {
426
573
  rethrowGitDeadline(cause);
427
574
  available = false;
428
- error = new Error(`Git history contains an unreadable data record at ${summary.commit.slice(0, 12)}:${path}.`, { cause });
575
+ error = new Error(
576
+ `Git history contains an unreadable data record at ${summary.commit.slice(0, 12)}:${path}.`,
577
+ { cause },
578
+ );
429
579
  break;
430
580
  }
431
581
  }
@@ -434,15 +584,25 @@ export function getDataRecordHistoryIndex(input, options = {}) {
434
584
  try {
435
585
  for (const line of lines(historyGit(["rev-list", "--parents", head]))) {
436
586
  const [commit, ...parents] = line.split(" ");
437
- if (!/^[a-f0-9]{40}$/i.test(commit) || parents.some((parent) => !/^[a-f0-9]{40}$/i.test(parent))) {
587
+ if (
588
+ !/^[a-f0-9]{40}$/i.test(commit) ||
589
+ parents.some((parent) => !/^[a-f0-9]{40}$/i.test(parent))
590
+ ) {
438
591
  throw new Error("Git returned invalid commit ancestry.");
439
592
  }
440
593
  parentsByCommit.set(commit, parents);
441
594
  if (parentsByCommit.size > DATA_HISTORY_MAX_ANCESTRY_COMMITS) {
442
- throw dataHistoryLimitError("Git ancestry is too large to reconcile safely.");
595
+ throw dataHistoryLimitError(
596
+ "Git ancestry is too large to reconcile safely.",
597
+ );
443
598
  }
444
599
  }
445
- if (!parentsByCommit.has(head) || [...changesByCommit.keys()].some((commit) => !parentsByCommit.has(commit))) {
600
+ if (
601
+ !parentsByCommit.has(head) ||
602
+ [...changesByCommit.keys()].some(
603
+ (commit) => !parentsByCommit.has(commit),
604
+ )
605
+ ) {
446
606
  throw new Error("Git returned incomplete commit ancestry.");
447
607
  }
448
608
  } catch (cause) {
@@ -454,12 +614,15 @@ export function getDataRecordHistoryIndex(input, options = {}) {
454
614
  for (const [id, history] of historiesById) {
455
615
  history.reverse();
456
616
  const seen = new Set();
457
- historiesById.set(id, history.filter(({ commit, path }) => {
458
- const key = `${commit}\u0000${path}`;
459
- if (seen.has(key)) return false;
460
- seen.add(key);
461
- return true;
462
- }));
617
+ historiesById.set(
618
+ id,
619
+ history.filter(({ commit, path }) => {
620
+ const key = `${commit}\u0000${path}`;
621
+ if (seen.has(key)) return false;
622
+ seen.add(key);
623
+ return true;
624
+ }),
625
+ );
463
626
  }
464
627
  const result = {
465
628
  head,
@@ -473,17 +636,32 @@ export function getDataRecordHistoryIndex(input, options = {}) {
473
636
  recordsByCommit: available ? recordsByCommit : new Map(),
474
637
  historiesById: available ? historiesById : new Map(),
475
638
  sourceBytes: available
476
- ? sources.reduce((total, source) => total + (typeof source === "string" ? Buffer.byteLength(source, "utf8") : 0), 0)
639
+ ? sources.reduce(
640
+ (total, source) =>
641
+ total +
642
+ (typeof source === "string"
643
+ ? Buffer.byteLength(source, "utf8")
644
+ : 0),
645
+ 0,
646
+ )
477
647
  : 0,
478
648
  ancestryCache: new Map(),
479
- indexedFileCache: new Map()
649
+ indexedFileCache: new Map(),
480
650
  };
481
- result.failureExpiresAt = available ? null : performance.now() + DATA_HISTORY_FAILURE_CACHE_MS;
651
+ result.failureExpiresAt = available
652
+ ? null
653
+ : performance.now() + DATA_HISTORY_FAILURE_CACHE_MS;
482
654
  result.estimatedBytes = available
483
- ? result.sourceBytes * 3
484
- + [...result.changesByCommit.values()].reduce((total, commitChanges) => total + commitChanges.length * 512, 0)
485
- + result.parentsByCommit.size * 256
486
- + [...result.historiesById.values()].reduce((total, history) => total + history.length * 128, 0)
655
+ ? result.sourceBytes * 3 +
656
+ [...result.changesByCommit.values()].reduce(
657
+ (total, commitChanges) => total + commitChanges.length * 512,
658
+ 0,
659
+ ) +
660
+ result.parentsByCommit.size * 256 +
661
+ [...result.historiesById.values()].reduce(
662
+ (total, history) => total + history.length * 128,
663
+ 0,
664
+ )
487
665
  : 512 + Buffer.byteLength(error?.message || "", "utf8");
488
666
  result.cacheBytes = result.estimatedBytes;
489
667
  if (result.estimatedBytes <= DATA_HISTORY_CACHE_MAX_BYTES) {
@@ -491,7 +669,10 @@ export function getDataRecordHistoryIndex(input, options = {}) {
491
669
  dataRecordHistoryIndexCacheBytes += result.cacheBytes;
492
670
  }
493
671
  if (dataRecordHistoryIndexCache.has(root)) {
494
- while (dataRecordHistoryIndexCache.size > 4 || dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES) {
672
+ while (
673
+ dataRecordHistoryIndexCache.size > 4 ||
674
+ dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES
675
+ ) {
495
676
  const oldestRoot = dataRecordHistoryIndexCache.keys().next().value;
496
677
  const oldest = dataRecordHistoryIndexCache.get(oldestRoot);
497
678
  dataRecordHistoryIndexCache.delete(oldestRoot);
@@ -512,15 +693,27 @@ function parseDataRecordHistory(output) {
512
693
  while (fields[index] === "") index += 1;
513
694
  const commit = fields[index++];
514
695
  if (commit === undefined) break;
515
- if (!/^[a-f0-9]{40}$/i.test(commit)) throw new Error("Git returned an invalid data-history commit.");
696
+ if (!/^[a-f0-9]{40}$/i.test(commit))
697
+ throw new Error("Git returned an invalid data-history commit.");
516
698
  const timestamp = fields[index++];
517
699
  const author = fields[index++];
518
700
  const subject = fields[index++];
519
- if (timestamp === undefined || author === undefined || subject === undefined) {
701
+ if (
702
+ timestamp === undefined ||
703
+ author === undefined ||
704
+ subject === undefined
705
+ ) {
520
706
  throw new Error("Git returned an incomplete data-history header.");
521
707
  }
522
- const summary = { commit, shortCommit: commit.slice(0, 8), timestamp, author, subject };
523
- if (commits.has(commit)) throw new Error("Git returned duplicate data-history commit output.");
708
+ const summary = {
709
+ commit,
710
+ shortCommit: commit.slice(0, 8),
711
+ timestamp,
712
+ author,
713
+ subject,
714
+ };
715
+ if (commits.has(commit))
716
+ throw new Error("Git returned duplicate data-history commit output.");
524
717
  commits.add(commit);
525
718
  while (index < fields.length) {
526
719
  while (fields[index] === "") index += 1;
@@ -534,11 +727,13 @@ function parseDataRecordHistory(output) {
534
727
  const first = fields[index++];
535
728
  const renamed = status.startsWith("R") || status.startsWith("C");
536
729
  const second = renamed ? fields[index++] : null;
537
- if (!first || (renamed && !second)) throw new Error("Git returned an incomplete data-history path.");
730
+ if (!first || (renamed && !second))
731
+ throw new Error("Git returned an incomplete data-history path.");
538
732
  const beforePath = status === "A" ? null : first;
539
733
  const afterPath = status === "D" ? null : renamed ? second : first;
540
734
  for (const path of [beforePath, afterPath].filter(Boolean)) {
541
- if (!isSafeDataGitPath(path)) throw new Error("Git returned an unsafe data-history path.");
735
+ if (!isSafeDataGitPath(path))
736
+ throw new Error("Git returned an unsafe data-history path.");
542
737
  }
543
738
  const change = { summary, status, beforePath, afterPath };
544
739
  changes.push(change);
@@ -568,10 +763,11 @@ function limitedHistory(history, limit) {
568
763
 
569
764
  function indexedDataFile(index, revision, relativePath) {
570
765
  if (
571
- !index?.available
572
- || !relativePath?.match(/\.(?:json|md)$/)
573
- || !index.parentsByCommit.has(revision)
574
- ) return undefined;
766
+ !index?.available ||
767
+ !relativePath?.match(/\.(?:json|md)$/) ||
768
+ !index.parentsByCommit.has(revision)
769
+ )
770
+ return undefined;
575
771
  const key = `${revision}\0${relativePath}`;
576
772
  if (index.indexedFileCache.has(key)) return index.indexedFileCache.get(key);
577
773
  let commit = revision;
@@ -585,7 +781,8 @@ function indexedDataFile(index, revision, relativePath) {
585
781
  commit = index.parentsByCommit.get(commit)?.[0] || null;
586
782
  }
587
783
  index.indexedFileCache.set(key, source);
588
- while (index.indexedFileCache.size > 20_000) index.indexedFileCache.delete(index.indexedFileCache.keys().next().value);
784
+ while (index.indexedFileCache.size > 20_000)
785
+ index.indexedFileCache.delete(index.indexedFileCache.keys().next().value);
589
786
  return source;
590
787
  }
591
788
 
@@ -607,20 +804,29 @@ function indexedAncestor(index, ancestor, descendant) {
607
804
  pending.push(...(index.parentsByCommit.get(commit) || []));
608
805
  }
609
806
  index.ancestryCache.set(key, result);
610
- while (index.ancestryCache.size > 20_000) index.ancestryCache.delete(index.ancestryCache.keys().next().value);
807
+ while (index.ancestryCache.size > 20_000)
808
+ index.ancestryCache.delete(index.ancestryCache.keys().next().value);
611
809
  return result;
612
810
  }
613
811
 
614
812
  export function isGitAncestor(input, ancestor, descendant) {
615
- if (!/^[a-f0-9]{40}$/i.test(String(ancestor)) || !/^[a-f0-9]{40}$/i.test(String(descendant))) return false;
813
+ if (
814
+ !/^[a-f0-9]{40}$/i.test(String(ancestor)) ||
815
+ !/^[a-f0-9]{40}$/i.test(String(descendant))
816
+ )
817
+ return false;
616
818
  const root = resolveWorkspaceRoot(input);
617
819
  try {
618
- observedExecFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
619
- cwd: root,
620
- stdio: "ignore",
621
- timeout: 10_000,
622
- env: gitEnvironment()
623
- });
820
+ executeGitSync(
821
+ root,
822
+ ["merge-base", "--is-ancestor", ancestor, descendant],
823
+ {
824
+ cwd: root,
825
+ stdio: "ignore",
826
+ timeout: 10_000,
827
+ env: gitEnvironment(),
828
+ },
829
+ );
624
830
  return true;
625
831
  } catch (error) {
626
832
  rethrowGitDeadline(error);
@@ -643,19 +849,26 @@ export function isDataHistoryAncestor(input, ancestor, descendant) {
643
849
  }
644
850
 
645
851
  export function getFileBufferAtRevision(input, revision, relativePath) {
646
- if (!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)) return null;
852
+ if (
853
+ !/^[a-f0-9]{40}$/i.test(String(revision)) ||
854
+ !isSafeDataGitPath(relativePath)
855
+ )
856
+ return null;
647
857
  const root = resolveWorkspaceRoot(input);
648
858
  try {
649
859
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
650
860
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
651
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
652
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
653
- return observedExecFileSync("git", ["show", `${revision}:${repositoryPath}`], {
861
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
862
+ return null;
863
+ const repositoryPath = workspacePrefix
864
+ ? `${workspacePrefix}/${relativePath}`
865
+ : relativePath;
866
+ return executeGitSync(root, ["show", `${revision}:${repositoryPath}`], {
654
867
  cwd: root,
655
868
  stdio: ["ignore", "pipe", "ignore"],
656
869
  timeout: 10_000,
657
870
  maxBuffer: 20_000_000,
658
- env: gitEnvironment()
871
+ env: gitEnvironment(),
659
872
  });
660
873
  } catch (error) {
661
874
  rethrowGitDeadline(error);
@@ -664,13 +877,20 @@ export function getFileBufferAtRevision(input, revision, relativePath) {
664
877
  }
665
878
 
666
879
  export function getFileObjectIdAtRevision(input, revision, relativePath) {
667
- if (!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)) return null;
880
+ if (
881
+ !/^[a-f0-9]{40}$/i.test(String(revision)) ||
882
+ !isSafeDataGitPath(relativePath)
883
+ )
884
+ return null;
668
885
  const root = resolveWorkspaceRoot(input);
669
886
  try {
670
887
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
671
888
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
672
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
673
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
889
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
890
+ return null;
891
+ const repositoryPath = workspacePrefix
892
+ ? `${workspacePrefix}/${relativePath}`
893
+ : relativePath;
674
894
  const objectId = git(root, ["rev-parse", `${revision}:${repositoryPath}`]);
675
895
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
676
896
  } catch (error) {
@@ -686,26 +906,36 @@ export function getWorkingFileObjectId(input, relativePath) {
686
906
  try {
687
907
  const objectFormat = repositoryObjectFormat(root);
688
908
  if (!objectFormat) return null;
689
- descriptor = openSync(resolve(root, relativePath), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
909
+ descriptor = openSync(
910
+ resolve(root, relativePath),
911
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
912
+ );
690
913
  const before = fstatSync(descriptor);
691
914
  if (!before.isFile()) return null;
692
915
  const hash = createHash(objectFormat).update(`blob ${before.size}\0`);
693
916
  const buffer = Buffer.allocUnsafe(64 * 1024);
694
917
  let position = 0;
695
918
  while (position < before.size) {
696
- const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
919
+ const count = readSync(
920
+ descriptor,
921
+ buffer,
922
+ 0,
923
+ Math.min(buffer.length, before.size - position),
924
+ position,
925
+ );
697
926
  if (!count) return null;
698
927
  hash.update(buffer.subarray(0, count));
699
928
  position += count;
700
929
  }
701
930
  const after = fstatSync(descriptor);
702
931
  if (
703
- after.size !== before.size
704
- || after.dev !== before.dev
705
- || after.ino !== before.ino
706
- || after.mtimeMs !== before.mtimeMs
707
- || after.ctimeMs !== before.ctimeMs
708
- ) return null;
932
+ after.size !== before.size ||
933
+ after.dev !== before.dev ||
934
+ after.ino !== before.ino ||
935
+ after.mtimeMs !== before.mtimeMs ||
936
+ after.ctimeMs !== before.ctimeMs
937
+ )
938
+ return null;
709
939
  return hash.digest("hex");
710
940
  } catch (error) {
711
941
  rethrowGitDeadline(error);
@@ -716,17 +946,26 @@ export function getWorkingFileObjectId(input, relativePath) {
716
946
  }
717
947
 
718
948
  function repositoryObjectFormat(root) {
719
- if (repositoryObjectFormats.has(root)) return repositoryObjectFormats.get(root);
949
+ if (repositoryObjectFormats.has(root))
950
+ return repositoryObjectFormats.get(root);
720
951
  if (!tryGit(root, ["rev-parse", "--git-dir"])) return null;
721
- const objectFormat = tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
952
+ const objectFormat =
953
+ tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
722
954
  if (!["sha1", "sha256"].includes(objectFormat)) {
723
- throw new Error(`FileGRC does not support the repository object format "${objectFormat}".`);
955
+ throw new Error(
956
+ `FileGRC does not support the repository object format "${objectFormat}".`,
957
+ );
724
958
  }
725
959
  repositoryObjectFormats.set(root, objectFormat);
726
960
  return objectFormat;
727
961
  }
728
962
 
729
- export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
963
+ export function getWorkspaceHistories(
964
+ input,
965
+ relativePaths,
966
+ limitPerFile = 12,
967
+ options = {},
968
+ ) {
730
969
  const root = resolveWorkspaceRoot(input);
731
970
  const wanted = new Set(relativePaths);
732
971
  const histories = new Map([...wanted].map((path) => [path, []]));
@@ -735,16 +974,22 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
735
974
  if (options.deadlineAt === undefined) return undefined;
736
975
  const remaining = Math.ceil(options.deadlineAt - performance.now());
737
976
  if (remaining > 0) return remaining;
738
- const error = new Error("The Git history deadline expired before another subprocess could start.");
977
+ const error = new Error(
978
+ "The Git history deadline expired before another subprocess could start.",
979
+ );
739
980
  error.code = "FILEGRC_GIT_DEADLINE";
740
981
  throw error;
741
982
  };
742
- const withinDeadline = (task) => options.deadlineAt === undefined
743
- ? task()
744
- : withGitCommandDeadline(options.deadlineAt, task);
983
+ const withinDeadline = (task) =>
984
+ options.deadlineAt === undefined
985
+ ? task()
986
+ : withGitCommandDeadline(options.deadlineAt, task);
745
987
  let head = null;
746
988
  try {
747
- head = withinDeadline(() => git(root, ["rev-parse", "HEAD"], { timeoutMs: remainingTime() })) || null;
989
+ head =
990
+ withinDeadline(() =>
991
+ git(root, ["rev-parse", "HEAD"], { timeoutMs: remainingTime() }),
992
+ ) || null;
748
993
  } catch (error) {
749
994
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
750
995
  head = null;
@@ -754,15 +999,29 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
754
999
  if (options.strict === true && cached.available === false) {
755
1000
  throw gitHistoryUnavailableError();
756
1001
  }
757
- for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
1002
+ for (const path of wanted)
1003
+ histories.set(path, cached.histories.get(path) ?? []);
758
1004
  return histories;
759
1005
  }
760
1006
  const allHistories = new Map();
761
1007
  let available = true;
762
1008
  try {
763
- const output = withinDeadline(() => git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"], {
764
- timeoutMs: remainingTime()
765
- }));
1009
+ const output = withinDeadline(() =>
1010
+ git(
1011
+ root,
1012
+ [
1013
+ "log",
1014
+ "--relative",
1015
+ "--format=%x1e%H%x1f%aI%x1f%an%x1f%s",
1016
+ "--name-only",
1017
+ "--",
1018
+ "data",
1019
+ ],
1020
+ {
1021
+ timeoutMs: remainingTime(),
1022
+ },
1023
+ ),
1024
+ );
766
1025
  for (const block of output.split("\x1e")) {
767
1026
  const lines = block.trim().split("\n").filter(Boolean);
768
1027
  if (lines.length < 2) continue;
@@ -781,13 +1040,22 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
781
1040
  }
782
1041
  // Browser and workflow views tolerate an uncommitted workspace with no history yet.
783
1042
  }
784
- if (available) workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories, available });
1043
+ if (available)
1044
+ workspaceHistoryCache.set(root, {
1045
+ head,
1046
+ limitPerFile,
1047
+ histories: allHistories,
1048
+ available,
1049
+ });
785
1050
  for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
786
1051
  return histories;
787
1052
  }
788
1053
 
789
1054
  function gitHistoryUnavailableError(cause) {
790
- const error = new Error("Git history is unavailable for the requested workspace files.", { cause });
1055
+ const error = new Error(
1056
+ "Git history is unavailable for the requested workspace files.",
1057
+ { cause },
1058
+ );
791
1059
  error.code = "FILEGRC_GIT_HISTORY_UNAVAILABLE";
792
1060
  return error;
793
1061
  }
@@ -795,7 +1063,11 @@ function gitHistoryUnavailableError(cause) {
795
1063
  export function getFileAtRevision(input, revision, relativePath) {
796
1064
  const root = resolveWorkspaceRoot(input);
797
1065
  historicalRevisionReadObserver?.({ root, revision, relativePath });
798
- const indexed = indexedDataFile(dataRecordHistoryIndexCache.get(root), revision, relativePath);
1066
+ const indexed = indexedDataFile(
1067
+ dataRecordHistoryIndexCache.get(root),
1068
+ revision,
1069
+ relativePath,
1070
+ );
799
1071
  return indexed === undefined
800
1072
  ? getFilesAtRevisions(root, [{ revision, relativePath }])[0]
801
1073
  : indexed;
@@ -804,30 +1076,47 @@ export function getFileAtRevision(input, revision, relativePath) {
804
1076
  export function getFilesAtRevisions(input, requests, options = {}) {
805
1077
  const root = resolveWorkspaceRoot(input);
806
1078
  const hasDeadline = options.deadline !== undefined;
807
- const deadline = hasDeadline ? Number(options.deadline) : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
1079
+ const deadline = hasDeadline
1080
+ ? Number(options.deadline)
1081
+ : performance.now() + DATA_HISTORY_BUILD_TIMEOUT_MS;
808
1082
  if (!Number.isFinite(deadline) || deadline <= performance.now()) {
809
- const error = new Error("Git historical file export exceeded its cumulative deadline.");
1083
+ const error = new Error(
1084
+ "Git historical file export exceeded its cumulative deadline.",
1085
+ );
810
1086
  error.code = "FILEGRC_HISTORY_DEADLINE";
811
1087
  throw error;
812
1088
  }
813
1089
  const remainingTime = () => {
814
1090
  const remainingMs = Math.floor(deadline - performance.now());
815
1091
  if (remainingMs <= 0) {
816
- const error = new Error("Git historical file export exceeded its cumulative deadline.");
1092
+ const error = new Error(
1093
+ "Git historical file export exceeded its cumulative deadline.",
1094
+ );
817
1095
  error.code = "FILEGRC_HISTORY_DEADLINE";
818
1096
  throw error;
819
1097
  }
820
1098
  return remainingMs;
821
1099
  };
822
- const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
823
- !/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
824
- ));
1100
+ const invalid =
1101
+ Array.isArray(requests) &&
1102
+ requests.find(
1103
+ ({ revision, relativePath } = {}) =>
1104
+ !/^[a-f0-9]{40}$/i.test(String(revision)) ||
1105
+ !isSafeDataGitPath(relativePath),
1106
+ );
825
1107
  if (!Array.isArray(requests) || invalid) {
826
- throw new Error("Historical file exports require a Git commit and a data/ path.");
1108
+ throw new Error(
1109
+ "Historical file exports require a Git commit and a data/ path.",
1110
+ );
827
1111
  }
828
- const maxRequests = Number(options.maxRequests) > 0 ? Math.floor(Number(options.maxRequests)) : null;
1112
+ const maxRequests =
1113
+ Number(options.maxRequests) > 0
1114
+ ? Math.floor(Number(options.maxRequests))
1115
+ : null;
829
1116
  if (maxRequests && requests.length > maxRequests) {
830
- const error = new Error(`Git historical file exports exceed the ${maxRequests}-request safety limit.`);
1117
+ const error = new Error(
1118
+ `Git historical file exports exceed the ${maxRequests}-request safety limit.`,
1119
+ );
831
1120
  error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
832
1121
  throw error;
833
1122
  }
@@ -844,54 +1133,84 @@ export function getFilesAtRevisions(input, requests, options = {}) {
844
1133
  missing.push({ ...request, index, key });
845
1134
  }
846
1135
  });
847
- const maxTotalBytes = Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
1136
+ const maxTotalBytes =
1137
+ Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
848
1138
  const cachedBytes = maxTotalBytes
849
- ? results.reduce((total, value) => total + (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0), 0)
1139
+ ? results.reduce(
1140
+ (total, value) =>
1141
+ total +
1142
+ (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0),
1143
+ 0,
1144
+ )
850
1145
  : 0;
851
- if (maxTotalBytes && cachedBytes > maxTotalBytes) throw historicalExportLimitError(maxTotalBytes);
1146
+ if (maxTotalBytes && cachedBytes > maxTotalBytes)
1147
+ throw historicalExportLimitError(maxTotalBytes);
852
1148
  if (!missing.length) return results;
853
1149
  try {
854
- const topLevel = git(root, ["rev-parse", "--show-toplevel"], { timeoutMs: remainingTime() });
1150
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"], {
1151
+ timeoutMs: remainingTime(),
1152
+ });
855
1153
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
856
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
1154
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
1155
+ return requests.map(() => null);
857
1156
  const missingSpecifications = missing.map(({ revision, relativePath }) => {
858
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
1157
+ const repositoryPath = workspacePrefix
1158
+ ? `${workspacePrefix}/${relativePath}`
1159
+ : relativePath;
859
1160
  return `${revision}:${repositoryPath}`;
860
1161
  });
861
1162
  if (maxTotalBytes) {
862
- assertHistoricalExportSize(root, missingSpecifications, maxTotalBytes - cachedBytes, maxTotalBytes, remainingTime());
1163
+ assertHistoricalExportSize(
1164
+ root,
1165
+ missingSpecifications,
1166
+ maxTotalBytes - cachedBytes,
1167
+ maxTotalBytes,
1168
+ remainingTime(),
1169
+ );
863
1170
  }
864
- const batchSize = Math.max(1, Math.min(Number(options.batchSize) || 4, 512));
1171
+ const batchSize = Math.max(
1172
+ 1,
1173
+ Math.min(Number(options.batchSize) || 4, 512),
1174
+ );
865
1175
  for (let offset = 0; offset < missing.length; offset += batchSize) {
866
1176
  const batch = missing.slice(offset, offset + batchSize);
867
- const specifications = missingSpecifications.slice(offset, offset + batchSize);
1177
+ const specifications = missingSpecifications.slice(
1178
+ offset,
1179
+ offset + batchSize,
1180
+ );
868
1181
  let values;
869
1182
  try {
870
1183
  const run = () => {
871
1184
  const remainingMs = remainingTime();
872
- return observedExecFileSync("git", ["cat-file", "--batch"], {
1185
+ return executeGitSync(root, ["cat-file", "--batch"], {
873
1186
  cwd: root,
874
1187
  input: `${specifications.join("\n")}\n`,
875
1188
  stdio: ["pipe", "pipe", "ignore"],
876
1189
  timeout: remainingMs,
877
1190
  maxBuffer: 80_000_000,
878
- env: gitEnvironment()
1191
+ env: gitEnvironment(),
879
1192
  });
880
1193
  };
881
- const output = measureTimingSync("git-history-export", () => (
882
- historicalBatchInterceptor ? historicalBatchInterceptor({ root, specifications, run }) : run()
883
- ));
1194
+ const output = measureTimingSync("git-history-export", () =>
1195
+ historicalBatchInterceptor
1196
+ ? historicalBatchInterceptor({ root, specifications, run })
1197
+ : run(),
1198
+ );
884
1199
  values = parseBatchObjects(output, batch.length);
885
1200
  } catch (cause) {
886
1201
  rethrowGitDeadline(cause);
887
- const error = new Error("Git could not read the historical file batch safely.", { cause });
1202
+ const error = new Error(
1203
+ "Git could not read the historical file batch safely.",
1204
+ { cause },
1205
+ );
888
1206
  error.code = "FILEGRC_HISTORY_BATCH_FAILED";
889
1207
  throw error;
890
1208
  }
891
1209
  batch.forEach(({ index, key }, batchIndex) => {
892
1210
  const value = values[batchIndex];
893
1211
  results[index] = value;
894
- if (value !== null && Buffer.byteLength(value, "utf8") <= 65_536) historicalFileCache.set(key, value);
1212
+ if (value !== null && Buffer.byteLength(value, "utf8") <= 65_536)
1213
+ historicalFileCache.set(key, value);
895
1214
  });
896
1215
  while (historicalFileCache.size > 2_048) {
897
1216
  historicalFileCache.delete(historicalFileCache.keys().next().value);
@@ -899,7 +1218,15 @@ export function getFilesAtRevisions(input, requests, options = {}) {
899
1218
  }
900
1219
  return results;
901
1220
  } catch (error) {
902
- if (["FILEGRC_HISTORY_EXPORT_LIMIT", "FILEGRC_HISTORY_BATCH_FAILED", "FILEGRC_HISTORY_DEADLINE", "FILEGRC_GIT_DEADLINE"].includes(error?.code)) throw error;
1221
+ if (
1222
+ [
1223
+ "FILEGRC_HISTORY_EXPORT_LIMIT",
1224
+ "FILEGRC_HISTORY_BATCH_FAILED",
1225
+ "FILEGRC_HISTORY_DEADLINE",
1226
+ "FILEGRC_GIT_DEADLINE",
1227
+ ].includes(error?.code)
1228
+ )
1229
+ throw error;
903
1230
  return results.map((value) => value ?? null);
904
1231
  }
905
1232
  }
@@ -912,35 +1239,49 @@ export function setHistoricalBatchInterceptorForTests(interceptor) {
912
1239
  };
913
1240
  }
914
1241
 
915
- function assertHistoricalExportSize(root, specifications, remainingBytes, maxTotalBytes, timeoutMs = GIT_DEFAULT_TIMEOUT_MS) {
916
- const output = measureTimingSync("git-history-size", () => observedExecFileSync("git", [
917
- "cat-file",
918
- "--batch-check=%(objectname) %(objecttype) %(objectsize)"
919
- ], {
920
- cwd: root,
921
- input: `${specifications.join("\n")}\n`,
922
- encoding: "utf8",
923
- stdio: ["pipe", "pipe", "ignore"],
924
- timeout: timeoutMs,
925
- maxBuffer: 20_000_000,
926
- env: gitEnvironment()
927
- }));
1242
+ function assertHistoricalExportSize(
1243
+ root,
1244
+ specifications,
1245
+ remainingBytes,
1246
+ maxTotalBytes,
1247
+ timeoutMs = GIT_DEFAULT_TIMEOUT_MS,
1248
+ ) {
1249
+ const output = measureTimingSync("git-history-size", () =>
1250
+ executeGitSync(
1251
+ root,
1252
+ ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
1253
+ {
1254
+ cwd: root,
1255
+ input: `${specifications.join("\n")}\n`,
1256
+ encoding: "utf8",
1257
+ stdio: ["pipe", "pipe", "ignore"],
1258
+ timeout: timeoutMs,
1259
+ maxBuffer: 20_000_000,
1260
+ env: gitEnvironment(),
1261
+ },
1262
+ ),
1263
+ );
928
1264
  let total = 0;
929
1265
  for (const line of output.trim().split("\n")) {
930
1266
  if (!line || line.endsWith(" missing")) continue;
931
1267
  const match = line.match(/^[a-f0-9]+ blob (\d+)$/i);
932
- if (!match) throw new Error("Git returned invalid historical object metadata.");
1268
+ if (!match)
1269
+ throw new Error("Git returned invalid historical object metadata.");
933
1270
  total += Number(match[1]);
934
- if (!Number.isSafeInteger(total) || total > remainingBytes) throw historicalExportLimitError(maxTotalBytes);
1271
+ if (!Number.isSafeInteger(total) || total > remainingBytes)
1272
+ throw historicalExportLimitError(maxTotalBytes);
935
1273
  }
936
1274
  }
937
1275
 
938
1276
  function historicalExportLimitError(maxTotalBytes) {
939
1277
  const megabytes = maxTotalBytes / (1024 * 1024);
940
- const limit = Number.isInteger(megabytes) && megabytes >= 1
941
- ? `${megabytes} MB`
942
- : `${maxTotalBytes} byte`;
943
- const error = new Error(`Git historical file exports exceed the ${limit} safety limit.`);
1278
+ const limit =
1279
+ Number.isInteger(megabytes) && megabytes >= 1
1280
+ ? `${megabytes} MB`
1281
+ : `${maxTotalBytes} byte`;
1282
+ const error = new Error(
1283
+ `Git historical file exports exceed the ${limit} safety limit.`,
1284
+ );
944
1285
  error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
945
1286
  return error;
946
1287
  }
@@ -964,7 +1305,9 @@ export function getDataFilesAtRevision(input, revision) {
964
1305
  }
965
1306
  const files = new Set();
966
1307
  for (let position = lineage.length - 1; position >= 0; position -= 1) {
967
- for (const [path, source] of index.fileChangesByCommit.get(lineage[position]) || []) {
1308
+ for (const [path, source] of index.fileChangesByCommit.get(
1309
+ lineage[position],
1310
+ ) || []) {
968
1311
  if (source === null) files.delete(path);
969
1312
  else files.add(path);
970
1313
  }
@@ -974,11 +1317,18 @@ export function getDataFilesAtRevision(input, revision) {
974
1317
  try {
975
1318
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
976
1319
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
977
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
1320
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
1321
+ return [];
978
1322
  const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
979
- return lines(git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]))
980
- .filter((path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"))
981
- .map((path) => workspacePrefix ? path.slice(workspacePrefix.length + 1) : path);
1323
+ return lines(
1324
+ git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]),
1325
+ )
1326
+ .filter(
1327
+ (path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"),
1328
+ )
1329
+ .map((path) =>
1330
+ workspacePrefix ? path.slice(workspacePrefix.length + 1) : path,
1331
+ );
982
1332
  } catch (error) {
983
1333
  rethrowGitDeadline(error);
984
1334
  return [];
@@ -987,14 +1337,16 @@ export function getDataFilesAtRevision(input, revision) {
987
1337
 
988
1338
  function readHistoricalFile(root, specification) {
989
1339
  try {
990
- return measureTimingSync("git-history-export", () => observedExecFileSync("git", ["show", specification], {
991
- cwd: root,
992
- encoding: "utf8",
993
- stdio: ["ignore", "pipe", "ignore"],
994
- timeout: 10_000,
995
- maxBuffer: 20_000_000,
996
- env: gitEnvironment()
997
- }));
1340
+ return measureTimingSync("git-history-export", () =>
1341
+ executeGitSync(root, ["show", specification], {
1342
+ cwd: root,
1343
+ encoding: "utf8",
1344
+ stdio: ["ignore", "pipe", "ignore"],
1345
+ timeout: 10_000,
1346
+ maxBuffer: 20_000_000,
1347
+ env: gitEnvironment(),
1348
+ }),
1349
+ );
998
1350
  } catch (error) {
999
1351
  rethrowGitDeadline(error);
1000
1352
  return null;
@@ -1006,7 +1358,8 @@ function parseBatchObjects(output, expected) {
1006
1358
  let offset = 0;
1007
1359
  for (let index = 0; index < expected; index += 1) {
1008
1360
  const headerEnd = output.indexOf(10, offset);
1009
- if (headerEnd < 0) throw new Error("Git returned an incomplete historical object header.");
1361
+ if (headerEnd < 0)
1362
+ throw new Error("Git returned an incomplete historical object header.");
1010
1363
  const header = output.subarray(offset, headerEnd).toString("utf8");
1011
1364
  offset = headerEnd + 1;
1012
1365
  if (header.endsWith(" missing")) {
@@ -1014,31 +1367,57 @@ function parseBatchObjects(output, expected) {
1014
1367
  continue;
1015
1368
  }
1016
1369
  const size = Number(header.split(" ").at(-1));
1017
- if (!Number.isSafeInteger(size) || size < 0 || offset + size >= output.length) {
1370
+ if (
1371
+ !Number.isSafeInteger(size) ||
1372
+ size < 0 ||
1373
+ offset + size >= output.length
1374
+ ) {
1018
1375
  throw new Error("Git returned an invalid historical object size.");
1019
1376
  }
1020
1377
  results.push(output.subarray(offset, offset + size).toString("utf8"));
1021
1378
  offset += size;
1022
- if (output[offset++] !== 10) throw new Error("Git returned an incomplete historical object.");
1379
+ if (output[offset++] !== 10)
1380
+ throw new Error("Git returned an incomplete historical object.");
1023
1381
  }
1024
1382
  return results;
1025
1383
  }
1026
1384
 
1027
1385
  function isSafeDataGitPath(value) {
1028
- return isCanonicalDataPath(value)
1029
- && !/[\r\n]/.test(value)
1030
- && value.startsWith("data/")
1031
- && value !== "data/";
1386
+ return (
1387
+ isCanonicalDataPath(value) &&
1388
+ !/[\r\n]/.test(value) &&
1389
+ value.startsWith("data/") &&
1390
+ value !== "data/"
1391
+ );
1032
1392
  }
1033
1393
 
1034
1394
  export function getChangedDataPathsSinceRevision(input, revision) {
1035
1395
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return null;
1036
1396
  const root = resolveWorkspaceRoot(input);
1037
1397
  try {
1038
- return [...new Set([
1039
- ...lines(git(root, ["diff", "--name-only", "--relative", revision, "--", "data"])),
1040
- ...lines(git(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
1041
- ])].filter((path) => path.startsWith("data/"));
1398
+ return [
1399
+ ...new Set([
1400
+ ...lines(
1401
+ git(root, [
1402
+ "diff",
1403
+ "--name-only",
1404
+ "--relative",
1405
+ revision,
1406
+ "--",
1407
+ "data",
1408
+ ]),
1409
+ ),
1410
+ ...lines(
1411
+ git(root, [
1412
+ "ls-files",
1413
+ "--others",
1414
+ "--exclude-standard",
1415
+ "--",
1416
+ "data",
1417
+ ]),
1418
+ ),
1419
+ ]),
1420
+ ].filter((path) => path.startsWith("data/"));
1042
1421
  } catch (error) {
1043
1422
  rethrowGitDeadline(error);
1044
1423
  return null;
@@ -1060,11 +1439,15 @@ export function hasGitRevision(input, revision) {
1060
1439
  }
1061
1440
 
1062
1441
  export async function commitWorkspace(input, message) {
1063
- return serializeWorkspaceMutation(input, (root) => commitWorkspaceUnlocked(root, message));
1442
+ return serializeWorkspaceMutation(input, (root) =>
1443
+ commitWorkspaceUnlocked(root, message),
1444
+ );
1064
1445
  }
1065
1446
 
1066
1447
  export async function commitAndPushWorkspace(input, message) {
1067
- return serializeWorkspaceMutation(input, (root) => commitAndPushWorkspaceUnlocked(root, message));
1448
+ return serializeWorkspaceMutation(input, (root) =>
1449
+ commitAndPushWorkspaceUnlocked(root, message),
1450
+ );
1068
1451
  }
1069
1452
 
1070
1453
  export async function pullWorkspace(input = process.cwd()) {
@@ -1082,13 +1465,17 @@ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
1082
1465
  return repositorySnapshotPromises.get(root);
1083
1466
  }
1084
1467
  const snapshot = buildRepositorySnapshot(root).finally(() => {
1085
- if (repositorySnapshotPromises.get(root) === snapshot) repositorySnapshotPromises.delete(root);
1468
+ if (repositorySnapshotPromises.get(root) === snapshot)
1469
+ repositorySnapshotPromises.delete(root);
1086
1470
  });
1087
1471
  repositorySnapshotPromises.set(root, snapshot);
1088
1472
  return snapshot;
1089
1473
  }
1090
1474
 
1091
- export async function getRepositoryStateSignature(input = process.cwd(), options = {}) {
1475
+ export async function getRepositoryStateSignature(
1476
+ input = process.cwd(),
1477
+ options = {},
1478
+ ) {
1092
1479
  const root = resolveWorkspaceRoot(input);
1093
1480
  const timeout = { timeoutMs: options.timeoutMs };
1094
1481
  let status;
@@ -1097,25 +1484,26 @@ export async function getRepositoryStateSignature(input = process.cwd(), options
1097
1484
  let remotes;
1098
1485
  try {
1099
1486
  [status, gitDirectory, refs, remotes] = await Promise.all([
1100
- runGitCommand(root, [
1101
- "status",
1102
- "--porcelain=v2",
1103
- "--branch",
1104
- "-z",
1105
- "--untracked-files=all"
1106
- ], { ...timeout, operation: "verify repository state" }),
1487
+ runGitCommand(
1488
+ root,
1489
+ ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"],
1490
+ { ...timeout, operation: "verify repository state" },
1491
+ ),
1107
1492
  runGitCommand(root, ["rev-parse", "--absolute-git-dir"], {
1108
1493
  ...timeout,
1109
- operation: "locate repository state"
1494
+ operation: "locate repository state",
1110
1495
  }),
1111
1496
  runGitCommand(root, ["rev-parse", "--verify", "@{upstream}"], {
1112
1497
  ...timeout,
1113
- operation: "verify the upstream repository revision"
1498
+ operation: "verify the upstream repository revision",
1114
1499
  }).catch((error) => {
1115
1500
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1116
1501
  return "";
1117
1502
  }),
1118
- runGitCommand(root, ["remote", "-v"], { ...timeout, operation: "verify repository remotes" })
1503
+ runGitCommand(root, ["remote", "-v"], {
1504
+ ...timeout,
1505
+ operation: "verify repository remotes",
1506
+ }),
1119
1507
  ]);
1120
1508
  } catch (error) {
1121
1509
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1125,14 +1513,16 @@ export async function getRepositoryStateSignature(input = process.cwd(), options
1125
1513
  remotes = "";
1126
1514
  }
1127
1515
  const background = backgroundSynchronizations.get(root);
1128
- const backgroundState = background ? {
1129
- status: background.status,
1130
- commit: background.commit,
1131
- startedAt: background.startedAt ?? null,
1132
- finishedAt: background.finishedAt ?? null,
1133
- remotePushed: background.remotePushed === true,
1134
- error: background.error ?? null
1135
- } : null;
1516
+ const backgroundState = background
1517
+ ? {
1518
+ status: background.status,
1519
+ commit: background.commit,
1520
+ startedAt: background.startedAt ?? null,
1521
+ finishedAt: background.finishedAt ?? null,
1522
+ remotePushed: background.remotePushed === true,
1523
+ error: background.error ?? null,
1524
+ }
1525
+ : null;
1136
1526
  return createHash("sha256")
1137
1527
  .update(status)
1138
1528
  .update("\0")
@@ -1152,15 +1542,21 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1152
1542
  const root = resolveWorkspaceRoot(input);
1153
1543
  try {
1154
1544
  assertNoWorkspaceContentFilters(root);
1155
- const source = await measureTiming("repository-revision", () => runGitCommand(root, [
1156
- "status",
1157
- "--porcelain=v2",
1158
- "--branch",
1159
- "-z",
1160
- "--untracked-files=all",
1161
- "--",
1162
- "data"
1163
- ], { operation: "resolve the workspace revision" }));
1545
+ const source = await measureTiming("repository-revision", () =>
1546
+ runGitCommand(
1547
+ root,
1548
+ [
1549
+ "status",
1550
+ "--porcelain=v2",
1551
+ "--branch",
1552
+ "-z",
1553
+ "--untracked-files=all",
1554
+ "--",
1555
+ "data",
1556
+ ],
1557
+ { operation: "resolve the workspace revision" },
1558
+ ),
1559
+ );
1164
1560
  const parsed = parseWorkspaceRevision(source);
1165
1561
  return {
1166
1562
  available: true,
@@ -1169,7 +1565,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1169
1565
  branch: parsed.branch,
1170
1566
  clean: parsed.changePaths.length === 0,
1171
1567
  changes: parsed.changePaths,
1172
- workspaceChangePaths: parsed.changePaths
1568
+ workspaceChangePaths: parsed.changePaths,
1173
1569
  };
1174
1570
  } catch (error) {
1175
1571
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1180,11 +1576,13 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1180
1576
  async function buildRepositorySnapshot(root) {
1181
1577
  let repositoryPaths;
1182
1578
  try {
1183
- repositoryPaths = await measureTiming("git-discovery", () => runGitCommand(
1184
- root,
1185
- ["rev-parse", "--show-toplevel", "--absolute-git-dir"],
1186
- { operation: "locate the repository" }
1187
- ));
1579
+ repositoryPaths = await measureTiming("git-discovery", () =>
1580
+ runGitCommand(
1581
+ root,
1582
+ ["rev-parse", "--show-toplevel", "--absolute-git-dir"],
1583
+ { operation: "locate the repository" },
1584
+ ),
1585
+ );
1188
1586
  } catch (error) {
1189
1587
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1190
1588
  return unavailableSnapshot(error);
@@ -1193,42 +1591,61 @@ async function buildRepositorySnapshot(root) {
1193
1591
  try {
1194
1592
  assertNoWorkspaceContentFilters(root);
1195
1593
  const [status, remotes] = await Promise.all([
1196
- runGitCommand(topLevel, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], {
1197
- operation: "inspect repository status"
1198
- }),
1199
- runGitCommand(root, ["remote"], { operation: "list repository remotes" })
1594
+ runGitCommand(
1595
+ topLevel,
1596
+ ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"],
1597
+ {
1598
+ operation: "inspect repository status",
1599
+ },
1600
+ ),
1601
+ runGitCommand(root, ["remote"], { operation: "list repository remotes" }),
1200
1602
  ]);
1201
1603
  const parsed = parsePorcelainV2(status, topLevel, root);
1202
1604
  const last = parsed.commit
1203
- ? parseLogLine(await runGitCommand(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"], {
1204
- operation: "read the latest commit"
1205
- }))
1605
+ ? parseLogLine(
1606
+ await runGitCommand(
1607
+ root,
1608
+ ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"],
1609
+ {
1610
+ operation: "read the latest commit",
1611
+ },
1612
+ ),
1613
+ )
1206
1614
  : null;
1207
1615
  let upstreamCommit = null;
1208
1616
  let pendingCommits = [];
1209
1617
  let pendingCommitsFilegrcOnly = parsed.ahead === 0 ? true : null;
1210
1618
  if (parsed.upstream) {
1211
- upstreamCommit = (await runGitCommand(root, ["rev-parse", parsed.upstream], {
1212
- operation: `resolve upstream ${parsed.upstream}`
1213
- })).trim() || null;
1619
+ upstreamCommit =
1620
+ (
1621
+ await runGitCommand(root, ["rev-parse", parsed.upstream], {
1622
+ operation: `resolve upstream ${parsed.upstream}`,
1623
+ })
1624
+ ).trim() || null;
1214
1625
  }
1215
1626
  if (parsed.ahead > 0 && parsed.upstream) {
1216
- const pending = await runGitCommand(root, [
1217
- "log",
1218
- "--format=%x1e%H%x1f%s",
1219
- "--name-only",
1220
- `${parsed.upstream}..HEAD`
1221
- ], { operation: `inspect commits ahead of ${parsed.upstream}` });
1627
+ const pending = await runGitCommand(
1628
+ root,
1629
+ [
1630
+ "log",
1631
+ "--format=%x1e%H%x1f%s",
1632
+ "--name-only",
1633
+ `${parsed.upstream}..HEAD`,
1634
+ ],
1635
+ { operation: `inspect commits ahead of ${parsed.upstream}` },
1636
+ );
1222
1637
  const prefix = relative(topLevel, root).split(sep).join("/");
1223
1638
  const commits = parsePendingCommitPaths(pending);
1224
1639
  pendingCommits = commits.map(({ commit, subject }) => ({
1225
1640
  commit,
1226
1641
  shortCommit: commit.slice(0, 8),
1227
- subject
1642
+ subject,
1228
1643
  }));
1229
- pendingCommitsFilegrcOnly = commits.every(({ paths }) => (
1230
- paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix))
1231
- ));
1644
+ pendingCommitsFilegrcOnly = commits.every(
1645
+ ({ paths }) =>
1646
+ paths.length > 0 &&
1647
+ paths.every((path) => pathInsideWorkspace(path, prefix)),
1648
+ );
1232
1649
  }
1233
1650
  return {
1234
1651
  available: true,
@@ -1249,7 +1666,11 @@ async function buildRepositorySnapshot(root) {
1249
1666
  pendingCommits,
1250
1667
  pendingCommitsFilegrcOnly,
1251
1668
  lastCommit: last,
1252
- invocationCount: 3 + (parsed.commit ? 1 : 0) + (parsed.upstream ? 1 : 0) + (parsed.ahead > 0 ? 1 : 0)
1669
+ invocationCount:
1670
+ 3 +
1671
+ (parsed.commit ? 1 : 0) +
1672
+ (parsed.upstream ? 1 : 0) +
1673
+ (parsed.ahead > 0 ? 1 : 0),
1253
1674
  };
1254
1675
  } catch (error) {
1255
1676
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1263,17 +1684,23 @@ function unavailableSnapshot(error, extra = {}) {
1263
1684
  clean: null,
1264
1685
  changes: [],
1265
1686
  error,
1266
- message: error instanceof GitOperationError
1267
- ? error.message
1268
- : "Git history is unavailable. Commit the workspace to enable audit metadata.",
1269
- ...extra
1687
+ message:
1688
+ error instanceof GitOperationError
1689
+ ? error.message
1690
+ : "Git history is unavailable. Commit the workspace to enable audit metadata.",
1691
+ ...extra,
1270
1692
  };
1271
1693
  }
1272
1694
 
1273
- export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
1274
- const root = input?.entries && input?.root ? input.root : resolveWorkspaceRoot(input);
1695
+ export async function getBrowserRepositoryState(
1696
+ input = process.cwd(),
1697
+ options = {},
1698
+ ) {
1699
+ const root =
1700
+ input?.entries && input?.root ? input.root : resolveWorkspaceRoot(input);
1275
1701
  const config = await getRepositoryConfig(input);
1276
- const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
1702
+ const gitSummary =
1703
+ options.repositorySnapshot ?? (await getRepositorySnapshot(root));
1277
1704
  if (config.mode !== "trunk") {
1278
1705
  return {
1279
1706
  mode: "manual",
@@ -1289,8 +1716,10 @@ export async function getBrowserRepositoryState(input = process.cwd(), options =
1289
1716
  behind: null,
1290
1717
  pendingCommits: [],
1291
1718
  pendingCommitsFilegrcOnly: null,
1292
- lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
1293
- message: "Browser writes stay local until a user commits and synchronizes them."
1719
+ lastSuccessfulSynchronization:
1720
+ lastSuccessfulSynchronizations.get(root) ?? null,
1721
+ message:
1722
+ "Browser writes stay local until a user commits and synchronizes them.",
1294
1723
  };
1295
1724
  }
1296
1725
 
@@ -1303,27 +1732,34 @@ export async function getBrowserRepositoryState(input = process.cwd(), options =
1303
1732
  writesAllowed: !options.readOnly,
1304
1733
  status: "not-synced",
1305
1734
  label: "Not synced",
1306
- message: "Development override is active. Browser writes stay local and FileGRC will not commit or push them."
1735
+ message:
1736
+ "Development override is active. Browser writes stay local and FileGRC will not commit or push them.",
1307
1737
  };
1308
1738
  }
1309
1739
  return {
1310
1740
  ...details,
1311
1741
  developmentOverride: false,
1312
- writesAllowed: !options.readOnly && details.writesAllowed
1742
+ writesAllowed: !options.readOnly && details.writesAllowed,
1313
1743
  };
1314
1744
  }
1315
1745
 
1316
1746
  export async function runBrowserMutation(input, options, task) {
1317
1747
  return serializeWorkspaceMutation(input, async (root) => {
1318
1748
  const config = await getRepositoryConfig(root);
1319
- if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
1749
+ if (
1750
+ config.mode !== "trunk" ||
1751
+ options?.allowNonAuthoritativeWrites === true
1752
+ ) {
1320
1753
  return task(root);
1321
1754
  }
1322
1755
  return runTrunkMutationUnlocked(root, config, options, task);
1323
1756
  });
1324
1757
  }
1325
1758
 
1326
- export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
1759
+ export async function prefetchBrowserRemote(
1760
+ input = process.cwd(),
1761
+ options = {},
1762
+ ) {
1327
1763
  const root = resolveWorkspaceRoot(input);
1328
1764
  const key = `${root}\0${options.allowNonAuthoritativeWrites === true}`;
1329
1765
  if (browserRemotePrefetchPromises.has(key)) {
@@ -1331,7 +1767,8 @@ export async function prefetchBrowserRemote(input = process.cwd(), options = {})
1331
1767
  return browserRemotePrefetchPromises.get(key);
1332
1768
  }
1333
1769
  const prefetch = prefetchBrowserRemoteCoalesced(root, options).finally(() => {
1334
- if (browserRemotePrefetchPromises.get(key) === prefetch) browserRemotePrefetchPromises.delete(key);
1770
+ if (browserRemotePrefetchPromises.get(key) === prefetch)
1771
+ browserRemotePrefetchPromises.delete(key);
1335
1772
  });
1336
1773
  browserRemotePrefetchPromises.set(key, prefetch);
1337
1774
  return prefetch;
@@ -1340,11 +1777,20 @@ export async function prefetchBrowserRemote(input = process.cwd(), options = {})
1340
1777
  async function prefetchBrowserRemoteCoalesced(root, options) {
1341
1778
  const prepared = await serializeWorkspaceMutation(root, async () => {
1342
1779
  const config = await getRepositoryConfig(root);
1343
- if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) return { config, repository: null };
1344
- const repository = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
1780
+ if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true)
1781
+ return { config, repository: null };
1782
+ const repository = await measureTiming("git-preconditions", () =>
1783
+ requireTrunkPreconditionsAsync(root, config),
1784
+ );
1345
1785
  return { config, repository };
1346
1786
  });
1347
- if (!prepared.repository) return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
1787
+ if (!prepared.repository)
1788
+ return {
1789
+ status: "not-needed",
1790
+ token: null,
1791
+ fetchedAt: null,
1792
+ expiresAt: null,
1793
+ };
1348
1794
 
1349
1795
  // Fetch updates only remote-tracking refs, so it does not occupy the source mutation queue.
1350
1796
  await fetchConfiguredRemote(root, prepared.config);
@@ -1352,7 +1798,9 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1352
1798
  const summary = await getRepositorySnapshot(root, { fresh: true });
1353
1799
  if (!summary.available) throw summary.error;
1354
1800
  if (summary.commit !== prepared.repository.currentCommit) {
1355
- throw new Error("The authoritative branch changed while FileGRC checked its remote. Reload and try again.");
1801
+ throw new Error(
1802
+ "The authoritative branch changed while FileGRC checked its remote. Reload and try again.",
1803
+ );
1356
1804
  }
1357
1805
  const repository = inspectTrunkRepository(root, prepared.config, summary);
1358
1806
  const fetchedAt = new Date().toISOString();
@@ -1362,13 +1810,15 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1362
1810
  remote: prepared.config.remote,
1363
1811
  currentCommit: summary.commit,
1364
1812
  upstreamCommit: repository.upstreamCommit,
1365
- fetchedAt: Date.parse(fetchedAt)
1813
+ fetchedAt: Date.parse(fetchedAt),
1366
1814
  });
1367
1815
  return {
1368
1816
  status: "checked",
1369
1817
  token,
1370
1818
  fetchedAt,
1371
- expiresAt: new Date(Date.parse(fetchedAt) + BROWSER_REMOTE_PREFETCH_MAX_AGE_MS).toISOString()
1819
+ expiresAt: new Date(
1820
+ Date.parse(fetchedAt) + BROWSER_REMOTE_PREFETCH_MAX_AGE_MS,
1821
+ ).toISOString(),
1372
1822
  };
1373
1823
  });
1374
1824
  }
@@ -1376,37 +1826,72 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1376
1826
  export async function retryBrowserSync(input = process.cwd(), options = {}) {
1377
1827
  return serializeWorkspaceMutation(input, async (root) => {
1378
1828
  const config = await getRepositoryConfig(root);
1379
- if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
1829
+ if (config.mode !== "trunk")
1830
+ throw new Error("Retry sync is available only in trunk repository mode.");
1380
1831
  if (backgroundSynchronizations.get(root)?.status === "syncing") {
1381
- throw new Error("A FileGRC background push is already in progress. Wait for it to finish before retrying sync.");
1832
+ throw new Error(
1833
+ "A FileGRC background push is already in progress. Wait for it to finish before retrying sync.",
1834
+ );
1382
1835
  }
1383
1836
  if (options.allowNonAuthoritativeWrites === true) {
1384
- throw new Error("Retry sync is disabled while the development write override is active.");
1837
+ throw new Error(
1838
+ "Retry sync is disabled while the development write override is active.",
1839
+ );
1385
1840
  }
1386
- const before = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
1841
+ const before = await requireTrunkPreconditionsAsync(root, config, {
1842
+ allowAhead: true,
1843
+ });
1387
1844
  await fetchConfiguredRemote(root, config);
1388
- const synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
1845
+ const synchronized = inspectTrunkRepository(
1846
+ root,
1847
+ config,
1848
+ await getRepositorySnapshot(root, { fresh: true }),
1849
+ );
1389
1850
  if (synchronized.behind > 0 && synchronized.ahead > 0) {
1390
- throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
1851
+ throw new Error(
1852
+ "The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.",
1853
+ );
1391
1854
  }
1392
1855
  if (synchronized.behind > 0) {
1393
- throw new Error("The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before retrying sync.");
1856
+ throw new Error(
1857
+ "The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before retrying sync.",
1858
+ );
1394
1859
  }
1395
- const ready = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
1860
+ const ready = inspectTrunkRepository(
1861
+ root,
1862
+ config,
1863
+ await getRepositorySnapshot(root, { fresh: true }),
1864
+ );
1396
1865
  if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
1397
- throw new Error("At least one commit ahead of upstream changes files outside this FileGRC workspace. FileGRC will not push it. Reconcile the repository with Git.");
1866
+ throw new Error(
1867
+ "At least one commit ahead of upstream changes files outside this FileGRC workspace. FileGRC will not push it. Reconcile the repository with Git.",
1868
+ );
1398
1869
  }
1399
1870
  if (ready.ahead > 0) {
1400
- const pushed = await pushConfiguredBranch(root, config, ready.currentCommit, ready.upstreamCommit);
1871
+ const pushed = await pushConfiguredBranch(
1872
+ root,
1873
+ config,
1874
+ ready.currentCommit,
1875
+ ready.upstreamCommit,
1876
+ );
1401
1877
  if (pushed.trackingError) {
1402
- const committed = { commit: ready.currentCommit, shortCommit: ready.currentCommit.slice(0, 8) };
1878
+ const committed = {
1879
+ commit: ready.currentCommit,
1880
+ shortCommit: ready.currentCommit.slice(0, 8),
1881
+ };
1403
1882
  recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
1404
1883
  throw new Error(pushed.trackingError);
1405
1884
  }
1406
1885
  }
1407
- const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
1886
+ const after = inspectTrunkRepository(
1887
+ root,
1888
+ config,
1889
+ await getRepositorySnapshot(root, { fresh: true }),
1890
+ );
1408
1891
  if (after.ahead !== 0 || after.behind !== 0) {
1409
- throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
1892
+ throw new Error(
1893
+ "The authoritative branch is still not synchronized. Reload the repository state before trying again.",
1894
+ );
1410
1895
  }
1411
1896
  const synchronizedAt = new Date().toISOString();
1412
1897
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
@@ -1417,29 +1902,50 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
1417
1902
  branch: config.authoritativeBranch,
1418
1903
  upstream: after.upstream,
1419
1904
  synchronizedAt,
1420
- retriedCommits: before.ahead ?? 0
1905
+ retriedCommits: before.ahead ?? 0,
1421
1906
  };
1422
1907
  });
1423
1908
  }
1424
1909
 
1425
1910
  async function runTrunkMutationUnlocked(root, config, options, task) {
1426
- const beforeFetch = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
1911
+ const beforeFetch = await measureTiming("git-preconditions", () =>
1912
+ requireTrunkPreconditionsAsync(root, config),
1913
+ );
1427
1914
  let synchronized = beforeFetch;
1428
- if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
1915
+ if (
1916
+ !consumeFreshBrowserRemotePrefetch(
1917
+ root,
1918
+ config,
1919
+ options?.prefetchToken,
1920
+ beforeFetch,
1921
+ )
1922
+ ) {
1429
1923
  await fetchConfiguredRemote(root, config);
1430
- synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
1924
+ synchronized = inspectTrunkRepository(
1925
+ root,
1926
+ config,
1927
+ await getRepositorySnapshot(root, { fresh: true }),
1928
+ );
1431
1929
  }
1432
1930
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
1433
- throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
1931
+ throw new Error(
1932
+ "The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.",
1933
+ );
1434
1934
  }
1435
1935
  if (synchronized.ahead > 0) {
1436
- throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
1936
+ throw new Error(
1937
+ "The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.",
1938
+ );
1437
1939
  }
1438
1940
  if (synchronized.behind > 0) {
1439
- throw new Error("The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before making a browser change.");
1941
+ throw new Error(
1942
+ "The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before making a browser change.",
1943
+ );
1440
1944
  }
1441
1945
  if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
1442
- throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
1946
+ throw new Error(
1947
+ "The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.",
1948
+ );
1443
1949
  }
1444
1950
 
1445
1951
  let result;
@@ -1447,93 +1953,162 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
1447
1953
  let validationProof;
1448
1954
  let validatedManifest;
1449
1955
  try {
1450
- result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root, {
1451
- repositorySnapshot: synchronized
1452
- })));
1453
- subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
1956
+ result = await measureTiming("write", () =>
1957
+ withDeferredWorkspaceValidation(() =>
1958
+ task(root, {
1959
+ repositorySnapshot: synchronized,
1960
+ }),
1961
+ ),
1962
+ );
1963
+ subject = generatedCommitMessage(
1964
+ typeof options?.message === "function"
1965
+ ? options.message(result)
1966
+ : options?.message,
1967
+ );
1454
1968
  assertNoIgnoredAuthoritativeFiles(root);
1455
- const beforeValidation = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
1456
- const validation = await withGitCommandCache(new Map(), () => validateWorkspace(root));
1969
+ const beforeValidation = measureTimingSync("workspace-manifest", () =>
1970
+ workspaceByteManifest(root),
1971
+ );
1972
+ const validation = await withGitCommandCache(new Map(), () =>
1973
+ validateWorkspace(root),
1974
+ );
1457
1975
  if (!validation.ok) {
1458
- throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`);
1976
+ throw new Error(
1977
+ `The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`,
1978
+ );
1459
1979
  }
1460
- validationProof = options?.includeValidationProof === false
1461
- ? null
1462
- : {
1463
- validation,
1464
- fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
1465
- };
1466
- validatedManifest = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
1980
+ validationProof =
1981
+ options?.includeValidationProof === false
1982
+ ? null
1983
+ : {
1984
+ validation,
1985
+ fingerprint: (
1986
+ await measureTiming("fingerprint", () =>
1987
+ fingerprintWorkspace(validation.loaded),
1988
+ )
1989
+ ).fingerprint,
1990
+ };
1991
+ validatedManifest = measureTimingSync("workspace-manifest", () =>
1992
+ workspaceByteManifest(root),
1993
+ );
1467
1994
  assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1468
- await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
1995
+ await measureTiming("outside-worktree-check", () =>
1996
+ assertNoOutsideWorktreeChangesAsync(root),
1997
+ );
1469
1998
  } catch (error) {
1470
- throw new Error(`${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`);
1999
+ let changed = false;
2000
+ try {
2001
+ changed =
2002
+ Boolean(
2003
+ await runGitCommand(
2004
+ root,
2005
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
2006
+ {
2007
+ operation: "check the worktree after a failed browser mutation",
2008
+ },
2009
+ ),
2010
+ ) || ignoredAuthoritativeFiles(root).length > 0;
2011
+ } catch {
2012
+ // Keep the mutation error when Git cannot prove that the failed action left changes.
2013
+ }
2014
+ if (!changed) throw error;
2015
+ throw new Error(
2016
+ `${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`,
2017
+ );
1471
2018
  }
1472
2019
 
1473
- const changed = Boolean(await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
1474
- operation: "check the FileGRC workspace change"
1475
- }));
2020
+ const changed = Boolean(
2021
+ await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
2022
+ operation: "check the FileGRC workspace change",
2023
+ }),
2024
+ );
1476
2025
  if (!changed && options?.allowNoChanges === true) {
1477
- return withValidationProof({
1478
- ...result,
1479
- synchronization: {
1480
- status: "unchanged",
1481
- commit: synchronized.currentCommit,
1482
- shortCommit: synchronized.currentCommit?.slice(0, 8) ?? null,
1483
- upstream: synchronized.upstream,
1484
- synchronizedAt: lastSuccessfulSynchronizations.get(root) ?? null,
1485
- pushError: null
1486
- }
1487
- }, validationProof);
2026
+ return withValidationProof(
2027
+ {
2028
+ ...result,
2029
+ synchronization: {
2030
+ status: "unchanged",
2031
+ commit: synchronized.currentCommit,
2032
+ shortCommit: synchronized.currentCommit?.slice(0, 8) ?? null,
2033
+ upstream: synchronized.upstream,
2034
+ synchronizedAt: lastSuccessfulSynchronizations.get(root) ?? null,
2035
+ pushError: null,
2036
+ },
2037
+ },
2038
+ validationProof,
2039
+ );
1488
2040
  }
1489
2041
  if (!changed) {
1490
- throw new Error("The browser action did not change any FileGRC workspace files.");
2042
+ throw new Error(
2043
+ "The browser action did not change any FileGRC workspace files.",
2044
+ );
1491
2045
  }
1492
2046
  assertValidGitIdentity(root);
1493
2047
  assertNoWorkspaceContentFilters(root);
1494
- await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
2048
+ await measureTiming("outside-worktree-check", () =>
2049
+ assertNoOutsideWorktreeChangesAsync(root),
2050
+ );
1495
2051
  let commit;
1496
2052
  let indexReconciled;
1497
2053
  try {
1498
2054
  const expectedRef = `refs/heads/${config.authoritativeBranch}`;
1499
2055
  assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
1500
- validatedManifest = measureTimingSync("workspace-manifest-objects", () => writeWorkspaceManifestObjects(root, validatedManifest));
1501
- ({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
1502
- expectedParent: synchronized.currentCommit,
1503
- expectedRef
1504
- })));
2056
+ validatedManifest = measureTimingSync("workspace-manifest-objects", () =>
2057
+ writeWorkspaceManifestObjects(root, validatedManifest),
2058
+ );
2059
+ ({ commit, indexReconciled } = await measureTiming("commit", () =>
2060
+ commitValidatedIndexAsync(root, subject, validatedManifest, {
2061
+ expectedParent: synchronized.currentCommit,
2062
+ expectedRef,
2063
+ }),
2064
+ ));
1505
2065
  } catch (error) {
1506
- throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
2066
+ throw new Error(
2067
+ `${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`,
2068
+ );
1507
2069
  }
1508
2070
 
1509
- const committed = { commit, shortCommit: commit.slice(0, 8), upstreamCommit: synchronized.upstreamCommit };
2071
+ const committed = {
2072
+ commit,
2073
+ shortCommit: commit.slice(0, 8),
2074
+ upstreamCommit: synchronized.upstreamCommit,
2075
+ };
1510
2076
  if (!indexReconciled) {
1511
- const error = new Error("FileGRC created the commit, but the shared Git index changed or could not be reconciled afterward. Review and reconcile the Git index before syncing.");
2077
+ const error = new Error(
2078
+ "FileGRC created the commit, but the shared Git index changed or could not be reconciled afterward. Review and reconcile the Git index before syncing.",
2079
+ );
1512
2080
  recordBackgroundPushFailure(root, committed, error);
1513
- return withValidationProof({
2081
+ return withValidationProof(
2082
+ {
2083
+ ...result,
2084
+ synchronization: {
2085
+ status: "not-synced",
2086
+ commit: committed.commit,
2087
+ shortCommit: committed.shortCommit,
2088
+ upstream: synchronized.upstream,
2089
+ synchronizedAt: null,
2090
+ pushError:
2091
+ backgroundSynchronizations.get(root)?.error ?? error.message,
2092
+ },
2093
+ },
2094
+ validationProof,
2095
+ );
2096
+ }
2097
+ queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
2098
+ return withValidationProof(
2099
+ {
1514
2100
  ...result,
1515
2101
  synchronization: {
1516
- status: "not-synced",
2102
+ status: "syncing",
1517
2103
  commit: committed.commit,
1518
2104
  shortCommit: committed.shortCommit,
1519
2105
  upstream: synchronized.upstream,
1520
2106
  synchronizedAt: null,
1521
- pushError: backgroundSynchronizations.get(root)?.error ?? error.message
1522
- }
1523
- }, validationProof);
1524
- }
1525
- queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
1526
- return withValidationProof({
1527
- ...result,
1528
- synchronization: {
1529
- status: "syncing",
1530
- commit: committed.commit,
1531
- shortCommit: committed.shortCommit,
1532
- upstream: synchronized.upstream,
1533
- synchronizedAt: null,
1534
- pushError: null
1535
- }
1536
- }, validationProof);
2107
+ pushError: null,
2108
+ },
2109
+ },
2110
+ validationProof,
2111
+ );
1537
2112
  }
1538
2113
 
1539
2114
  function withValidationProof(result, proof) {
@@ -1547,16 +2122,17 @@ function consumeFreshBrowserRemotePrefetch(root, config, token, repository) {
1547
2122
  const prefetch = browserRemotePrefetches.get(root);
1548
2123
  browserRemotePrefetches.delete(root);
1549
2124
  if (
1550
- !token
1551
- || !prefetch
1552
- || prefetch.token !== token
1553
- || prefetch.remote !== config.remote
1554
- || Date.now() - prefetch.fetchedAt > BROWSER_REMOTE_PREFETCH_MAX_AGE_MS
2125
+ !token ||
2126
+ !prefetch ||
2127
+ prefetch.token !== token ||
2128
+ prefetch.remote !== config.remote ||
2129
+ Date.now() - prefetch.fetchedAt > BROWSER_REMOTE_PREFETCH_MAX_AGE_MS
1555
2130
  ) {
1556
2131
  return false;
1557
2132
  }
1558
- const reusable = repository.currentCommit === prefetch.currentCommit
1559
- && repository.upstreamCommit === prefetch.upstreamCommit;
2133
+ const reusable =
2134
+ repository.currentCommit === prefetch.currentCommit &&
2135
+ repository.upstreamCommit === prefetch.upstreamCommit;
1560
2136
  if (reusable) recordTiming("fetch-reused", 0);
1561
2137
  return reusable;
1562
2138
  }
@@ -1567,22 +2143,28 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
1567
2143
  commit: committed.commit,
1568
2144
  shortCommit: committed.shortCommit,
1569
2145
  startedAt: new Date().toISOString(),
1570
- error: null
2146
+ error: null,
1571
2147
  });
1572
2148
  const start = async () => {
1573
2149
  try {
1574
2150
  const ready = await requireTrunkPreconditionsAsync(root, config, {
1575
2151
  allowAhead: true,
1576
- backgroundCommit: committed.commit
2152
+ backgroundCommit: committed.commit,
1577
2153
  });
1578
2154
  if (ready.currentCommit !== committed.commit) {
1579
- throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
2155
+ throw new Error(
2156
+ "The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.",
2157
+ );
1580
2158
  }
1581
2159
  if (ready.behind > 0) {
1582
- throw new Error("The authoritative branch changed upstream after FileGRC created its browser commit. FileGRC did not push it.");
2160
+ throw new Error(
2161
+ "The authoritative branch changed upstream after FileGRC created its browser commit. FileGRC did not push it.",
2162
+ );
1583
2163
  }
1584
2164
  if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
1585
- throw new Error("The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.");
2165
+ throw new Error(
2166
+ "The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.",
2167
+ );
1586
2168
  }
1587
2169
  await finishBackgroundPush(root, config, committed);
1588
2170
  } catch (error) {
@@ -1599,45 +2181,65 @@ async function finishBackgroundPush(root, config, committed) {
1599
2181
  let outcome = "failed";
1600
2182
  let remotePushed = false;
1601
2183
  try {
1602
- const pushed = await pushConfiguredBranch(root, config, committed.commit, committed.upstreamCommit);
2184
+ const pushed = await pushConfiguredBranch(
2185
+ root,
2186
+ config,
2187
+ committed.commit,
2188
+ committed.upstreamCommit,
2189
+ );
1603
2190
  remotePushed = pushed.remotePushed;
1604
2191
  if (pushed.trackingError) {
1605
2192
  recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
1606
2193
  outcome = "pushed-tracking-stale";
1607
2194
  return;
1608
2195
  }
1609
- const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }), { ignoreBackground: true });
2196
+ const after = inspectTrunkRepository(
2197
+ root,
2198
+ config,
2199
+ await getRepositorySnapshot(root, { fresh: true }),
2200
+ { ignoreBackground: true },
2201
+ );
1610
2202
  if (after.ahead !== 0 || after.behind !== 0) {
1611
- throw new Error("The authoritative branch is still not synchronized after the background push.");
2203
+ throw new Error(
2204
+ "The authoritative branch is still not synchronized after the background push.",
2205
+ );
1612
2206
  }
1613
2207
  const synchronizedAt = new Date().toISOString();
1614
2208
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
1615
2209
  deleteBackgroundSynchronizationIfCurrent(root, committed.commit);
1616
2210
  outcome = "synced";
1617
2211
  } catch (error) {
1618
- if (remotePushed) recordBackgroundRemoteSuccessFailure(root, committed, error.message);
2212
+ if (remotePushed)
2213
+ recordBackgroundRemoteSuccessFailure(root, committed, error.message);
1619
2214
  else recordBackgroundPushFailure(root, committed, error);
1620
2215
  } finally {
1621
2216
  if (timingEnabled()) {
1622
- console.error(`[filegrc timing] ${JSON.stringify({
1623
- operation: "background-sync",
1624
- push: { count: 1, durationMs: performance.now() - started },
1625
- outcome
1626
- })}`);
2217
+ console.error(
2218
+ `[filegrc timing] ${JSON.stringify({
2219
+ operation: "background-sync",
2220
+ push: { count: 1, durationMs: performance.now() - started },
2221
+ outcome,
2222
+ })}`,
2223
+ );
1627
2224
  }
1628
2225
  }
1629
2226
  }
1630
2227
 
1631
2228
  function recordBackgroundPushFailure(root, committed, error) {
1632
- setBackgroundSynchronizationIfCurrent(root, committed, {
1633
- status: "failed",
1634
- commit: committed.commit,
1635
- shortCommit: committed.shortCommit,
1636
- startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
1637
- finishedAt: new Date().toISOString(),
1638
- remotePushed: false,
1639
- error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`
1640
- }, { allowMissing: true });
2229
+ setBackgroundSynchronizationIfCurrent(
2230
+ root,
2231
+ committed,
2232
+ {
2233
+ status: "failed",
2234
+ commit: committed.commit,
2235
+ shortCommit: committed.shortCommit,
2236
+ startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
2237
+ finishedAt: new Date().toISOString(),
2238
+ remotePushed: false,
2239
+ error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`,
2240
+ },
2241
+ { allowMissing: true },
2242
+ );
1641
2243
  }
1642
2244
 
1643
2245
  function recordBackgroundTrackingFailure(root, committed, error) {
@@ -1645,24 +2247,34 @@ function recordBackgroundTrackingFailure(root, committed, error) {
1645
2247
  }
1646
2248
 
1647
2249
  function recordBackgroundRemoteSuccessFailure(root, committed, error) {
1648
- setBackgroundSynchronizationIfCurrent(root, committed, {
1649
- status: "failed",
1650
- commit: committed.commit,
1651
- shortCommit: committed.shortCommit,
1652
- startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
1653
- finishedAt: new Date().toISOString(),
1654
- remotePushed: true,
1655
- error: `${error} The remote accepted the exact FileGRC commit. Fetch with Git to reconcile local state; do not retry the push.`
1656
- }, { allowMissing: true });
1657
- }
1658
-
1659
- function setBackgroundSynchronizationIfCurrent(root, committed, next, options = {}) {
2250
+ setBackgroundSynchronizationIfCurrent(
2251
+ root,
2252
+ committed,
2253
+ {
2254
+ status: "failed",
2255
+ commit: committed.commit,
2256
+ shortCommit: committed.shortCommit,
2257
+ startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
2258
+ finishedAt: new Date().toISOString(),
2259
+ remotePushed: true,
2260
+ error: `${error} The remote accepted the exact FileGRC commit. Fetch with Git to reconcile local state; do not retry the push.`,
2261
+ },
2262
+ { allowMissing: true },
2263
+ );
2264
+ }
2265
+
2266
+ function setBackgroundSynchronizationIfCurrent(
2267
+ root,
2268
+ committed,
2269
+ next,
2270
+ options = {},
2271
+ ) {
1660
2272
  const current = backgroundSynchronizations.get(root);
1661
2273
  if (current && current.commit !== committed.commit) return false;
1662
2274
  if (!current && options.allowMissing !== true) return false;
1663
2275
  backgroundSynchronizations.set(root, {
1664
2276
  ...next,
1665
- remotePushed: current?.remotePushed === true || next.remotePushed === true
2277
+ remotePushed: current?.remotePushed === true || next.remotePushed === true,
1666
2278
  });
1667
2279
  return true;
1668
2280
  }
@@ -1674,61 +2286,86 @@ function deleteBackgroundSynchronizationIfCurrent(root, commit) {
1674
2286
 
1675
2287
  async function commitWorkspaceUnlocked(root, message) {
1676
2288
  const subject = String(message ?? "").trim();
1677
- if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
1678
- throw new Error("Commit messages must be one line from 1 through 200 characters.");
2289
+ if (
2290
+ !subject ||
2291
+ subject.length > 200 ||
2292
+ /[\u0000-\u001f\u007f]/.test(subject)
2293
+ ) {
2294
+ throw new Error(
2295
+ "Commit messages must be one line from 1 through 200 characters.",
2296
+ );
1679
2297
  }
1680
2298
  assertNoWorkspaceContentFilters(root);
1681
2299
  const before = getGitSummary(root);
1682
- if (!before.available) throw new Error("Git history is unavailable for this workspace.");
1683
- if (!before.branch) throw new Error("Check out a branch before creating a browser commit.");
2300
+ if (!before.available)
2301
+ throw new Error("Git history is unavailable for this workspace.");
2302
+ if (!before.branch)
2303
+ throw new Error("Check out a branch before creating a browser commit.");
1684
2304
  assertNoGitOperationInProgress(root);
1685
2305
  if (before.clean) throw new Error("The workspace has no changes to commit.");
1686
- if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
1687
- throw new Error("Configure git user.name and user.email before committing.");
2306
+ if (
2307
+ !tryGit(root, ["config", "user.name"]) ||
2308
+ !tryGit(root, ["config", "user.email"])
2309
+ ) {
2310
+ throw new Error(
2311
+ "Configure git user.name and user.email before committing.",
2312
+ );
1688
2313
  }
1689
2314
  assertValidGitIdentity(root);
1690
2315
  assertNoIgnoredAuthoritativeFiles(root);
1691
2316
  const beforeValidation = workspaceByteManifest(root);
1692
2317
  const validation = await validateWorkspace(root);
1693
2318
  if (!validation.ok) {
1694
- throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before committing.`);
2319
+ throw new Error(
2320
+ `The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before committing.`,
2321
+ );
1695
2322
  }
1696
2323
  let validatedManifest = workspaceByteManifest(root);
1697
2324
  assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1698
2325
  const commitOptions = {
1699
2326
  expectedParent: before.commit,
1700
- expectedRef: `refs/heads/${before.branch}`
2327
+ expectedRef: `refs/heads/${before.branch}`,
1701
2328
  };
1702
- assertExpectedCheckout(root, commitOptions.expectedRef, commitOptions.expectedParent);
2329
+ assertExpectedCheckout(
2330
+ root,
2331
+ commitOptions.expectedRef,
2332
+ commitOptions.expectedParent,
2333
+ );
1703
2334
  validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1704
- const committed = await commitValidatedIndexAsync(root, subject, validatedManifest, commitOptions);
2335
+ const committed = await commitValidatedIndexAsync(
2336
+ root,
2337
+ subject,
2338
+ validatedManifest,
2339
+ commitOptions,
2340
+ );
1705
2341
  const after = getGitSummary(root);
1706
2342
  return {
1707
2343
  commit: committed.commit,
1708
2344
  shortCommit: committed.commit.slice(0, 8),
1709
2345
  indexReconciled: committed.indexReconciled,
1710
- subject: after.lastCommit?.subject || subject
2346
+ subject: after.lastCommit?.subject || subject,
1711
2347
  };
1712
2348
  }
1713
2349
 
1714
2350
  async function commitAndPushWorkspaceUnlocked(root, message) {
1715
2351
  const before = getGitSummary(root);
1716
- const target = before.upstream || before.remotes?.length
1717
- ? captureManualPushTarget(root, before)
1718
- : null;
2352
+ const target =
2353
+ before.upstream || before.remotes?.length
2354
+ ? captureManualPushTarget(root, before)
2355
+ : null;
1719
2356
  const committed = await commitWorkspaceUnlocked(root, message);
1720
2357
  if (!target) {
1721
2358
  return {
1722
2359
  ...committed,
1723
2360
  pushed: false,
1724
- pushSkipped: true
2361
+ pushSkipped: true,
1725
2362
  };
1726
2363
  }
1727
2364
  try {
1728
2365
  const pushed = await pushCapturedWorkspaceCommit(root, {
1729
2366
  before,
1730
2367
  target,
1731
- commit: committed.commit
2368
+ commit: committed.commit,
1732
2369
  });
1733
2370
  return {
1734
2371
  ...committed,
@@ -1736,14 +2373,14 @@ async function commitAndPushWorkspaceUnlocked(root, message) {
1736
2373
  pushSkipped: false,
1737
2374
  upstream: pushed.upstream,
1738
2375
  trackingConfigured: pushed.trackingConfigured,
1739
- trackingError: pushed.trackingError
2376
+ trackingError: pushed.trackingError,
1740
2377
  };
1741
2378
  } catch (error) {
1742
2379
  return {
1743
2380
  ...committed,
1744
2381
  pushed: false,
1745
2382
  pushSkipped: false,
1746
- pushError: error.message
2383
+ pushError: error.message,
1747
2384
  };
1748
2385
  }
1749
2386
  }
@@ -1752,21 +2389,35 @@ async function pullWorkspaceUnlocked(root) {
1752
2389
  const before = syncReadySummary(root, "pull");
1753
2390
  assertNoGitOperationInProgress(root);
1754
2391
  if (!before.upstream) {
1755
- throw new Error("This branch has no upstream branch. Push it first or configure an upstream with Git.");
2392
+ throw new Error(
2393
+ "This branch has no upstream branch. Push it first or configure an upstream with Git.",
2394
+ );
1756
2395
  }
1757
2396
  const remote = before.upstream.split("/")[0];
1758
2397
  const branch = before.upstream.slice(remote.length + 1);
1759
2398
  const url = assertSafeAutomaticTransport(root, remote);
1760
- gitForWrite(root, exactFetchArgs({ remote, branch, url }), `fetch ${remote} before checking incoming commits`);
1761
- const target = git(root, ["rev-parse", "--verify", `${before.upstream}^{commit}`]);
1762
- const incomingCommits = lines(git(root, ["rev-list", "--reverse", `${before.commit}..${target}`]));
2399
+ gitForWrite(
2400
+ root,
2401
+ exactFetchArgs({ remote, branch, url }),
2402
+ `fetch ${remote} before checking incoming commits`,
2403
+ );
2404
+ const target = git(root, [
2405
+ "rev-parse",
2406
+ "--verify",
2407
+ `${before.upstream}^{commit}`,
2408
+ ]);
2409
+ const incomingCommits = lines(
2410
+ git(root, ["rev-list", "--reverse", `${before.commit}..${target}`]),
2411
+ );
1763
2412
  assertCommitsInsideWorkspace(root, incomingCommits);
1764
2413
  for (const commit of incomingCommits) {
1765
2414
  assertNoCommitWorkspaceContentFilters(root, commit);
1766
2415
  }
1767
2416
  assertExpectedCheckout(root, `refs/heads/${before.branch}`, before.commit);
1768
2417
  if (incomingCommits.length) {
1769
- throw new Error("FileGRC fetched incoming commits but did not integrate them. Reconcile the branch with Git, then reload FileGRC.");
2418
+ throw new Error(
2419
+ "FileGRC fetched incoming commits but did not integrate them. Reconcile the branch with Git, then reload FileGRC.",
2420
+ );
1770
2421
  }
1771
2422
  const after = getGitSummary(root);
1772
2423
  return {
@@ -1774,7 +2425,7 @@ async function pullWorkspaceUnlocked(root) {
1774
2425
  commit: after.commit,
1775
2426
  shortCommit: after.shortCommit,
1776
2427
  branch: after.branch,
1777
- upstream: after.upstream
2428
+ upstream: after.upstream,
1778
2429
  };
1779
2430
  }
1780
2431
 
@@ -1784,9 +2435,15 @@ async function pushWorkspaceUnlocked(root) {
1784
2435
  const target = captureManualPushTarget(root, before);
1785
2436
  const validation = await validateWorkspace(root);
1786
2437
  if (!validation.ok) {
1787
- throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before pushing.`);
2438
+ throw new Error(
2439
+ `The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before pushing.`,
2440
+ );
1788
2441
  }
1789
- return pushCapturedWorkspaceCommit(root, { before, target, commit: before.commit });
2442
+ return pushCapturedWorkspaceCommit(root, {
2443
+ before,
2444
+ target,
2445
+ commit: before.commit,
2446
+ });
1790
2447
  }
1791
2448
 
1792
2449
  async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
@@ -1794,14 +2451,23 @@ async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
1794
2451
  await gitForWriteAsync(root, exactPushArgs(target, commit), "push", {
1795
2452
  expectedCheckout: {
1796
2453
  expectedRef: `refs/heads/${before.branch}`,
1797
- expectedCommit: commit
1798
- }
2454
+ expectedCommit: commit,
2455
+ },
1799
2456
  });
1800
2457
  let trackingError = reconcileRemoteTracking(root, target, commit);
1801
2458
  if (!before.upstream && !trackingError) {
1802
2459
  try {
1803
2460
  assertExpectedCheckout(root, `refs/heads/${before.branch}`, commit);
1804
- gitForWrite(root, ["branch", "--set-upstream-to", `${target.remote}/${before.branch}`, before.branch], "configure the pushed branch upstream");
2461
+ gitForWrite(
2462
+ root,
2463
+ [
2464
+ "branch",
2465
+ "--set-upstream-to",
2466
+ `${target.remote}/${before.branch}`,
2467
+ before.branch,
2468
+ ],
2469
+ "configure the pushed branch upstream",
2470
+ );
1805
2471
  } catch (error) {
1806
2472
  trackingError = error.message;
1807
2473
  }
@@ -1810,9 +2476,11 @@ async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
1810
2476
  commit,
1811
2477
  shortCommit: commit.slice(0, 8),
1812
2478
  branch: before.branch,
1813
- upstream: before.upstream || (trackingError ? null : `${target.remote}/${before.branch}`),
2479
+ upstream:
2480
+ before.upstream ||
2481
+ (trackingError ? null : `${target.remote}/${before.branch}`),
1814
2482
  trackingConfigured: Boolean(before.upstream) || !trackingError,
1815
- trackingError
2483
+ trackingError,
1816
2484
  };
1817
2485
  }
1818
2486
 
@@ -1820,13 +2488,27 @@ function captureManualPushTarget(root, before) {
1820
2488
  let remote;
1821
2489
  let destination;
1822
2490
  if (before.upstream) {
1823
- remote = tryGit(root, ["config", "--get", `branch.${before.branch}.remote`]);
1824
- destination = tryGit(root, ["config", "--get", `branch.${before.branch}.merge`]);
2491
+ remote = tryGit(root, [
2492
+ "config",
2493
+ "--get",
2494
+ `branch.${before.branch}.remote`,
2495
+ ]);
2496
+ destination = tryGit(root, [
2497
+ "config",
2498
+ "--get",
2499
+ `branch.${before.branch}.merge`,
2500
+ ]);
1825
2501
  const destinationBranch = destination?.startsWith("refs/heads/")
1826
2502
  ? destination.slice("refs/heads/".length)
1827
2503
  : "";
1828
- if (!isSafeGitName(remote) || !isSafeGitName(destinationBranch) || before.upstream !== `${remote}/${destinationBranch}`) {
1829
- throw new Error("The current branch has an unsafe or unsupported upstream configuration. Configure a normal remote branch with Git before pushing.");
2504
+ if (
2505
+ !isSafeGitName(remote) ||
2506
+ !isSafeGitName(destinationBranch) ||
2507
+ before.upstream !== `${remote}/${destinationBranch}`
2508
+ ) {
2509
+ throw new Error(
2510
+ "The current branch has an unsafe or unsupported upstream configuration. Configure a normal remote branch with Git before pushing.",
2511
+ );
1830
2512
  }
1831
2513
  } else {
1832
2514
  remote = before.remotes.includes("origin")
@@ -1835,9 +2517,11 @@ function captureManualPushTarget(root, before) {
1835
2517
  ? before.remotes[0]
1836
2518
  : null;
1837
2519
  if (!remote) {
1838
- throw new Error(before.remotes.length
1839
- ? "This branch has no upstream and the repository has multiple remotes. Configure an upstream with Git."
1840
- : "This repository has no Git remote. Add one before pushing.");
2520
+ throw new Error(
2521
+ before.remotes.length
2522
+ ? "This branch has no upstream and the repository has multiple remotes. Configure an upstream with Git."
2523
+ : "This repository has no Git remote. Add one before pushing.",
2524
+ );
1841
2525
  }
1842
2526
  destination = `refs/heads/${before.branch}`;
1843
2527
  }
@@ -1847,19 +2531,28 @@ function captureManualPushTarget(root, before) {
1847
2531
  destination,
1848
2532
  destinationBranch,
1849
2533
  trackingRef: `refs/remotes/${remote}/${destinationBranch}`,
1850
- expectedTrackingCommit: before.upstream ? tryGit(root, ["rev-parse", "--verify", before.upstream]) : null,
1851
- url: assertSafeAutomaticTransport(root, remote, { push: true })
2534
+ expectedTrackingCommit: before.upstream
2535
+ ? tryGit(root, ["rev-parse", "--verify", before.upstream])
2536
+ : null,
2537
+ url: assertSafeAutomaticTransport(root, remote, { push: true }),
1852
2538
  };
1853
2539
  }
1854
2540
 
1855
2541
  function reconcileRemoteTracking(root, target, commit) {
1856
2542
  try {
1857
- gitForWrite(root, [
1858
- "update-ref", "-m", "FileGRC exact push",
1859
- target.trackingRef,
1860
- commit,
1861
- target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
1862
- ], "reconcile the remote-tracking branch after a successful push");
2543
+ gitForWrite(
2544
+ root,
2545
+ [
2546
+ "update-ref",
2547
+ "-m",
2548
+ "FileGRC exact push",
2549
+ target.trackingRef,
2550
+ commit,
2551
+ target.expectedTrackingCommit ||
2552
+ "0000000000000000000000000000000000000000",
2553
+ ],
2554
+ "reconcile the remote-tracking branch after a successful push",
2555
+ );
1863
2556
  return null;
1864
2557
  } catch (error) {
1865
2558
  return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
@@ -1869,24 +2562,43 @@ function reconcileRemoteTracking(root, target, commit) {
1869
2562
  function exactPushArgs(target, commit) {
1870
2563
  const expected = target.expectedTrackingCommit || "";
1871
2564
  return [
1872
- "-c", "push.pushOption=",
1873
- "push", "--porcelain", "--no-follow-tags", "--recurse-submodules=no", "--no-signed", "--no-push-option",
2565
+ "-c",
2566
+ "push.pushOption=",
2567
+ "push",
2568
+ "--porcelain",
2569
+ "--no-follow-tags",
2570
+ "--recurse-submodules=no",
2571
+ "--no-signed",
2572
+ "--no-push-option",
1874
2573
  `--force-with-lease=${target.destination}:${expected}`,
1875
- "--", target.url, `${commit}:${target.destination}`
2574
+ "--",
2575
+ target.url,
2576
+ `${commit}:${target.destination}`,
1876
2577
  ];
1877
2578
  }
1878
2579
 
1879
2580
  function syncReadySummary(root, action) {
1880
2581
  const summary = getGitSummary(root);
1881
- if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so filegrc cannot ${action}.`);
1882
- if (!summary.branch) throw new Error(`Check out a branch before trying to ${action}.`);
1883
- if (!summary.clean) throw new Error(`Commit or discard workspace changes before trying to ${action}.`);
2582
+ if (!summary.available)
2583
+ throw new Error(
2584
+ `Git history is unavailable for this workspace, so filegrc cannot ${action}.`,
2585
+ );
2586
+ if (!summary.branch)
2587
+ throw new Error(`Check out a branch before trying to ${action}.`);
2588
+ if (!summary.clean)
2589
+ throw new Error(
2590
+ `Commit or discard workspace changes before trying to ${action}.`,
2591
+ );
1884
2592
  return summary;
1885
2593
  }
1886
2594
 
1887
2595
  async function getRepositoryConfig(input) {
1888
- const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
1889
- const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
2596
+ const loaded =
2597
+ input?.entries && input?.root ? input : await loadWorkspace(input);
2598
+ const renderer = loaded.resources.find(
2599
+ ({ type, id }) =>
2600
+ type === "renderer-settings" && id === "renderer-settings",
2601
+ );
1890
2602
  const mode = renderer?.repositoryMode;
1891
2603
  const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
1892
2604
  const remote = cleanGitName(renderer?.repositoryRemote);
@@ -1897,15 +2609,17 @@ async function getRepositoryConfig(input) {
1897
2609
  configurationError: !["trunk", "manual"].includes(mode)
1898
2610
  ? "Repository mode is missing or invalid. Run the model migration or update renderer settings."
1899
2611
  : !isSafeGitName(authoritativeBranch)
1900
- ? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
1901
- : !isSafeGitName(remote)
1902
- ? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
1903
- : null
2612
+ ? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
2613
+ : !isSafeGitName(remote)
2614
+ ? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
2615
+ : null,
1904
2616
  };
1905
2617
  }
1906
2618
 
1907
2619
  function inspectTrunkRepository(root, config, summary, options = {}) {
1908
- const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
2620
+ const background = options.ignoreBackground
2621
+ ? null
2622
+ : backgroundSynchronizations.get(root);
1909
2623
  const base = {
1910
2624
  mode: "trunk",
1911
2625
  authoritativeBranch: config.authoritativeBranch,
@@ -1917,34 +2631,44 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1917
2631
  behind: null,
1918
2632
  pendingCommits: [],
1919
2633
  pendingCommitsFilegrcOnly: null,
1920
- lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
2634
+ lastSuccessfulSynchronization:
2635
+ lastSuccessfulSynchronizations.get(root) ?? null,
1921
2636
  wholeWorktreeClean: summary.available ? summary.wholeWorktreeClean : null,
1922
2637
  operationInProgress: summary.available ? summary.operationInProgress : null,
1923
- backgroundSynchronization: background ? {
1924
- status: background.status,
1925
- commit: background.commit,
1926
- shortCommit: background.shortCommit,
1927
- startedAt: background.startedAt,
1928
- finishedAt: background.finishedAt ?? null,
1929
- remotePushed: background.remotePushed === true,
1930
- error: background.error
1931
- } : null,
1932
- writesAllowed: false
2638
+ backgroundSynchronization: background
2639
+ ? {
2640
+ status: background.status,
2641
+ commit: background.commit,
2642
+ shortCommit: background.shortCommit,
2643
+ startedAt: background.startedAt,
2644
+ finishedAt: background.finishedAt ?? null,
2645
+ remotePushed: background.remotePushed === true,
2646
+ error: background.error,
2647
+ }
2648
+ : null,
2649
+ writesAllowed: false,
1933
2650
  };
1934
2651
  if (config.configurationError) {
1935
2652
  return {
1936
2653
  ...base,
1937
2654
  status: "git-setup-required",
1938
2655
  label: "Git setup required",
1939
- message: config.configurationError
2656
+ message: config.configurationError,
1940
2657
  };
1941
2658
  }
1942
2659
  if (!summary.available) {
1943
2660
  return {
1944
2661
  ...base,
1945
- status: summary.error?.kind === "missing-executable" ? "git-setup-required" : "git-error",
1946
- label: summary.error?.kind === "missing-executable" ? "Git setup required" : "Git error",
1947
- message: summary.message || "Git history is unavailable for this workspace."
2662
+ status:
2663
+ summary.error?.kind === "missing-executable"
2664
+ ? "git-setup-required"
2665
+ : "git-error",
2666
+ label:
2667
+ summary.error?.kind === "missing-executable"
2668
+ ? "Git setup required"
2669
+ : "Git error",
2670
+ message:
2671
+ summary.message || "Git history is unavailable for this workspace.",
1948
2672
  };
1949
2673
  }
1950
2674
  if (summary.branch !== config.authoritativeBranch) {
@@ -1952,7 +2676,8 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1952
2676
  ...base,
1953
2677
  status: "read-only-checkout",
1954
2678
  label: "Read-only checkout",
1955
- message: "This checkout is not the authoritative FileGRC branch. You can review the program here, but browser changes are disabled. Run FileGRC from the main checkout or use the explicit development override."
2679
+ message:
2680
+ "This checkout is not the authoritative FileGRC branch. You can review the program here, but browser changes are disabled. Run FileGRC from the main checkout or use the explicit development override.",
1956
2681
  };
1957
2682
  }
1958
2683
  if (!summary.remotes.includes(config.remote)) {
@@ -1960,7 +2685,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1960
2685
  ...base,
1961
2686
  status: "git-setup-required",
1962
2687
  label: "Git setup required",
1963
- message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`
2688
+ message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`,
1964
2689
  };
1965
2690
  }
1966
2691
  const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
@@ -1969,7 +2694,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1969
2694
  ...base,
1970
2695
  status: "git-setup-required",
1971
2696
  label: "Git setup required",
1972
- message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
2697
+ message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`,
1973
2698
  };
1974
2699
  }
1975
2700
  const upstreamCommit = summary.upstreamCommit;
@@ -1982,14 +2707,14 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1982
2707
  ahead: counts.ahead,
1983
2708
  behind: counts.behind,
1984
2709
  pendingCommits,
1985
- pendingCommitsFilegrcOnly
2710
+ pendingCommitsFilegrcOnly,
1986
2711
  };
1987
2712
  if (base.operationInProgress) {
1988
2713
  return {
1989
2714
  ...details,
1990
2715
  status: "not-synced",
1991
2716
  label: "Not synced",
1992
- message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`
2717
+ message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`,
1993
2718
  };
1994
2719
  }
1995
2720
  if (!base.wholeWorktreeClean) {
@@ -1997,17 +2722,21 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1997
2722
  ...details,
1998
2723
  status: "not-synced",
1999
2724
  label: "Not synced",
2000
- message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
2725
+ message:
2726
+ "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes.",
2001
2727
  };
2002
2728
  }
2003
- if (background?.status === "syncing" && background.commit === summary.commit) {
2729
+ if (
2730
+ background?.status === "syncing" &&
2731
+ background.commit === summary.commit
2732
+ ) {
2004
2733
  return {
2005
2734
  ...details,
2006
2735
  status: "syncing",
2007
2736
  label: "Syncing",
2008
2737
  message: `The FileGRC commit ${background.shortCommit} is saved locally and is being pushed to ${expectedUpstream}.`,
2009
2738
  writesAllowed: false,
2010
- retrySafe: false
2739
+ retrySafe: false,
2011
2740
  };
2012
2741
  }
2013
2742
  if (counts.ahead === null || counts.behind === null) {
@@ -2015,34 +2744,38 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
2015
2744
  ...details,
2016
2745
  status: "git-setup-required",
2017
2746
  label: "Git setup required",
2018
- message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`
2747
+ message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`,
2019
2748
  };
2020
2749
  }
2021
2750
  if (counts.ahead > 0 || counts.behind > 0) {
2022
2751
  const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
2023
- const backgroundFailure = background?.status === "failed"
2024
- && background.commit === summary.commit
2025
- && (background.remotePushed === true || (
2026
- counts.ahead > 0
2027
- && counts.behind === 0
2028
- && pendingCommitsFilegrcOnly
2029
- ))
2030
- ? background.error
2031
- : null;
2752
+ const backgroundFailure =
2753
+ background?.status === "failed" &&
2754
+ background.commit === summary.commit &&
2755
+ (background.remotePushed === true ||
2756
+ (counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly))
2757
+ ? background.error
2758
+ : null;
2032
2759
  return {
2033
2760
  ...details,
2034
2761
  status: "not-synced",
2035
2762
  label: "Not synced",
2036
- message: backgroundFailure || (external
2037
- ? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
2038
- : counts.ahead > 0 && counts.behind > 0
2039
- ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
2040
- : counts.ahead > 0
2041
- ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
2042
- : "The authoritative branch is behind upstream. Fast-forward it with Git, then reload FileGRC."),
2763
+ message:
2764
+ backgroundFailure ||
2765
+ (external
2766
+ ? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
2767
+ : counts.ahead > 0 && counts.behind > 0
2768
+ ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
2769
+ : counts.ahead > 0
2770
+ ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
2771
+ : "The authoritative branch is behind upstream. Fast-forward it with Git, then reload FileGRC."),
2043
2772
  writesAllowed: false,
2044
- retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly && background?.remotePushed !== true,
2045
- backgroundSyncError: backgroundFailure
2773
+ retrySafe:
2774
+ counts.ahead > 0 &&
2775
+ counts.behind === 0 &&
2776
+ pendingCommitsFilegrcOnly &&
2777
+ background?.remotePushed !== true,
2778
+ backgroundSyncError: backgroundFailure,
2046
2779
  };
2047
2780
  }
2048
2781
  return {
@@ -2051,7 +2784,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
2051
2784
  label: "Synced",
2052
2785
  message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
2053
2786
  writesAllowed: true,
2054
- retrySafe: false
2787
+ retrySafe: false,
2055
2788
  };
2056
2789
  }
2057
2790
 
@@ -2060,58 +2793,99 @@ async function requireTrunkPreconditionsAsync(root, config, options = {}) {
2060
2793
  const state = inspectTrunkRepository(root, config, summary);
2061
2794
  if (config.configurationError) throw new Error(state.message);
2062
2795
  if (!summary.available) throw summary.error || new Error(state.message);
2063
- if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
2796
+ if (summary.branch !== config.authoritativeBranch)
2797
+ throw new Error(state.message);
2064
2798
  if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
2065
- if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
2799
+ if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`)
2800
+ throw new Error(state.message);
2066
2801
  if (
2067
- state.backgroundSynchronization?.status === "syncing"
2068
- && state.backgroundSynchronization.commit !== options.backgroundCommit
2802
+ state.backgroundSynchronization?.status === "syncing" &&
2803
+ state.backgroundSynchronization.commit !== options.backgroundCommit
2069
2804
  ) {
2070
- throw new Error("A FileGRC background push is still finalizing. Wait for it to finish before making another browser change.");
2805
+ throw new Error(
2806
+ "A FileGRC background push is still finalizing. Wait for it to finish before making another browser change.",
2807
+ );
2071
2808
  }
2072
2809
  if (state.operationInProgress) throw new Error(state.message);
2073
2810
  if (!state.wholeWorktreeClean) throw new Error(state.message);
2811
+ assertNoIgnoredAuthoritativeFiles(root);
2074
2812
  if (!options.allowAhead && state.ahead > 0) {
2075
- throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
2813
+ throw new Error(
2814
+ "The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.",
2815
+ );
2076
2816
  }
2077
2817
  return state;
2078
2818
  }
2079
2819
 
2080
2820
  async function fetchConfiguredRemote(root, config) {
2081
2821
  const url = assertSafeAutomaticTransport(root, config.remote);
2082
- return measureTiming("fetch", () => gitForWriteAsync(
2083
- root,
2084
- exactFetchArgs({ remote: config.remote, branch: config.authoritativeBranch, url }),
2085
- `fetch ${config.remote}`
2086
- ));
2822
+ return measureTiming("fetch", () =>
2823
+ gitForWriteAsync(
2824
+ root,
2825
+ exactFetchArgs({
2826
+ remote: config.remote,
2827
+ branch: config.authoritativeBranch,
2828
+ url,
2829
+ }),
2830
+ `fetch ${config.remote}`,
2831
+ ),
2832
+ );
2087
2833
  }
2088
2834
 
2089
2835
  function exactFetchArgs({ remote, branch, url }) {
2090
2836
  return [
2091
- "fetch", "--prune", "--no-prune-tags", "--no-tags", "--recurse-submodules=no",
2092
- "--", url, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`
2837
+ "fetch",
2838
+ "--prune",
2839
+ "--no-prune-tags",
2840
+ "--no-tags",
2841
+ "--recurse-submodules=no",
2842
+ "--",
2843
+ url,
2844
+ `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
2093
2845
  ];
2094
2846
  }
2095
2847
 
2096
2848
  function assertCommitsInsideWorkspace(root, commits) {
2097
2849
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
2098
2850
  const prefix = relative(topLevel, root).split(sep).join("/");
2099
- if (prefix === ".." || prefix.startsWith("../")) throw new Error("The FileGRC workspace is outside its Git repository.");
2851
+ if (prefix === ".." || prefix.startsWith("../"))
2852
+ throw new Error("The FileGRC workspace is outside its Git repository.");
2100
2853
  if (!prefix) return;
2101
2854
  for (const commit of commits) {
2102
- const changed = nulFields(gitRaw(topLevel, [
2103
- "diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-m", "-r", commit, "--"
2104
- ]));
2105
- const outside = changed.filter((path) => !pathInsideWorkspace(path, prefix));
2855
+ const changed = nulFields(
2856
+ gitRaw(topLevel, [
2857
+ "diff-tree",
2858
+ "--root",
2859
+ "--no-commit-id",
2860
+ "--name-only",
2861
+ "-z",
2862
+ "-m",
2863
+ "-r",
2864
+ commit,
2865
+ "--",
2866
+ ]),
2867
+ );
2868
+ const outside = changed.filter(
2869
+ (path) => !pathInsideWorkspace(path, prefix),
2870
+ );
2106
2871
  if (outside.length) {
2107
- throw new Error(`FileGRC will not rebase commit ${commit.slice(0, 12)} because it changes files outside this nested workspace: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? "…" : ""}. Reconcile the whole repository with Git.`);
2872
+ throw new Error(
2873
+ `FileGRC will not rebase commit ${commit.slice(0, 12)} because it changes files outside this nested workspace: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? "…" : ""}. Reconcile the whole repository with Git.`,
2874
+ );
2108
2875
  }
2109
2876
  }
2110
2877
  }
2111
2878
 
2112
- async function pushConfiguredBranch(root, config, source, expectedTrackingCommit) {
2879
+ async function pushConfiguredBranch(
2880
+ root,
2881
+ config,
2882
+ source,
2883
+ expectedTrackingCommit,
2884
+ ) {
2113
2885
  if (!/^[a-f0-9]{40}$/i.test(source)) {
2114
- throw new Error("FileGRC requires an exact commit ID before pushing the authoritative branch.");
2886
+ throw new Error(
2887
+ "FileGRC requires an exact commit ID before pushing the authoritative branch.",
2888
+ );
2115
2889
  }
2116
2890
  const url = assertSafeAutomaticTransport(root, config.remote, { push: true });
2117
2891
  const target = {
@@ -2119,31 +2893,44 @@ async function pushConfiguredBranch(root, config, source, expectedTrackingCommit
2119
2893
  url,
2120
2894
  destination: `refs/heads/${config.authoritativeBranch}`,
2121
2895
  trackingRef: `refs/remotes/${config.remote}/${config.authoritativeBranch}`,
2122
- expectedTrackingCommit
2896
+ expectedTrackingCommit,
2123
2897
  };
2124
- await measureTiming("push", () => gitForWriteAsync(
2898
+ await measureTiming("push", () =>
2899
+ gitForWriteAsync(
2900
+ root,
2901
+ exactPushArgs(target, source),
2902
+ `push ${config.authoritativeBranch} to ${config.remote}`,
2903
+ {
2904
+ expectedCheckout: {
2905
+ expectedRef: `refs/heads/${config.authoritativeBranch}`,
2906
+ expectedCommit: source,
2907
+ },
2908
+ },
2909
+ ),
2910
+ );
2911
+ const trackingError = await reconcileRemoteTrackingAsync(
2125
2912
  root,
2126
- exactPushArgs(target, source),
2127
- `push ${config.authoritativeBranch} to ${config.remote}`,
2128
- {
2129
- expectedCheckout: {
2130
- expectedRef: `refs/heads/${config.authoritativeBranch}`,
2131
- expectedCommit: source
2132
- }
2133
- }
2134
- ));
2135
- const trackingError = await reconcileRemoteTrackingAsync(root, target, source);
2913
+ target,
2914
+ source,
2915
+ );
2136
2916
  return { remotePushed: true, trackingError };
2137
2917
  }
2138
2918
 
2139
2919
  async function reconcileRemoteTrackingAsync(root, target, commit) {
2140
2920
  try {
2141
- await gitForWriteAsync(root, [
2142
- "update-ref", "-m", "FileGRC exact push",
2143
- target.trackingRef,
2144
- commit,
2145
- target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
2146
- ], "reconcile the remote-tracking branch after a successful push");
2921
+ await gitForWriteAsync(
2922
+ root,
2923
+ [
2924
+ "update-ref",
2925
+ "-m",
2926
+ "FileGRC exact push",
2927
+ target.trackingRef,
2928
+ commit,
2929
+ target.expectedTrackingCommit ||
2930
+ "0000000000000000000000000000000000000000",
2931
+ ],
2932
+ "reconcile the remote-tracking branch after a successful push",
2933
+ );
2147
2934
  return null;
2148
2935
  } catch (error) {
2149
2936
  return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
@@ -2152,32 +2939,52 @@ async function reconcileRemoteTrackingAsync(root, target, commit) {
2152
2939
 
2153
2940
  function assertSafeAutomaticTransport(root, remote, options = {}) {
2154
2941
  assertNoRepositoryExecutableGitConfig(root);
2155
- const url = tryGit(root, ["remote", "get-url", ...(options.push ? ["--push"] : []), "--", remote]);
2156
- const safe = url
2157
- && !/[\0\r\n]/.test(url)
2158
- && (
2159
- /^(?:https?|ssh|file):\/\//i.test(url)
2160
- || /^(?:\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(url)
2161
- || /^[^\s/@:]+@[^\s/:]+:.+$/.test(url)
2162
- );
2942
+ const url = tryGit(root, [
2943
+ "remote",
2944
+ "get-url",
2945
+ ...(options.push ? ["--push"] : []),
2946
+ "--",
2947
+ remote,
2948
+ ]);
2949
+ const safe =
2950
+ url &&
2951
+ !/[\0\r\n]/.test(url) &&
2952
+ (/^(?:https?|ssh|file):\/\//i.test(url) ||
2953
+ /^(?:\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(url) ||
2954
+ /^[^\s/@:]+@[^\s/:]+:.+$/.test(url));
2163
2955
  if (!safe) {
2164
- throw new Error(`FileGRC will not use the configured remote "${remote}" because its URL scheme is not allowed for automatic fetch or push.`);
2956
+ throw new Error(
2957
+ `FileGRC will not use the configured remote "${remote}" because its URL scheme is not allowed for automatic fetch or push.`,
2958
+ );
2165
2959
  }
2166
2960
  return url;
2167
2961
  }
2168
2962
 
2169
2963
  function assertNoRepositoryExecutableGitConfig(root) {
2170
2964
  assertWorkspaceInsideGitWorktree(root);
2171
- const pattern = "^(core\\.(sshcommand|gitproxy|askpass|alternaterefscommand|worktree|sparsecheckout.*)|credential\\.(helper|.*\\.helper)|fetch\\.bundleuri|filter\\..*\\.(clean|smudge|process)|merge\\..*\\.driver|http\\..*|remote\\..*\\.(proxy|uploadpack|receivepack)|url\\..*\\.(insteadof|pushinsteadof)|protocol\\..*\\.allow)$";
2965
+ const pattern =
2966
+ "^(core\\.(sshcommand|gitproxy|askpass|alternaterefscommand|worktree|sparsecheckout.*)|credential\\.(helper|.*\\.helper)|fetch\\.bundleuri|filter\\..*\\.(clean|smudge|process)|merge\\..*\\.driver|http\\..*|remote\\..*\\.(proxy|uploadpack|receivepack)|url\\..*\\.(insteadof|pushinsteadof)|protocol\\..*\\.allow)$";
2172
2967
  const scopes = ["--local"];
2173
- if (gitOptionalMatch(root, ["config", "--local", "--type=bool", "--get", "extensions.worktreeConfig"]) === "true") {
2968
+ if (
2969
+ gitOptionalMatch(root, [
2970
+ "config",
2971
+ "--local",
2972
+ "--type=bool",
2973
+ "--get",
2974
+ "extensions.worktreeConfig",
2975
+ ]) === "true"
2976
+ ) {
2174
2977
  scopes.push("--worktree");
2175
2978
  }
2176
2979
  const executableConfiguration = scopes
2177
- .map((scope) => gitOptionalMatch(root, ["config", scope, "--get-regexp", pattern]))
2980
+ .map((scope) =>
2981
+ gitOptionalMatch(root, ["config", scope, "--get-regexp", pattern]),
2982
+ )
2178
2983
  .find(Boolean);
2179
2984
  if (executableConfiguration) {
2180
- throw new Error("FileGRC will not run managed Git synchronization while repository-local executable transport, filter, merge, HTTP, credential, URL rewrite, or protocol configuration is present. Move trusted user and transport settings to normal user-level Git configuration and remove the local override.");
2985
+ throw new Error(
2986
+ "FileGRC will not run managed Git synchronization while repository-local executable transport, filter, merge, HTTP, credential, URL rewrite, or protocol configuration is present. Move trusted user and transport settings to normal user-level Git configuration and remove the local override.",
2987
+ );
2181
2988
  }
2182
2989
  assertNoHiddenIndexEntries(root);
2183
2990
  }
@@ -2186,19 +2993,25 @@ function assertWorkspaceInsideGitWorktree(root) {
2186
2993
  const workspace = realpathSync(root);
2187
2994
  const topLevel = realpathSync(git(root, ["rev-parse", "--show-toplevel"]));
2188
2995
  if (workspace !== topLevel && !workspace.startsWith(`${topLevel}${sep}`)) {
2189
- throw new Error("FileGRC will not run managed Git writes because repository configuration redirects the Git worktree outside this workspace. Remove core.worktree and reopen the authoritative checkout.");
2996
+ throw new Error(
2997
+ "FileGRC will not run managed Git writes because repository configuration redirects the Git worktree outside this workspace. Remove core.worktree and reopen the authoritative checkout.",
2998
+ );
2190
2999
  }
2191
3000
  }
2192
3001
 
2193
3002
  function assertExpectedCheckout(root, expectedRef, expectedCommit) {
2194
3003
  if (!checkoutMatches(root, expectedRef, expectedCommit)) {
2195
- throw new Error("The checked-out Git branch or commit changed while FileGRC prepared the operation. Review the worktree and return to the expected branch before trying again.");
3004
+ throw new Error(
3005
+ "The checked-out Git branch or commit changed while FileGRC prepared the operation. Review the worktree and return to the expected branch before trying again.",
3006
+ );
2196
3007
  }
2197
3008
  }
2198
3009
 
2199
3010
  function checkoutMatches(root, expectedRef, expectedCommit) {
2200
- return tryGit(root, ["symbolic-ref", "--quiet", "HEAD"]) === expectedRef
2201
- && tryGit(root, ["rev-parse", "HEAD"]) === expectedCommit;
3011
+ return (
3012
+ tryGit(root, ["symbolic-ref", "--quiet", "HEAD"]) === expectedRef &&
3013
+ tryGit(root, ["rev-parse", "HEAD"]) === expectedCommit
3014
+ );
2202
3015
  }
2203
3016
 
2204
3017
  function assertValidGitIdentity(root) {
@@ -2206,38 +3019,74 @@ function assertValidGitIdentity(root) {
2206
3019
  git(root, ["var", "GIT_AUTHOR_IDENT"]);
2207
3020
  git(root, ["var", "GIT_COMMITTER_IDENT"]);
2208
3021
  } catch {
2209
- throw new Error("Configure valid git user.name and git user.email values before FileGRC creates commits. The saved files remain uncommitted and later browser changes are blocked.");
3022
+ throw new Error(
3023
+ "Configure valid git user.name and git user.email values before FileGRC creates commits. The saved files remain uncommitted and later browser changes are blocked.",
3024
+ );
2210
3025
  }
2211
3026
  }
2212
3027
 
2213
3028
  async function assertNoOutsideWorktreeChangesAsync(root) {
2214
- const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
2215
- operation: "locate the repository before checking worktree changes"
2216
- })).trim();
3029
+ const topLevel = (
3030
+ await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
3031
+ operation: "locate the repository before checking worktree changes",
3032
+ })
3033
+ ).trim();
2217
3034
  const prefix = relative(topLevel, root).split(sep).join("/");
2218
- const output = await runGitCommand(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
2219
- operation: "inspect worktree changes"
2220
- });
3035
+ const output = await runGitCommand(
3036
+ topLevel,
3037
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
3038
+ {
3039
+ operation: "inspect worktree changes",
3040
+ },
3041
+ );
2221
3042
  const paths = statusPathsFromRaw(output);
2222
3043
  if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
2223
- throw new Error("Files outside this FileGRC workspace changed while the browser action was running. FileGRC preserved the current worktree; reconcile the Git diff before another browser mutation.");
3044
+ throw new Error(
3045
+ "Files outside this FileGRC workspace changed while the browser action was running. FileGRC preserved the current worktree; reconcile the Git diff before another browser mutation.",
3046
+ );
2224
3047
  }
2225
3048
  }
2226
3049
 
2227
3050
  function assertNoWorkspaceContentFilters(root) {
2228
- const paths = [...new Set(nulFields(gitRaw(root, [
2229
- "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
2230
- ])))];
3051
+ const paths = [
3052
+ ...new Set(
3053
+ nulFields(
3054
+ gitRaw(root, [
3055
+ "ls-files",
3056
+ "-z",
3057
+ "--cached",
3058
+ "--others",
3059
+ "--exclude-standard",
3060
+ "--",
3061
+ ".",
3062
+ ]),
3063
+ ),
3064
+ ),
3065
+ ];
2231
3066
  for (let offset = 0; offset < paths.length; offset += 256) {
2232
3067
  const batch = paths.slice(offset, offset + 256);
2233
- const attributes = nulFields(gitRaw(root, ["check-attr", "-z", "filter", "merge", "--", ...batch]));
3068
+ const attributes = nulFields(
3069
+ gitRaw(root, ["check-attr", "-z", "filter", "merge", "--", ...batch]),
3070
+ );
2234
3071
  for (let index = 0; index < attributes.length; index += 3) {
2235
3072
  const [path, attribute, value] = attributes.slice(index, index + 3);
2236
- if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
2237
- throw new Error(`FileGRC will not commit "${path}" because Git content filter "${value}" could change or execute while staging authoritative files. Remove the filter attribute and review the resulting diff.`);
3073
+ if (
3074
+ attribute === "filter" &&
3075
+ value &&
3076
+ !["unspecified", "unset"].includes(value)
3077
+ ) {
3078
+ throw new Error(
3079
+ `FileGRC will not commit "${path}" because Git content filter "${value}" could change or execute while staging authoritative files. Remove the filter attribute and review the resulting diff.`,
3080
+ );
2238
3081
  }
2239
- if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
2240
- throw new Error(`FileGRC will not manage "${path}" because Git merge driver "${value}" could execute while synchronizing authoritative files. Remove the named merge attribute and review the resulting diff.`);
3082
+ if (
3083
+ attribute === "merge" &&
3084
+ value &&
3085
+ !["unspecified", "unset", "set"].includes(value)
3086
+ ) {
3087
+ throw new Error(
3088
+ `FileGRC will not manage "${path}" because Git merge driver "${value}" could execute while synchronizing authoritative files. Remove the named merge attribute and review the resulting diff.`,
3089
+ );
2241
3090
  }
2242
3091
  }
2243
3092
  }
@@ -2250,34 +3099,72 @@ function assertNoCommitWorkspaceContentFilters(root, commit) {
2250
3099
  if (prefix === ".." || prefix.startsWith("../")) {
2251
3100
  throw new Error("The FileGRC workspace is outside its Git repository.");
2252
3101
  }
2253
- const exactCommit = git(root, ["rev-parse", "--verify", `${commit}^{commit}`]);
2254
- const paths = nulFields(gitRaw(topLevel, [
2255
- "ls-tree", "-r", "-z", exactCommit, "--", prefix || "."
2256
- ])).map((entry) => {
3102
+ const exactCommit = git(root, [
3103
+ "rev-parse",
3104
+ "--verify",
3105
+ `${commit}^{commit}`,
3106
+ ]);
3107
+ const paths = nulFields(
3108
+ gitRaw(topLevel, ["ls-tree", "-r", "-z", exactCommit, "--", prefix || "."]),
3109
+ ).map((entry) => {
2257
3110
  const separator = entry.indexOf("\t");
2258
- const metadata = separator === -1 ? [] : entry.slice(0, separator).split(" ");
3111
+ const metadata =
3112
+ separator === -1 ? [] : entry.slice(0, separator).split(" ");
2259
3113
  const path = separator === -1 ? "" : entry.slice(separator + 1);
2260
3114
  const [mode, type, objectId] = metadata;
2261
- if (!/^(?:100644|100755)$/.test(mode || "") || type !== "blob" || !/^[a-f0-9]+$/i.test(objectId || "") || !path) {
2262
- throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because its workspace tree contains a non-regular entry. Replace symlinks and submodules with regular, reviewable files before synchronizing.`);
3115
+ if (
3116
+ !/^(?:100644|100755)$/.test(mode || "") ||
3117
+ type !== "blob" ||
3118
+ !/^[a-f0-9]+$/i.test(objectId || "") ||
3119
+ !path
3120
+ ) {
3121
+ throw new Error(
3122
+ `FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because its workspace tree contains a non-regular entry. Replace symlinks and submodules with regular, reviewable files before synchronizing.`,
3123
+ );
2263
3124
  }
2264
3125
  return path;
2265
3126
  });
2266
- const indexFile = resolve(gitDirectory, `filegrc-inspection-index-${randomUUID()}`);
3127
+ const indexFile = resolve(
3128
+ gitDirectory,
3129
+ `filegrc-inspection-index-${randomUUID()}`,
3130
+ );
2267
3131
  try {
2268
- gitForWrite(topLevel, ["read-tree", exactCommit], "inspect the incoming Git tree", { gitIndexFile: indexFile });
3132
+ gitForWrite(
3133
+ topLevel,
3134
+ ["read-tree", exactCommit],
3135
+ "inspect the incoming Git tree",
3136
+ { gitIndexFile: indexFile },
3137
+ );
2269
3138
  for (let offset = 0; offset < paths.length; offset += 256) {
2270
3139
  const batch = paths.slice(offset, offset + 256);
2271
- const attributes = nulFields(gitRaw(topLevel, ["check-attr", "-z", "--cached", "filter", "merge", "--", ...batch], {
2272
- gitIndexFile: indexFile
2273
- }));
3140
+ const attributes = nulFields(
3141
+ gitRaw(
3142
+ topLevel,
3143
+ ["check-attr", "-z", "--cached", "filter", "merge", "--", ...batch],
3144
+ {
3145
+ gitIndexFile: indexFile,
3146
+ },
3147
+ ),
3148
+ );
2274
3149
  for (let index = 0; index < attributes.length; index += 3) {
2275
3150
  const [path, attribute, value] = attributes.slice(index, index + 3);
2276
- if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
2277
- throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git content filter "${value}". Remove the filter attribute in a reviewed Git change before synchronizing.`);
3151
+ if (
3152
+ attribute === "filter" &&
3153
+ value &&
3154
+ !["unspecified", "unset"].includes(value)
3155
+ ) {
3156
+ throw new Error(
3157
+ `FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git content filter "${value}". Remove the filter attribute in a reviewed Git change before synchronizing.`,
3158
+ );
2278
3159
  }
2279
- if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
2280
- throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git merge driver "${value}". Remove the named merge attribute in a reviewed Git change before synchronizing.`);
3160
+ if (
3161
+ attribute === "merge" &&
3162
+ value &&
3163
+ !["unspecified", "unset", "set"].includes(value)
3164
+ ) {
3165
+ throw new Error(
3166
+ `FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git merge driver "${value}". Remove the named merge attribute in a reviewed Git change before synchronizing.`,
3167
+ );
2281
3168
  }
2282
3169
  }
2283
3170
  }
@@ -2288,97 +3175,167 @@ function assertNoCommitWorkspaceContentFilters(root, commit) {
2288
3175
  }
2289
3176
 
2290
3177
  function assertNoIgnoredAuthoritativeFiles(root) {
2291
- const ignored = nulFields(gitRaw(root, [
2292
- "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", "data"
2293
- ]));
3178
+ const ignored = ignoredAuthoritativeFiles(root);
2294
3179
  if (ignored.length) {
2295
- throw new Error(`FileGRC will not commit while authoritative data files are ignored by Git: ${ignored.slice(0, 5).join(", ")}${ignored.length > 5 ? "…" : ""}. Remove the ignore rule and review the complete staged diff.`);
3180
+ throw new Error(
3181
+ `FileGRC will not commit while authoritative data files are ignored by Git: ${ignored.slice(0, 5).join(", ")}${ignored.length > 5 ? "…" : ""}. Remove the ignore rule and review the complete staged diff.`,
3182
+ );
2296
3183
  }
2297
3184
  }
2298
3185
 
3186
+ function ignoredAuthoritativeFiles(root) {
3187
+ return nulFields(
3188
+ gitRaw(root, [
3189
+ "ls-files",
3190
+ "-z",
3191
+ "--others",
3192
+ "--ignored",
3193
+ "--exclude-standard",
3194
+ "--",
3195
+ "data",
3196
+ ]),
3197
+ );
3198
+ }
3199
+
2299
3200
  function workspaceByteManifest(root) {
2300
3201
  assertNoHiddenIndexEntries(root);
2301
3202
  const objectFormat = repositoryObjectFormat(root);
2302
- if (!objectFormat) throw new Error("FileGRC could not determine the Git repository object format.");
2303
- const paths = [...new Set(nulFields(gitRaw(root, [
2304
- "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
2305
- ])))].sort();
3203
+ if (!objectFormat)
3204
+ throw new Error(
3205
+ "FileGRC could not determine the Git repository object format.",
3206
+ );
3207
+ const paths = [
3208
+ ...new Set(
3209
+ nulFields(
3210
+ gitRaw(root, [
3211
+ "ls-files",
3212
+ "-z",
3213
+ "--cached",
3214
+ "--others",
3215
+ "--exclude-standard",
3216
+ "--",
3217
+ ".",
3218
+ ]),
3219
+ ),
3220
+ ),
3221
+ ].sort();
2306
3222
  const workspace = realpathSync(root);
2307
- return new Map(paths.map((path) => {
2308
- if (!path || path.startsWith("/") || path.split("/").includes("..") || /[\0\r\n]/.test(path)) {
2309
- throw new Error("Git reported an unsafe workspace path. Reconcile the worktree before committing.");
2310
- }
2311
- const absolute = resolve(root, path);
2312
- let stat;
2313
- try {
2314
- stat = lstatSync(absolute);
2315
- } catch (error) {
2316
- if (error?.code === "ENOENT") return [path, { objectId: null, mode: null }];
2317
- throw error;
2318
- }
2319
- if (stat.isSymbolicLink()) {
2320
- throw new Error(`FileGRC will not commit symbolic link "${path}". Store authoritative content as a regular file and review its target explicitly.`);
2321
- }
2322
- if (!stat.isFile()) {
2323
- throw new Error(`FileGRC will not commit non-file workspace entry "${path}".`);
2324
- }
2325
- const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
2326
- try {
2327
- const opened = fstatSync(descriptor);
2328
- const resolved = realpathSync(absolute);
2329
- const current = lstatSync(absolute);
3223
+ return new Map(
3224
+ paths.map((path) => {
2330
3225
  if (
2331
- !opened.isFile()
2332
- || (resolved !== workspace && !resolved.startsWith(`${workspace}${sep}`))
2333
- || current.isSymbolicLink()
2334
- || !current.isFile()
2335
- || opened.dev !== current.dev
2336
- || opened.ino !== current.ino
3226
+ !path ||
3227
+ path.startsWith("/") ||
3228
+ path.split("/").includes("..") ||
3229
+ /[\0\r\n]/.test(path)
2337
3230
  ) {
2338
- throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
3231
+ throw new Error(
3232
+ "Git reported an unsafe workspace path. Reconcile the worktree before committing.",
3233
+ );
2339
3234
  }
2340
- const objectHash = createHash(objectFormat).update(`blob ${opened.size}\0`);
2341
- const byteHash = createHash("sha256");
2342
- const buffer = Buffer.allocUnsafe(64 * 1024);
2343
- let position = 0;
2344
- while (position < opened.size) {
2345
- const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, opened.size - position), position);
2346
- if (!count) throw new Error(`FileGRC could not read complete workspace entry "${path}".`);
2347
- const chunk = buffer.subarray(0, count);
2348
- objectHash.update(chunk);
2349
- byteHash.update(chunk);
2350
- position += count;
3235
+ const absolute = resolve(root, path);
3236
+ let stat;
3237
+ try {
3238
+ stat = lstatSync(absolute);
3239
+ } catch (error) {
3240
+ if (error?.code === "ENOENT")
3241
+ return [path, { objectId: null, mode: null }];
3242
+ throw error;
2351
3243
  }
2352
- const after = fstatSync(descriptor);
2353
- if (
2354
- after.size !== opened.size
2355
- || after.dev !== opened.dev
2356
- || after.ino !== opened.ino
2357
- || after.mtimeMs !== opened.mtimeMs
2358
- || after.ctimeMs !== opened.ctimeMs
2359
- ) {
2360
- throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
3244
+ if (stat.isSymbolicLink()) {
3245
+ throw new Error(
3246
+ `FileGRC will not commit symbolic link "${path}". Store authoritative content as a regular file and review its target explicitly.`,
3247
+ );
2361
3248
  }
2362
- const calculatedObjectId = objectHash.digest("hex");
2363
- const objectId = workspaceBlobObjectIdOverride?.(null, objectFormat, path) ?? calculatedObjectId;
2364
- return [path, {
2365
- objectId,
2366
- byteDigest: byteHash.digest("hex"),
2367
- size: opened.size,
2368
- mode: opened.mode & 0o111 ? "100755" : "100644"
2369
- }];
2370
- } finally {
2371
- closeSync(descriptor);
2372
- }
2373
- }));
3249
+ if (!stat.isFile()) {
3250
+ throw new Error(
3251
+ `FileGRC will not commit non-file workspace entry "${path}".`,
3252
+ );
3253
+ }
3254
+ const descriptor = openSync(
3255
+ absolute,
3256
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
3257
+ );
3258
+ try {
3259
+ const opened = fstatSync(descriptor);
3260
+ const resolved = realpathSync(absolute);
3261
+ const current = lstatSync(absolute);
3262
+ if (
3263
+ !opened.isFile() ||
3264
+ (resolved !== workspace &&
3265
+ !resolved.startsWith(`${workspace}${sep}`)) ||
3266
+ current.isSymbolicLink() ||
3267
+ !current.isFile() ||
3268
+ opened.dev !== current.dev ||
3269
+ opened.ino !== current.ino
3270
+ ) {
3271
+ throw new Error(
3272
+ `FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`,
3273
+ );
3274
+ }
3275
+ const objectHash = createHash(objectFormat).update(
3276
+ `blob ${opened.size}\0`,
3277
+ );
3278
+ const byteHash = createHash("sha256");
3279
+ const buffer = Buffer.allocUnsafe(64 * 1024);
3280
+ let position = 0;
3281
+ while (position < opened.size) {
3282
+ const count = readSync(
3283
+ descriptor,
3284
+ buffer,
3285
+ 0,
3286
+ Math.min(buffer.length, opened.size - position),
3287
+ position,
3288
+ );
3289
+ if (!count)
3290
+ throw new Error(
3291
+ `FileGRC could not read complete workspace entry "${path}".`,
3292
+ );
3293
+ const chunk = buffer.subarray(0, count);
3294
+ objectHash.update(chunk);
3295
+ byteHash.update(chunk);
3296
+ position += count;
3297
+ }
3298
+ const after = fstatSync(descriptor);
3299
+ if (
3300
+ after.size !== opened.size ||
3301
+ after.dev !== opened.dev ||
3302
+ after.ino !== opened.ino ||
3303
+ after.mtimeMs !== opened.mtimeMs ||
3304
+ after.ctimeMs !== opened.ctimeMs
3305
+ ) {
3306
+ throw new Error(
3307
+ `FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`,
3308
+ );
3309
+ }
3310
+ const calculatedObjectId = objectHash.digest("hex");
3311
+ const objectId =
3312
+ workspaceBlobObjectIdOverride?.(null, objectFormat, path) ??
3313
+ calculatedObjectId;
3314
+ return [
3315
+ path,
3316
+ {
3317
+ objectId,
3318
+ byteDigest: byteHash.digest("hex"),
3319
+ size: opened.size,
3320
+ mode: opened.mode & 0o111 ? "100755" : "100644",
3321
+ },
3322
+ ];
3323
+ } finally {
3324
+ closeSync(descriptor);
3325
+ }
3326
+ }),
3327
+ );
2374
3328
  }
2375
3329
 
2376
3330
  function assertNoHiddenIndexEntries(root) {
2377
- const hidden = nulFields(gitRaw(root, ["ls-files", "-v", "-z", "--cached", "--", "."]))
2378
- .filter((entry) => entry[0] === "S" || entry[0] === entry[0]?.toLowerCase());
3331
+ const hidden = nulFields(
3332
+ gitRaw(root, ["ls-files", "-v", "-z", "--cached", "--", "."]),
3333
+ ).filter((entry) => entry[0] === "S" || entry[0] === entry[0]?.toLowerCase());
2379
3334
  if (hidden.length) {
2380
3335
  const paths = hidden.slice(0, 5).map((entry) => entry.slice(2));
2381
- throw new Error(`FileGRC will not run managed Git operations while index entries use skip-worktree or assume-unchanged: ${paths.join(", ")}${hidden.length > 5 ? "…" : ""}. Clear those Git index flags and review the complete workspace diff.`);
3336
+ throw new Error(
3337
+ `FileGRC will not run managed Git operations while index entries use skip-worktree or assume-unchanged: ${paths.join(", ")}${hidden.length > 5 ? "…" : ""}. Clear those Git index flags and review the complete workspace diff.`,
3338
+ );
2382
3339
  }
2383
3340
  }
2384
3341
 
@@ -2387,8 +3344,13 @@ function writeWorkspaceManifestObjects(root, manifest) {
2387
3344
  for (const value of manifest.values()) {
2388
3345
  if (!value.objectId) continue;
2389
3346
  const prior = valuesByObjectId.get(value.objectId);
2390
- if (prior && (prior.size !== value.size || prior.byteDigest !== value.byteDigest)) {
2391
- throw new Error(`Git object ${value.objectId} identifies different validated workspace bytes.`);
3347
+ if (
3348
+ prior &&
3349
+ (prior.size !== value.size || prior.byteDigest !== value.byteDigest)
3350
+ ) {
3351
+ throw new Error(
3352
+ `Git object ${value.objectId} identifies different validated workspace bytes.`,
3353
+ );
2392
3354
  }
2393
3355
  valuesByObjectId.set(value.objectId, value);
2394
3356
  }
@@ -2399,21 +3361,25 @@ function writeWorkspaceManifestObjects(root, manifest) {
2399
3361
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) {
2400
3362
  throw new Error("The FileGRC workspace is outside its Git repository.");
2401
3363
  }
2402
- const written = observedExecFileSync("git", ["hash-object", "-w", "--stdin-paths"], {
3364
+ const written = executeGitSync(root, ["hash-object", "-w", "--stdin-paths"], {
2403
3365
  cwd: root,
2404
- input: `${entries.map(([path]) => workspacePrefix ? `${workspacePrefix}/${path}` : path).join("\n")}\n`,
3366
+ input: `${entries.map(([path]) => (workspacePrefix ? `${workspacePrefix}/${path}` : path)).join("\n")}\n`,
2405
3367
  encoding: "utf8",
2406
3368
  stdio: ["pipe", "pipe", "ignore"],
2407
3369
  timeout: 30_000,
2408
3370
  maxBuffer: 20_000_000,
2409
- env: gitEnvironment()
2410
- }).trim().split("\n");
3371
+ env: gitEnvironment(),
3372
+ })
3373
+ .trim()
3374
+ .split("\n");
2411
3375
  if (written.length !== entries.length) {
2412
3376
  throw new Error("Git returned an unexpected number of workspace objects.");
2413
3377
  }
2414
3378
  for (let index = 0; index < entries.length; index += 1) {
2415
3379
  if (written[index] !== entries[index][1].objectId) {
2416
- throw new Error(`Git wrote an unexpected object while preparing validated workspace bytes for commit.`);
3380
+ throw new Error(
3381
+ `Git wrote an unexpected object while preparing validated workspace bytes for commit.`,
3382
+ );
2417
3383
  }
2418
3384
  }
2419
3385
  assertWorkspaceManifestEqual(manifest, workspaceByteManifest(root));
@@ -2422,20 +3388,26 @@ function writeWorkspaceManifestObjects(root, manifest) {
2422
3388
 
2423
3389
  function assertWorkspaceManifestEqual(expected, current) {
2424
3390
  if (!workspaceManifestsEqual(expected, current)) {
2425
- throw new Error("Workspace files changed while FileGRC was validating them. Review the concurrent edit and try the action again.");
3391
+ throw new Error(
3392
+ "Workspace files changed while FileGRC was validating them. Review the concurrent edit and try the action again.",
3393
+ );
2426
3394
  }
2427
3395
  }
2428
3396
 
2429
3397
  function workspaceManifestsEqual(expected, current) {
2430
- return expected.size === current.size
2431
- && [...expected].every(([path, value]) => {
3398
+ return (
3399
+ expected.size === current.size &&
3400
+ [...expected].every(([path, value]) => {
2432
3401
  const other = current.get(path);
2433
- return other
2434
- && value.objectId === other.objectId
2435
- && value.mode === other.mode
2436
- && value.size === other.size
2437
- && value.byteDigest === other.byteDigest;
2438
- });
3402
+ return (
3403
+ other &&
3404
+ value.objectId === other.objectId &&
3405
+ value.mode === other.mode &&
3406
+ value.size === other.size &&
3407
+ value.byteDigest === other.byteDigest
3408
+ );
3409
+ })
3410
+ );
2439
3411
  }
2440
3412
 
2441
3413
  async function commitValidatedIndexAsync(root, subject, manifest, options) {
@@ -2444,39 +3416,72 @@ async function commitValidatedIndexAsync(root, subject, manifest, options) {
2444
3416
  const parent = options.expectedParent;
2445
3417
  const ref = options.expectedRef;
2446
3418
  assertExpectedCheckout(root, ref, parent);
2447
- const gitDirectory = await runGitCommand(root, ["rev-parse", "--absolute-git-dir"], { operation: "locate the repository index" });
2448
- const sharedIndex = resolve(root, await runGitCommand(root, ["rev-parse", "--git-path", "index"], { operation: "locate the shared repository index" }));
3419
+ const gitDirectory = await runGitCommand(
3420
+ root,
3421
+ ["rev-parse", "--absolute-git-dir"],
3422
+ { operation: "locate the repository index" },
3423
+ );
3424
+ const sharedIndex = resolve(
3425
+ root,
3426
+ await runGitCommand(root, ["rev-parse", "--git-path", "index"], {
3427
+ operation: "locate the shared repository index",
3428
+ }),
3429
+ );
2449
3430
  const expectedSharedIndex = readFileSync(sharedIndex);
2450
3431
  const indexFile = resolve(gitDirectory, `filegrc-index-${randomUUID()}`);
2451
- const sharedUpdateIndex = resolve(gitDirectory, `filegrc-shared-index-${randomUUID()}`);
3432
+ const sharedUpdateIndex = resolve(
3433
+ gitDirectory,
3434
+ `filegrc-shared-index-${randomUUID()}`,
3435
+ );
2452
3436
  let indexReconciled = false;
2453
3437
  try {
2454
3438
  writeFileSync(sharedUpdateIndex, expectedSharedIndex);
2455
3439
  await applyManifestToIndexAsync(root, manifest, sharedUpdateIndex);
2456
- await runGitCommand(root, ["read-tree", parent], { operation: "initialize the private FileGRC index", gitIndexFile: indexFile });
3440
+ await runGitCommand(root, ["read-tree", parent], {
3441
+ operation: "initialize the private FileGRC index",
3442
+ gitIndexFile: indexFile,
3443
+ });
2457
3444
  await applyManifestToIndexAsync(root, manifest, indexFile);
2458
- const tree = await runGitCommand(root, ["write-tree"], { operation: "capture the validated FileGRC tree", gitIndexFile: indexFile });
2459
- const commit = await runGitCommand(root, ["commit-tree", tree, "-p", parent, "-m", subject], {
2460
- operation: "create the validated FileGRC browser commit",
2461
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
3445
+ const tree = await runGitCommand(root, ["write-tree"], {
3446
+ operation: "capture the validated FileGRC tree",
3447
+ gitIndexFile: indexFile,
2462
3448
  });
3449
+ const commit = await runGitCommand(
3450
+ root,
3451
+ ["commit-tree", tree, "-p", parent, "-m", subject],
3452
+ {
3453
+ operation: "create the validated FileGRC browser commit",
3454
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS,
3455
+ },
3456
+ );
2463
3457
  assertNoGitOperationInProgress(root);
2464
- await runGitCommand(root, ["update-ref", "-m", subject, ref, commit, parent], {
2465
- operation: "advance the authoritative branch to the validated FileGRC commit",
2466
- expectedCheckout: { expectedRef: ref, expectedCommit: parent },
2467
- expectedNoOperation: true
2468
- });
3458
+ await runGitCommand(
3459
+ root,
3460
+ ["update-ref", "-m", subject, ref, commit, parent],
3461
+ {
3462
+ operation:
3463
+ "advance the authoritative branch to the validated FileGRC commit",
3464
+ expectedCheckout: { expectedRef: ref, expectedCommit: parent },
3465
+ expectedNoOperation: true,
3466
+ },
3467
+ );
2469
3468
  if (!gitOperationInProgress(root) && checkoutMatches(root, ref, commit)) {
2470
- indexReconciled = replaceSharedIndexIfUnchanged(sharedIndex, sharedUpdateIndex, expectedSharedIndex);
3469
+ indexReconciled = replaceSharedIndexIfUnchanged(
3470
+ sharedIndex,
3471
+ sharedUpdateIndex,
3472
+ expectedSharedIndex,
3473
+ );
2471
3474
  }
2472
3475
  return { commit, indexReconciled: indexReconciled ?? false };
2473
3476
  } finally {
2474
- await Promise.all([
2475
- rm(indexFile, { force: true }),
2476
- rm(`${indexFile}.lock`, { force: true }),
2477
- rm(sharedUpdateIndex, { force: true }),
2478
- rm(`${sharedUpdateIndex}.lock`, { force: true })
2479
- ].map((cleanup) => cleanup.catch(() => undefined)));
3477
+ await Promise.all(
3478
+ [
3479
+ rm(indexFile, { force: true }),
3480
+ rm(`${indexFile}.lock`, { force: true }),
3481
+ rm(sharedUpdateIndex, { force: true }),
3482
+ rm(`${sharedUpdateIndex}.lock`, { force: true }),
3483
+ ].map((cleanup) => cleanup.catch(() => undefined)),
3484
+ );
2480
3485
  }
2481
3486
  }
2482
3487
 
@@ -2486,7 +3491,11 @@ function replaceSharedIndexIfUnchanged(sharedIndex, privateIndex, expected) {
2486
3491
  let installed = false;
2487
3492
  let ownsLock = false;
2488
3493
  try {
2489
- descriptor = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666);
3494
+ descriptor = openSync(
3495
+ lock,
3496
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
3497
+ 0o666,
3498
+ );
2490
3499
  ownsLock = true;
2491
3500
  if (!readFileSync(sharedIndex).equals(expected)) return false;
2492
3501
  writeFileSync(descriptor, readFileSync(privateIndex));
@@ -2500,36 +3509,61 @@ function replaceSharedIndexIfUnchanged(sharedIndex, privateIndex, expected) {
2500
3509
  return false;
2501
3510
  } finally {
2502
3511
  if (descriptor !== undefined) {
2503
- try { closeSync(descriptor); } catch { /* Best-effort cleanup after reconciliation. */ }
3512
+ try {
3513
+ closeSync(descriptor);
3514
+ } catch {
3515
+ /* Best-effort cleanup after reconciliation. */
3516
+ }
2504
3517
  }
2505
3518
  if (ownsLock && !installed) {
2506
- try { rmSync(lock, { force: true }); } catch { /* Best-effort cleanup after reconciliation. */ }
3519
+ try {
3520
+ rmSync(lock, { force: true });
3521
+ } catch {
3522
+ /* Best-effort cleanup after reconciliation. */
3523
+ }
2507
3524
  }
2508
3525
  }
2509
3526
  }
2510
3527
 
2511
3528
  async function applyManifestToIndexAsync(root, manifest, indexFile) {
2512
- const options = { operation: "write the validated FileGRC index", ...(indexFile ? { gitIndexFile: indexFile } : {}) };
2513
- for (const args of manifestIndexCommands(root, manifest)) await runGitCommand(root, args, options);
3529
+ const options = {
3530
+ operation: "write the validated FileGRC index",
3531
+ ...(indexFile ? { gitIndexFile: indexFile } : {}),
3532
+ };
3533
+ for (const args of manifestIndexCommands(root, manifest))
3534
+ await runGitCommand(root, args, options);
2514
3535
  }
2515
3536
 
2516
3537
  function manifestIndexCommands(root, manifest) {
2517
3538
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
2518
3539
  const prefix = relative(topLevel, root).split(sep).join("/");
2519
- if (prefix === ".." || prefix.startsWith("../")) throw new Error("The FileGRC workspace is outside its Git repository.");
3540
+ if (prefix === ".." || prefix.startsWith("../"))
3541
+ throw new Error("The FileGRC workspace is outside its Git repository.");
2520
3542
  const additions = [];
2521
3543
  const deletions = [];
2522
3544
  for (const [path, value] of manifest) {
2523
3545
  const repositoryPath = prefix ? `${prefix}/${path}` : path;
2524
- if (value.objectId) additions.push(`${value.mode},${value.objectId},${repositoryPath}`);
3546
+ if (value.objectId)
3547
+ additions.push(`${value.mode},${value.objectId},${repositoryPath}`);
2525
3548
  else deletions.push(repositoryPath);
2526
3549
  }
2527
3550
  const commands = [];
2528
3551
  for (let offset = 0; offset < additions.length; offset += 128) {
2529
- commands.push(["update-index", "--add", ...additions.slice(offset, offset + 128).flatMap((entry) => ["--cacheinfo", entry])]);
3552
+ commands.push([
3553
+ "update-index",
3554
+ "--add",
3555
+ ...additions
3556
+ .slice(offset, offset + 128)
3557
+ .flatMap((entry) => ["--cacheinfo", entry]),
3558
+ ]);
2530
3559
  }
2531
3560
  for (let offset = 0; offset < deletions.length; offset += 256) {
2532
- commands.push(["update-index", "--force-remove", "--", ...deletions.slice(offset, offset + 256)]);
3561
+ commands.push([
3562
+ "update-index",
3563
+ "--force-remove",
3564
+ "--",
3565
+ ...deletions.slice(offset, offset + 256),
3566
+ ]);
2533
3567
  }
2534
3568
  return commands;
2535
3569
  }
@@ -2546,7 +3580,8 @@ function statusPathsFromRaw(output) {
2546
3580
  }
2547
3581
  const status = field.slice(0, 2);
2548
3582
  paths.push(field.slice(3));
2549
- if (/[RC]/.test(status) && fields[index + 1] !== undefined) paths.push(fields[++index]);
3583
+ if (/[RC]/.test(status) && fields[index + 1] !== undefined)
3584
+ paths.push(fields[++index]);
2550
3585
  }
2551
3586
  return paths;
2552
3587
  }
@@ -2571,7 +3606,13 @@ function cleanGitName(value, fallback) {
2571
3606
  function parseLogLine(line) {
2572
3607
  if (!line) return null;
2573
3608
  const [commit, timestamp, author, subject] = line.split("\x1f");
2574
- return { commit, shortCommit: commit?.slice(0, 8), timestamp, author, subject };
3609
+ return {
3610
+ commit,
3611
+ shortCommit: commit?.slice(0, 8),
3612
+ timestamp,
3613
+ author,
3614
+ subject,
3615
+ };
2575
3616
  }
2576
3617
 
2577
3618
  function lines(source) {
@@ -2590,8 +3631,10 @@ function parsePorcelainV2(source, topLevel, root) {
2590
3631
  const workspaceChanges = [];
2591
3632
  for (let index = 0; index < fields.length; index += 1) {
2592
3633
  const field = fields[index];
2593
- if (field.startsWith("# branch.oid ")) commit = field.slice(13) === "(initial)" ? null : field.slice(13);
2594
- else if (field.startsWith("# branch.head ")) branch = field.slice(14) === "(detached)" ? null : field.slice(14);
3634
+ if (field.startsWith("# branch.oid "))
3635
+ commit = field.slice(13) === "(initial)" ? null : field.slice(13);
3636
+ else if (field.startsWith("# branch.head "))
3637
+ branch = field.slice(14) === "(detached)" ? null : field.slice(14);
2595
3638
  else if (field.startsWith("# branch.upstream ")) upstream = field.slice(18);
2596
3639
  else if (field.startsWith("# branch.ab ")) {
2597
3640
  const match = /\+(\d+) -(\d+)/.exec(field);
@@ -2600,13 +3643,24 @@ function parsePorcelainV2(source, topLevel, root) {
2600
3643
  const path = porcelainV2Path(field);
2601
3644
  allChanges.push(field);
2602
3645
  const originalPath = field.startsWith("2 ") ? fields[index + 1] : null;
2603
- if (pathInsideWorkspace(path, prefix) || originalPath && pathInsideWorkspace(originalPath, prefix)) {
3646
+ if (
3647
+ pathInsideWorkspace(path, prefix) ||
3648
+ (originalPath && pathInsideWorkspace(originalPath, prefix))
3649
+ ) {
2604
3650
  workspaceChanges.push(field);
2605
3651
  }
2606
3652
  if (originalPath) index += 1;
2607
3653
  }
2608
3654
  }
2609
- return { commit, branch, upstream, ahead, behind, allChanges, workspaceChanges };
3655
+ return {
3656
+ commit,
3657
+ branch,
3658
+ upstream,
3659
+ ahead,
3660
+ behind,
3661
+ allChanges,
3662
+ workspaceChanges,
3663
+ };
2610
3664
  }
2611
3665
 
2612
3666
  function parseWorkspaceRevision(source) {
@@ -2630,13 +3684,17 @@ function parseWorkspaceRevision(source) {
2630
3684
  return {
2631
3685
  commit,
2632
3686
  branch,
2633
- changePaths: [...new Set(changePaths.filter(Boolean))].sort()
3687
+ changePaths: [...new Set(changePaths.filter(Boolean))].sort(),
2634
3688
  };
2635
3689
  }
2636
3690
 
2637
3691
  function porcelainV2Path(field) {
2638
3692
  if (field.startsWith("? ") || field.startsWith("! ")) return field.slice(2);
2639
- const requiredSpaces = field.startsWith("2 ") ? 9 : field.startsWith("u ") ? 10 : 8;
3693
+ const requiredSpaces = field.startsWith("2 ")
3694
+ ? 9
3695
+ : field.startsWith("u ")
3696
+ ? 10
3697
+ : 8;
2640
3698
  let offset = 0;
2641
3699
  for (let count = 0; count < requiredSpaces; count += 1) {
2642
3700
  offset = field.indexOf(" ", offset) + 1;
@@ -2663,7 +3721,7 @@ function repositoryOperationFromDirectory(gitDirectory) {
2663
3721
  ["cherry-pick", "CHERRY_PICK_HEAD"],
2664
3722
  ["sequencer", "sequencer"],
2665
3723
  ["revert", "REVERT_HEAD"],
2666
- ["bisect", "BISECT_START"]
3724
+ ["bisect", "BISECT_START"],
2667
3725
  ]) {
2668
3726
  if (existsSync(resolve(gitDirectory, path))) return name;
2669
3727
  }
@@ -2673,7 +3731,9 @@ function repositoryOperationFromDirectory(gitDirectory) {
2673
3731
  function assertNoGitOperationInProgress(root) {
2674
3732
  const operation = gitOperationInProgress(root);
2675
3733
  if (operation) {
2676
- throw new Error(`A Git ${operation} is already in progress. Finish or abort it with Git before using FileGRC synchronization.`);
3734
+ throw new Error(
3735
+ `A Git ${operation} is already in progress. Finish or abort it with Git before using FileGRC synchronization.`,
3736
+ );
2677
3737
  }
2678
3738
  }
2679
3739
 
@@ -2684,48 +3744,81 @@ function gitOperationInProgress(root) {
2684
3744
 
2685
3745
  export function setGitCommandInterceptorForTests(interceptor) {
2686
3746
  if (interceptor !== null && typeof interceptor !== "function") {
2687
- throw new TypeError("The Git command interceptor must be a function or null.");
3747
+ throw new TypeError(
3748
+ "The Git command interceptor must be a function or null.",
3749
+ );
2688
3750
  }
2689
3751
  const previous = gitCommandInterceptor;
2690
3752
  gitCommandInterceptor = interceptor;
2691
- return () => { gitCommandInterceptor = previous; };
3753
+ return () => {
3754
+ gitCommandInterceptor = previous;
3755
+ };
3756
+ }
3757
+
3758
+ export function withGitCommandAdapterForTests(adapter, callback) {
3759
+ if (
3760
+ !adapter ||
3761
+ typeof adapter.run !== "function" ||
3762
+ typeof adapter.runSync !== "function"
3763
+ ) {
3764
+ throw new TypeError(
3765
+ "The Git command adapter requires run and runSync functions.",
3766
+ );
3767
+ }
3768
+ if (typeof callback !== "function")
3769
+ throw new TypeError("The Git command adapter callback must be a function.");
3770
+ return gitCommandAdapters.run(adapter, callback);
2692
3771
  }
2693
3772
 
2694
3773
  export function setGitSubprocessObserverForTests(observer) {
2695
3774
  if (observer !== null && typeof observer !== "function") {
2696
- throw new TypeError("The Git subprocess observer must be a function or null.");
3775
+ throw new TypeError(
3776
+ "The Git subprocess observer must be a function or null.",
3777
+ );
2697
3778
  }
2698
3779
  const previous = gitSubprocessObserver;
2699
3780
  gitSubprocessObserver = observer;
2700
- return () => { gitSubprocessObserver = previous; };
3781
+ return () => {
3782
+ gitSubprocessObserver = previous;
3783
+ };
2701
3784
  }
2702
3785
 
2703
3786
  export function setHistoricalRevisionReadObserverForTests(observer) {
2704
3787
  if (observer !== null && typeof observer !== "function") {
2705
- throw new TypeError("The historical revision-read observer must be a function or null.");
3788
+ throw new TypeError(
3789
+ "The historical revision-read observer must be a function or null.",
3790
+ );
2706
3791
  }
2707
3792
  const previous = historicalRevisionReadObserver;
2708
3793
  historicalRevisionReadObserver = observer;
2709
- return () => { historicalRevisionReadObserver = previous; };
3794
+ return () => {
3795
+ historicalRevisionReadObserver = previous;
3796
+ };
2710
3797
  }
2711
3798
 
2712
3799
  export function setWorkspaceBlobObjectIdOverrideForTests(override) {
2713
3800
  if (override !== null && typeof override !== "function") {
2714
- throw new TypeError("The workspace blob object ID override must be a function or null.");
3801
+ throw new TypeError(
3802
+ "The workspace blob object ID override must be a function or null.",
3803
+ );
2715
3804
  }
2716
3805
  const previous = workspaceBlobObjectIdOverride;
2717
3806
  workspaceBlobObjectIdOverride = override;
2718
- return () => { workspaceBlobObjectIdOverride = previous; };
3807
+ return () => {
3808
+ workspaceBlobObjectIdOverride = previous;
3809
+ };
2719
3810
  }
2720
3811
 
2721
3812
  export function runGitCommand(cwd, args, options = {}) {
2722
3813
  if (gitCommandInterceptor) {
2723
- return Promise.resolve().then(() => gitCommandInterceptor({
2724
- cwd,
2725
- args: [...args],
2726
- options: { ...options },
2727
- run: () => runGitCommandNative(cwd, args, options)
2728
- }));
3814
+ return Promise.resolve().then(() =>
3815
+ gitCommandInterceptor({
3816
+ cwd,
3817
+ args: [...args],
3818
+ options: { ...options },
3819
+ run: () => runGitCommandNative(cwd, args, options),
3820
+ }),
3821
+ );
2729
3822
  }
2730
3823
  return runGitCommandNative(cwd, args, options);
2731
3824
  }
@@ -2739,27 +3832,46 @@ function runGitCommandNative(cwd, args, options = {}) {
2739
3832
  assertExpectedCheckout(
2740
3833
  cwd,
2741
3834
  options.expectedCheckout.expectedRef,
2742
- options.expectedCheckout.expectedCommit
3835
+ options.expectedCheckout.expectedCommit,
2743
3836
  );
2744
3837
  }
2745
3838
  if (options.expectedNoOperation) assertNoGitOperationInProgress(cwd);
3839
+ const adapter = gitCommandAdapters.getStore();
3840
+ if (adapter) {
3841
+ return Promise.resolve(
3842
+ adapter.run({ cwd, args: [...args], options: { ...options } }),
3843
+ ).then((output) => String(output ?? "").trim());
3844
+ }
2746
3845
  const operation = options.operation || "run a Git command";
2747
3846
  const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
2748
- const { timeoutMs, deadlineLimited } = gitTimeoutPlan(configuredTimeout, GIT_DEFAULT_TIMEOUT_MS);
2749
- const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
2750
- gitSubprocessObserver?.({ kind: "async", cwd, args: [...args], options: { ...options } });
3847
+ const { timeoutMs, deadlineLimited } = gitTimeoutPlan(
3848
+ configuredTimeout,
3849
+ GIT_DEFAULT_TIMEOUT_MS,
3850
+ );
3851
+ const maxOutputBytes = Math.max(
3852
+ 1,
3853
+ Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES,
3854
+ );
3855
+ gitSubprocessObserver?.({
3856
+ kind: "async",
3857
+ cwd,
3858
+ args: [...args],
3859
+ options: { ...options },
3860
+ });
2751
3861
  return new Promise((resolveCommand, rejectCommand) => {
2752
3862
  const child = spawn("git", args, {
2753
3863
  cwd,
2754
3864
  stdio: ["ignore", "pipe", "pipe"],
2755
3865
  detached: process.platform !== "win32",
2756
3866
  env: gitEnvironment({
2757
- ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
3867
+ ...(options.gitIndexFile
3868
+ ? { GIT_INDEX_FILE: options.gitIndexFile }
3869
+ : {}),
2758
3870
  GIT_TERMINAL_PROMPT: "0",
2759
3871
  GIT_ASKPASS: "",
2760
3872
  SSH_ASKPASS: "",
2761
- GIT_MERGE_AUTOEDIT: "no"
2762
- })
3873
+ GIT_MERGE_AUTOEDIT: "no",
3874
+ }),
2763
3875
  });
2764
3876
  const stdout = [];
2765
3877
  const stderr = [];
@@ -2770,10 +3882,15 @@ function runGitCommandNative(cwd, args, options = {}) {
2770
3882
  let forceKillTimer;
2771
3883
  const terminate = (signal) => {
2772
3884
  try {
2773
- if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
3885
+ if (process.platform !== "win32" && child.pid)
3886
+ process.kill(-child.pid, signal);
2774
3887
  else child.kill(signal);
2775
3888
  } catch {
2776
- try { child.kill(signal); } catch { /* The child already exited. */ }
3889
+ try {
3890
+ child.kill(signal);
3891
+ } catch {
3892
+ /* The child already exited. */
3893
+ }
2777
3894
  }
2778
3895
  };
2779
3896
  const stop = () => {
@@ -2801,12 +3918,14 @@ function runGitCommandNative(cwd, args, options = {}) {
2801
3918
  settled = true;
2802
3919
  clearTimeout(timer);
2803
3920
  clearTimeout(forceKillTimer);
2804
- rejectCommand(new GitOperationError(
2805
- error.code === "ENOENT" ? "missing-executable" : "command-failure",
2806
- operation,
2807
- error.code === "ENOENT" ? "" : error.message,
2808
- { cause: error, code: error.code }
2809
- ));
3921
+ rejectCommand(
3922
+ new GitOperationError(
3923
+ error.code === "ENOENT" ? "missing-executable" : "command-failure",
3924
+ operation,
3925
+ error.code === "ENOENT" ? "" : error.message,
3926
+ { cause: error, code: error.code },
3927
+ ),
3928
+ );
2810
3929
  });
2811
3930
  child.once("close", (code, signal) => {
2812
3931
  if (settled) return;
@@ -2815,20 +3934,25 @@ function runGitCommandNative(cwd, args, options = {}) {
2815
3934
  clearTimeout(forceKillTimer);
2816
3935
  const output = Buffer.concat(stdout).toString("utf8");
2817
3936
  const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
2818
- if (code === 0 && !timedOut && !outputExceeded) return resolveCommand(output.trim());
3937
+ if (code === 0 && !timedOut && !outputExceeded)
3938
+ return resolveCommand(output.trim());
2819
3939
  const detail = outputExceeded
2820
3940
  ? `Git output exceeded ${maxOutputBytes} bytes.`
2821
3941
  : timedOut
2822
3942
  ? `The operation exceeded ${timeoutMs} ms and the process group was terminated.`
2823
- : errorOutput || `Git exited with status ${code ?? signal ?? "unknown"}.`;
3943
+ : errorOutput ||
3944
+ `Git exited with status ${code ?? signal ?? "unknown"}.`;
2824
3945
  const kind = timedOut
2825
3946
  ? "timeout"
2826
3947
  : /not a git repository|outside repository/i.test(errorOutput)
2827
3948
  ? "invalid-repository"
2828
3949
  : "command-failure";
2829
- rejectCommand(new GitOperationError(kind, operation, detail, {
2830
- code: timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined
2831
- }));
3950
+ rejectCommand(
3951
+ new GitOperationError(kind, operation, detail, {
3952
+ code:
3953
+ timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined,
3954
+ }),
3955
+ );
2832
3956
  });
2833
3957
  });
2834
3958
  }
@@ -2843,14 +3967,19 @@ async function tryGitAsync(cwd, args, operation) {
2843
3967
 
2844
3968
  function git(cwd, args, options = {}) {
2845
3969
  return cachedGitCommand("text", cwd, args, options, () => {
2846
- return measureTimingSync("git-command-sync", () => observedExecFileSync("git", args, {
2847
- cwd,
2848
- encoding: "utf8",
2849
- stdio: ["ignore", "pipe", "ignore"],
2850
- timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2851
- maxBuffer: 20_000_000,
2852
- env: gitEnvironment()
2853
- }).trim());
3970
+ return measureTimingSync("git-command-sync", () =>
3971
+ executeGitSync(cwd, args, {
3972
+ cwd,
3973
+ encoding: "utf8",
3974
+ stdio: ["ignore", "pipe", "ignore"],
3975
+ timeout: Math.max(
3976
+ 1,
3977
+ Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS,
3978
+ ),
3979
+ maxBuffer: 20_000_000,
3980
+ env: gitEnvironment(),
3981
+ }).trim(),
3982
+ );
2854
3983
  });
2855
3984
  }
2856
3985
 
@@ -2883,13 +4012,15 @@ function tryGitRaw(cwd, args) {
2883
4012
 
2884
4013
  function gitRaw(cwd, args, options = {}) {
2885
4014
  return cachedGitCommand("raw", cwd, args, options, () => {
2886
- return observedExecFileSync("git", args, {
4015
+ return executeGitSync(cwd, args, {
2887
4016
  cwd,
2888
4017
  encoding: "utf8",
2889
4018
  stdio: ["ignore", "pipe", "ignore"],
2890
4019
  timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2891
4020
  maxBuffer: 20_000_000,
2892
- env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
4021
+ env: gitEnvironment(
4022
+ options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {},
4023
+ ),
2893
4024
  });
2894
4025
  });
2895
4026
  }
@@ -2901,7 +4032,7 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2901
4032
  outputKind,
2902
4033
  resolve(cwd),
2903
4034
  args,
2904
- options.gitIndexFile || null
4035
+ options.gitIndexFile || null,
2905
4036
  ]);
2906
4037
  const cached = cache.get(key);
2907
4038
  if (cached) {
@@ -2913,7 +4044,10 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2913
4044
  const size = Buffer.byteLength(value);
2914
4045
  if (size <= GIT_COMMAND_CACHE_MAX_ENTRY_BYTES) {
2915
4046
  let totalBytes = gitCommandCacheBytes.get(cache) || 0;
2916
- while (cache.size >= GIT_COMMAND_CACHE_MAX_ENTRIES || totalBytes + size > GIT_COMMAND_CACHE_MAX_BYTES) {
4047
+ while (
4048
+ cache.size >= GIT_COMMAND_CACHE_MAX_ENTRIES ||
4049
+ totalBytes + size > GIT_COMMAND_CACHE_MAX_BYTES
4050
+ ) {
2917
4051
  const oldestKey = cache.keys().next().value;
2918
4052
  if (oldestKey === undefined) break;
2919
4053
  totalBytes -= cache.get(oldestKey)?.size || 0;
@@ -2926,9 +4060,10 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2926
4060
  }
2927
4061
 
2928
4062
  function observedExecFileSync(executable, args, options = {}) {
2929
- const timeoutPlan = executable === "git"
2930
- ? gitTimeoutPlan(options.timeout, GIT_DEFAULT_TIMEOUT_MS)
2931
- : null;
4063
+ const timeoutPlan =
4064
+ executable === "git"
4065
+ ? gitTimeoutPlan(options.timeout, GIT_DEFAULT_TIMEOUT_MS)
4066
+ : null;
2932
4067
  const boundedOptions = timeoutPlan
2933
4068
  ? { ...options, timeout: timeoutPlan.timeoutMs }
2934
4069
  : options;
@@ -2938,13 +4073,22 @@ function observedExecFileSync(executable, args, options = {}) {
2938
4073
  kind: "sync",
2939
4074
  cwd: boundedOptions.cwd,
2940
4075
  args: [...args],
2941
- options: { ...boundedOptions, input: boundedOptions.input === undefined ? undefined : "[redacted]" }
4076
+ options: {
4077
+ ...boundedOptions,
4078
+ input: boundedOptions.input === undefined ? undefined : "[redacted]",
4079
+ },
2942
4080
  });
2943
4081
  }
2944
4082
  return execFileSync(executable, args, boundedOptions);
2945
4083
  } catch (error) {
2946
- if (timeoutPlan?.deadlineLimited && (error?.code === "ETIMEDOUT" || error?.signal)) {
2947
- const deadlineError = new Error("The Git command exceeded the shared request deadline.", { cause: error });
4084
+ if (
4085
+ timeoutPlan?.deadlineLimited &&
4086
+ (error?.code === "ETIMEDOUT" || error?.signal)
4087
+ ) {
4088
+ const deadlineError = new Error(
4089
+ "The Git command exceeded the shared request deadline.",
4090
+ { cause: error },
4091
+ );
2948
4092
  deadlineError.code = "FILEGRC_GIT_DEADLINE";
2949
4093
  throw deadlineError;
2950
4094
  }
@@ -2959,16 +4103,19 @@ function boundedGitTimeout(configured, fallback) {
2959
4103
  function gitTimeoutPlan(configured, fallback) {
2960
4104
  const requested = Math.max(1, Number(configured) || fallback);
2961
4105
  const deadlineAt = gitCommandDeadlines.getStore();
2962
- if (deadlineAt === undefined) return { timeoutMs: requested, deadlineLimited: false };
4106
+ if (deadlineAt === undefined)
4107
+ return { timeoutMs: requested, deadlineLimited: false };
2963
4108
  const remaining = Math.ceil(deadlineAt - performance.now());
2964
4109
  if (remaining <= 0) {
2965
- const error = new Error("The Git command deadline expired before another subprocess could start.");
4110
+ const error = new Error(
4111
+ "The Git command deadline expired before another subprocess could start.",
4112
+ );
2966
4113
  error.code = "FILEGRC_GIT_DEADLINE";
2967
4114
  throw error;
2968
4115
  }
2969
4116
  return {
2970
4117
  timeoutMs: Math.min(requested, remaining),
2971
- deadlineLimited: remaining <= requested
4118
+ deadlineLimited: remaining <= requested,
2972
4119
  };
2973
4120
  }
2974
4121
 
@@ -2983,30 +4130,59 @@ function nulFields(source) {
2983
4130
  function gitForWrite(cwd, args, action = "create the commit", options = {}) {
2984
4131
  try {
2985
4132
  assertWorkspaceInsideGitWorktree(cwd);
2986
- return observedExecFileSync("git", args, {
4133
+ return executeGitSync(cwd, args, {
2987
4134
  cwd,
2988
4135
  encoding: "utf8",
2989
4136
  stdio: ["ignore", "pipe", "pipe"],
2990
4137
  timeout: 30_000,
2991
4138
  maxBuffer: 20_000_000,
2992
4139
  env: gitEnvironment({
2993
- ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
4140
+ ...(options.gitIndexFile
4141
+ ? { GIT_INDEX_FILE: options.gitIndexFile }
4142
+ : {}),
2994
4143
  GIT_TERMINAL_PROMPT: "0",
2995
- GIT_MERGE_AUTOEDIT: "no"
2996
- })
4144
+ GIT_MERGE_AUTOEDIT: "no",
4145
+ }),
2997
4146
  }).trim();
2998
4147
  } catch (error) {
2999
- const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
4148
+ const message = sanitizeGitErrorMessage(
4149
+ error.stderr?.trim() || error.stdout?.trim() || error.message,
4150
+ );
3000
4151
  throw new Error(`Git could not ${action}. ${message}`);
3001
4152
  }
3002
4153
  }
3003
4154
 
3004
- async function gitForWriteAsync(cwd, args, action = "update the repository", options = {}) {
4155
+ function executeGitSync(cwd, args, options = {}) {
4156
+ const adapter = gitCommandAdapters.getStore();
4157
+ if (adapter) {
4158
+ const outputKind = options.encoding ? "text" : "buffer";
4159
+ const output = adapter.runSync({
4160
+ cwd,
4161
+ args: [...args],
4162
+ options: { ...options },
4163
+ output: outputKind,
4164
+ });
4165
+ if (outputKind === "buffer") {
4166
+ return Buffer.isBuffer(output)
4167
+ ? output
4168
+ : Buffer.from(String(output ?? ""));
4169
+ }
4170
+ return String(output ?? "");
4171
+ }
4172
+ return observedExecFileSync("git", args, options);
4173
+ }
4174
+
4175
+ async function gitForWriteAsync(
4176
+ cwd,
4177
+ args,
4178
+ action = "update the repository",
4179
+ options = {},
4180
+ ) {
3005
4181
  assertWorkspaceInsideGitWorktree(cwd);
3006
4182
  return runGitCommand(cwd, args, {
3007
4183
  operation: action,
3008
4184
  timeoutMs: GIT_REMOTE_TIMEOUT_MS,
3009
- ...options
4185
+ ...options,
3010
4186
  });
3011
4187
  }
3012
4188