filegrc 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { scopedCollectionRecords } from "./collection-scope.js";
2
3
  import { applyResourceBatch } from "./files.js";
3
4
  import { getGitSummary } from "./git.js";
4
5
  import { loadWorkspace } from "./workspace.js";
5
- import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
6
+ import { resolveProgram, selectedRequirementIds } from "./program.js";
6
7
 
7
8
  export function collectionRevision(loaded, resourceType, options = {}) {
8
9
  const program = resolveProgram(loaded, options.programId);
@@ -200,26 +201,6 @@ export async function applyCollectionReview(input = process.cwd(), options = {})
200
201
  };
201
202
  }
202
203
 
203
- function scopedCollectionRecords(loaded, resourceType, program) {
204
- if (String(loaded.model.modelVersion) !== "4") {
205
- return loaded.resources.filter((record) => record.type === resourceType);
206
- }
207
- const components = programComponents(loaded, program);
208
- const componentIds = new Set(components.map(({ id }) => id));
209
- const selected = {
210
- system: new Set(program.systemIds || []),
211
- component: componentIds,
212
- framework: new Set(program.frameworkIds || []),
213
- vendor: new Set(components.map(({ vendorId }) => vendorId).filter(Boolean)),
214
- asset: new Set(loaded.resources.filter((record) => (
215
- record.type === "asset" && (record.componentIds || []).some((id) => componentIds.has(id))
216
- )).map(({ id }) => id))
217
- }[resourceType];
218
- return loaded.resources.filter((record) => (
219
- record.type === resourceType && (!selected || selected.has(record.id))
220
- ));
221
- }
222
-
223
204
  function requiredType(loaded, value) {
224
205
  const resourceType = String(value || "").trim();
225
206
  if (!loaded.model.collectionReviews?.[resourceType]) {
@@ -0,0 +1,24 @@
1
+ import { programComponents } from "./program.js";
2
+
3
+ export function scopedCollectionRecords(loaded, resourceType, program) {
4
+ if (String(loaded.model.modelVersion) !== "4") {
5
+ return loaded.resources.filter((record) => record.type === resourceType);
6
+ }
7
+ if (resourceType === "vendor") {
8
+ return loaded.resources.filter((record) => record.type === "vendor");
9
+ }
10
+ const scopedProgram = program || {};
11
+ const components = programComponents(loaded, scopedProgram);
12
+ const componentIds = new Set(components.map(({ id }) => id));
13
+ const selected = {
14
+ system: new Set(scopedProgram.systemIds || []),
15
+ component: componentIds,
16
+ framework: new Set(scopedProgram.frameworkIds || []),
17
+ asset: new Set(loaded.resources.filter((record) => (
18
+ record.type === "asset" && (record.componentIds || []).some((id) => componentIds.has(id))
19
+ )).map(({ id }) => id))
20
+ }[resourceType];
21
+ return loaded.resources.filter((record) => (
22
+ record.type === resourceType && (!selected || selected.has(record.id))
23
+ ));
24
+ }
package/src/git.js CHANGED
@@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks";
7
7
  import { isSafeGitName } from "./git-name.js";
8
8
  import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
9
9
  import { resolveWorkspaceRoot } from "./paths.js";
10
- import { measureTiming, measureTimingSync, recordTiming, timingEnabled } from "./timing.js";
10
+ import { measureTiming, recordTiming, timingEnabled } from "./timing.js";
11
11
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
12
12
  import { loadWorkspace } from "./workspace.js";
13
13
 
@@ -15,9 +15,32 @@ const lastSuccessfulSynchronizations = new Map();
15
15
  const workspaceHistoryCache = new Map();
16
16
  const backgroundSynchronizations = new Map();
17
17
  const browserRemotePrefetches = new Map();
18
+ const browserRemotePrefetchPromises = new Map();
19
+ const repositorySnapshotPromises = new Map();
20
+ let gitCommandInterceptor = null;
18
21
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
22
+ const GIT_DEFAULT_TIMEOUT_MS = 10_000;
23
+ const GIT_REMOTE_TIMEOUT_MS = 30_000;
24
+ const GIT_MAX_OUTPUT_BYTES = 20_000_000;
19
25
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
20
26
 
27
+ export class GitOperationError extends Error {
28
+ constructor(kind, operation, detail, options = {}) {
29
+ const prefix = kind === "missing-executable"
30
+ ? "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
31
+ : kind === "timeout"
32
+ ? `Git timed out while trying to ${operation}.`
33
+ : kind === "invalid-repository"
34
+ ? `Git could not ${operation} because this workspace is not in a valid Git repository.`
35
+ : `Git could not ${operation}.`;
36
+ super(detail ? `${prefix} ${sanitizeGitErrorMessage(detail)}` : prefix, options);
37
+ this.name = "GitOperationError";
38
+ this.kind = kind;
39
+ this.operation = operation;
40
+ this.code = options.code;
41
+ }
42
+ }
43
+
21
44
  export function getGitSummary(input = process.cwd()) {
22
45
  const root = resolveWorkspaceRoot(input);
23
46
  try {
@@ -145,10 +168,111 @@ export async function pushWorkspace(input = process.cwd()) {
145
168
  return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
146
169
  }
147
170
 
171
+ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
172
+ const root = resolveWorkspaceRoot(input);
173
+ if (!options.fresh && repositorySnapshotPromises.has(root)) {
174
+ recordTiming("repository-snapshot-reused", 0);
175
+ return repositorySnapshotPromises.get(root);
176
+ }
177
+ const snapshot = buildRepositorySnapshot(root).finally(() => {
178
+ if (repositorySnapshotPromises.get(root) === snapshot) repositorySnapshotPromises.delete(root);
179
+ });
180
+ repositorySnapshotPromises.set(root, snapshot);
181
+ return snapshot;
182
+ }
183
+
184
+ async function buildRepositorySnapshot(root) {
185
+ let repositoryPaths;
186
+ try {
187
+ repositoryPaths = await runGitCommand(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir"], {
188
+ operation: "locate the repository"
189
+ });
190
+ } catch (error) {
191
+ return unavailableSnapshot(error);
192
+ }
193
+ const [topLevel, gitDirectory] = repositoryPaths.split("\n");
194
+ try {
195
+ const [status, remotes] = await Promise.all([
196
+ runGitCommand(topLevel, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], {
197
+ operation: "inspect repository status"
198
+ }),
199
+ runGitCommand(root, ["remote"], { operation: "list repository remotes" })
200
+ ]);
201
+ const parsed = parsePorcelainV2(status, topLevel, root);
202
+ const last = parsed.commit
203
+ ? parseLogLine(await runGitCommand(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"], {
204
+ operation: "read the latest commit"
205
+ }))
206
+ : null;
207
+ let upstreamCommit = null;
208
+ let pendingCommits = [];
209
+ let pendingCommitsFilegrcOnly = parsed.ahead === 0 ? true : null;
210
+ if (parsed.upstream) {
211
+ upstreamCommit = (await runGitCommand(root, ["rev-parse", parsed.upstream], {
212
+ operation: `resolve upstream ${parsed.upstream}`
213
+ })).trim() || null;
214
+ }
215
+ if (parsed.ahead > 0 && parsed.upstream) {
216
+ const pending = await runGitCommand(root, [
217
+ "log",
218
+ "--format=%x1e%H%x1f%s",
219
+ "--name-only",
220
+ `${parsed.upstream}..HEAD`
221
+ ], { operation: `inspect commits ahead of ${parsed.upstream}` });
222
+ const prefix = relative(topLevel, root).split(sep).join("/");
223
+ const commits = parsePendingCommitPaths(pending);
224
+ pendingCommits = commits.map(({ commit, subject }) => ({
225
+ commit,
226
+ shortCommit: commit.slice(0, 8),
227
+ subject
228
+ }));
229
+ pendingCommitsFilegrcOnly = commits.every(({ paths }) => (
230
+ paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix))
231
+ ));
232
+ }
233
+ return {
234
+ available: true,
235
+ root: topLevel,
236
+ gitDirectory,
237
+ commit: parsed.commit,
238
+ shortCommit: parsed.commit?.slice(0, 8) ?? "no commits",
239
+ branch: parsed.branch,
240
+ upstream: parsed.upstream,
241
+ remotes: lines(remotes),
242
+ clean: parsed.workspaceChanges.length === 0,
243
+ changes: parsed.workspaceChanges,
244
+ wholeWorktreeClean: parsed.allChanges.length === 0,
245
+ operationInProgress: repositoryOperationFromDirectory(gitDirectory),
246
+ upstreamCommit,
247
+ ahead: parsed.ahead,
248
+ behind: parsed.behind,
249
+ pendingCommits,
250
+ pendingCommitsFilegrcOnly,
251
+ lastCommit: last,
252
+ invocationCount: 3 + (parsed.commit ? 1 : 0) + (parsed.upstream ? 1 : 0) + (parsed.ahead > 0 ? 1 : 0)
253
+ };
254
+ } catch (error) {
255
+ return unavailableSnapshot(error, { root: topLevel, gitDirectory });
256
+ }
257
+ }
258
+
259
+ function unavailableSnapshot(error, extra = {}) {
260
+ return {
261
+ available: false,
262
+ clean: null,
263
+ changes: [],
264
+ error,
265
+ message: error instanceof GitOperationError
266
+ ? error.message
267
+ : "Git history is unavailable. Commit the workspace to enable audit metadata.",
268
+ ...extra
269
+ };
270
+ }
271
+
148
272
  export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
149
273
  const root = resolveWorkspaceRoot(input);
150
274
  const config = await getRepositoryConfig(root);
151
- const gitSummary = getGitSummary(root);
275
+ const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
152
276
  if (config.mode !== "trunk") {
153
277
  return {
154
278
  mode: "manual",
@@ -199,20 +323,42 @@ export async function runBrowserMutation(input, options, task) {
199
323
  }
200
324
 
201
325
  export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
202
- return serializeWorkspaceMutation(input, async (root) => {
326
+ const root = resolveWorkspaceRoot(input);
327
+ const key = `${root}\0${options.allowNonAuthoritativeWrites === true}`;
328
+ if (browserRemotePrefetchPromises.has(key)) {
329
+ recordTiming("prefetch-coalesced", 0);
330
+ return browserRemotePrefetchPromises.get(key);
331
+ }
332
+ const prefetch = prefetchBrowserRemoteCoalesced(root, options).finally(() => {
333
+ if (browserRemotePrefetchPromises.get(key) === prefetch) browserRemotePrefetchPromises.delete(key);
334
+ });
335
+ browserRemotePrefetchPromises.set(key, prefetch);
336
+ return prefetch;
337
+ }
338
+
339
+ async function prefetchBrowserRemoteCoalesced(root, options) {
340
+ const prepared = await serializeWorkspaceMutation(root, async () => {
203
341
  const config = await getRepositoryConfig(root);
204
- if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
205
- return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
342
+ if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) return { config, repository: null };
343
+ const repository = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
344
+ return { config, repository };
345
+ });
346
+ if (!prepared.repository) return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
347
+
348
+ // Fetch updates only remote-tracking refs, so it does not occupy the source mutation queue.
349
+ await fetchConfiguredRemote(root, prepared.config.remote);
350
+ return serializeWorkspaceMutation(root, async () => {
351
+ const summary = await getRepositorySnapshot(root, { fresh: true });
352
+ if (!summary.available) throw summary.error;
353
+ if (summary.commit !== prepared.repository.currentCommit) {
354
+ throw new Error("The authoritative branch changed while FileGRC checked its remote. Reload and try again.");
206
355
  }
207
- measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
208
- await fetchConfiguredRemote(root, config.remote);
209
- const summary = getGitSummary(root);
210
- const repository = inspectTrunkRepository(root, config, summary);
356
+ const repository = inspectTrunkRepository(root, prepared.config, summary);
211
357
  const fetchedAt = new Date().toISOString();
212
358
  const token = randomUUID();
213
359
  browserRemotePrefetches.set(root, {
214
360
  token,
215
- remote: config.remote,
361
+ remote: prepared.config.remote,
216
362
  currentCommit: summary.commit,
217
363
  upstreamCommit: repository.upstreamCommit,
218
364
  fetchedAt: Date.parse(fetchedAt)
@@ -236,21 +382,21 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
236
382
  if (options.allowNonAuthoritativeWrites === true) {
237
383
  throw new Error("Retry sync is disabled while the development write override is active.");
238
384
  }
239
- const before = requireTrunkPreconditions(root, config, { allowAhead: true });
385
+ const before = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
240
386
  await fetchConfiguredRemote(root, config.remote);
241
- const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
387
+ const synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
242
388
  if (synchronized.behind > 0 && synchronized.ahead > 0) {
243
389
  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.");
244
390
  }
245
391
  if (synchronized.behind > 0) {
246
- fastForwardConfiguredBranch(root, synchronized.upstream);
392
+ await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
247
393
  }
248
- const ready = inspectTrunkRepository(root, config, getGitSummary(root));
394
+ const ready = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
249
395
  if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
250
396
  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.");
251
397
  }
252
398
  if (ready.ahead > 0) await pushConfiguredBranch(root, config);
253
- const after = inspectTrunkRepository(root, config, getGitSummary(root));
399
+ const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
254
400
  if (after.ahead !== 0 || after.behind !== 0) {
255
401
  throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
256
402
  }
@@ -269,11 +415,11 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
269
415
  }
270
416
 
271
417
  async function runTrunkMutationUnlocked(root, config, options, task) {
272
- const beforeFetch = measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
418
+ const beforeFetch = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
273
419
  let synchronized = beforeFetch;
274
420
  if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
275
421
  await fetchConfiguredRemote(root, config.remote);
276
- synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
422
+ synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
277
423
  }
278
424
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
279
425
  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.");
@@ -282,8 +428,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
282
428
  throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
283
429
  }
284
430
  if (synchronized.behind > 0) {
285
- fastForwardConfiguredBranch(root, synchronized.upstream);
286
- synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
431
+ await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
432
+ synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
287
433
  }
288
434
  if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
289
435
  throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
@@ -293,7 +439,9 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
293
439
  let subject;
294
440
  let validationProof;
295
441
  try {
296
- result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root)));
442
+ result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root, {
443
+ repositorySnapshot: synchronized
444
+ })));
297
445
  subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
298
446
  const validation = await validateWorkspace(root);
299
447
  if (!validation.ok) {
@@ -305,17 +453,19 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
305
453
  validation,
306
454
  fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
307
455
  };
308
- assertNoOutsideWorktreeChanges(root);
456
+ await assertNoOutsideWorktreeChangesAsync(root);
309
457
  } catch (error) {
310
458
  try {
311
- await rollbackWorkspaceChanges(root);
459
+ await rollbackWorkspaceChangesAsync(root);
312
460
  } catch (rollbackError) {
313
461
  throw new Error(`${error.message} FileGRC could not roll back the workspace change. ${rollbackError.message} Later browser mutations are blocked until the Git worktree is reconciled.`);
314
462
  }
315
463
  throw error;
316
464
  }
317
465
 
318
- const changed = getGitSummary(root).changes.length > 0;
466
+ const changed = Boolean(await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
467
+ operation: "check the FileGRC workspace change"
468
+ }));
319
469
  if (!changed && options?.allowNoChanges === true) {
320
470
  return withValidationProof({
321
471
  ...result,
@@ -332,23 +482,37 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
332
482
  if (!changed) {
333
483
  throw new Error("The browser action did not change any FileGRC workspace files.");
334
484
  }
335
- if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
485
+ let identity;
486
+ try {
487
+ identity = await runGitCommand(root, ["config", "--get-regexp", "^user\\.(name|email)$"], {
488
+ operation: "read the Git user identity"
489
+ });
490
+ } catch (error) {
491
+ if (error instanceof GitOperationError && error.kind === "command-failure") identity = "";
492
+ else throw error;
493
+ }
494
+ if (!/^user\.name\s+.+$/m.test(identity) || !/^user\.email\s+.+$/m.test(identity)) {
336
495
  throw new Error("Configure git user.name and git user.email before browser changes can be committed. The saved files remain uncommitted and later browser changes are blocked.");
337
496
  }
338
- measureTimingSync("stage", () => {
339
- gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
340
- });
341
- assertNoOutsideWorktreeChanges(root, false);
342
- assertOnlyWorkspaceFilesStaged(root);
497
+ await measureTiming("stage", () => runGitCommand(root, ["add", "--all", "--", "."], {
498
+ operation: "stage the FileGRC workspace change",
499
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
500
+ }));
501
+ await assertNoOutsideWorktreeChangesAsync(root, false);
502
+ await assertOnlyWorkspaceFilesStagedAsync(root);
343
503
  try {
344
- measureTimingSync("commit", () => {
345
- gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
346
- });
504
+ await measureTiming("commit", () => runGitCommand(root, ["commit", "-m", subject, "--", "."], {
505
+ operation: "create the FileGRC browser commit",
506
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
507
+ }));
347
508
  } catch (error) {
348
509
  throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
349
510
  }
350
511
 
351
- const committed = getGitSummary(root);
512
+ const commit = await runGitCommand(root, ["rev-parse", "HEAD"], {
513
+ operation: "read the FileGRC browser commit"
514
+ });
515
+ const committed = { commit, shortCommit: commit.slice(0, 8) };
352
516
  queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
353
517
  return withValidationProof({
354
518
  ...result,
@@ -396,9 +560,9 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
396
560
  startedAt: new Date().toISOString(),
397
561
  error: null
398
562
  });
399
- const start = () => {
563
+ const start = async () => {
400
564
  try {
401
- const ready = requireTrunkPreconditions(root, config, { allowAhead: true });
565
+ const ready = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
402
566
  if (ready.currentCommit !== committed.commit) {
403
567
  throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
404
568
  }
@@ -408,7 +572,7 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
408
572
  if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
409
573
  throw new Error("The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.");
410
574
  }
411
- void finishBackgroundPush(root, config, committed);
575
+ await finishBackgroundPush(root, config, committed);
412
576
  } catch (error) {
413
577
  recordBackgroundPushFailure(root, committed, error);
414
578
  }
@@ -423,7 +587,7 @@ async function finishBackgroundPush(root, config, committed) {
423
587
  let outcome = "failed";
424
588
  try {
425
589
  await pushConfiguredBranch(root, config, committed.commit);
426
- const after = inspectTrunkRepository(root, config, getGitSummary(root), { ignoreBackground: true });
590
+ const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }), { ignoreBackground: true });
427
591
  if (after.ahead !== 0 || after.behind !== 0) {
428
592
  throw new Error("The authoritative branch is still not synchronized after the background push.");
429
593
  }
@@ -588,7 +752,7 @@ async function getRepositoryConfig(root) {
588
752
  };
589
753
  }
590
754
 
591
- function inspectTrunkRepository(root, config, summary = getGitSummary(root), options = {}) {
755
+ function inspectTrunkRepository(root, config, summary, options = {}) {
592
756
  const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
593
757
  const base = {
594
758
  mode: "trunk",
@@ -602,8 +766,8 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
602
766
  pendingCommits: [],
603
767
  pendingCommitsFilegrcOnly: null,
604
768
  lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
605
- wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
606
- operationInProgress: summary.available ? repositoryOperation(root) : null,
769
+ wholeWorktreeClean: summary.available ? summary.wholeWorktreeClean : null,
770
+ operationInProgress: summary.available ? summary.operationInProgress : null,
607
771
  backgroundSynchronization: background ? {
608
772
  status: background.status,
609
773
  commit: background.commit,
@@ -625,9 +789,9 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
625
789
  if (!summary.available) {
626
790
  return {
627
791
  ...base,
628
- status: "git-setup-required",
629
- label: "Git setup required",
630
- message: "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
792
+ status: summary.error?.kind === "missing-executable" ? "git-setup-required" : "git-error",
793
+ label: summary.error?.kind === "missing-executable" ? "Git setup required" : "Git error",
794
+ message: summary.message || "Git history is unavailable for this workspace."
631
795
  };
632
796
  }
633
797
  if (summary.branch !== config.authoritativeBranch) {
@@ -655,10 +819,10 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
655
819
  message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
656
820
  };
657
821
  }
658
- const upstreamCommit = tryGit(root, ["rev-parse", expectedUpstream]) || null;
659
- const counts = upstreamCommit ? aheadBehind(root, expectedUpstream) : { ahead: null, behind: null };
660
- const pendingCommits = counts.ahead > 0 ? commitsAhead(root, expectedUpstream) : [];
661
- const pendingCommitsFilegrcOnly = counts.ahead > 0 ? commitsOnlyTouchWorkspace(root, expectedUpstream) : true;
822
+ const upstreamCommit = summary.upstreamCommit;
823
+ const counts = { ahead: summary.ahead, behind: summary.behind };
824
+ const pendingCommits = summary.pendingCommits;
825
+ const pendingCommitsFilegrcOnly = summary.pendingCommitsFilegrcOnly;
662
826
  const details = {
663
827
  ...base,
664
828
  upstreamCommit,
@@ -736,11 +900,11 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
736
900
  };
737
901
  }
738
902
 
739
- function requireTrunkPreconditions(root, config, options = {}) {
740
- const summary = getGitSummary(root);
903
+ async function requireTrunkPreconditionsAsync(root, config, options = {}) {
904
+ const summary = await getRepositorySnapshot(root, { fresh: true });
741
905
  const state = inspectTrunkRepository(root, config, summary);
742
906
  if (config.configurationError) throw new Error(state.message);
743
- if (!summary.available) throw new Error(state.message);
907
+ if (!summary.available) throw summary.error || new Error(state.message);
744
908
  if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
745
909
  if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
746
910
  if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
@@ -756,8 +920,11 @@ async function fetchConfiguredRemote(root, remote) {
756
920
  return measureTiming("fetch", () => gitForWriteAsync(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`));
757
921
  }
758
922
 
759
- function fastForwardConfiguredBranch(root, upstream) {
760
- gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
923
+ function fastForwardConfiguredBranchAsync(root, upstream) {
924
+ return runGitCommand(root, ["merge", "--ff-only", "--", upstream], {
925
+ operation: `fast-forward from ${upstream}`,
926
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
927
+ });
761
928
  }
762
929
 
763
930
  async function pushConfiguredBranch(root, config, source = "HEAD") {
@@ -768,60 +935,15 @@ async function pushConfiguredBranch(root, config, source = "HEAD") {
768
935
  ));
769
936
  }
770
937
 
771
- function wholeWorktreeClean(root) {
772
- return git(root, ["status", "--porcelain=v1"]) === "";
773
- }
774
-
775
- function repositoryOperation(root) {
776
- for (const [name, gitPath] of [
777
- ["merge", "MERGE_HEAD"],
778
- ["rebase", "rebase-merge"],
779
- ["rebase", "rebase-apply"],
780
- ["cherry-pick", "CHERRY_PICK_HEAD"]
781
- ]) {
782
- const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
783
- if (path && existsSync(resolve(root, path))) return name;
784
- }
785
- return null;
786
- }
787
-
788
- function aheadBehind(root, upstream) {
789
- const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
790
- const [ahead, behind] = output.split(/\s+/).map(Number);
791
- return Number.isInteger(ahead) && Number.isInteger(behind)
792
- ? { ahead, behind }
793
- : { ahead: null, behind: null };
794
- }
795
-
796
- function commitsAhead(root, upstream) {
797
- return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
798
- const [commit, subject] = line.split("\x1f");
799
- return { commit, shortCommit: commit.slice(0, 8), subject };
800
- });
801
- }
802
-
803
- function commitsOnlyTouchWorkspace(root, upstream) {
804
- const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
938
+ async function assertNoOutsideWorktreeChangesAsync(root, rollbackExpected = true) {
939
+ const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
940
+ operation: "locate the repository before checking worktree changes"
941
+ })).trim();
805
942
  const prefix = relative(topLevel, root).split(sep).join("/");
806
- const commits = lines(tryGit(root, ["rev-list", `${upstream}..HEAD`]));
807
- return commits.every((commit) => {
808
- const paths = nulFields(tryGitRaw(topLevel, [
809
- "diff-tree",
810
- "--no-commit-id",
811
- "--name-only",
812
- "-z",
813
- "-r",
814
- "--root",
815
- commit
816
- ]));
817
- return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
943
+ const output = await runGitCommand(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
944
+ operation: "inspect worktree changes"
818
945
  });
819
- }
820
-
821
- function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
822
- const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
823
- const prefix = relative(topLevel, root).split(sep).join("/");
824
- const paths = statusPaths(topLevel);
946
+ const paths = statusPathsFromRaw(output);
825
947
  if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
826
948
  throw new Error(rollbackExpected
827
949
  ? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
@@ -829,25 +951,31 @@ function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
829
951
  }
830
952
  }
831
953
 
832
- function assertOnlyWorkspaceFilesStaged(root) {
833
- const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
954
+ async function assertOnlyWorkspaceFilesStagedAsync(root) {
955
+ const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
956
+ operation: "locate the repository before checking staged files"
957
+ })).trim();
834
958
  const prefix = relative(topLevel, root).split(sep).join("/");
835
- const staged = nulFields(tryGitRaw(topLevel, [
836
- "diff",
837
- "--cached",
838
- "--name-only",
839
- "-z",
840
- "--diff-filter=ACDMRTUXB"
841
- ]));
959
+ const output = await runGitCommand(topLevel, [
960
+ "diff", "--cached", "--name-only", "-z", "--diff-filter=ACDMRTUXB"
961
+ ], { operation: "inspect staged FileGRC files" });
962
+ const staged = nulFields(output);
842
963
  if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
843
964
  if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
844
965
  throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
845
966
  }
846
967
  }
847
968
 
848
- async function rollbackWorkspaceChanges(root) {
849
- gitForWrite(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], "roll back the FileGRC workspace change");
850
- const untracked = nulFields(tryGitRaw(root, ["ls-files", "-z", "--others", "--exclude-standard", "--", "."]));
969
+ async function rollbackWorkspaceChangesAsync(root) {
970
+ await runGitCommand(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], {
971
+ operation: "roll back the FileGRC workspace change",
972
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
973
+ });
974
+ const untracked = nulFields(await runGitCommand(
975
+ root,
976
+ ["ls-files", "-z", "--others", "--exclude-standard", "--", "."],
977
+ { operation: "list untracked FileGRC files" }
978
+ ));
851
979
  for (const path of untracked) {
852
980
  const absolute = resolve(root, path);
853
981
  if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
@@ -855,8 +983,7 @@ async function rollbackWorkspaceChanges(root) {
855
983
  }
856
984
  }
857
985
 
858
- function statusPaths(topLevel) {
859
- const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
986
+ function statusPathsFromRaw(output) {
860
987
  if (!output) return [];
861
988
  const fields = nulFields(output);
862
989
  const paths = [];
@@ -900,6 +1027,187 @@ function lines(source) {
900
1027
  return source ? source.split("\n").filter(Boolean) : [];
901
1028
  }
902
1029
 
1030
+ function parsePorcelainV2(source, topLevel, root) {
1031
+ const fields = source.split("\0").filter(Boolean);
1032
+ const prefix = relative(topLevel, root).split(sep).join("/");
1033
+ let commit = null;
1034
+ let branch = null;
1035
+ let upstream = null;
1036
+ let ahead = null;
1037
+ let behind = null;
1038
+ const allChanges = [];
1039
+ const workspaceChanges = [];
1040
+ for (let index = 0; index < fields.length; index += 1) {
1041
+ const field = fields[index];
1042
+ if (field.startsWith("# branch.oid ")) commit = field.slice(13) === "(initial)" ? null : field.slice(13);
1043
+ else if (field.startsWith("# branch.head ")) branch = field.slice(14) === "(detached)" ? null : field.slice(14);
1044
+ else if (field.startsWith("# branch.upstream ")) upstream = field.slice(18);
1045
+ else if (field.startsWith("# branch.ab ")) {
1046
+ const match = /\+(\d+) -(\d+)/.exec(field);
1047
+ if (match) [ahead, behind] = match.slice(1).map(Number);
1048
+ } else if (/^[12u?!] /.test(field)) {
1049
+ const path = porcelainV2Path(field);
1050
+ allChanges.push(field);
1051
+ const originalPath = field.startsWith("2 ") ? fields[index + 1] : null;
1052
+ if (pathInsideWorkspace(path, prefix) || originalPath && pathInsideWorkspace(originalPath, prefix)) {
1053
+ workspaceChanges.push(field);
1054
+ }
1055
+ if (originalPath) index += 1;
1056
+ }
1057
+ }
1058
+ return { commit, branch, upstream, ahead, behind, allChanges, workspaceChanges };
1059
+ }
1060
+
1061
+ function porcelainV2Path(field) {
1062
+ if (field.startsWith("? ") || field.startsWith("! ")) return field.slice(2);
1063
+ const requiredSpaces = field.startsWith("2 ") ? 9 : field.startsWith("u ") ? 10 : 8;
1064
+ let offset = 0;
1065
+ for (let count = 0; count < requiredSpaces; count += 1) {
1066
+ offset = field.indexOf(" ", offset) + 1;
1067
+ if (!offset) return "";
1068
+ }
1069
+ return field.slice(offset);
1070
+ }
1071
+
1072
+ function parsePendingCommitPaths(source) {
1073
+ return source.split("\x1e").flatMap((block) => {
1074
+ const [header, ...paths] = block.trim().split("\n").filter(Boolean);
1075
+ if (!header) return [];
1076
+ const [commit, subject] = header.split("\x1f");
1077
+ return [{ commit, subject, paths }];
1078
+ });
1079
+ }
1080
+
1081
+ function repositoryOperationFromDirectory(gitDirectory) {
1082
+ if (!gitDirectory) return null;
1083
+ for (const [name, path] of [
1084
+ ["merge", "MERGE_HEAD"],
1085
+ ["rebase", "rebase-merge"],
1086
+ ["rebase", "rebase-apply"],
1087
+ ["cherry-pick", "CHERRY_PICK_HEAD"]
1088
+ ]) {
1089
+ if (existsSync(resolve(gitDirectory, path))) return name;
1090
+ }
1091
+ return null;
1092
+ }
1093
+
1094
+ export function setGitCommandInterceptorForTests(interceptor) {
1095
+ if (interceptor !== null && typeof interceptor !== "function") {
1096
+ throw new TypeError("The Git command interceptor must be a function or null.");
1097
+ }
1098
+ const previous = gitCommandInterceptor;
1099
+ gitCommandInterceptor = interceptor;
1100
+ return () => { gitCommandInterceptor = previous; };
1101
+ }
1102
+
1103
+ export function runGitCommand(cwd, args, options = {}) {
1104
+ if (gitCommandInterceptor) {
1105
+ return Promise.resolve().then(() => gitCommandInterceptor({
1106
+ cwd,
1107
+ args: [...args],
1108
+ options: { ...options },
1109
+ run: () => runGitCommandNative(cwd, args, options)
1110
+ }));
1111
+ }
1112
+ return runGitCommandNative(cwd, args, options);
1113
+ }
1114
+
1115
+ function runGitCommandNative(cwd, args, options = {}) {
1116
+ const operation = options.operation || "run a Git command";
1117
+ const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
1118
+ const timeoutMs = Math.max(1, Number(configuredTimeout) || GIT_DEFAULT_TIMEOUT_MS);
1119
+ const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
1120
+ return new Promise((resolveCommand, rejectCommand) => {
1121
+ const child = spawn("git", args, {
1122
+ cwd,
1123
+ stdio: ["ignore", "pipe", "pipe"],
1124
+ detached: process.platform !== "win32",
1125
+ env: {
1126
+ ...process.env,
1127
+ GIT_TERMINAL_PROMPT: "0",
1128
+ GIT_ASKPASS: "",
1129
+ SSH_ASKPASS: "",
1130
+ GIT_MERGE_AUTOEDIT: "no"
1131
+ }
1132
+ });
1133
+ const stdout = [];
1134
+ const stderr = [];
1135
+ let outputSize = 0;
1136
+ let settled = false;
1137
+ let timedOut = false;
1138
+ let outputExceeded = false;
1139
+ let forceKillTimer;
1140
+ const terminate = (signal) => {
1141
+ try {
1142
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
1143
+ else child.kill(signal);
1144
+ } catch {
1145
+ try { child.kill(signal); } catch { /* The child already exited. */ }
1146
+ }
1147
+ };
1148
+ const stop = () => {
1149
+ terminate("SIGTERM");
1150
+ forceKillTimer = setTimeout(() => terminate("SIGKILL"), 1_000);
1151
+ forceKillTimer.unref?.();
1152
+ };
1153
+ const timer = setTimeout(() => {
1154
+ timedOut = true;
1155
+ stop();
1156
+ }, timeoutMs);
1157
+ timer.unref?.();
1158
+ const collect = (target, chunk) => {
1159
+ outputSize += chunk.length;
1160
+ if (outputSize <= maxOutputBytes) target.push(chunk);
1161
+ else if (!outputExceeded) {
1162
+ outputExceeded = true;
1163
+ stop();
1164
+ }
1165
+ };
1166
+ child.stdout.on("data", (chunk) => collect(stdout, chunk));
1167
+ child.stderr.on("data", (chunk) => collect(stderr, chunk));
1168
+ child.once("error", (error) => {
1169
+ if (settled) return;
1170
+ settled = true;
1171
+ clearTimeout(timer);
1172
+ clearTimeout(forceKillTimer);
1173
+ rejectCommand(new GitOperationError(
1174
+ error.code === "ENOENT" ? "missing-executable" : "command-failure",
1175
+ operation,
1176
+ error.code === "ENOENT" ? "" : error.message,
1177
+ { cause: error, code: error.code }
1178
+ ));
1179
+ });
1180
+ child.once("close", (code, signal) => {
1181
+ if (settled) return;
1182
+ settled = true;
1183
+ clearTimeout(timer);
1184
+ clearTimeout(forceKillTimer);
1185
+ const output = Buffer.concat(stdout).toString("utf8");
1186
+ const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
1187
+ if (code === 0 && !timedOut && !outputExceeded) return resolveCommand(output.trim());
1188
+ const detail = outputExceeded
1189
+ ? `Git output exceeded ${maxOutputBytes} bytes.`
1190
+ : timedOut
1191
+ ? `The operation exceeded ${timeoutMs} ms and the process group was terminated.`
1192
+ : errorOutput || `Git exited with status ${code ?? signal ?? "unknown"}.`;
1193
+ const kind = timedOut
1194
+ ? "timeout"
1195
+ : /not a git repository|outside repository/i.test(errorOutput)
1196
+ ? "invalid-repository"
1197
+ : "command-failure";
1198
+ rejectCommand(new GitOperationError(kind, operation, detail));
1199
+ });
1200
+ });
1201
+ }
1202
+
1203
+ async function tryGitAsync(cwd, args, operation) {
1204
+ try {
1205
+ return await runGitCommand(cwd, args, { operation });
1206
+ } catch {
1207
+ return "";
1208
+ }
1209
+ }
1210
+
903
1211
  function git(cwd, args) {
904
1212
  return execFileSync("git", args, {
905
1213
  cwd,
@@ -961,77 +1269,18 @@ function gitForWrite(cwd, args, action = "create the commit") {
961
1269
  }
962
1270
 
963
1271
  async function gitForWriteAsync(cwd, args, action = "update the repository") {
964
- return new Promise((resolve, reject) => {
965
- const child = spawn("git", args, {
966
- cwd,
967
- stdio: ["ignore", "pipe", "pipe"],
968
- detached: process.platform !== "win32",
969
- env: {
970
- ...process.env,
971
- GIT_TERMINAL_PROMPT: "0",
972
- GIT_MERGE_AUTOEDIT: "no"
973
- }
974
- });
975
- const stdout = [];
976
- const stderr = [];
977
- let size = 0;
978
- let timedOut = false;
979
- let forceKillTimer = null;
980
- const terminate = (signal) => {
981
- try {
982
- if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
983
- else child.kill(signal);
984
- } catch {
985
- try {
986
- child.kill(signal);
987
- } catch {
988
- // The process may have exited between the timeout and termination.
989
- }
990
- }
991
- };
992
- const timer = setTimeout(() => {
993
- timedOut = true;
994
- terminate("SIGTERM");
995
- forceKillTimer = setTimeout(() => terminate("SIGKILL"), 2_000);
996
- }, 30_000);
997
- child.stdout.on("data", (chunk) => {
998
- size += chunk.length;
999
- if (size <= 20_000_000) stdout.push(chunk);
1000
- });
1001
- child.stderr.on("data", (chunk) => {
1002
- size += chunk.length;
1003
- if (size <= 20_000_000) stderr.push(chunk);
1004
- });
1005
- child.once("error", (error) => {
1006
- clearTimeout(timer);
1007
- clearTimeout(forceKillTimer);
1008
- reject(new Error(`Git could not ${action}. ${sanitizeGitErrorMessage(error.message)}`));
1009
- });
1010
- child.once("close", (code) => {
1011
- clearTimeout(timer);
1012
- clearTimeout(forceKillTimer);
1013
- const output = Buffer.concat(stdout).toString("utf8").trim();
1014
- const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
1015
- if (code === 0 && !timedOut && size <= 20_000_000) {
1016
- resolve(output);
1017
- return;
1018
- }
1019
- const detail = size > 20_000_000
1020
- ? "Git output exceeded 20 MB."
1021
- : timedOut
1022
- ? "Git timed out after 30 seconds."
1023
- : errorOutput || output || `Git exited with status ${code}.`;
1024
- const message = sanitizeGitErrorMessage(detail);
1025
- reject(new Error(`Git could not ${action}. ${message}`));
1026
- });
1272
+ return runGitCommand(cwd, args, {
1273
+ operation: action,
1274
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
1027
1275
  });
1028
1276
  }
1029
1277
 
1030
1278
  export function sanitizeGitErrorMessage(value) {
1031
- return String(value || "Git returned no error detail.")
1279
+ const sanitized = String(value || "Git returned no error detail.")
1032
1280
  .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
1033
1281
  .replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
1034
1282
  .replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
1283
+ return sanitized.length > 8_000 ? `${sanitized.slice(0, 8_000)}…` : sanitized;
1035
1284
  }
1036
1285
 
1037
1286
  function tryGitForWrite(cwd, args) {
package/src/index.js CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  getBrowserRepositoryState,
41
41
  getFileHistory,
42
42
  getGitSummary,
43
+ getRepositorySnapshot,
43
44
  getWorkspaceHistories,
44
45
  prefetchBrowserRemote,
45
46
  pullWorkspace,
package/src/server.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  commitAndPushWorkspace,
24
24
  getBrowserRepositoryState,
25
25
  getFileHistory,
26
- getGitSummary,
26
+ getRepositorySnapshot,
27
27
  prefetchBrowserRemote,
28
28
  pullWorkspace,
29
29
  pushWorkspace,
@@ -174,8 +174,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
174
174
  message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`,
175
175
  fastResponse: prefersFastMutation(request),
176
176
  prefetchToken
177
- }, () => applyApplicabilityReview(input, {
177
+ }, (_root, mutationContext) => applyApplicabilityReview(input, {
178
178
  ...reviewPayload,
179
+ scopeRevision: mutationContext?.repositorySnapshot?.currentCommit,
179
180
  confirmed: payload.confirmed === true
180
181
  })));
181
182
  }
@@ -189,8 +190,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
189
190
  message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`,
190
191
  fastResponse: prefersFastMutation(request),
191
192
  prefetchToken
192
- }, () => applyCollectionReview(input, {
193
+ }, (_root, mutationContext) => applyCollectionReview(input, {
193
194
  ...reviewPayload,
195
+ scopeRevision: mutationContext?.repositorySnapshot?.currentCommit,
194
196
  confirmed: payload.confirmed === true
195
197
  })));
196
198
  }
@@ -351,10 +353,11 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
351
353
  return json(response, 200, { ...result, state });
352
354
  }
353
355
  if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
356
+ const git = { ...await getRepositorySnapshot(input) };
354
357
  const repository = await getBrowserRepositoryState(input, {
355
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
358
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
359
+ repositorySnapshot: git
356
360
  });
357
- const git = getGitSummary(input);
358
361
  delete git.root;
359
362
  return json(response, 200, {
360
363
  repository,
@@ -705,7 +708,7 @@ function statusFor(error) {
705
708
  if (/exceeds 25 MB/i.test(error.message)) return 413;
706
709
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
707
710
  if (/already exists|target file already exists/i.test(error.message)) return 409;
708
- if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
711
+ if (/Git could not|Git is unavailable|Git timed out|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
709
712
  if (/not found|ENOENT/i.test(error.message)) return 404;
710
713
  if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
711
714
  return 500;
package/src/state.js CHANGED
@@ -2,10 +2,10 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { assessAuditPreparation } from "./audit-preparation.js";
4
4
  import { assessCollectionReviews } from "./collection-review.js";
5
- import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
5
+ import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js";
6
6
  import { renderMarkdown } from "./markdown.js";
7
7
  import { planObligations } from "./obligations.js";
8
- import { resolveDataPath } from "./paths.js";
8
+ import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
9
9
  import { assessProgramReadiness } from "./program-readiness.js";
10
10
  import { markdownEntries } from "./resource-markdown.js";
11
11
  import { currentCalendarDate } from "./time.js";
@@ -16,9 +16,23 @@ import { measureTiming } from "./timing.js";
16
16
 
17
17
  const renderedMarkdownCache = new Map();
18
18
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
19
+ const appStatePromises = new Map();
19
20
 
20
21
  export async function createAppState(input = process.cwd(), options = {}) {
21
- return serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options));
22
+ const key = JSON.stringify([
23
+ resolveWorkspaceRoot(input),
24
+ options.readOnly === true,
25
+ options.allowNonAuthoritativeWrites === true,
26
+ options.includeDetails !== false,
27
+ options.asOf ?? null,
28
+ options.now ?? null
29
+ ]);
30
+ if (!options.validationProof && appStatePromises.has(key)) return appStatePromises.get(key);
31
+ const promise = serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options)).finally(() => {
32
+ if (appStatePromises.get(key) === promise) appStatePromises.delete(key);
33
+ });
34
+ if (!options.validationProof) appStatePromises.set(key, promise);
35
+ return promise;
22
36
  }
23
37
 
24
38
  async function createAppStateUnlocked(input, options) {
@@ -47,11 +61,12 @@ async function createAppStateUnlocked(input, options) {
47
61
  }
48
62
  });
49
63
 
50
- const git = getGitSummary(loaded.root);
64
+ const git = { ...await measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root)) };
51
65
  delete git.root;
52
66
  const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded.root, {
53
67
  readOnly: options.readOnly,
54
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
68
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
69
+ repositorySnapshot: git
55
70
  }));
56
71
  const workspace = loaded.workspace ?? {
57
72
  dataModelVersion: loaded.model.modelVersion,
package/src/validate.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { getResourceDefinition } from "../model/index.js";
5
+ import { scopedCollectionRecords } from "./collection-scope.js";
5
6
  import { isSafeGitName } from "./git-name.js";
6
7
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
7
8
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
@@ -78,7 +79,7 @@ async function validateWorkspaceUnmeasured(input) {
78
79
  if (record.type === "control") validateControlComponents(record, byId, displayPath, diagnostics);
79
80
  if (record.type === "audit") validateAuditSubservices(record, byId, displayPath, diagnostics);
80
81
  if (record.type === "collection-review") {
81
- validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
82
+ validateCollectionReview(record, loaded, byId, displayPath, diagnostics);
82
83
  }
83
84
  if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
84
85
  if (record.type === "action-item") {
@@ -158,8 +159,9 @@ async function validateWorkspaceUnmeasured(input) {
158
159
  };
159
160
  }
160
161
 
161
- function validateCollectionReview(record, model, resources, byId, path, diagnostics) {
162
+ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
162
163
  if (record.status !== "active") return;
164
+ const { model } = loaded;
163
165
  const configuration = model.collectionReviews?.[record.resourceType];
164
166
  if (!configuration) return;
165
167
  const allowedDecisions = configuration.decisions || ["complete"];
@@ -174,21 +176,7 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
174
176
  const program = String(model.modelVersion) === "4"
175
177
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
176
178
  : null;
177
- const programSystemIds = new Set(program?.systemIds || []);
178
- const componentIds = new Set(resources.filter((candidate) => (
179
- candidate.type === "component"
180
- && (candidate.systemUses || []).some(({ systemId }) => programSystemIds.has(systemId))
181
- )).map(({ id }) => id));
182
- const selectedIds = {
183
- system: programSystemIds,
184
- component: componentIds,
185
- framework: new Set(program?.frameworkIds || []),
186
- vendor: new Set(resources.filter(({ id }) => componentIds.has(id)).map(({ vendorId }) => vendorId).filter(Boolean)),
187
- asset: new Set(resources.filter(({ type, componentIds: ids }) => type === "asset" && (ids || []).some((id) => componentIds.has(id))).map(({ id }) => id))
188
- }[record.resourceType];
189
- const recordCount = resources.filter(({ type, id }) => (
190
- type === record.resourceType && (!program || !selectedIds || selectedIds.has(id))
191
- )).length;
179
+ const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
192
180
  if (!recordCount && record.decision === "complete") {
193
181
  diagnostics.push(error(
194
182
  "invalid-collection-review-decision",
package/src/web.js CHANGED
@@ -512,6 +512,7 @@ function openCollectionReviewDialog(type) {
512
512
  rationale: form.elements.rationale.value.trim(),
513
513
  reviewedByIds: [form.elements.reviewerId.value],
514
514
  reviewedOn: form.elements.reviewedOn.value,
515
+ scopeRevision: state.git?.commit || undefined,
515
516
  [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
516
517
  expectedRevision: assessment.reviewRevision || undefined
517
518
  };
@@ -519,6 +520,7 @@ function openCollectionReviewDialog(type) {
519
520
  try {
520
521
  saveStatus.textContent = "Validating and saving…";
521
522
  const prefetch = await repositoryPrefetch;
523
+ if (prefetch?.error) throw prefetch.error;
522
524
  const response = await localFetch("/api/collection-review", {
523
525
  method: "POST",
524
526
  headers: { "content-type": "application/json", prefer: "respond-async" },
@@ -1509,6 +1511,7 @@ function openApplicabilityReviewDialog(type, entries) {
1509
1511
  decisions,
1510
1512
  reviewedByIds: [form.elements.reviewerId.value],
1511
1513
  reviewedOn: form.elements.reviewedOn.value,
1514
+ scopeRevision: state.git?.commit || undefined,
1512
1515
  expectedRevisions: Object.fromEntries([
1513
1516
  ...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
1514
1517
  ...(type === "requirement" && String(state.model.modelVersion) === "4"
@@ -1532,6 +1535,7 @@ function openApplicabilityReviewDialog(type, entries) {
1532
1535
  } else {
1533
1536
  saveStatus.textContent = "Validating and saving…";
1534
1537
  const prefetch = await repositoryPrefetch;
1538
+ if (prefetch?.error) throw prefetch.error;
1535
1539
  const response = await localFetch("/api/applicability-review", {
1536
1540
  method: "POST",
1537
1541
  headers: { "content-type": "application/json", prefer: "respond-async" },
@@ -4147,9 +4151,9 @@ function prefetchRepositoryForReview(status) {
4147
4151
  status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
4148
4152
  return result;
4149
4153
  })
4150
- .catch(() => {
4151
- status.textContent = "Repository will be checked when you save";
4152
- return null;
4154
+ .catch((error) => {
4155
+ status.textContent = "Repository check failed";
4156
+ return { error };
4153
4157
  });
4154
4158
  }
4155
4159