filegrc 0.13.0 → 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, 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
 
@@ -23,13 +45,16 @@ const backgroundSynchronizations = new Map();
23
45
  const browserRemotePrefetches = new Map();
24
46
  const browserRemotePrefetchPromises = new Map();
25
47
  const repositorySnapshotPromises = new Map();
48
+ const repositoryObjectFormats = new Map();
26
49
  const gitCommandCaches = new AsyncLocalStorage();
27
50
  const gitCommandDeadlines = new AsyncLocalStorage();
51
+ const gitCommandAdapters = new AsyncLocalStorage();
28
52
  const gitCommandCacheBytes = new WeakMap();
29
53
  let gitCommandInterceptor = null;
30
54
  let gitSubprocessObserver = null;
31
55
  let historicalBatchInterceptor = null;
32
56
  let historicalRevisionReadObserver = null;
57
+ let workspaceBlobObjectIdOverride = null;
33
58
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
34
59
  const GIT_DEFAULT_TIMEOUT_MS = 10_000;
35
60
  const GIT_REMOTE_TIMEOUT_MS = 30_000;
@@ -48,19 +73,23 @@ const GIT_COMMAND_CACHE_MAX_ENTRY_BYTES = 512 * 1024;
48
73
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
49
74
 
50
75
  export function withGitCommandCache(cache, callback) {
51
- 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.");
52
78
  return gitCommandCaches.run(cache, callback);
53
79
  }
54
80
 
55
81
  export function withGitCommandDeadline(deadlineAt, callback) {
56
- 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.");
57
84
  return gitCommandDeadlines.run(deadlineAt, callback);
58
85
  }
59
86
 
60
87
  function gitEnvironment(overrides = {}) {
61
88
  return {
62
89
  ...Object.fromEntries(
63
- Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_"))
90
+ Object.entries(process.env).filter(
91
+ ([name]) => !name.toUpperCase().startsWith("GIT_"),
92
+ ),
64
93
  ),
65
94
  ...overrides,
66
95
  GIT_NO_REPLACE_OBJECTS: "1",
@@ -85,20 +114,24 @@ function gitEnvironment(overrides = {}) {
85
114
  GIT_CONFIG_KEY_6: "protocol.ssh.allow",
86
115
  GIT_CONFIG_VALUE_6: "always",
87
116
  GIT_CONFIG_KEY_7: "protocol.file.allow",
88
- GIT_CONFIG_VALUE_7: "always"
117
+ GIT_CONFIG_VALUE_7: "always",
89
118
  };
90
119
  }
91
120
 
92
121
  export class GitOperationError extends Error {
93
122
  constructor(kind, operation, detail, options = {}) {
94
- const prefix = kind === "missing-executable"
95
- ? "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
96
- : kind === "timeout"
97
- ? `Git timed out while trying to ${operation}.`
98
- : kind === "invalid-repository"
99
- ? `Git could not ${operation} because this workspace is not in a valid Git repository.`
100
- : `Git could not ${operation}.`;
101
- 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
+ );
102
135
  this.name = "GitOperationError";
103
136
  this.kind = kind;
104
137
  this.operation = operation;
@@ -109,14 +142,27 @@ export class GitOperationError extends Error {
109
142
  export function getGitSummary(input = process.cwd()) {
110
143
  const root = resolveWorkspaceRoot(input);
111
144
  try {
112
- 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
+ );
113
148
  assertNoWorkspaceContentFilters(root);
114
149
  const status = git(root, ["status", "--porcelain=v1", "--", "."]);
115
150
  const commit = tryGit(root, ["rev-parse", "HEAD"]) || null;
116
151
  const branch = tryGit(root, ["symbolic-ref", "--short", "HEAD"]) || null;
117
- 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;
118
160
  const remotes = lines(tryGit(root, ["remote"]));
119
- 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;
120
166
  return {
121
167
  available: true,
122
168
  root: topLevel,
@@ -127,7 +173,7 @@ export function getGitSummary(input = process.cwd()) {
127
173
  remotes,
128
174
  clean: status === "",
129
175
  changes: status ? status.split("\n") : [],
130
- lastCommit: last
176
+ lastCommit: last,
131
177
  };
132
178
  } catch (error) {
133
179
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -135,7 +181,8 @@ export function getGitSummary(input = process.cwd()) {
135
181
  available: false,
136
182
  clean: null,
137
183
  changes: [],
138
- 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.",
139
186
  };
140
187
  }
141
188
  }
@@ -143,19 +190,23 @@ export function getGitSummary(input = process.cwd()) {
143
190
  export function getFileHistory(input, relativePath, limit = 50) {
144
191
  const root = resolveWorkspaceRoot(input);
145
192
  if (!isSafeDataGitPath(relativePath)) return null;
146
- const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
193
+ const indexed = indexedPathHistory(
194
+ dataRecordHistoryIndexCache.get(root),
195
+ relativePath,
196
+ );
147
197
  if (indexed) return limitedHistory(indexed, limit);
148
198
  try {
149
- const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
150
- ? []
151
- : [`--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))}`];
152
203
  const output = git(root, [
153
204
  "log",
154
205
  "--follow",
155
206
  ...countArgs,
156
207
  "--format=%H%x1f%cI%x1f%an%x1f%s",
157
208
  "--",
158
- relativePath
209
+ relativePath,
159
210
  ]);
160
211
  if (!output) return [];
161
212
  return output.split("\n").map(parseLogLine);
@@ -168,12 +219,16 @@ export function getFileHistory(input, relativePath, limit = 50) {
168
219
  export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
169
220
  const root = resolveWorkspaceRoot(input);
170
221
  if (!isSafeDataGitPath(relativePath)) return null;
171
- const indexed = indexedPathHistory(dataRecordHistoryIndexCache.get(root), relativePath);
222
+ const indexed = indexedPathHistory(
223
+ dataRecordHistoryIndexCache.get(root),
224
+ relativePath,
225
+ );
172
226
  if (indexed) return limitedHistory(indexed, limit);
173
227
  try {
174
- const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
175
- ? []
176
- : [`--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))}`];
177
232
  const output = gitRaw(root, [
178
233
  "log",
179
234
  "--follow",
@@ -183,7 +238,7 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
183
238
  "--name-status",
184
239
  "-M",
185
240
  "--",
186
- relativePath
241
+ relativePath,
187
242
  ]);
188
243
  if (!output) return [];
189
244
  const tokens = output.split("\0");
@@ -191,22 +246,30 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
191
246
  const history = [];
192
247
  let index = 0;
193
248
  while (index < tokens.length) {
194
- 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;
195
254
  if (index >= tokens.length) break;
196
255
  const summary = {
197
256
  commit: tokens[index++].trim(),
198
257
  timestamp: tokens[index++] || "",
199
258
  author: tokens[index++] || "",
200
- subject: tokens[index++] || ""
259
+ subject: tokens[index++] || "",
201
260
  };
202
261
  history.push({ ...summary, path: trackedPath });
203
- 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
+ ) {
204
266
  const status = tokens[index++].trim();
205
267
  if (!status) continue;
206
268
  const oldPath = tokens[index++] || "";
207
269
  if (status.startsWith("R") || status.startsWith("C")) {
208
270
  const newPath = tokens[index++] || "";
209
- if (status.startsWith("R") && newPath === trackedPath) trackedPath = oldPath;
271
+ if (status.startsWith("R") && newPath === trackedPath)
272
+ trackedPath = oldPath;
210
273
  }
211
274
  }
212
275
  }
@@ -219,15 +282,24 @@ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
219
282
 
220
283
  export function getFilePathAtRevision(input, relativePath, revision) {
221
284
  const root = resolveWorkspaceRoot(input);
222
- 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;
223
290
  const index = dataRecordHistoryIndexCache.get(root);
224
291
  const indexed = indexedPathHistory(index, relativePath);
225
292
  if (indexed) {
226
- return indexed.find(({ commit }) => indexedAncestor(index, commit, revision))?.path || null;
293
+ return (
294
+ indexed.find(({ commit }) => indexedAncestor(index, commit, revision))
295
+ ?.path || null
296
+ );
227
297
  }
228
- const history = getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
229
- const summary = history.find(({ commit }) => commit === revision)
230
- || 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));
231
303
  return summary?.path || null;
232
304
  }
233
305
 
@@ -247,18 +319,37 @@ export function getChangedDataJsonFilesAtRevision(input, revision) {
247
319
  try {
248
320
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
249
321
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
250
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
322
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
323
+ return [];
251
324
  const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
252
325
  const output = git(root, [
253
- "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,
254
336
  ]);
255
337
  const paths = [];
256
338
  for (const line of lines(output)) {
257
339
  const [status, first, second] = line.split("\t");
258
340
  if (status === "D") continue;
259
- const repositoryPath = status?.startsWith("R") || status?.startsWith("C") ? second : first;
260
- if (!repositoryPath?.startsWith(`${dataPrefix}/`) || !repositoryPath.endsWith(".json")) continue;
261
- 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
+ );
262
353
  }
263
354
  return [...new Set(paths)];
264
355
  } catch (error) {
@@ -273,38 +364,51 @@ export function getRecordIdentityHistory(input, id) {
273
364
 
274
365
  export function getRecordIdentityHistories(input, ids) {
275
366
  const index = getDataRecordHistoryIndex(input);
276
- 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
+ );
277
370
  }
278
371
 
279
372
  export function getDataRecordHistoryIndex(input, options = {}) {
280
373
  const root = resolveWorkspaceRoot(input);
281
374
  const cached = dataRecordHistoryIndexCache.get(root);
282
- 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;
283
378
  if (
284
- suppliedHead
285
- && cached?.head === suppliedHead
286
- && !cached.available
287
- && cached.failureExpiresAt > performance.now()
288
- ) return cached;
289
- if (suppliedHead && cached?.head === suppliedHead && cached.available) return cached;
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;
290
387
  const hasDeadline = options.deadline !== undefined;
291
- 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;
292
391
  if (!Number.isFinite(deadline) || deadline <= performance.now()) {
293
- 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
+ );
294
395
  error.code = "FILEGRC_HISTORY_DEADLINE";
295
396
  throw error;
296
397
  }
297
398
  const remainingTime = () => {
298
399
  const remainingMs = Math.floor(deadline - performance.now());
299
400
  if (remainingMs <= 0) {
300
- 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
+ );
301
404
  error.code = "FILEGRC_HISTORY_DEADLINE";
302
405
  throw error;
303
406
  }
304
407
  return remainingMs;
305
408
  };
306
409
  const historyGit = (args) => git(root, args, { timeoutMs: remainingTime() });
307
- const historyGitRaw = (args) => gitRaw(root, args, { timeoutMs: remainingTime() });
410
+ const historyGitRaw = (args) =>
411
+ gitRaw(root, args, { timeoutMs: remainingTime() });
308
412
  let head = null;
309
413
  let discoveryError = null;
310
414
  if (suppliedHead) {
@@ -335,24 +439,46 @@ export function getDataRecordHistoryIndex(input, options = {}) {
335
439
  }
336
440
  }
337
441
  let available = Boolean(head) && !shallow;
338
- let error = discoveryError || (!head
339
- ? new Error("Git history is unavailable because the workspace has no committed HEAD.")
340
- : 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);
341
451
  if (discoveryError) available = false;
342
452
  try {
343
453
  if (available) {
344
454
  const output = historyGitRaw([
345
- "log", "--topo-order", "--reverse", "--diff-merges=first-parent", "-z", "--relative",
346
- "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", head, "--", "data"
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",
347
467
  ]);
348
468
  const parsed = parseDataRecordHistory(output);
349
- if (parsed.commitCount > DATA_HISTORY_MAX_COMMITS || parsed.changes.length > DATA_HISTORY_MAX_CHANGES) {
350
- throw dataHistoryLimitError("Git data history is too large to reconcile safely.");
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
+ );
351
476
  }
352
477
  changes.push(...parsed.recordChanges);
353
478
  sourceChanges.push(...parsed.sourceChanges);
354
479
  for (const change of parsed.changes) {
355
- if (!changesByCommit.has(change.summary.commit)) changesByCommit.set(change.summary.commit, []);
480
+ if (!changesByCommit.has(change.summary.commit))
481
+ changesByCommit.set(change.summary.commit, []);
356
482
  changesByCommit.get(change.summary.commit).push(change);
357
483
  }
358
484
  }
@@ -366,13 +492,16 @@ export function getDataRecordHistoryIndex(input, options = {}) {
366
492
  try {
367
493
  sources = getFilesAtRevisions(
368
494
  root,
369
- sourceChanges.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
495
+ sourceChanges.map(({ summary, path }) => ({
496
+ revision: summary.commit,
497
+ relativePath: path,
498
+ })),
370
499
  {
371
500
  batchSize: 512,
372
501
  maxRequests: DATA_HISTORY_MAX_SOURCE_REQUESTS,
373
502
  maxTotalBytes: DATA_HISTORY_MAX_SOURCE_BYTES,
374
- deadline
375
- }
503
+ deadline,
504
+ },
376
505
  );
377
506
  } catch (cause) {
378
507
  rethrowGitDeadline(cause);
@@ -395,13 +524,21 @@ export function getDataRecordHistoryIndex(input, options = {}) {
395
524
  for (const [commit, commitChanges] of changesByCommit) {
396
525
  const fileChanges = new Map();
397
526
  for (const change of commitChanges) {
398
- if (change.beforePath?.match(/\.(?:json|md)$/) && change.beforePath !== change.afterPath) {
399
- if (change.beforePath.endsWith(".json")) historicalRecordPaths.add(change.beforePath);
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);
400
533
  fileChanges.set(change.beforePath, null);
401
534
  }
402
535
  if (change.afterPath?.match(/\.(?:json|md)$/)) {
403
- if (change.afterPath.endsWith(".json")) historicalRecordPaths.add(change.afterPath);
404
- fileChanges.set(change.afterPath, sourceByCommitAndPath.get(`${commit}\0${change.afterPath}`) ?? null);
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
+ );
405
542
  }
406
543
  }
407
544
  fileChangesByCommit.set(commit, fileChanges);
@@ -409,13 +546,25 @@ export function getDataRecordHistoryIndex(input, options = {}) {
409
546
  for (let index = 0; index < changes.length; index += 1) {
410
547
  const { summary, path } = changes[index];
411
548
  try {
412
- const record = JSON.parse(sourceByCommitAndPath.get(`${summary.commit}\0${path}`));
413
- if (!record || Array.isArray(record) || typeof record.id !== "string" || typeof record.type !== "string") {
414
- throw new Error("Historical data records require string IDs and types.");
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
+ );
415
561
  }
416
- if (!recordsByCommit.has(summary.commit)) recordsByCommit.set(summary.commit, new Map());
562
+ if (!recordsByCommit.has(summary.commit))
563
+ recordsByCommit.set(summary.commit, new Map());
417
564
  if (recordsByCommit.get(summary.commit).has(record.id)) {
418
- 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
+ );
419
568
  }
420
569
  recordsByCommit.get(summary.commit).set(record.id, { record, path });
421
570
  if (!historiesById.has(record.id)) historiesById.set(record.id, []);
@@ -423,7 +572,10 @@ export function getDataRecordHistoryIndex(input, options = {}) {
423
572
  } catch (cause) {
424
573
  rethrowGitDeadline(cause);
425
574
  available = false;
426
- 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
+ );
427
579
  break;
428
580
  }
429
581
  }
@@ -432,15 +584,25 @@ export function getDataRecordHistoryIndex(input, options = {}) {
432
584
  try {
433
585
  for (const line of lines(historyGit(["rev-list", "--parents", head]))) {
434
586
  const [commit, ...parents] = line.split(" ");
435
- 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
+ ) {
436
591
  throw new Error("Git returned invalid commit ancestry.");
437
592
  }
438
593
  parentsByCommit.set(commit, parents);
439
594
  if (parentsByCommit.size > DATA_HISTORY_MAX_ANCESTRY_COMMITS) {
440
- throw dataHistoryLimitError("Git ancestry is too large to reconcile safely.");
595
+ throw dataHistoryLimitError(
596
+ "Git ancestry is too large to reconcile safely.",
597
+ );
441
598
  }
442
599
  }
443
- 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
+ ) {
444
606
  throw new Error("Git returned incomplete commit ancestry.");
445
607
  }
446
608
  } catch (cause) {
@@ -452,12 +614,15 @@ export function getDataRecordHistoryIndex(input, options = {}) {
452
614
  for (const [id, history] of historiesById) {
453
615
  history.reverse();
454
616
  const seen = new Set();
455
- historiesById.set(id, history.filter(({ commit, path }) => {
456
- const key = `${commit}\u0000${path}`;
457
- if (seen.has(key)) return false;
458
- seen.add(key);
459
- return true;
460
- }));
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
+ );
461
626
  }
462
627
  const result = {
463
628
  head,
@@ -471,17 +636,32 @@ export function getDataRecordHistoryIndex(input, options = {}) {
471
636
  recordsByCommit: available ? recordsByCommit : new Map(),
472
637
  historiesById: available ? historiesById : new Map(),
473
638
  sourceBytes: available
474
- ? 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
+ )
475
647
  : 0,
476
648
  ancestryCache: new Map(),
477
- indexedFileCache: new Map()
649
+ indexedFileCache: new Map(),
478
650
  };
479
- 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;
480
654
  result.estimatedBytes = available
481
- ? result.sourceBytes * 3
482
- + [...result.changesByCommit.values()].reduce((total, commitChanges) => total + commitChanges.length * 512, 0)
483
- + result.parentsByCommit.size * 256
484
- + [...result.historiesById.values()].reduce((total, history) => total + history.length * 128, 0)
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
+ )
485
665
  : 512 + Buffer.byteLength(error?.message || "", "utf8");
486
666
  result.cacheBytes = result.estimatedBytes;
487
667
  if (result.estimatedBytes <= DATA_HISTORY_CACHE_MAX_BYTES) {
@@ -489,7 +669,10 @@ export function getDataRecordHistoryIndex(input, options = {}) {
489
669
  dataRecordHistoryIndexCacheBytes += result.cacheBytes;
490
670
  }
491
671
  if (dataRecordHistoryIndexCache.has(root)) {
492
- while (dataRecordHistoryIndexCache.size > 4 || dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES) {
672
+ while (
673
+ dataRecordHistoryIndexCache.size > 4 ||
674
+ dataRecordHistoryIndexCacheBytes > DATA_HISTORY_CACHE_MAX_BYTES
675
+ ) {
493
676
  const oldestRoot = dataRecordHistoryIndexCache.keys().next().value;
494
677
  const oldest = dataRecordHistoryIndexCache.get(oldestRoot);
495
678
  dataRecordHistoryIndexCache.delete(oldestRoot);
@@ -510,15 +693,27 @@ function parseDataRecordHistory(output) {
510
693
  while (fields[index] === "") index += 1;
511
694
  const commit = fields[index++];
512
695
  if (commit === undefined) break;
513
- 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.");
514
698
  const timestamp = fields[index++];
515
699
  const author = fields[index++];
516
700
  const subject = fields[index++];
517
- if (timestamp === undefined || author === undefined || subject === undefined) {
701
+ if (
702
+ timestamp === undefined ||
703
+ author === undefined ||
704
+ subject === undefined
705
+ ) {
518
706
  throw new Error("Git returned an incomplete data-history header.");
519
707
  }
520
- const summary = { commit, shortCommit: commit.slice(0, 8), timestamp, author, subject };
521
- if (commits.has(commit)) throw new Error("Git returned duplicate data-history commit output.");
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.");
522
717
  commits.add(commit);
523
718
  while (index < fields.length) {
524
719
  while (fields[index] === "") index += 1;
@@ -532,11 +727,13 @@ function parseDataRecordHistory(output) {
532
727
  const first = fields[index++];
533
728
  const renamed = status.startsWith("R") || status.startsWith("C");
534
729
  const second = renamed ? fields[index++] : null;
535
- 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.");
536
732
  const beforePath = status === "A" ? null : first;
537
733
  const afterPath = status === "D" ? null : renamed ? second : first;
538
734
  for (const path of [beforePath, afterPath].filter(Boolean)) {
539
- 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.");
540
737
  }
541
738
  const change = { summary, status, beforePath, afterPath };
542
739
  changes.push(change);
@@ -566,10 +763,11 @@ function limitedHistory(history, limit) {
566
763
 
567
764
  function indexedDataFile(index, revision, relativePath) {
568
765
  if (
569
- !index?.available
570
- || !relativePath?.match(/\.(?:json|md)$/)
571
- || !index.parentsByCommit.has(revision)
572
- ) return undefined;
766
+ !index?.available ||
767
+ !relativePath?.match(/\.(?:json|md)$/) ||
768
+ !index.parentsByCommit.has(revision)
769
+ )
770
+ return undefined;
573
771
  const key = `${revision}\0${relativePath}`;
574
772
  if (index.indexedFileCache.has(key)) return index.indexedFileCache.get(key);
575
773
  let commit = revision;
@@ -583,7 +781,8 @@ function indexedDataFile(index, revision, relativePath) {
583
781
  commit = index.parentsByCommit.get(commit)?.[0] || null;
584
782
  }
585
783
  index.indexedFileCache.set(key, source);
586
- 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);
587
786
  return source;
588
787
  }
589
788
 
@@ -605,20 +804,29 @@ function indexedAncestor(index, ancestor, descendant) {
605
804
  pending.push(...(index.parentsByCommit.get(commit) || []));
606
805
  }
607
806
  index.ancestryCache.set(key, result);
608
- 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);
609
809
  return result;
610
810
  }
611
811
 
612
812
  export function isGitAncestor(input, ancestor, descendant) {
613
- 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;
614
818
  const root = resolveWorkspaceRoot(input);
615
819
  try {
616
- observedExecFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
617
- cwd: root,
618
- stdio: "ignore",
619
- timeout: 10_000,
620
- env: gitEnvironment()
621
- });
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
+ );
622
830
  return true;
623
831
  } catch (error) {
624
832
  rethrowGitDeadline(error);
@@ -641,19 +849,26 @@ export function isDataHistoryAncestor(input, ancestor, descendant) {
641
849
  }
642
850
 
643
851
  export function getFileBufferAtRevision(input, revision, relativePath) {
644
- 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;
645
857
  const root = resolveWorkspaceRoot(input);
646
858
  try {
647
859
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
648
860
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
649
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
650
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
651
- 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}`], {
652
867
  cwd: root,
653
868
  stdio: ["ignore", "pipe", "ignore"],
654
869
  timeout: 10_000,
655
870
  maxBuffer: 20_000_000,
656
- env: gitEnvironment()
871
+ env: gitEnvironment(),
657
872
  });
658
873
  } catch (error) {
659
874
  rethrowGitDeadline(error);
@@ -662,13 +877,20 @@ export function getFileBufferAtRevision(input, revision, relativePath) {
662
877
  }
663
878
 
664
879
  export function getFileObjectIdAtRevision(input, revision, relativePath) {
665
- 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;
666
885
  const root = resolveWorkspaceRoot(input);
667
886
  try {
668
887
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
669
888
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
670
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
671
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
889
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
890
+ return null;
891
+ const repositoryPath = workspacePrefix
892
+ ? `${workspacePrefix}/${relativePath}`
893
+ : relativePath;
672
894
  const objectId = git(root, ["rev-parse", `${revision}:${repositoryPath}`]);
673
895
  return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
674
896
  } catch (error) {
@@ -680,16 +902,70 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
680
902
  export function getWorkingFileObjectId(input, relativePath) {
681
903
  if (!isSafeDataGitPath(relativePath)) return null;
682
904
  const root = resolveWorkspaceRoot(input);
905
+ let descriptor;
683
906
  try {
684
- const objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
685
- return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
907
+ const objectFormat = repositoryObjectFormat(root);
908
+ if (!objectFormat) return null;
909
+ descriptor = openSync(
910
+ resolve(root, relativePath),
911
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
912
+ );
913
+ const before = fstatSync(descriptor);
914
+ if (!before.isFile()) return null;
915
+ const hash = createHash(objectFormat).update(`blob ${before.size}\0`);
916
+ const buffer = Buffer.allocUnsafe(64 * 1024);
917
+ let position = 0;
918
+ while (position < before.size) {
919
+ const count = readSync(
920
+ descriptor,
921
+ buffer,
922
+ 0,
923
+ Math.min(buffer.length, before.size - position),
924
+ position,
925
+ );
926
+ if (!count) return null;
927
+ hash.update(buffer.subarray(0, count));
928
+ position += count;
929
+ }
930
+ const after = fstatSync(descriptor);
931
+ if (
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;
939
+ return hash.digest("hex");
686
940
  } catch (error) {
687
941
  rethrowGitDeadline(error);
688
942
  return null;
943
+ } finally {
944
+ if (descriptor !== undefined) closeSync(descriptor);
689
945
  }
690
946
  }
691
947
 
692
- export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
948
+ function repositoryObjectFormat(root) {
949
+ if (repositoryObjectFormats.has(root))
950
+ return repositoryObjectFormats.get(root);
951
+ if (!tryGit(root, ["rev-parse", "--git-dir"])) return null;
952
+ const objectFormat =
953
+ tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
954
+ if (!["sha1", "sha256"].includes(objectFormat)) {
955
+ throw new Error(
956
+ `FileGRC does not support the repository object format "${objectFormat}".`,
957
+ );
958
+ }
959
+ repositoryObjectFormats.set(root, objectFormat);
960
+ return objectFormat;
961
+ }
962
+
963
+ export function getWorkspaceHistories(
964
+ input,
965
+ relativePaths,
966
+ limitPerFile = 12,
967
+ options = {},
968
+ ) {
693
969
  const root = resolveWorkspaceRoot(input);
694
970
  const wanted = new Set(relativePaths);
695
971
  const histories = new Map([...wanted].map((path) => [path, []]));
@@ -698,16 +974,22 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
698
974
  if (options.deadlineAt === undefined) return undefined;
699
975
  const remaining = Math.ceil(options.deadlineAt - performance.now());
700
976
  if (remaining > 0) return remaining;
701
- 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
+ );
702
980
  error.code = "FILEGRC_GIT_DEADLINE";
703
981
  throw error;
704
982
  };
705
- const withinDeadline = (task) => options.deadlineAt === undefined
706
- ? task()
707
- : withGitCommandDeadline(options.deadlineAt, task);
983
+ const withinDeadline = (task) =>
984
+ options.deadlineAt === undefined
985
+ ? task()
986
+ : withGitCommandDeadline(options.deadlineAt, task);
708
987
  let head = null;
709
988
  try {
710
- head = withinDeadline(() => git(root, ["rev-parse", "HEAD"], { timeoutMs: remainingTime() })) || null;
989
+ head =
990
+ withinDeadline(() =>
991
+ git(root, ["rev-parse", "HEAD"], { timeoutMs: remainingTime() }),
992
+ ) || null;
711
993
  } catch (error) {
712
994
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
713
995
  head = null;
@@ -717,15 +999,29 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
717
999
  if (options.strict === true && cached.available === false) {
718
1000
  throw gitHistoryUnavailableError();
719
1001
  }
720
- 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) ?? []);
721
1004
  return histories;
722
1005
  }
723
1006
  const allHistories = new Map();
724
1007
  let available = true;
725
1008
  try {
726
- const output = withinDeadline(() => git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"], {
727
- timeoutMs: remainingTime()
728
- }));
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
+ );
729
1025
  for (const block of output.split("\x1e")) {
730
1026
  const lines = block.trim().split("\n").filter(Boolean);
731
1027
  if (lines.length < 2) continue;
@@ -744,13 +1040,22 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, o
744
1040
  }
745
1041
  // Browser and workflow views tolerate an uncommitted workspace with no history yet.
746
1042
  }
747
- 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
+ });
748
1050
  for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
749
1051
  return histories;
750
1052
  }
751
1053
 
752
1054
  function gitHistoryUnavailableError(cause) {
753
- 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
+ );
754
1059
  error.code = "FILEGRC_GIT_HISTORY_UNAVAILABLE";
755
1060
  return error;
756
1061
  }
@@ -758,7 +1063,11 @@ function gitHistoryUnavailableError(cause) {
758
1063
  export function getFileAtRevision(input, revision, relativePath) {
759
1064
  const root = resolveWorkspaceRoot(input);
760
1065
  historicalRevisionReadObserver?.({ root, revision, relativePath });
761
- const indexed = indexedDataFile(dataRecordHistoryIndexCache.get(root), revision, relativePath);
1066
+ const indexed = indexedDataFile(
1067
+ dataRecordHistoryIndexCache.get(root),
1068
+ revision,
1069
+ relativePath,
1070
+ );
762
1071
  return indexed === undefined
763
1072
  ? getFilesAtRevisions(root, [{ revision, relativePath }])[0]
764
1073
  : indexed;
@@ -767,30 +1076,47 @@ export function getFileAtRevision(input, revision, relativePath) {
767
1076
  export function getFilesAtRevisions(input, requests, options = {}) {
768
1077
  const root = resolveWorkspaceRoot(input);
769
1078
  const hasDeadline = options.deadline !== undefined;
770
- 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;
771
1082
  if (!Number.isFinite(deadline) || deadline <= performance.now()) {
772
- 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
+ );
773
1086
  error.code = "FILEGRC_HISTORY_DEADLINE";
774
1087
  throw error;
775
1088
  }
776
1089
  const remainingTime = () => {
777
1090
  const remainingMs = Math.floor(deadline - performance.now());
778
1091
  if (remainingMs <= 0) {
779
- 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
+ );
780
1095
  error.code = "FILEGRC_HISTORY_DEADLINE";
781
1096
  throw error;
782
1097
  }
783
1098
  return remainingMs;
784
1099
  };
785
- const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
786
- !/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
787
- ));
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
+ );
788
1107
  if (!Array.isArray(requests) || invalid) {
789
- 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
+ );
790
1111
  }
791
- 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;
792
1116
  if (maxRequests && requests.length > maxRequests) {
793
- 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
+ );
794
1120
  error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
795
1121
  throw error;
796
1122
  }
@@ -807,54 +1133,84 @@ export function getFilesAtRevisions(input, requests, options = {}) {
807
1133
  missing.push({ ...request, index, key });
808
1134
  }
809
1135
  });
810
- const maxTotalBytes = Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
1136
+ const maxTotalBytes =
1137
+ Number(options.maxTotalBytes) > 0 ? Number(options.maxTotalBytes) : null;
811
1138
  const cachedBytes = maxTotalBytes
812
- ? 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
+ )
813
1145
  : 0;
814
- if (maxTotalBytes && cachedBytes > maxTotalBytes) throw historicalExportLimitError(maxTotalBytes);
1146
+ if (maxTotalBytes && cachedBytes > maxTotalBytes)
1147
+ throw historicalExportLimitError(maxTotalBytes);
815
1148
  if (!missing.length) return results;
816
1149
  try {
817
- const topLevel = git(root, ["rev-parse", "--show-toplevel"], { timeoutMs: remainingTime() });
1150
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"], {
1151
+ timeoutMs: remainingTime(),
1152
+ });
818
1153
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
819
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
1154
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
1155
+ return requests.map(() => null);
820
1156
  const missingSpecifications = missing.map(({ revision, relativePath }) => {
821
- const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
1157
+ const repositoryPath = workspacePrefix
1158
+ ? `${workspacePrefix}/${relativePath}`
1159
+ : relativePath;
822
1160
  return `${revision}:${repositoryPath}`;
823
1161
  });
824
1162
  if (maxTotalBytes) {
825
- assertHistoricalExportSize(root, missingSpecifications, maxTotalBytes - cachedBytes, maxTotalBytes, remainingTime());
1163
+ assertHistoricalExportSize(
1164
+ root,
1165
+ missingSpecifications,
1166
+ maxTotalBytes - cachedBytes,
1167
+ maxTotalBytes,
1168
+ remainingTime(),
1169
+ );
826
1170
  }
827
- 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
+ );
828
1175
  for (let offset = 0; offset < missing.length; offset += batchSize) {
829
1176
  const batch = missing.slice(offset, offset + batchSize);
830
- const specifications = missingSpecifications.slice(offset, offset + batchSize);
1177
+ const specifications = missingSpecifications.slice(
1178
+ offset,
1179
+ offset + batchSize,
1180
+ );
831
1181
  let values;
832
1182
  try {
833
1183
  const run = () => {
834
1184
  const remainingMs = remainingTime();
835
- return observedExecFileSync("git", ["cat-file", "--batch"], {
1185
+ return executeGitSync(root, ["cat-file", "--batch"], {
836
1186
  cwd: root,
837
1187
  input: `${specifications.join("\n")}\n`,
838
1188
  stdio: ["pipe", "pipe", "ignore"],
839
1189
  timeout: remainingMs,
840
1190
  maxBuffer: 80_000_000,
841
- env: gitEnvironment()
1191
+ env: gitEnvironment(),
842
1192
  });
843
1193
  };
844
- const output = measureTimingSync("git-history-export", () => (
845
- historicalBatchInterceptor ? historicalBatchInterceptor({ root, specifications, run }) : run()
846
- ));
1194
+ const output = measureTimingSync("git-history-export", () =>
1195
+ historicalBatchInterceptor
1196
+ ? historicalBatchInterceptor({ root, specifications, run })
1197
+ : run(),
1198
+ );
847
1199
  values = parseBatchObjects(output, batch.length);
848
1200
  } catch (cause) {
849
1201
  rethrowGitDeadline(cause);
850
- 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
+ );
851
1206
  error.code = "FILEGRC_HISTORY_BATCH_FAILED";
852
1207
  throw error;
853
1208
  }
854
1209
  batch.forEach(({ index, key }, batchIndex) => {
855
1210
  const value = values[batchIndex];
856
1211
  results[index] = value;
857
- 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);
858
1214
  });
859
1215
  while (historicalFileCache.size > 2_048) {
860
1216
  historicalFileCache.delete(historicalFileCache.keys().next().value);
@@ -862,7 +1218,15 @@ export function getFilesAtRevisions(input, requests, options = {}) {
862
1218
  }
863
1219
  return results;
864
1220
  } catch (error) {
865
- 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;
866
1230
  return results.map((value) => value ?? null);
867
1231
  }
868
1232
  }
@@ -875,35 +1239,49 @@ export function setHistoricalBatchInterceptorForTests(interceptor) {
875
1239
  };
876
1240
  }
877
1241
 
878
- function assertHistoricalExportSize(root, specifications, remainingBytes, maxTotalBytes, timeoutMs = GIT_DEFAULT_TIMEOUT_MS) {
879
- const output = measureTimingSync("git-history-size", () => observedExecFileSync("git", [
880
- "cat-file",
881
- "--batch-check=%(objectname) %(objecttype) %(objectsize)"
882
- ], {
883
- cwd: root,
884
- input: `${specifications.join("\n")}\n`,
885
- encoding: "utf8",
886
- stdio: ["pipe", "pipe", "ignore"],
887
- timeout: timeoutMs,
888
- maxBuffer: 20_000_000,
889
- env: gitEnvironment()
890
- }));
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
+ );
891
1264
  let total = 0;
892
1265
  for (const line of output.trim().split("\n")) {
893
1266
  if (!line || line.endsWith(" missing")) continue;
894
1267
  const match = line.match(/^[a-f0-9]+ blob (\d+)$/i);
895
- if (!match) throw new Error("Git returned invalid historical object metadata.");
1268
+ if (!match)
1269
+ throw new Error("Git returned invalid historical object metadata.");
896
1270
  total += Number(match[1]);
897
- if (!Number.isSafeInteger(total) || total > remainingBytes) throw historicalExportLimitError(maxTotalBytes);
1271
+ if (!Number.isSafeInteger(total) || total > remainingBytes)
1272
+ throw historicalExportLimitError(maxTotalBytes);
898
1273
  }
899
1274
  }
900
1275
 
901
1276
  function historicalExportLimitError(maxTotalBytes) {
902
1277
  const megabytes = maxTotalBytes / (1024 * 1024);
903
- const limit = Number.isInteger(megabytes) && megabytes >= 1
904
- ? `${megabytes} MB`
905
- : `${maxTotalBytes} byte`;
906
- const error = new Error(`Git historical file exports exceed the ${limit} safety limit.`);
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
+ );
907
1285
  error.code = "FILEGRC_HISTORY_EXPORT_LIMIT";
908
1286
  return error;
909
1287
  }
@@ -927,7 +1305,9 @@ export function getDataFilesAtRevision(input, revision) {
927
1305
  }
928
1306
  const files = new Set();
929
1307
  for (let position = lineage.length - 1; position >= 0; position -= 1) {
930
- for (const [path, source] of index.fileChangesByCommit.get(lineage[position]) || []) {
1308
+ for (const [path, source] of index.fileChangesByCommit.get(
1309
+ lineage[position],
1310
+ ) || []) {
931
1311
  if (source === null) files.delete(path);
932
1312
  else files.add(path);
933
1313
  }
@@ -937,11 +1317,18 @@ export function getDataFilesAtRevision(input, revision) {
937
1317
  try {
938
1318
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
939
1319
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
940
- if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
1320
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../"))
1321
+ return [];
941
1322
  const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
942
- return lines(git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]))
943
- .filter((path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"))
944
- .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
+ );
945
1332
  } catch (error) {
946
1333
  rethrowGitDeadline(error);
947
1334
  return [];
@@ -950,14 +1337,16 @@ export function getDataFilesAtRevision(input, revision) {
950
1337
 
951
1338
  function readHistoricalFile(root, specification) {
952
1339
  try {
953
- return measureTimingSync("git-history-export", () => observedExecFileSync("git", ["show", specification], {
954
- cwd: root,
955
- encoding: "utf8",
956
- stdio: ["ignore", "pipe", "ignore"],
957
- timeout: 10_000,
958
- maxBuffer: 20_000_000,
959
- env: gitEnvironment()
960
- }));
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
+ );
961
1350
  } catch (error) {
962
1351
  rethrowGitDeadline(error);
963
1352
  return null;
@@ -969,7 +1358,8 @@ function parseBatchObjects(output, expected) {
969
1358
  let offset = 0;
970
1359
  for (let index = 0; index < expected; index += 1) {
971
1360
  const headerEnd = output.indexOf(10, offset);
972
- 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.");
973
1363
  const header = output.subarray(offset, headerEnd).toString("utf8");
974
1364
  offset = headerEnd + 1;
975
1365
  if (header.endsWith(" missing")) {
@@ -977,31 +1367,57 @@ function parseBatchObjects(output, expected) {
977
1367
  continue;
978
1368
  }
979
1369
  const size = Number(header.split(" ").at(-1));
980
- 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
+ ) {
981
1375
  throw new Error("Git returned an invalid historical object size.");
982
1376
  }
983
1377
  results.push(output.subarray(offset, offset + size).toString("utf8"));
984
1378
  offset += size;
985
- 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.");
986
1381
  }
987
1382
  return results;
988
1383
  }
989
1384
 
990
1385
  function isSafeDataGitPath(value) {
991
- return isCanonicalDataPath(value)
992
- && !/[\r\n]/.test(value)
993
- && value.startsWith("data/")
994
- && value !== "data/";
1386
+ return (
1387
+ isCanonicalDataPath(value) &&
1388
+ !/[\r\n]/.test(value) &&
1389
+ value.startsWith("data/") &&
1390
+ value !== "data/"
1391
+ );
995
1392
  }
996
1393
 
997
1394
  export function getChangedDataPathsSinceRevision(input, revision) {
998
1395
  if (!/^[a-f0-9]{40}$/i.test(String(revision))) return null;
999
1396
  const root = resolveWorkspaceRoot(input);
1000
1397
  try {
1001
- return [...new Set([
1002
- ...lines(git(root, ["diff", "--name-only", "--relative", revision, "--", "data"])),
1003
- ...lines(git(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
1004
- ])].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/"));
1005
1421
  } catch (error) {
1006
1422
  rethrowGitDeadline(error);
1007
1423
  return null;
@@ -1023,11 +1439,15 @@ export function hasGitRevision(input, revision) {
1023
1439
  }
1024
1440
 
1025
1441
  export async function commitWorkspace(input, message) {
1026
- return serializeWorkspaceMutation(input, (root) => commitWorkspaceUnlocked(root, message));
1442
+ return serializeWorkspaceMutation(input, (root) =>
1443
+ commitWorkspaceUnlocked(root, message),
1444
+ );
1027
1445
  }
1028
1446
 
1029
1447
  export async function commitAndPushWorkspace(input, message) {
1030
- return serializeWorkspaceMutation(input, (root) => commitAndPushWorkspaceUnlocked(root, message));
1448
+ return serializeWorkspaceMutation(input, (root) =>
1449
+ commitAndPushWorkspaceUnlocked(root, message),
1450
+ );
1031
1451
  }
1032
1452
 
1033
1453
  export async function pullWorkspace(input = process.cwd()) {
@@ -1045,13 +1465,17 @@ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
1045
1465
  return repositorySnapshotPromises.get(root);
1046
1466
  }
1047
1467
  const snapshot = buildRepositorySnapshot(root).finally(() => {
1048
- if (repositorySnapshotPromises.get(root) === snapshot) repositorySnapshotPromises.delete(root);
1468
+ if (repositorySnapshotPromises.get(root) === snapshot)
1469
+ repositorySnapshotPromises.delete(root);
1049
1470
  });
1050
1471
  repositorySnapshotPromises.set(root, snapshot);
1051
1472
  return snapshot;
1052
1473
  }
1053
1474
 
1054
- export async function getRepositoryStateSignature(input = process.cwd(), options = {}) {
1475
+ export async function getRepositoryStateSignature(
1476
+ input = process.cwd(),
1477
+ options = {},
1478
+ ) {
1055
1479
  const root = resolveWorkspaceRoot(input);
1056
1480
  const timeout = { timeoutMs: options.timeoutMs };
1057
1481
  let status;
@@ -1060,25 +1484,26 @@ export async function getRepositoryStateSignature(input = process.cwd(), options
1060
1484
  let remotes;
1061
1485
  try {
1062
1486
  [status, gitDirectory, refs, remotes] = await Promise.all([
1063
- runGitCommand(root, [
1064
- "status",
1065
- "--porcelain=v2",
1066
- "--branch",
1067
- "-z",
1068
- "--untracked-files=all"
1069
- ], { ...timeout, operation: "verify repository state" }),
1487
+ runGitCommand(
1488
+ root,
1489
+ ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"],
1490
+ { ...timeout, operation: "verify repository state" },
1491
+ ),
1070
1492
  runGitCommand(root, ["rev-parse", "--absolute-git-dir"], {
1071
1493
  ...timeout,
1072
- operation: "locate repository state"
1494
+ operation: "locate repository state",
1073
1495
  }),
1074
1496
  runGitCommand(root, ["rev-parse", "--verify", "@{upstream}"], {
1075
1497
  ...timeout,
1076
- operation: "verify the upstream repository revision"
1498
+ operation: "verify the upstream repository revision",
1077
1499
  }).catch((error) => {
1078
1500
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1079
1501
  return "";
1080
1502
  }),
1081
- runGitCommand(root, ["remote", "-v"], { ...timeout, operation: "verify repository remotes" })
1503
+ runGitCommand(root, ["remote", "-v"], {
1504
+ ...timeout,
1505
+ operation: "verify repository remotes",
1506
+ }),
1082
1507
  ]);
1083
1508
  } catch (error) {
1084
1509
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1088,14 +1513,16 @@ export async function getRepositoryStateSignature(input = process.cwd(), options
1088
1513
  remotes = "";
1089
1514
  }
1090
1515
  const background = backgroundSynchronizations.get(root);
1091
- const backgroundState = background ? {
1092
- status: background.status,
1093
- commit: background.commit,
1094
- startedAt: background.startedAt ?? null,
1095
- finishedAt: background.finishedAt ?? null,
1096
- remotePushed: background.remotePushed === true,
1097
- error: background.error ?? null
1098
- } : null;
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;
1099
1526
  return createHash("sha256")
1100
1527
  .update(status)
1101
1528
  .update("\0")
@@ -1115,15 +1542,21 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1115
1542
  const root = resolveWorkspaceRoot(input);
1116
1543
  try {
1117
1544
  assertNoWorkspaceContentFilters(root);
1118
- const source = await measureTiming("repository-revision", () => runGitCommand(root, [
1119
- "status",
1120
- "--porcelain=v2",
1121
- "--branch",
1122
- "-z",
1123
- "--untracked-files=all",
1124
- "--",
1125
- "data"
1126
- ], { 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
+ );
1127
1560
  const parsed = parseWorkspaceRevision(source);
1128
1561
  return {
1129
1562
  available: true,
@@ -1132,7 +1565,7 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1132
1565
  branch: parsed.branch,
1133
1566
  clean: parsed.changePaths.length === 0,
1134
1567
  changes: parsed.changePaths,
1135
- workspaceChangePaths: parsed.changePaths
1568
+ workspaceChangePaths: parsed.changePaths,
1136
1569
  };
1137
1570
  } catch (error) {
1138
1571
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1143,11 +1576,13 @@ export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
1143
1576
  async function buildRepositorySnapshot(root) {
1144
1577
  let repositoryPaths;
1145
1578
  try {
1146
- repositoryPaths = await measureTiming("git-discovery", () => runGitCommand(
1147
- root,
1148
- ["rev-parse", "--show-toplevel", "--absolute-git-dir"],
1149
- { operation: "locate the repository" }
1150
- ));
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
+ );
1151
1586
  } catch (error) {
1152
1587
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1153
1588
  return unavailableSnapshot(error);
@@ -1156,42 +1591,61 @@ async function buildRepositorySnapshot(root) {
1156
1591
  try {
1157
1592
  assertNoWorkspaceContentFilters(root);
1158
1593
  const [status, remotes] = await Promise.all([
1159
- runGitCommand(topLevel, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], {
1160
- operation: "inspect repository status"
1161
- }),
1162
- 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" }),
1163
1602
  ]);
1164
1603
  const parsed = parsePorcelainV2(status, topLevel, root);
1165
1604
  const last = parsed.commit
1166
- ? parseLogLine(await runGitCommand(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"], {
1167
- operation: "read the latest commit"
1168
- }))
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
+ )
1169
1614
  : null;
1170
1615
  let upstreamCommit = null;
1171
1616
  let pendingCommits = [];
1172
1617
  let pendingCommitsFilegrcOnly = parsed.ahead === 0 ? true : null;
1173
1618
  if (parsed.upstream) {
1174
- upstreamCommit = (await runGitCommand(root, ["rev-parse", parsed.upstream], {
1175
- operation: `resolve upstream ${parsed.upstream}`
1176
- })).trim() || null;
1619
+ upstreamCommit =
1620
+ (
1621
+ await runGitCommand(root, ["rev-parse", parsed.upstream], {
1622
+ operation: `resolve upstream ${parsed.upstream}`,
1623
+ })
1624
+ ).trim() || null;
1177
1625
  }
1178
1626
  if (parsed.ahead > 0 && parsed.upstream) {
1179
- const pending = await runGitCommand(root, [
1180
- "log",
1181
- "--format=%x1e%H%x1f%s",
1182
- "--name-only",
1183
- `${parsed.upstream}..HEAD`
1184
- ], { 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
+ );
1185
1637
  const prefix = relative(topLevel, root).split(sep).join("/");
1186
1638
  const commits = parsePendingCommitPaths(pending);
1187
1639
  pendingCommits = commits.map(({ commit, subject }) => ({
1188
1640
  commit,
1189
1641
  shortCommit: commit.slice(0, 8),
1190
- subject
1642
+ subject,
1191
1643
  }));
1192
- pendingCommitsFilegrcOnly = commits.every(({ paths }) => (
1193
- paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix))
1194
- ));
1644
+ pendingCommitsFilegrcOnly = commits.every(
1645
+ ({ paths }) =>
1646
+ paths.length > 0 &&
1647
+ paths.every((path) => pathInsideWorkspace(path, prefix)),
1648
+ );
1195
1649
  }
1196
1650
  return {
1197
1651
  available: true,
@@ -1212,7 +1666,11 @@ async function buildRepositorySnapshot(root) {
1212
1666
  pendingCommits,
1213
1667
  pendingCommitsFilegrcOnly,
1214
1668
  lastCommit: last,
1215
- 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),
1216
1674
  };
1217
1675
  } catch (error) {
1218
1676
  if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
@@ -1226,17 +1684,23 @@ function unavailableSnapshot(error, extra = {}) {
1226
1684
  clean: null,
1227
1685
  changes: [],
1228
1686
  error,
1229
- message: error instanceof GitOperationError
1230
- ? error.message
1231
- : "Git history is unavailable. Commit the workspace to enable audit metadata.",
1232
- ...extra
1687
+ message:
1688
+ error instanceof GitOperationError
1689
+ ? error.message
1690
+ : "Git history is unavailable. Commit the workspace to enable audit metadata.",
1691
+ ...extra,
1233
1692
  };
1234
1693
  }
1235
1694
 
1236
- export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
1237
- 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);
1238
1701
  const config = await getRepositoryConfig(input);
1239
- const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
1702
+ const gitSummary =
1703
+ options.repositorySnapshot ?? (await getRepositorySnapshot(root));
1240
1704
  if (config.mode !== "trunk") {
1241
1705
  return {
1242
1706
  mode: "manual",
@@ -1252,8 +1716,10 @@ export async function getBrowserRepositoryState(input = process.cwd(), options =
1252
1716
  behind: null,
1253
1717
  pendingCommits: [],
1254
1718
  pendingCommitsFilegrcOnly: null,
1255
- lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
1256
- 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.",
1257
1723
  };
1258
1724
  }
1259
1725
 
@@ -1266,27 +1732,34 @@ export async function getBrowserRepositoryState(input = process.cwd(), options =
1266
1732
  writesAllowed: !options.readOnly,
1267
1733
  status: "not-synced",
1268
1734
  label: "Not synced",
1269
- 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.",
1270
1737
  };
1271
1738
  }
1272
1739
  return {
1273
1740
  ...details,
1274
1741
  developmentOverride: false,
1275
- writesAllowed: !options.readOnly && details.writesAllowed
1742
+ writesAllowed: !options.readOnly && details.writesAllowed,
1276
1743
  };
1277
1744
  }
1278
1745
 
1279
1746
  export async function runBrowserMutation(input, options, task) {
1280
1747
  return serializeWorkspaceMutation(input, async (root) => {
1281
1748
  const config = await getRepositoryConfig(root);
1282
- if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
1749
+ if (
1750
+ config.mode !== "trunk" ||
1751
+ options?.allowNonAuthoritativeWrites === true
1752
+ ) {
1283
1753
  return task(root);
1284
1754
  }
1285
1755
  return runTrunkMutationUnlocked(root, config, options, task);
1286
1756
  });
1287
1757
  }
1288
1758
 
1289
- export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
1759
+ export async function prefetchBrowserRemote(
1760
+ input = process.cwd(),
1761
+ options = {},
1762
+ ) {
1290
1763
  const root = resolveWorkspaceRoot(input);
1291
1764
  const key = `${root}\0${options.allowNonAuthoritativeWrites === true}`;
1292
1765
  if (browserRemotePrefetchPromises.has(key)) {
@@ -1294,7 +1767,8 @@ export async function prefetchBrowserRemote(input = process.cwd(), options = {})
1294
1767
  return browserRemotePrefetchPromises.get(key);
1295
1768
  }
1296
1769
  const prefetch = prefetchBrowserRemoteCoalesced(root, options).finally(() => {
1297
- if (browserRemotePrefetchPromises.get(key) === prefetch) browserRemotePrefetchPromises.delete(key);
1770
+ if (browserRemotePrefetchPromises.get(key) === prefetch)
1771
+ browserRemotePrefetchPromises.delete(key);
1298
1772
  });
1299
1773
  browserRemotePrefetchPromises.set(key, prefetch);
1300
1774
  return prefetch;
@@ -1303,11 +1777,20 @@ export async function prefetchBrowserRemote(input = process.cwd(), options = {})
1303
1777
  async function prefetchBrowserRemoteCoalesced(root, options) {
1304
1778
  const prepared = await serializeWorkspaceMutation(root, async () => {
1305
1779
  const config = await getRepositoryConfig(root);
1306
- if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) return { config, repository: null };
1307
- 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
+ );
1308
1785
  return { config, repository };
1309
1786
  });
1310
- 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
+ };
1311
1794
 
1312
1795
  // Fetch updates only remote-tracking refs, so it does not occupy the source mutation queue.
1313
1796
  await fetchConfiguredRemote(root, prepared.config);
@@ -1315,7 +1798,9 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1315
1798
  const summary = await getRepositorySnapshot(root, { fresh: true });
1316
1799
  if (!summary.available) throw summary.error;
1317
1800
  if (summary.commit !== prepared.repository.currentCommit) {
1318
- 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
+ );
1319
1804
  }
1320
1805
  const repository = inspectTrunkRepository(root, prepared.config, summary);
1321
1806
  const fetchedAt = new Date().toISOString();
@@ -1325,13 +1810,15 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1325
1810
  remote: prepared.config.remote,
1326
1811
  currentCommit: summary.commit,
1327
1812
  upstreamCommit: repository.upstreamCommit,
1328
- fetchedAt: Date.parse(fetchedAt)
1813
+ fetchedAt: Date.parse(fetchedAt),
1329
1814
  });
1330
1815
  return {
1331
1816
  status: "checked",
1332
1817
  token,
1333
1818
  fetchedAt,
1334
- 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(),
1335
1822
  };
1336
1823
  });
1337
1824
  }
@@ -1339,37 +1826,72 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
1339
1826
  export async function retryBrowserSync(input = process.cwd(), options = {}) {
1340
1827
  return serializeWorkspaceMutation(input, async (root) => {
1341
1828
  const config = await getRepositoryConfig(root);
1342
- 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.");
1343
1831
  if (backgroundSynchronizations.get(root)?.status === "syncing") {
1344
- 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
+ );
1345
1835
  }
1346
1836
  if (options.allowNonAuthoritativeWrites === true) {
1347
- 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
+ );
1348
1840
  }
1349
- const before = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
1841
+ const before = await requireTrunkPreconditionsAsync(root, config, {
1842
+ allowAhead: true,
1843
+ });
1350
1844
  await fetchConfiguredRemote(root, config);
1351
- 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
+ );
1352
1850
  if (synchronized.behind > 0 && synchronized.ahead > 0) {
1353
- 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
+ );
1354
1854
  }
1355
1855
  if (synchronized.behind > 0) {
1356
- 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
+ );
1357
1859
  }
1358
- 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
+ );
1359
1865
  if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
1360
- 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
+ );
1361
1869
  }
1362
1870
  if (ready.ahead > 0) {
1363
- 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
+ );
1364
1877
  if (pushed.trackingError) {
1365
- 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
+ };
1366
1882
  recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
1367
1883
  throw new Error(pushed.trackingError);
1368
1884
  }
1369
1885
  }
1370
- 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
+ );
1371
1891
  if (after.ahead !== 0 || after.behind !== 0) {
1372
- 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
+ );
1373
1895
  }
1374
1896
  const synchronizedAt = new Date().toISOString();
1375
1897
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
@@ -1380,29 +1902,50 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
1380
1902
  branch: config.authoritativeBranch,
1381
1903
  upstream: after.upstream,
1382
1904
  synchronizedAt,
1383
- retriedCommits: before.ahead ?? 0
1905
+ retriedCommits: before.ahead ?? 0,
1384
1906
  };
1385
1907
  });
1386
1908
  }
1387
1909
 
1388
1910
  async function runTrunkMutationUnlocked(root, config, options, task) {
1389
- const beforeFetch = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
1911
+ const beforeFetch = await measureTiming("git-preconditions", () =>
1912
+ requireTrunkPreconditionsAsync(root, config),
1913
+ );
1390
1914
  let synchronized = beforeFetch;
1391
- if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
1915
+ if (
1916
+ !consumeFreshBrowserRemotePrefetch(
1917
+ root,
1918
+ config,
1919
+ options?.prefetchToken,
1920
+ beforeFetch,
1921
+ )
1922
+ ) {
1392
1923
  await fetchConfiguredRemote(root, config);
1393
- synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
1924
+ synchronized = inspectTrunkRepository(
1925
+ root,
1926
+ config,
1927
+ await getRepositorySnapshot(root, { fresh: true }),
1928
+ );
1394
1929
  }
1395
1930
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
1396
- 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
+ );
1397
1934
  }
1398
1935
  if (synchronized.ahead > 0) {
1399
- 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
+ );
1400
1939
  }
1401
1940
  if (synchronized.behind > 0) {
1402
- 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
+ );
1403
1944
  }
1404
1945
  if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
1405
- 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
+ );
1406
1949
  }
1407
1950
 
1408
1951
  let result;
@@ -1410,93 +1953,162 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
1410
1953
  let validationProof;
1411
1954
  let validatedManifest;
1412
1955
  try {
1413
- result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root, {
1414
- repositorySnapshot: synchronized
1415
- })));
1416
- 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
+ );
1417
1968
  assertNoIgnoredAuthoritativeFiles(root);
1418
- const beforeValidation = workspaceByteManifest(root);
1419
- const validation = await validateWorkspace(root);
1969
+ const beforeValidation = measureTimingSync("workspace-manifest", () =>
1970
+ workspaceByteManifest(root),
1971
+ );
1972
+ const validation = await withGitCommandCache(new Map(), () =>
1973
+ validateWorkspace(root),
1974
+ );
1420
1975
  if (!validation.ok) {
1421
- 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
+ );
1422
1979
  }
1423
- validationProof = options?.includeValidationProof === false
1424
- ? null
1425
- : {
1426
- validation,
1427
- fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
1428
- };
1429
- validatedManifest = 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
+ );
1430
1994
  assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1431
- await assertNoOutsideWorktreeChangesAsync(root);
1995
+ await measureTiming("outside-worktree-check", () =>
1996
+ assertNoOutsideWorktreeChangesAsync(root),
1997
+ );
1432
1998
  } catch (error) {
1433
- 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
+ );
1434
2018
  }
1435
2019
 
1436
- const changed = Boolean(await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
1437
- operation: "check the FileGRC workspace change"
1438
- }));
2020
+ const changed = Boolean(
2021
+ await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
2022
+ operation: "check the FileGRC workspace change",
2023
+ }),
2024
+ );
1439
2025
  if (!changed && options?.allowNoChanges === true) {
1440
- return withValidationProof({
1441
- ...result,
1442
- synchronization: {
1443
- status: "unchanged",
1444
- commit: synchronized.currentCommit,
1445
- shortCommit: synchronized.currentCommit?.slice(0, 8) ?? null,
1446
- upstream: synchronized.upstream,
1447
- synchronizedAt: lastSuccessfulSynchronizations.get(root) ?? null,
1448
- pushError: null
1449
- }
1450
- }, 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
+ );
1451
2040
  }
1452
2041
  if (!changed) {
1453
- 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
+ );
1454
2045
  }
1455
2046
  assertValidGitIdentity(root);
1456
2047
  assertNoWorkspaceContentFilters(root);
1457
- await assertNoOutsideWorktreeChangesAsync(root);
2048
+ await measureTiming("outside-worktree-check", () =>
2049
+ assertNoOutsideWorktreeChangesAsync(root),
2050
+ );
1458
2051
  let commit;
1459
2052
  let indexReconciled;
1460
2053
  try {
1461
2054
  const expectedRef = `refs/heads/${config.authoritativeBranch}`;
1462
2055
  assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
1463
- validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1464
- ({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
1465
- expectedParent: synchronized.currentCommit,
1466
- expectedRef
1467
- })));
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
+ ));
1468
2065
  } catch (error) {
1469
- 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
+ );
1470
2069
  }
1471
2070
 
1472
- 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
+ };
1473
2076
  if (!indexReconciled) {
1474
- 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
+ );
1475
2080
  recordBackgroundPushFailure(root, committed, error);
1476
- 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
+ {
1477
2100
  ...result,
1478
2101
  synchronization: {
1479
- status: "not-synced",
2102
+ status: "syncing",
1480
2103
  commit: committed.commit,
1481
2104
  shortCommit: committed.shortCommit,
1482
2105
  upstream: synchronized.upstream,
1483
2106
  synchronizedAt: null,
1484
- pushError: backgroundSynchronizations.get(root)?.error ?? error.message
1485
- }
1486
- }, validationProof);
1487
- }
1488
- queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
1489
- return withValidationProof({
1490
- ...result,
1491
- synchronization: {
1492
- status: "syncing",
1493
- commit: committed.commit,
1494
- shortCommit: committed.shortCommit,
1495
- upstream: synchronized.upstream,
1496
- synchronizedAt: null,
1497
- pushError: null
1498
- }
1499
- }, validationProof);
2107
+ pushError: null,
2108
+ },
2109
+ },
2110
+ validationProof,
2111
+ );
1500
2112
  }
1501
2113
 
1502
2114
  function withValidationProof(result, proof) {
@@ -1510,16 +2122,17 @@ function consumeFreshBrowserRemotePrefetch(root, config, token, repository) {
1510
2122
  const prefetch = browserRemotePrefetches.get(root);
1511
2123
  browserRemotePrefetches.delete(root);
1512
2124
  if (
1513
- !token
1514
- || !prefetch
1515
- || prefetch.token !== token
1516
- || prefetch.remote !== config.remote
1517
- || 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
1518
2130
  ) {
1519
2131
  return false;
1520
2132
  }
1521
- const reusable = repository.currentCommit === prefetch.currentCommit
1522
- && repository.upstreamCommit === prefetch.upstreamCommit;
2133
+ const reusable =
2134
+ repository.currentCommit === prefetch.currentCommit &&
2135
+ repository.upstreamCommit === prefetch.upstreamCommit;
1523
2136
  if (reusable) recordTiming("fetch-reused", 0);
1524
2137
  return reusable;
1525
2138
  }
@@ -1530,22 +2143,28 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
1530
2143
  commit: committed.commit,
1531
2144
  shortCommit: committed.shortCommit,
1532
2145
  startedAt: new Date().toISOString(),
1533
- error: null
2146
+ error: null,
1534
2147
  });
1535
2148
  const start = async () => {
1536
2149
  try {
1537
2150
  const ready = await requireTrunkPreconditionsAsync(root, config, {
1538
2151
  allowAhead: true,
1539
- backgroundCommit: committed.commit
2152
+ backgroundCommit: committed.commit,
1540
2153
  });
1541
2154
  if (ready.currentCommit !== committed.commit) {
1542
- 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
+ );
1543
2158
  }
1544
2159
  if (ready.behind > 0) {
1545
- 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
+ );
1546
2163
  }
1547
2164
  if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
1548
- 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
+ );
1549
2168
  }
1550
2169
  await finishBackgroundPush(root, config, committed);
1551
2170
  } catch (error) {
@@ -1562,45 +2181,65 @@ async function finishBackgroundPush(root, config, committed) {
1562
2181
  let outcome = "failed";
1563
2182
  let remotePushed = false;
1564
2183
  try {
1565
- 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
+ );
1566
2190
  remotePushed = pushed.remotePushed;
1567
2191
  if (pushed.trackingError) {
1568
2192
  recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
1569
2193
  outcome = "pushed-tracking-stale";
1570
2194
  return;
1571
2195
  }
1572
- 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
+ );
1573
2202
  if (after.ahead !== 0 || after.behind !== 0) {
1574
- 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
+ );
1575
2206
  }
1576
2207
  const synchronizedAt = new Date().toISOString();
1577
2208
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
1578
2209
  deleteBackgroundSynchronizationIfCurrent(root, committed.commit);
1579
2210
  outcome = "synced";
1580
2211
  } catch (error) {
1581
- if (remotePushed) recordBackgroundRemoteSuccessFailure(root, committed, error.message);
2212
+ if (remotePushed)
2213
+ recordBackgroundRemoteSuccessFailure(root, committed, error.message);
1582
2214
  else recordBackgroundPushFailure(root, committed, error);
1583
2215
  } finally {
1584
2216
  if (timingEnabled()) {
1585
- console.error(`[filegrc timing] ${JSON.stringify({
1586
- operation: "background-sync",
1587
- push: { count: 1, durationMs: performance.now() - started },
1588
- outcome
1589
- })}`);
2217
+ console.error(
2218
+ `[filegrc timing] ${JSON.stringify({
2219
+ operation: "background-sync",
2220
+ push: { count: 1, durationMs: performance.now() - started },
2221
+ outcome,
2222
+ })}`,
2223
+ );
1590
2224
  }
1591
2225
  }
1592
2226
  }
1593
2227
 
1594
2228
  function recordBackgroundPushFailure(root, committed, error) {
1595
- setBackgroundSynchronizationIfCurrent(root, committed, {
1596
- status: "failed",
1597
- commit: committed.commit,
1598
- shortCommit: committed.shortCommit,
1599
- startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
1600
- finishedAt: new Date().toISOString(),
1601
- remotePushed: false,
1602
- error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`
1603
- }, { 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
+ );
1604
2243
  }
1605
2244
 
1606
2245
  function recordBackgroundTrackingFailure(root, committed, error) {
@@ -1608,24 +2247,34 @@ function recordBackgroundTrackingFailure(root, committed, error) {
1608
2247
  }
1609
2248
 
1610
2249
  function recordBackgroundRemoteSuccessFailure(root, committed, error) {
1611
- setBackgroundSynchronizationIfCurrent(root, committed, {
1612
- status: "failed",
1613
- commit: committed.commit,
1614
- shortCommit: committed.shortCommit,
1615
- startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
1616
- finishedAt: new Date().toISOString(),
1617
- remotePushed: true,
1618
- error: `${error} The remote accepted the exact FileGRC commit. Fetch with Git to reconcile local state; do not retry the push.`
1619
- }, { allowMissing: true });
1620
- }
1621
-
1622
- 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
+ ) {
1623
2272
  const current = backgroundSynchronizations.get(root);
1624
2273
  if (current && current.commit !== committed.commit) return false;
1625
2274
  if (!current && options.allowMissing !== true) return false;
1626
2275
  backgroundSynchronizations.set(root, {
1627
2276
  ...next,
1628
- remotePushed: current?.remotePushed === true || next.remotePushed === true
2277
+ remotePushed: current?.remotePushed === true || next.remotePushed === true,
1629
2278
  });
1630
2279
  return true;
1631
2280
  }
@@ -1637,61 +2286,86 @@ function deleteBackgroundSynchronizationIfCurrent(root, commit) {
1637
2286
 
1638
2287
  async function commitWorkspaceUnlocked(root, message) {
1639
2288
  const subject = String(message ?? "").trim();
1640
- if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
1641
- 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
+ );
1642
2297
  }
1643
2298
  assertNoWorkspaceContentFilters(root);
1644
2299
  const before = getGitSummary(root);
1645
- if (!before.available) throw new Error("Git history is unavailable for this workspace.");
1646
- 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.");
1647
2304
  assertNoGitOperationInProgress(root);
1648
2305
  if (before.clean) throw new Error("The workspace has no changes to commit.");
1649
- if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
1650
- 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
+ );
1651
2313
  }
1652
2314
  assertValidGitIdentity(root);
1653
2315
  assertNoIgnoredAuthoritativeFiles(root);
1654
2316
  const beforeValidation = workspaceByteManifest(root);
1655
2317
  const validation = await validateWorkspace(root);
1656
2318
  if (!validation.ok) {
1657
- 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
+ );
1658
2322
  }
1659
2323
  let validatedManifest = workspaceByteManifest(root);
1660
2324
  assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1661
2325
  const commitOptions = {
1662
2326
  expectedParent: before.commit,
1663
- expectedRef: `refs/heads/${before.branch}`
2327
+ expectedRef: `refs/heads/${before.branch}`,
1664
2328
  };
1665
- assertExpectedCheckout(root, commitOptions.expectedRef, commitOptions.expectedParent);
2329
+ assertExpectedCheckout(
2330
+ root,
2331
+ commitOptions.expectedRef,
2332
+ commitOptions.expectedParent,
2333
+ );
1666
2334
  validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1667
- const committed = await commitValidatedIndexAsync(root, subject, validatedManifest, commitOptions);
2335
+ const committed = await commitValidatedIndexAsync(
2336
+ root,
2337
+ subject,
2338
+ validatedManifest,
2339
+ commitOptions,
2340
+ );
1668
2341
  const after = getGitSummary(root);
1669
2342
  return {
1670
2343
  commit: committed.commit,
1671
2344
  shortCommit: committed.commit.slice(0, 8),
1672
2345
  indexReconciled: committed.indexReconciled,
1673
- subject: after.lastCommit?.subject || subject
2346
+ subject: after.lastCommit?.subject || subject,
1674
2347
  };
1675
2348
  }
1676
2349
 
1677
2350
  async function commitAndPushWorkspaceUnlocked(root, message) {
1678
2351
  const before = getGitSummary(root);
1679
- const target = before.upstream || before.remotes?.length
1680
- ? captureManualPushTarget(root, before)
1681
- : null;
2352
+ const target =
2353
+ before.upstream || before.remotes?.length
2354
+ ? captureManualPushTarget(root, before)
2355
+ : null;
1682
2356
  const committed = await commitWorkspaceUnlocked(root, message);
1683
2357
  if (!target) {
1684
2358
  return {
1685
2359
  ...committed,
1686
2360
  pushed: false,
1687
- pushSkipped: true
2361
+ pushSkipped: true,
1688
2362
  };
1689
2363
  }
1690
2364
  try {
1691
2365
  const pushed = await pushCapturedWorkspaceCommit(root, {
1692
2366
  before,
1693
2367
  target,
1694
- commit: committed.commit
2368
+ commit: committed.commit,
1695
2369
  });
1696
2370
  return {
1697
2371
  ...committed,
@@ -1699,14 +2373,14 @@ async function commitAndPushWorkspaceUnlocked(root, message) {
1699
2373
  pushSkipped: false,
1700
2374
  upstream: pushed.upstream,
1701
2375
  trackingConfigured: pushed.trackingConfigured,
1702
- trackingError: pushed.trackingError
2376
+ trackingError: pushed.trackingError,
1703
2377
  };
1704
2378
  } catch (error) {
1705
2379
  return {
1706
2380
  ...committed,
1707
2381
  pushed: false,
1708
2382
  pushSkipped: false,
1709
- pushError: error.message
2383
+ pushError: error.message,
1710
2384
  };
1711
2385
  }
1712
2386
  }
@@ -1715,21 +2389,35 @@ async function pullWorkspaceUnlocked(root) {
1715
2389
  const before = syncReadySummary(root, "pull");
1716
2390
  assertNoGitOperationInProgress(root);
1717
2391
  if (!before.upstream) {
1718
- 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
+ );
1719
2395
  }
1720
2396
  const remote = before.upstream.split("/")[0];
1721
2397
  const branch = before.upstream.slice(remote.length + 1);
1722
2398
  const url = assertSafeAutomaticTransport(root, remote);
1723
- gitForWrite(root, exactFetchArgs({ remote, branch, url }), `fetch ${remote} before checking incoming commits`);
1724
- const target = git(root, ["rev-parse", "--verify", `${before.upstream}^{commit}`]);
1725
- 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
+ );
1726
2412
  assertCommitsInsideWorkspace(root, incomingCommits);
1727
2413
  for (const commit of incomingCommits) {
1728
2414
  assertNoCommitWorkspaceContentFilters(root, commit);
1729
2415
  }
1730
2416
  assertExpectedCheckout(root, `refs/heads/${before.branch}`, before.commit);
1731
2417
  if (incomingCommits.length) {
1732
- 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
+ );
1733
2421
  }
1734
2422
  const after = getGitSummary(root);
1735
2423
  return {
@@ -1737,7 +2425,7 @@ async function pullWorkspaceUnlocked(root) {
1737
2425
  commit: after.commit,
1738
2426
  shortCommit: after.shortCommit,
1739
2427
  branch: after.branch,
1740
- upstream: after.upstream
2428
+ upstream: after.upstream,
1741
2429
  };
1742
2430
  }
1743
2431
 
@@ -1747,9 +2435,15 @@ async function pushWorkspaceUnlocked(root) {
1747
2435
  const target = captureManualPushTarget(root, before);
1748
2436
  const validation = await validateWorkspace(root);
1749
2437
  if (!validation.ok) {
1750
- 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
+ );
1751
2441
  }
1752
- return pushCapturedWorkspaceCommit(root, { before, target, commit: before.commit });
2442
+ return pushCapturedWorkspaceCommit(root, {
2443
+ before,
2444
+ target,
2445
+ commit: before.commit,
2446
+ });
1753
2447
  }
1754
2448
 
1755
2449
  async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
@@ -1757,14 +2451,23 @@ async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
1757
2451
  await gitForWriteAsync(root, exactPushArgs(target, commit), "push", {
1758
2452
  expectedCheckout: {
1759
2453
  expectedRef: `refs/heads/${before.branch}`,
1760
- expectedCommit: commit
1761
- }
2454
+ expectedCommit: commit,
2455
+ },
1762
2456
  });
1763
2457
  let trackingError = reconcileRemoteTracking(root, target, commit);
1764
2458
  if (!before.upstream && !trackingError) {
1765
2459
  try {
1766
2460
  assertExpectedCheckout(root, `refs/heads/${before.branch}`, commit);
1767
- 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
+ );
1768
2471
  } catch (error) {
1769
2472
  trackingError = error.message;
1770
2473
  }
@@ -1773,9 +2476,11 @@ async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
1773
2476
  commit,
1774
2477
  shortCommit: commit.slice(0, 8),
1775
2478
  branch: before.branch,
1776
- upstream: before.upstream || (trackingError ? null : `${target.remote}/${before.branch}`),
2479
+ upstream:
2480
+ before.upstream ||
2481
+ (trackingError ? null : `${target.remote}/${before.branch}`),
1777
2482
  trackingConfigured: Boolean(before.upstream) || !trackingError,
1778
- trackingError
2483
+ trackingError,
1779
2484
  };
1780
2485
  }
1781
2486
 
@@ -1783,13 +2488,27 @@ function captureManualPushTarget(root, before) {
1783
2488
  let remote;
1784
2489
  let destination;
1785
2490
  if (before.upstream) {
1786
- remote = tryGit(root, ["config", "--get", `branch.${before.branch}.remote`]);
1787
- 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
+ ]);
1788
2501
  const destinationBranch = destination?.startsWith("refs/heads/")
1789
2502
  ? destination.slice("refs/heads/".length)
1790
2503
  : "";
1791
- if (!isSafeGitName(remote) || !isSafeGitName(destinationBranch) || before.upstream !== `${remote}/${destinationBranch}`) {
1792
- 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
+ );
1793
2512
  }
1794
2513
  } else {
1795
2514
  remote = before.remotes.includes("origin")
@@ -1798,9 +2517,11 @@ function captureManualPushTarget(root, before) {
1798
2517
  ? before.remotes[0]
1799
2518
  : null;
1800
2519
  if (!remote) {
1801
- throw new Error(before.remotes.length
1802
- ? "This branch has no upstream and the repository has multiple remotes. Configure an upstream with Git."
1803
- : "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
+ );
1804
2525
  }
1805
2526
  destination = `refs/heads/${before.branch}`;
1806
2527
  }
@@ -1810,19 +2531,28 @@ function captureManualPushTarget(root, before) {
1810
2531
  destination,
1811
2532
  destinationBranch,
1812
2533
  trackingRef: `refs/remotes/${remote}/${destinationBranch}`,
1813
- expectedTrackingCommit: before.upstream ? tryGit(root, ["rev-parse", "--verify", before.upstream]) : null,
1814
- 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 }),
1815
2538
  };
1816
2539
  }
1817
2540
 
1818
2541
  function reconcileRemoteTracking(root, target, commit) {
1819
2542
  try {
1820
- gitForWrite(root, [
1821
- "update-ref", "-m", "FileGRC exact push",
1822
- target.trackingRef,
1823
- commit,
1824
- target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
1825
- ], "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
+ );
1826
2556
  return null;
1827
2557
  } catch (error) {
1828
2558
  return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
@@ -1832,24 +2562,43 @@ function reconcileRemoteTracking(root, target, commit) {
1832
2562
  function exactPushArgs(target, commit) {
1833
2563
  const expected = target.expectedTrackingCommit || "";
1834
2564
  return [
1835
- "-c", "push.pushOption=",
1836
- "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",
1837
2573
  `--force-with-lease=${target.destination}:${expected}`,
1838
- "--", target.url, `${commit}:${target.destination}`
2574
+ "--",
2575
+ target.url,
2576
+ `${commit}:${target.destination}`,
1839
2577
  ];
1840
2578
  }
1841
2579
 
1842
2580
  function syncReadySummary(root, action) {
1843
2581
  const summary = getGitSummary(root);
1844
- if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so filegrc cannot ${action}.`);
1845
- if (!summary.branch) throw new Error(`Check out a branch before trying to ${action}.`);
1846
- 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
+ );
1847
2592
  return summary;
1848
2593
  }
1849
2594
 
1850
2595
  async function getRepositoryConfig(input) {
1851
- const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
1852
- 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
+ );
1853
2602
  const mode = renderer?.repositoryMode;
1854
2603
  const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
1855
2604
  const remote = cleanGitName(renderer?.repositoryRemote);
@@ -1860,15 +2609,17 @@ async function getRepositoryConfig(input) {
1860
2609
  configurationError: !["trunk", "manual"].includes(mode)
1861
2610
  ? "Repository mode is missing or invalid. Run the model migration or update renderer settings."
1862
2611
  : !isSafeGitName(authoritativeBranch)
1863
- ? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
1864
- : !isSafeGitName(remote)
1865
- ? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
1866
- : 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,
1867
2616
  };
1868
2617
  }
1869
2618
 
1870
2619
  function inspectTrunkRepository(root, config, summary, options = {}) {
1871
- const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
2620
+ const background = options.ignoreBackground
2621
+ ? null
2622
+ : backgroundSynchronizations.get(root);
1872
2623
  const base = {
1873
2624
  mode: "trunk",
1874
2625
  authoritativeBranch: config.authoritativeBranch,
@@ -1880,34 +2631,44 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1880
2631
  behind: null,
1881
2632
  pendingCommits: [],
1882
2633
  pendingCommitsFilegrcOnly: null,
1883
- lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
2634
+ lastSuccessfulSynchronization:
2635
+ lastSuccessfulSynchronizations.get(root) ?? null,
1884
2636
  wholeWorktreeClean: summary.available ? summary.wholeWorktreeClean : null,
1885
2637
  operationInProgress: summary.available ? summary.operationInProgress : null,
1886
- backgroundSynchronization: background ? {
1887
- status: background.status,
1888
- commit: background.commit,
1889
- shortCommit: background.shortCommit,
1890
- startedAt: background.startedAt,
1891
- finishedAt: background.finishedAt ?? null,
1892
- remotePushed: background.remotePushed === true,
1893
- error: background.error
1894
- } : null,
1895
- 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,
1896
2650
  };
1897
2651
  if (config.configurationError) {
1898
2652
  return {
1899
2653
  ...base,
1900
2654
  status: "git-setup-required",
1901
2655
  label: "Git setup required",
1902
- message: config.configurationError
2656
+ message: config.configurationError,
1903
2657
  };
1904
2658
  }
1905
2659
  if (!summary.available) {
1906
2660
  return {
1907
2661
  ...base,
1908
- status: summary.error?.kind === "missing-executable" ? "git-setup-required" : "git-error",
1909
- label: summary.error?.kind === "missing-executable" ? "Git setup required" : "Git error",
1910
- 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.",
1911
2672
  };
1912
2673
  }
1913
2674
  if (summary.branch !== config.authoritativeBranch) {
@@ -1915,7 +2676,8 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1915
2676
  ...base,
1916
2677
  status: "read-only-checkout",
1917
2678
  label: "Read-only checkout",
1918
- 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.",
1919
2681
  };
1920
2682
  }
1921
2683
  if (!summary.remotes.includes(config.remote)) {
@@ -1923,7 +2685,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1923
2685
  ...base,
1924
2686
  status: "git-setup-required",
1925
2687
  label: "Git setup required",
1926
- 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.`,
1927
2689
  };
1928
2690
  }
1929
2691
  const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
@@ -1932,7 +2694,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1932
2694
  ...base,
1933
2695
  status: "git-setup-required",
1934
2696
  label: "Git setup required",
1935
- 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.`,
1936
2698
  };
1937
2699
  }
1938
2700
  const upstreamCommit = summary.upstreamCommit;
@@ -1945,14 +2707,14 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1945
2707
  ahead: counts.ahead,
1946
2708
  behind: counts.behind,
1947
2709
  pendingCommits,
1948
- pendingCommitsFilegrcOnly
2710
+ pendingCommitsFilegrcOnly,
1949
2711
  };
1950
2712
  if (base.operationInProgress) {
1951
2713
  return {
1952
2714
  ...details,
1953
2715
  status: "not-synced",
1954
2716
  label: "Not synced",
1955
- 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.`,
1956
2718
  };
1957
2719
  }
1958
2720
  if (!base.wholeWorktreeClean) {
@@ -1960,17 +2722,21 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1960
2722
  ...details,
1961
2723
  status: "not-synced",
1962
2724
  label: "Not synced",
1963
- 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.",
1964
2727
  };
1965
2728
  }
1966
- if (background?.status === "syncing" && background.commit === summary.commit) {
2729
+ if (
2730
+ background?.status === "syncing" &&
2731
+ background.commit === summary.commit
2732
+ ) {
1967
2733
  return {
1968
2734
  ...details,
1969
2735
  status: "syncing",
1970
2736
  label: "Syncing",
1971
2737
  message: `The FileGRC commit ${background.shortCommit} is saved locally and is being pushed to ${expectedUpstream}.`,
1972
2738
  writesAllowed: false,
1973
- retrySafe: false
2739
+ retrySafe: false,
1974
2740
  };
1975
2741
  }
1976
2742
  if (counts.ahead === null || counts.behind === null) {
@@ -1978,34 +2744,38 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1978
2744
  ...details,
1979
2745
  status: "git-setup-required",
1980
2746
  label: "Git setup required",
1981
- 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.`,
1982
2748
  };
1983
2749
  }
1984
2750
  if (counts.ahead > 0 || counts.behind > 0) {
1985
2751
  const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
1986
- const backgroundFailure = background?.status === "failed"
1987
- && background.commit === summary.commit
1988
- && (background.remotePushed === true || (
1989
- counts.ahead > 0
1990
- && counts.behind === 0
1991
- && pendingCommitsFilegrcOnly
1992
- ))
1993
- ? background.error
1994
- : 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;
1995
2759
  return {
1996
2760
  ...details,
1997
2761
  status: "not-synced",
1998
2762
  label: "Not synced",
1999
- message: backgroundFailure || (external
2000
- ? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
2001
- : counts.ahead > 0 && counts.behind > 0
2002
- ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
2003
- : counts.ahead > 0
2004
- ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
2005
- : "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."),
2006
2772
  writesAllowed: false,
2007
- retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly && background?.remotePushed !== true,
2008
- backgroundSyncError: backgroundFailure
2773
+ retrySafe:
2774
+ counts.ahead > 0 &&
2775
+ counts.behind === 0 &&
2776
+ pendingCommitsFilegrcOnly &&
2777
+ background?.remotePushed !== true,
2778
+ backgroundSyncError: backgroundFailure,
2009
2779
  };
2010
2780
  }
2011
2781
  return {
@@ -2014,7 +2784,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
2014
2784
  label: "Synced",
2015
2785
  message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
2016
2786
  writesAllowed: true,
2017
- retrySafe: false
2787
+ retrySafe: false,
2018
2788
  };
2019
2789
  }
2020
2790
 
@@ -2023,58 +2793,99 @@ async function requireTrunkPreconditionsAsync(root, config, options = {}) {
2023
2793
  const state = inspectTrunkRepository(root, config, summary);
2024
2794
  if (config.configurationError) throw new Error(state.message);
2025
2795
  if (!summary.available) throw summary.error || new Error(state.message);
2026
- if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
2796
+ if (summary.branch !== config.authoritativeBranch)
2797
+ throw new Error(state.message);
2027
2798
  if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
2028
- 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);
2029
2801
  if (
2030
- state.backgroundSynchronization?.status === "syncing"
2031
- && state.backgroundSynchronization.commit !== options.backgroundCommit
2802
+ state.backgroundSynchronization?.status === "syncing" &&
2803
+ state.backgroundSynchronization.commit !== options.backgroundCommit
2032
2804
  ) {
2033
- 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
+ );
2034
2808
  }
2035
2809
  if (state.operationInProgress) throw new Error(state.message);
2036
2810
  if (!state.wholeWorktreeClean) throw new Error(state.message);
2811
+ assertNoIgnoredAuthoritativeFiles(root);
2037
2812
  if (!options.allowAhead && state.ahead > 0) {
2038
- 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
+ );
2039
2816
  }
2040
2817
  return state;
2041
2818
  }
2042
2819
 
2043
2820
  async function fetchConfiguredRemote(root, config) {
2044
2821
  const url = assertSafeAutomaticTransport(root, config.remote);
2045
- return measureTiming("fetch", () => gitForWriteAsync(
2046
- root,
2047
- exactFetchArgs({ remote: config.remote, branch: config.authoritativeBranch, url }),
2048
- `fetch ${config.remote}`
2049
- ));
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
+ );
2050
2833
  }
2051
2834
 
2052
2835
  function exactFetchArgs({ remote, branch, url }) {
2053
2836
  return [
2054
- "fetch", "--prune", "--no-prune-tags", "--no-tags", "--recurse-submodules=no",
2055
- "--", 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}`,
2056
2845
  ];
2057
2846
  }
2058
2847
 
2059
2848
  function assertCommitsInsideWorkspace(root, commits) {
2060
2849
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
2061
2850
  const prefix = relative(topLevel, root).split(sep).join("/");
2062
- 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.");
2063
2853
  if (!prefix) return;
2064
2854
  for (const commit of commits) {
2065
- const changed = nulFields(gitRaw(topLevel, [
2066
- "diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-m", "-r", commit, "--"
2067
- ]));
2068
- 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
+ );
2069
2871
  if (outside.length) {
2070
- 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
+ );
2071
2875
  }
2072
2876
  }
2073
2877
  }
2074
2878
 
2075
- async function pushConfiguredBranch(root, config, source, expectedTrackingCommit) {
2879
+ async function pushConfiguredBranch(
2880
+ root,
2881
+ config,
2882
+ source,
2883
+ expectedTrackingCommit,
2884
+ ) {
2076
2885
  if (!/^[a-f0-9]{40}$/i.test(source)) {
2077
- 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
+ );
2078
2889
  }
2079
2890
  const url = assertSafeAutomaticTransport(root, config.remote, { push: true });
2080
2891
  const target = {
@@ -2082,31 +2893,44 @@ async function pushConfiguredBranch(root, config, source, expectedTrackingCommit
2082
2893
  url,
2083
2894
  destination: `refs/heads/${config.authoritativeBranch}`,
2084
2895
  trackingRef: `refs/remotes/${config.remote}/${config.authoritativeBranch}`,
2085
- expectedTrackingCommit
2896
+ expectedTrackingCommit,
2086
2897
  };
2087
- 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(
2088
2912
  root,
2089
- exactPushArgs(target, source),
2090
- `push ${config.authoritativeBranch} to ${config.remote}`,
2091
- {
2092
- expectedCheckout: {
2093
- expectedRef: `refs/heads/${config.authoritativeBranch}`,
2094
- expectedCommit: source
2095
- }
2096
- }
2097
- ));
2098
- const trackingError = await reconcileRemoteTrackingAsync(root, target, source);
2913
+ target,
2914
+ source,
2915
+ );
2099
2916
  return { remotePushed: true, trackingError };
2100
2917
  }
2101
2918
 
2102
2919
  async function reconcileRemoteTrackingAsync(root, target, commit) {
2103
2920
  try {
2104
- await gitForWriteAsync(root, [
2105
- "update-ref", "-m", "FileGRC exact push",
2106
- target.trackingRef,
2107
- commit,
2108
- target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
2109
- ], "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
+ );
2110
2934
  return null;
2111
2935
  } catch (error) {
2112
2936
  return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
@@ -2115,32 +2939,52 @@ async function reconcileRemoteTrackingAsync(root, target, commit) {
2115
2939
 
2116
2940
  function assertSafeAutomaticTransport(root, remote, options = {}) {
2117
2941
  assertNoRepositoryExecutableGitConfig(root);
2118
- const url = tryGit(root, ["remote", "get-url", ...(options.push ? ["--push"] : []), "--", remote]);
2119
- const safe = url
2120
- && !/[\0\r\n]/.test(url)
2121
- && (
2122
- /^(?:https?|ssh|file):\/\//i.test(url)
2123
- || /^(?:\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(url)
2124
- || /^[^\s/@:]+@[^\s/:]+:.+$/.test(url)
2125
- );
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));
2126
2955
  if (!safe) {
2127
- 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
+ );
2128
2959
  }
2129
2960
  return url;
2130
2961
  }
2131
2962
 
2132
2963
  function assertNoRepositoryExecutableGitConfig(root) {
2133
2964
  assertWorkspaceInsideGitWorktree(root);
2134
- 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)$";
2135
2967
  const scopes = ["--local"];
2136
- 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
+ ) {
2137
2977
  scopes.push("--worktree");
2138
2978
  }
2139
2979
  const executableConfiguration = scopes
2140
- .map((scope) => gitOptionalMatch(root, ["config", scope, "--get-regexp", pattern]))
2980
+ .map((scope) =>
2981
+ gitOptionalMatch(root, ["config", scope, "--get-regexp", pattern]),
2982
+ )
2141
2983
  .find(Boolean);
2142
2984
  if (executableConfiguration) {
2143
- 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
+ );
2144
2988
  }
2145
2989
  assertNoHiddenIndexEntries(root);
2146
2990
  }
@@ -2149,19 +2993,25 @@ function assertWorkspaceInsideGitWorktree(root) {
2149
2993
  const workspace = realpathSync(root);
2150
2994
  const topLevel = realpathSync(git(root, ["rev-parse", "--show-toplevel"]));
2151
2995
  if (workspace !== topLevel && !workspace.startsWith(`${topLevel}${sep}`)) {
2152
- 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
+ );
2153
2999
  }
2154
3000
  }
2155
3001
 
2156
3002
  function assertExpectedCheckout(root, expectedRef, expectedCommit) {
2157
3003
  if (!checkoutMatches(root, expectedRef, expectedCommit)) {
2158
- 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
+ );
2159
3007
  }
2160
3008
  }
2161
3009
 
2162
3010
  function checkoutMatches(root, expectedRef, expectedCommit) {
2163
- return tryGit(root, ["symbolic-ref", "--quiet", "HEAD"]) === expectedRef
2164
- && tryGit(root, ["rev-parse", "HEAD"]) === expectedCommit;
3011
+ return (
3012
+ tryGit(root, ["symbolic-ref", "--quiet", "HEAD"]) === expectedRef &&
3013
+ tryGit(root, ["rev-parse", "HEAD"]) === expectedCommit
3014
+ );
2165
3015
  }
2166
3016
 
2167
3017
  function assertValidGitIdentity(root) {
@@ -2169,38 +3019,74 @@ function assertValidGitIdentity(root) {
2169
3019
  git(root, ["var", "GIT_AUTHOR_IDENT"]);
2170
3020
  git(root, ["var", "GIT_COMMITTER_IDENT"]);
2171
3021
  } catch {
2172
- 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
+ );
2173
3025
  }
2174
3026
  }
2175
3027
 
2176
3028
  async function assertNoOutsideWorktreeChangesAsync(root) {
2177
- const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
2178
- operation: "locate the repository before checking worktree changes"
2179
- })).trim();
3029
+ const topLevel = (
3030
+ await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
3031
+ operation: "locate the repository before checking worktree changes",
3032
+ })
3033
+ ).trim();
2180
3034
  const prefix = relative(topLevel, root).split(sep).join("/");
2181
- const output = await runGitCommand(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
2182
- operation: "inspect worktree changes"
2183
- });
3035
+ const output = await runGitCommand(
3036
+ topLevel,
3037
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
3038
+ {
3039
+ operation: "inspect worktree changes",
3040
+ },
3041
+ );
2184
3042
  const paths = statusPathsFromRaw(output);
2185
3043
  if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
2186
- 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
+ );
2187
3047
  }
2188
3048
  }
2189
3049
 
2190
3050
  function assertNoWorkspaceContentFilters(root) {
2191
- const paths = [...new Set(nulFields(gitRaw(root, [
2192
- "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
2193
- ])))];
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
+ ];
2194
3066
  for (let offset = 0; offset < paths.length; offset += 256) {
2195
3067
  const batch = paths.slice(offset, offset + 256);
2196
- 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
+ );
2197
3071
  for (let index = 0; index < attributes.length; index += 3) {
2198
3072
  const [path, attribute, value] = attributes.slice(index, index + 3);
2199
- if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
2200
- 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
+ );
2201
3081
  }
2202
- if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
2203
- 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
+ );
2204
3090
  }
2205
3091
  }
2206
3092
  }
@@ -2213,34 +3099,72 @@ function assertNoCommitWorkspaceContentFilters(root, commit) {
2213
3099
  if (prefix === ".." || prefix.startsWith("../")) {
2214
3100
  throw new Error("The FileGRC workspace is outside its Git repository.");
2215
3101
  }
2216
- const exactCommit = git(root, ["rev-parse", "--verify", `${commit}^{commit}`]);
2217
- const paths = nulFields(gitRaw(topLevel, [
2218
- "ls-tree", "-r", "-z", exactCommit, "--", prefix || "."
2219
- ])).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) => {
2220
3110
  const separator = entry.indexOf("\t");
2221
- const metadata = separator === -1 ? [] : entry.slice(0, separator).split(" ");
3111
+ const metadata =
3112
+ separator === -1 ? [] : entry.slice(0, separator).split(" ");
2222
3113
  const path = separator === -1 ? "" : entry.slice(separator + 1);
2223
3114
  const [mode, type, objectId] = metadata;
2224
- if (!/^(?:100644|100755)$/.test(mode || "") || type !== "blob" || !/^[a-f0-9]+$/i.test(objectId || "") || !path) {
2225
- 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
+ );
2226
3124
  }
2227
3125
  return path;
2228
3126
  });
2229
- const indexFile = resolve(gitDirectory, `filegrc-inspection-index-${randomUUID()}`);
3127
+ const indexFile = resolve(
3128
+ gitDirectory,
3129
+ `filegrc-inspection-index-${randomUUID()}`,
3130
+ );
2230
3131
  try {
2231
- 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
+ );
2232
3138
  for (let offset = 0; offset < paths.length; offset += 256) {
2233
3139
  const batch = paths.slice(offset, offset + 256);
2234
- const attributes = nulFields(gitRaw(topLevel, ["check-attr", "-z", "--cached", "filter", "merge", "--", ...batch], {
2235
- gitIndexFile: indexFile
2236
- }));
3140
+ const attributes = nulFields(
3141
+ gitRaw(
3142
+ topLevel,
3143
+ ["check-attr", "-z", "--cached", "filter", "merge", "--", ...batch],
3144
+ {
3145
+ gitIndexFile: indexFile,
3146
+ },
3147
+ ),
3148
+ );
2237
3149
  for (let index = 0; index < attributes.length; index += 3) {
2238
3150
  const [path, attribute, value] = attributes.slice(index, index + 3);
2239
- if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
2240
- 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
+ );
2241
3159
  }
2242
- if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
2243
- 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
+ );
2244
3168
  }
2245
3169
  }
2246
3170
  }
@@ -2251,103 +3175,239 @@ function assertNoCommitWorkspaceContentFilters(root, commit) {
2251
3175
  }
2252
3176
 
2253
3177
  function assertNoIgnoredAuthoritativeFiles(root) {
2254
- const ignored = nulFields(gitRaw(root, [
2255
- "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", "data"
2256
- ]));
3178
+ const ignored = ignoredAuthoritativeFiles(root);
2257
3179
  if (ignored.length) {
2258
- 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
+ );
2259
3183
  }
2260
3184
  }
2261
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
+
2262
3200
  function workspaceByteManifest(root) {
2263
3201
  assertNoHiddenIndexEntries(root);
2264
- const paths = [...new Set(nulFields(gitRaw(root, [
2265
- "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
2266
- ])))].sort();
3202
+ const objectFormat = repositoryObjectFormat(root);
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();
2267
3222
  const workspace = realpathSync(root);
2268
- return new Map(paths.map((path) => {
2269
- if (!path || path.startsWith("/") || path.split("/").includes("..") || /[\0\r\n]/.test(path)) {
2270
- throw new Error("Git reported an unsafe workspace path. Reconcile the worktree before committing.");
2271
- }
2272
- const absolute = resolve(root, path);
2273
- let stat;
2274
- try {
2275
- stat = lstatSync(absolute);
2276
- } catch (error) {
2277
- if (error?.code === "ENOENT") return [path, { objectId: null, mode: null }];
2278
- throw error;
2279
- }
2280
- if (stat.isSymbolicLink()) {
2281
- throw new Error(`FileGRC will not commit symbolic link "${path}". Store authoritative content as a regular file and review its target explicitly.`);
2282
- }
2283
- if (!stat.isFile()) {
2284
- throw new Error(`FileGRC will not commit non-file workspace entry "${path}".`);
2285
- }
2286
- const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
2287
- try {
2288
- const opened = fstatSync(descriptor);
2289
- const resolved = realpathSync(absolute);
2290
- const current = lstatSync(absolute);
3223
+ return new Map(
3224
+ paths.map((path) => {
2291
3225
  if (
2292
- !opened.isFile()
2293
- || (resolved !== workspace && !resolved.startsWith(`${workspace}${sep}`))
2294
- || current.isSymbolicLink()
2295
- || !current.isFile()
2296
- || opened.dev !== current.dev
2297
- || opened.ino !== current.ino
3226
+ !path ||
3227
+ path.startsWith("/") ||
3228
+ path.split("/").includes("..") ||
3229
+ /[\0\r\n]/.test(path)
2298
3230
  ) {
2299
- 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
+ );
2300
3234
  }
2301
- const bytes = readFileSync(descriptor);
2302
- const objectId = hashWorkspaceBytes(root, bytes);
2303
- return [path, { objectId, mode: opened.mode & 0o111 ? "100755" : "100644", bytes }];
2304
- } finally {
2305
- closeSync(descriptor);
2306
- }
2307
- }));
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;
3243
+ }
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
+ );
3248
+ }
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
+ );
2308
3328
  }
2309
3329
 
2310
3330
  function assertNoHiddenIndexEntries(root) {
2311
- const hidden = nulFields(gitRaw(root, ["ls-files", "-v", "-z", "--cached", "--", "."]))
2312
- .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());
2313
3334
  if (hidden.length) {
2314
3335
  const paths = hidden.slice(0, 5).map((entry) => entry.slice(2));
2315
- 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
+ );
2316
3339
  }
2317
3340
  }
2318
3341
 
2319
- function hashWorkspaceBytes(root, bytes, write = false) {
2320
- return observedExecFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
3342
+ function writeWorkspaceManifestObjects(root, manifest) {
3343
+ const valuesByObjectId = new Map();
3344
+ for (const value of manifest.values()) {
3345
+ if (!value.objectId) continue;
3346
+ const prior = valuesByObjectId.get(value.objectId);
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
+ );
3354
+ }
3355
+ valuesByObjectId.set(value.objectId, value);
3356
+ }
3357
+ if (!valuesByObjectId.size) return manifest;
3358
+ const entries = [...manifest].filter(([, value]) => value.objectId);
3359
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
3360
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
3361
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) {
3362
+ throw new Error("The FileGRC workspace is outside its Git repository.");
3363
+ }
3364
+ const written = executeGitSync(root, ["hash-object", "-w", "--stdin-paths"], {
2321
3365
  cwd: root,
2322
- input: bytes,
3366
+ input: `${entries.map(([path]) => (workspacePrefix ? `${workspacePrefix}/${path}` : path)).join("\n")}\n`,
2323
3367
  encoding: "utf8",
2324
3368
  stdio: ["pipe", "pipe", "ignore"],
2325
3369
  timeout: 30_000,
2326
3370
  maxBuffer: 20_000_000,
2327
- env: gitEnvironment()
2328
- }).trim();
2329
- }
2330
-
2331
- function writeWorkspaceManifestObjects(root, manifest) {
2332
- return new Map([...manifest].map(([path, value]) => (
2333
- value.objectId
2334
- ? [path, { ...value, objectId: hashWorkspaceBytes(root, value.bytes, true) }]
2335
- : [path, value]
2336
- )));
3371
+ env: gitEnvironment(),
3372
+ })
3373
+ .trim()
3374
+ .split("\n");
3375
+ if (written.length !== entries.length) {
3376
+ throw new Error("Git returned an unexpected number of workspace objects.");
3377
+ }
3378
+ for (let index = 0; index < entries.length; index += 1) {
3379
+ if (written[index] !== entries[index][1].objectId) {
3380
+ throw new Error(
3381
+ `Git wrote an unexpected object while preparing validated workspace bytes for commit.`,
3382
+ );
3383
+ }
3384
+ }
3385
+ assertWorkspaceManifestEqual(manifest, workspaceByteManifest(root));
3386
+ return manifest;
2337
3387
  }
2338
3388
 
2339
3389
  function assertWorkspaceManifestEqual(expected, current) {
2340
3390
  if (!workspaceManifestsEqual(expected, current)) {
2341
- 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
+ );
2342
3394
  }
2343
3395
  }
2344
3396
 
2345
3397
  function workspaceManifestsEqual(expected, current) {
2346
- return expected.size === current.size
2347
- && [...expected].every(([path, value]) => {
3398
+ return (
3399
+ expected.size === current.size &&
3400
+ [...expected].every(([path, value]) => {
2348
3401
  const other = current.get(path);
2349
- return other && value.objectId === other.objectId && value.mode === other.mode;
2350
- });
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
+ );
2351
3411
  }
2352
3412
 
2353
3413
  async function commitValidatedIndexAsync(root, subject, manifest, options) {
@@ -2356,39 +3416,72 @@ async function commitValidatedIndexAsync(root, subject, manifest, options) {
2356
3416
  const parent = options.expectedParent;
2357
3417
  const ref = options.expectedRef;
2358
3418
  assertExpectedCheckout(root, ref, parent);
2359
- const gitDirectory = await runGitCommand(root, ["rev-parse", "--absolute-git-dir"], { operation: "locate the repository index" });
2360
- 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
+ );
2361
3430
  const expectedSharedIndex = readFileSync(sharedIndex);
2362
3431
  const indexFile = resolve(gitDirectory, `filegrc-index-${randomUUID()}`);
2363
- const sharedUpdateIndex = resolve(gitDirectory, `filegrc-shared-index-${randomUUID()}`);
3432
+ const sharedUpdateIndex = resolve(
3433
+ gitDirectory,
3434
+ `filegrc-shared-index-${randomUUID()}`,
3435
+ );
2364
3436
  let indexReconciled = false;
2365
3437
  try {
2366
3438
  writeFileSync(sharedUpdateIndex, expectedSharedIndex);
2367
3439
  await applyManifestToIndexAsync(root, manifest, sharedUpdateIndex);
2368
- 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
+ });
2369
3444
  await applyManifestToIndexAsync(root, manifest, indexFile);
2370
- const tree = await runGitCommand(root, ["write-tree"], { operation: "capture the validated FileGRC tree", gitIndexFile: indexFile });
2371
- const commit = await runGitCommand(root, ["commit-tree", tree, "-p", parent, "-m", subject], {
2372
- operation: "create the validated FileGRC browser commit",
2373
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
3445
+ const tree = await runGitCommand(root, ["write-tree"], {
3446
+ operation: "capture the validated FileGRC tree",
3447
+ gitIndexFile: indexFile,
2374
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
+ );
2375
3457
  assertNoGitOperationInProgress(root);
2376
- await runGitCommand(root, ["update-ref", "-m", subject, ref, commit, parent], {
2377
- operation: "advance the authoritative branch to the validated FileGRC commit",
2378
- expectedCheckout: { expectedRef: ref, expectedCommit: parent },
2379
- expectedNoOperation: true
2380
- });
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
+ );
2381
3468
  if (!gitOperationInProgress(root) && checkoutMatches(root, ref, commit)) {
2382
- indexReconciled = replaceSharedIndexIfUnchanged(sharedIndex, sharedUpdateIndex, expectedSharedIndex);
3469
+ indexReconciled = replaceSharedIndexIfUnchanged(
3470
+ sharedIndex,
3471
+ sharedUpdateIndex,
3472
+ expectedSharedIndex,
3473
+ );
2383
3474
  }
2384
3475
  return { commit, indexReconciled: indexReconciled ?? false };
2385
3476
  } finally {
2386
- await Promise.all([
2387
- rm(indexFile, { force: true }),
2388
- rm(`${indexFile}.lock`, { force: true }),
2389
- rm(sharedUpdateIndex, { force: true }),
2390
- rm(`${sharedUpdateIndex}.lock`, { force: true })
2391
- ].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
+ );
2392
3485
  }
2393
3486
  }
2394
3487
 
@@ -2398,7 +3491,11 @@ function replaceSharedIndexIfUnchanged(sharedIndex, privateIndex, expected) {
2398
3491
  let installed = false;
2399
3492
  let ownsLock = false;
2400
3493
  try {
2401
- 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
+ );
2402
3499
  ownsLock = true;
2403
3500
  if (!readFileSync(sharedIndex).equals(expected)) return false;
2404
3501
  writeFileSync(descriptor, readFileSync(privateIndex));
@@ -2412,36 +3509,61 @@ function replaceSharedIndexIfUnchanged(sharedIndex, privateIndex, expected) {
2412
3509
  return false;
2413
3510
  } finally {
2414
3511
  if (descriptor !== undefined) {
2415
- try { closeSync(descriptor); } catch { /* Best-effort cleanup after reconciliation. */ }
3512
+ try {
3513
+ closeSync(descriptor);
3514
+ } catch {
3515
+ /* Best-effort cleanup after reconciliation. */
3516
+ }
2416
3517
  }
2417
3518
  if (ownsLock && !installed) {
2418
- 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
+ }
2419
3524
  }
2420
3525
  }
2421
3526
  }
2422
3527
 
2423
3528
  async function applyManifestToIndexAsync(root, manifest, indexFile) {
2424
- const options = { operation: "write the validated FileGRC index", ...(indexFile ? { gitIndexFile: indexFile } : {}) };
2425
- 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);
2426
3535
  }
2427
3536
 
2428
3537
  function manifestIndexCommands(root, manifest) {
2429
3538
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
2430
3539
  const prefix = relative(topLevel, root).split(sep).join("/");
2431
- 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.");
2432
3542
  const additions = [];
2433
3543
  const deletions = [];
2434
3544
  for (const [path, value] of manifest) {
2435
3545
  const repositoryPath = prefix ? `${prefix}/${path}` : path;
2436
- if (value.objectId) additions.push(`${value.mode},${value.objectId},${repositoryPath}`);
3546
+ if (value.objectId)
3547
+ additions.push(`${value.mode},${value.objectId},${repositoryPath}`);
2437
3548
  else deletions.push(repositoryPath);
2438
3549
  }
2439
3550
  const commands = [];
2440
3551
  for (let offset = 0; offset < additions.length; offset += 128) {
2441
- 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
+ ]);
2442
3559
  }
2443
3560
  for (let offset = 0; offset < deletions.length; offset += 256) {
2444
- 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
+ ]);
2445
3567
  }
2446
3568
  return commands;
2447
3569
  }
@@ -2458,7 +3580,8 @@ function statusPathsFromRaw(output) {
2458
3580
  }
2459
3581
  const status = field.slice(0, 2);
2460
3582
  paths.push(field.slice(3));
2461
- 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]);
2462
3585
  }
2463
3586
  return paths;
2464
3587
  }
@@ -2483,7 +3606,13 @@ function cleanGitName(value, fallback) {
2483
3606
  function parseLogLine(line) {
2484
3607
  if (!line) return null;
2485
3608
  const [commit, timestamp, author, subject] = line.split("\x1f");
2486
- 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
+ };
2487
3616
  }
2488
3617
 
2489
3618
  function lines(source) {
@@ -2502,8 +3631,10 @@ function parsePorcelainV2(source, topLevel, root) {
2502
3631
  const workspaceChanges = [];
2503
3632
  for (let index = 0; index < fields.length; index += 1) {
2504
3633
  const field = fields[index];
2505
- if (field.startsWith("# branch.oid ")) commit = field.slice(13) === "(initial)" ? null : field.slice(13);
2506
- 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);
2507
3638
  else if (field.startsWith("# branch.upstream ")) upstream = field.slice(18);
2508
3639
  else if (field.startsWith("# branch.ab ")) {
2509
3640
  const match = /\+(\d+) -(\d+)/.exec(field);
@@ -2512,13 +3643,24 @@ function parsePorcelainV2(source, topLevel, root) {
2512
3643
  const path = porcelainV2Path(field);
2513
3644
  allChanges.push(field);
2514
3645
  const originalPath = field.startsWith("2 ") ? fields[index + 1] : null;
2515
- if (pathInsideWorkspace(path, prefix) || originalPath && pathInsideWorkspace(originalPath, prefix)) {
3646
+ if (
3647
+ pathInsideWorkspace(path, prefix) ||
3648
+ (originalPath && pathInsideWorkspace(originalPath, prefix))
3649
+ ) {
2516
3650
  workspaceChanges.push(field);
2517
3651
  }
2518
3652
  if (originalPath) index += 1;
2519
3653
  }
2520
3654
  }
2521
- 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
+ };
2522
3664
  }
2523
3665
 
2524
3666
  function parseWorkspaceRevision(source) {
@@ -2542,13 +3684,17 @@ function parseWorkspaceRevision(source) {
2542
3684
  return {
2543
3685
  commit,
2544
3686
  branch,
2545
- changePaths: [...new Set(changePaths.filter(Boolean))].sort()
3687
+ changePaths: [...new Set(changePaths.filter(Boolean))].sort(),
2546
3688
  };
2547
3689
  }
2548
3690
 
2549
3691
  function porcelainV2Path(field) {
2550
3692
  if (field.startsWith("? ") || field.startsWith("! ")) return field.slice(2);
2551
- 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;
2552
3698
  let offset = 0;
2553
3699
  for (let count = 0; count < requiredSpaces; count += 1) {
2554
3700
  offset = field.indexOf(" ", offset) + 1;
@@ -2575,7 +3721,7 @@ function repositoryOperationFromDirectory(gitDirectory) {
2575
3721
  ["cherry-pick", "CHERRY_PICK_HEAD"],
2576
3722
  ["sequencer", "sequencer"],
2577
3723
  ["revert", "REVERT_HEAD"],
2578
- ["bisect", "BISECT_START"]
3724
+ ["bisect", "BISECT_START"],
2579
3725
  ]) {
2580
3726
  if (existsSync(resolve(gitDirectory, path))) return name;
2581
3727
  }
@@ -2585,7 +3731,9 @@ function repositoryOperationFromDirectory(gitDirectory) {
2585
3731
  function assertNoGitOperationInProgress(root) {
2586
3732
  const operation = gitOperationInProgress(root);
2587
3733
  if (operation) {
2588
- 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
+ );
2589
3737
  }
2590
3738
  }
2591
3739
 
@@ -2596,39 +3744,81 @@ function gitOperationInProgress(root) {
2596
3744
 
2597
3745
  export function setGitCommandInterceptorForTests(interceptor) {
2598
3746
  if (interceptor !== null && typeof interceptor !== "function") {
2599
- 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
+ );
2600
3750
  }
2601
3751
  const previous = gitCommandInterceptor;
2602
3752
  gitCommandInterceptor = interceptor;
2603
- 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);
2604
3771
  }
2605
3772
 
2606
3773
  export function setGitSubprocessObserverForTests(observer) {
2607
3774
  if (observer !== null && typeof observer !== "function") {
2608
- 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
+ );
2609
3778
  }
2610
3779
  const previous = gitSubprocessObserver;
2611
3780
  gitSubprocessObserver = observer;
2612
- return () => { gitSubprocessObserver = previous; };
3781
+ return () => {
3782
+ gitSubprocessObserver = previous;
3783
+ };
2613
3784
  }
2614
3785
 
2615
3786
  export function setHistoricalRevisionReadObserverForTests(observer) {
2616
3787
  if (observer !== null && typeof observer !== "function") {
2617
- 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
+ );
2618
3791
  }
2619
3792
  const previous = historicalRevisionReadObserver;
2620
3793
  historicalRevisionReadObserver = observer;
2621
- return () => { historicalRevisionReadObserver = previous; };
3794
+ return () => {
3795
+ historicalRevisionReadObserver = previous;
3796
+ };
3797
+ }
3798
+
3799
+ export function setWorkspaceBlobObjectIdOverrideForTests(override) {
3800
+ if (override !== null && typeof override !== "function") {
3801
+ throw new TypeError(
3802
+ "The workspace blob object ID override must be a function or null.",
3803
+ );
3804
+ }
3805
+ const previous = workspaceBlobObjectIdOverride;
3806
+ workspaceBlobObjectIdOverride = override;
3807
+ return () => {
3808
+ workspaceBlobObjectIdOverride = previous;
3809
+ };
2622
3810
  }
2623
3811
 
2624
3812
  export function runGitCommand(cwd, args, options = {}) {
2625
3813
  if (gitCommandInterceptor) {
2626
- return Promise.resolve().then(() => gitCommandInterceptor({
2627
- cwd,
2628
- args: [...args],
2629
- options: { ...options },
2630
- run: () => runGitCommandNative(cwd, args, options)
2631
- }));
3814
+ return Promise.resolve().then(() =>
3815
+ gitCommandInterceptor({
3816
+ cwd,
3817
+ args: [...args],
3818
+ options: { ...options },
3819
+ run: () => runGitCommandNative(cwd, args, options),
3820
+ }),
3821
+ );
2632
3822
  }
2633
3823
  return runGitCommandNative(cwd, args, options);
2634
3824
  }
@@ -2642,27 +3832,46 @@ function runGitCommandNative(cwd, args, options = {}) {
2642
3832
  assertExpectedCheckout(
2643
3833
  cwd,
2644
3834
  options.expectedCheckout.expectedRef,
2645
- options.expectedCheckout.expectedCommit
3835
+ options.expectedCheckout.expectedCommit,
2646
3836
  );
2647
3837
  }
2648
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
+ }
2649
3845
  const operation = options.operation || "run a Git command";
2650
3846
  const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
2651
- const { timeoutMs, deadlineLimited } = gitTimeoutPlan(configuredTimeout, GIT_DEFAULT_TIMEOUT_MS);
2652
- const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
2653
- 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
+ });
2654
3861
  return new Promise((resolveCommand, rejectCommand) => {
2655
3862
  const child = spawn("git", args, {
2656
3863
  cwd,
2657
3864
  stdio: ["ignore", "pipe", "pipe"],
2658
3865
  detached: process.platform !== "win32",
2659
3866
  env: gitEnvironment({
2660
- ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
3867
+ ...(options.gitIndexFile
3868
+ ? { GIT_INDEX_FILE: options.gitIndexFile }
3869
+ : {}),
2661
3870
  GIT_TERMINAL_PROMPT: "0",
2662
3871
  GIT_ASKPASS: "",
2663
3872
  SSH_ASKPASS: "",
2664
- GIT_MERGE_AUTOEDIT: "no"
2665
- })
3873
+ GIT_MERGE_AUTOEDIT: "no",
3874
+ }),
2666
3875
  });
2667
3876
  const stdout = [];
2668
3877
  const stderr = [];
@@ -2673,10 +3882,15 @@ function runGitCommandNative(cwd, args, options = {}) {
2673
3882
  let forceKillTimer;
2674
3883
  const terminate = (signal) => {
2675
3884
  try {
2676
- if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
3885
+ if (process.platform !== "win32" && child.pid)
3886
+ process.kill(-child.pid, signal);
2677
3887
  else child.kill(signal);
2678
3888
  } catch {
2679
- try { child.kill(signal); } catch { /* The child already exited. */ }
3889
+ try {
3890
+ child.kill(signal);
3891
+ } catch {
3892
+ /* The child already exited. */
3893
+ }
2680
3894
  }
2681
3895
  };
2682
3896
  const stop = () => {
@@ -2704,12 +3918,14 @@ function runGitCommandNative(cwd, args, options = {}) {
2704
3918
  settled = true;
2705
3919
  clearTimeout(timer);
2706
3920
  clearTimeout(forceKillTimer);
2707
- rejectCommand(new GitOperationError(
2708
- error.code === "ENOENT" ? "missing-executable" : "command-failure",
2709
- operation,
2710
- error.code === "ENOENT" ? "" : error.message,
2711
- { cause: error, code: error.code }
2712
- ));
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
+ );
2713
3929
  });
2714
3930
  child.once("close", (code, signal) => {
2715
3931
  if (settled) return;
@@ -2718,20 +3934,25 @@ function runGitCommandNative(cwd, args, options = {}) {
2718
3934
  clearTimeout(forceKillTimer);
2719
3935
  const output = Buffer.concat(stdout).toString("utf8");
2720
3936
  const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
2721
- if (code === 0 && !timedOut && !outputExceeded) return resolveCommand(output.trim());
3937
+ if (code === 0 && !timedOut && !outputExceeded)
3938
+ return resolveCommand(output.trim());
2722
3939
  const detail = outputExceeded
2723
3940
  ? `Git output exceeded ${maxOutputBytes} bytes.`
2724
3941
  : timedOut
2725
3942
  ? `The operation exceeded ${timeoutMs} ms and the process group was terminated.`
2726
- : errorOutput || `Git exited with status ${code ?? signal ?? "unknown"}.`;
3943
+ : errorOutput ||
3944
+ `Git exited with status ${code ?? signal ?? "unknown"}.`;
2727
3945
  const kind = timedOut
2728
3946
  ? "timeout"
2729
3947
  : /not a git repository|outside repository/i.test(errorOutput)
2730
3948
  ? "invalid-repository"
2731
3949
  : "command-failure";
2732
- rejectCommand(new GitOperationError(kind, operation, detail, {
2733
- code: timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined
2734
- }));
3950
+ rejectCommand(
3951
+ new GitOperationError(kind, operation, detail, {
3952
+ code:
3953
+ timedOut && deadlineLimited ? "FILEGRC_GIT_DEADLINE" : undefined,
3954
+ }),
3955
+ );
2735
3956
  });
2736
3957
  });
2737
3958
  }
@@ -2746,14 +3967,19 @@ async function tryGitAsync(cwd, args, operation) {
2746
3967
 
2747
3968
  function git(cwd, args, options = {}) {
2748
3969
  return cachedGitCommand("text", cwd, args, options, () => {
2749
- return measureTimingSync("git-command-sync", () => observedExecFileSync("git", args, {
2750
- cwd,
2751
- encoding: "utf8",
2752
- stdio: ["ignore", "pipe", "ignore"],
2753
- timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2754
- maxBuffer: 20_000_000,
2755
- env: gitEnvironment()
2756
- }).trim());
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
+ );
2757
3983
  });
2758
3984
  }
2759
3985
 
@@ -2786,13 +4012,15 @@ function tryGitRaw(cwd, args) {
2786
4012
 
2787
4013
  function gitRaw(cwd, args, options = {}) {
2788
4014
  return cachedGitCommand("raw", cwd, args, options, () => {
2789
- return observedExecFileSync("git", args, {
4015
+ return executeGitSync(cwd, args, {
2790
4016
  cwd,
2791
4017
  encoding: "utf8",
2792
4018
  stdio: ["ignore", "pipe", "ignore"],
2793
4019
  timeout: Math.max(1, Number(options.timeoutMs) || GIT_DEFAULT_TIMEOUT_MS),
2794
4020
  maxBuffer: 20_000_000,
2795
- env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
4021
+ env: gitEnvironment(
4022
+ options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {},
4023
+ ),
2796
4024
  });
2797
4025
  });
2798
4026
  }
@@ -2804,7 +4032,7 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2804
4032
  outputKind,
2805
4033
  resolve(cwd),
2806
4034
  args,
2807
- options.gitIndexFile || null
4035
+ options.gitIndexFile || null,
2808
4036
  ]);
2809
4037
  const cached = cache.get(key);
2810
4038
  if (cached) {
@@ -2816,7 +4044,10 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2816
4044
  const size = Buffer.byteLength(value);
2817
4045
  if (size <= GIT_COMMAND_CACHE_MAX_ENTRY_BYTES) {
2818
4046
  let totalBytes = gitCommandCacheBytes.get(cache) || 0;
2819
- 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
+ ) {
2820
4051
  const oldestKey = cache.keys().next().value;
2821
4052
  if (oldestKey === undefined) break;
2822
4053
  totalBytes -= cache.get(oldestKey)?.size || 0;
@@ -2829,9 +4060,10 @@ function cachedGitCommand(outputKind, cwd, args, options, run) {
2829
4060
  }
2830
4061
 
2831
4062
  function observedExecFileSync(executable, args, options = {}) {
2832
- const timeoutPlan = executable === "git"
2833
- ? gitTimeoutPlan(options.timeout, GIT_DEFAULT_TIMEOUT_MS)
2834
- : null;
4063
+ const timeoutPlan =
4064
+ executable === "git"
4065
+ ? gitTimeoutPlan(options.timeout, GIT_DEFAULT_TIMEOUT_MS)
4066
+ : null;
2835
4067
  const boundedOptions = timeoutPlan
2836
4068
  ? { ...options, timeout: timeoutPlan.timeoutMs }
2837
4069
  : options;
@@ -2841,13 +4073,22 @@ function observedExecFileSync(executable, args, options = {}) {
2841
4073
  kind: "sync",
2842
4074
  cwd: boundedOptions.cwd,
2843
4075
  args: [...args],
2844
- options: { ...boundedOptions, input: boundedOptions.input === undefined ? undefined : "[redacted]" }
4076
+ options: {
4077
+ ...boundedOptions,
4078
+ input: boundedOptions.input === undefined ? undefined : "[redacted]",
4079
+ },
2845
4080
  });
2846
4081
  }
2847
4082
  return execFileSync(executable, args, boundedOptions);
2848
4083
  } catch (error) {
2849
- if (timeoutPlan?.deadlineLimited && (error?.code === "ETIMEDOUT" || error?.signal)) {
2850
- const deadlineError = new Error("The Git command exceeded the shared request deadline.", { cause: error });
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
+ );
2851
4092
  deadlineError.code = "FILEGRC_GIT_DEADLINE";
2852
4093
  throw deadlineError;
2853
4094
  }
@@ -2862,16 +4103,19 @@ function boundedGitTimeout(configured, fallback) {
2862
4103
  function gitTimeoutPlan(configured, fallback) {
2863
4104
  const requested = Math.max(1, Number(configured) || fallback);
2864
4105
  const deadlineAt = gitCommandDeadlines.getStore();
2865
- if (deadlineAt === undefined) return { timeoutMs: requested, deadlineLimited: false };
4106
+ if (deadlineAt === undefined)
4107
+ return { timeoutMs: requested, deadlineLimited: false };
2866
4108
  const remaining = Math.ceil(deadlineAt - performance.now());
2867
4109
  if (remaining <= 0) {
2868
- 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
+ );
2869
4113
  error.code = "FILEGRC_GIT_DEADLINE";
2870
4114
  throw error;
2871
4115
  }
2872
4116
  return {
2873
4117
  timeoutMs: Math.min(requested, remaining),
2874
- deadlineLimited: remaining <= requested
4118
+ deadlineLimited: remaining <= requested,
2875
4119
  };
2876
4120
  }
2877
4121
 
@@ -2886,30 +4130,59 @@ function nulFields(source) {
2886
4130
  function gitForWrite(cwd, args, action = "create the commit", options = {}) {
2887
4131
  try {
2888
4132
  assertWorkspaceInsideGitWorktree(cwd);
2889
- return observedExecFileSync("git", args, {
4133
+ return executeGitSync(cwd, args, {
2890
4134
  cwd,
2891
4135
  encoding: "utf8",
2892
4136
  stdio: ["ignore", "pipe", "pipe"],
2893
4137
  timeout: 30_000,
2894
4138
  maxBuffer: 20_000_000,
2895
4139
  env: gitEnvironment({
2896
- ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
4140
+ ...(options.gitIndexFile
4141
+ ? { GIT_INDEX_FILE: options.gitIndexFile }
4142
+ : {}),
2897
4143
  GIT_TERMINAL_PROMPT: "0",
2898
- GIT_MERGE_AUTOEDIT: "no"
2899
- })
4144
+ GIT_MERGE_AUTOEDIT: "no",
4145
+ }),
2900
4146
  }).trim();
2901
4147
  } catch (error) {
2902
- 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
+ );
2903
4151
  throw new Error(`Git could not ${action}. ${message}`);
2904
4152
  }
2905
4153
  }
2906
4154
 
2907
- 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
+ ) {
2908
4181
  assertWorkspaceInsideGitWorktree(cwd);
2909
4182
  return runGitCommand(cwd, args, {
2910
4183
  operation: action,
2911
4184
  timeoutMs: GIT_REMOTE_TIMEOUT_MS,
2912
- ...options
4185
+ ...options,
2913
4186
  });
2914
4187
  }
2915
4188