filegrc 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { modelSupports } from "../model/index.js";
4
+ import { performance } from "node:perf_hooks";
5
+ import { loadModel, modelSupports, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
5
6
  import { createObligationEvent, obligationRule } from "./obligations.js";
6
7
  import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
7
- import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
8
+ import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js";
8
9
  import { markdownEntries } from "./resource-markdown.js";
9
10
  import { loadWorkspace } from "./workspace.js";
10
11
  import { serializeWorkspaceMutation } from "./mutation.js";
11
12
  import { currentCalendarDate } from "./time.js";
13
+ import { assertHistoricalRecordValid } from "./validate.js";
12
14
 
13
15
  const TRANSITIONS = {
14
16
  person: [
@@ -95,6 +97,27 @@ const TRANSITIONS = {
95
97
  message: "Confirm whether this Asset disposal needs sanitization and evidence work."
96
98
  }]
97
99
  };
100
+ const MAX_RECONCILIATION_HISTORY_BYTES = 32 * 1024 * 1024;
101
+ const MAX_RECONCILIATION_HISTORY_REQUESTS = 20_000;
102
+ const MAX_MERGE_IDENTITY_CHECKS = 100_000;
103
+ const MAX_RECONCILIATION_MATCH_EVALUATIONS = 100_000;
104
+ const MAX_RECONCILIATION_SNAPSHOT_RECORDS = 100_000;
105
+ const RECORD_STATE_CHECKPOINT_INTERVAL = 64;
106
+ const RECONCILIATION_HISTORY_TYPES = new Set([
107
+ ...Object.keys(TRANSITIONS),
108
+ "action-item",
109
+ "obligation",
110
+ "obligation-event",
111
+ "obligation-rule",
112
+ "reconciliation-dismissal"
113
+ ]);
114
+
115
+ function allowsHistoricalTypeTransition(parentModel, currentModel, beforeType, afterType) {
116
+ return parentModel?.modelVersion === "3"
117
+ && currentModel?.modelVersion === "4"
118
+ && beforeType === "system"
119
+ && afterType === "component";
120
+ }
98
121
 
99
122
  export async function planReconciliation(input = process.cwd(), options = {}) {
100
123
  const loaded = input?.entries && input?.resources && input?.model
@@ -109,71 +132,107 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
109
132
  message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
110
133
  };
111
134
  }
112
- const baseRevision = gitRevision(loaded.root);
135
+ const historyDeadline = performance.now() + 10_000;
136
+ const baseRevision = gitRevision(loaded.root, historyDeadline);
113
137
  const currentByPath = new Map(loaded.entries.map((entry) => [
114
138
  `data/${entry.relativePath}`,
115
139
  entry
116
140
  ]));
117
- const workingChanges = pairWorkingRecordRenames(
118
- loaded.root,
119
- gitChangedEntries(loaded.root),
120
- currentByPath
121
- );
122
- const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
123
- [beforePath, afterPath].filter(Boolean)
124
- )))].sort();
141
+ const rawWorkingChanges = gitChangedEntries(loaded.root, historyDeadline, baseRevision);
125
142
  if (!baseRevision) {
126
143
  return {
127
144
  contractVersion: 1,
128
145
  gitRevision: null,
129
- changedPaths,
146
+ changedPaths: [...new Set(rawWorkingChanges.flatMap(({ beforePath, afterPath }) => (
147
+ [beforePath, afterPath].filter(Boolean)
148
+ )))].sort(),
130
149
  candidates: [],
131
150
  message: "Commit the initial workspace before reconciling later direct-file transitions."
132
151
  };
133
152
  }
134
- const reconciliationHistory = reconciliationCommits(loaded.root);
135
- const historicalRecords = reconciliationHistory.length
136
- ? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
137
- : new Map();
138
- const historyIndex = getDataRecordHistoryIndex(loaded.root);
153
+ const historyIndex = getDataRecordHistoryIndex(loaded.root, { deadline: historyDeadline, head: baseRevision });
154
+ if (!historyIndex.available) {
155
+ throw new Error(`Git history is unavailable for reconciliation${historyIndex.error?.message ? `: ${historyIndex.error.message}` : "."}`);
156
+ }
157
+ const workingChanges = pairWorkingRecordRenames(loaded.root, rawWorkingChanges, currentByPath, baseRevision);
158
+ const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
159
+ [beforePath, afterPath].filter(Boolean)
160
+ )))].sort();
161
+ const historyContext = { ...historyIndex, recordStatesByCommit: new Map() };
162
+ for (const commit of historyIndex.commits) recordStateAtCommit(historyContext, loaded.model, commit);
163
+ validateMergeIdentities(historyContext, loaded.model);
164
+ const { commits: reconciliationHistory } = reconciliationCommits(historyContext, loaded.model);
139
165
  const committedRecordIds = new Set(historyIndex.historiesById.keys());
166
+ const currentTransitionIndex = indexTransitionResources(loaded.resources);
167
+ const transitionIndexes = new WeakMap([[loaded.resources, currentTransitionIndex]]);
168
+ let transitionMatchEvaluations = 0;
169
+ const consumeMatchEvaluation = () => {
170
+ transitionMatchEvaluations += 1;
171
+ if (transitionMatchEvaluations > MAX_RECONCILIATION_MATCH_EVALUATIONS) {
172
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_MATCH_EVALUATIONS}-match safety limit.`);
173
+ }
174
+ };
140
175
  const eventRecordSnapshots = new Map();
176
+ let snapshotRecordsProcessed = 0;
141
177
  const recordsForEvent = (event) => {
142
178
  if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
143
179
  const history = historyIndex.historiesById.get(event.id) || [];
144
180
  const firstCommit = history.at(-1)?.commit;
145
181
  const records = firstCommit
146
- ? [...recordsAtRevision(loaded.root, firstCommit).values()].map(({ record }) => record)
182
+ ? materializeRecordState(recordStateAtCommit(historyContext, loaded.model, firstCommit)).map(({ record }) => record)
147
183
  : loaded.resources;
184
+ snapshotRecordsProcessed += records.length;
185
+ if (snapshotRecordsProcessed > MAX_RECONCILIATION_SNAPSHOT_RECORDS) {
186
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_SNAPSHOT_RECORDS}-record snapshot safety limit.`);
187
+ }
148
188
  eventRecordSnapshots.set(event.id, records);
189
+ while (eventRecordSnapshots.size > 4) eventRecordSnapshots.delete(eventRecordSnapshots.keys().next().value);
149
190
  return records;
150
191
  };
151
192
  const currentMarkdownOwners = new Map();
152
193
  for (const entry of loaded.entries) {
153
194
  for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
154
195
  }
196
+ const workingMarkdownPathsByRecordId = new Map();
197
+ for (const changed of changedPaths) {
198
+ const ownerId = currentMarkdownOwners.get(changed)?.record.id;
199
+ if (!ownerId) continue;
200
+ if (!workingMarkdownPathsByRecordId.has(ownerId)) workingMarkdownPathsByRecordId.set(ownerId, []);
201
+ workingMarkdownPathsByRecordId.get(ownerId).push(changed);
202
+ }
155
203
  const rawCandidates = [];
156
204
  const filteredCandidates = [];
157
205
  const examined = new Set();
158
-
206
+ const committedContexts = [];
207
+ const historicalRequests = new Map();
159
208
  for (const commit of reconciliationHistory) {
160
- const markdownOwnersBefore = historicalMarkdownOwners(loaded.model, historicalRecords);
161
- for (const change of committedChangedEntries(loaded.root, commit)) {
162
- if (change.beforePath?.endsWith(".json") && !change.afterPath) {
163
- const removed = readRevisionRecord(loaded.root, `${commit}^`, change.beforePath);
164
- if (removed?.id) historicalRecords.delete(removed.id);
165
- }
166
- }
167
- for (const [id, item] of historyIndex.recordsByCommit.get(commit) || []) historicalRecords.set(id, item);
168
- const markdownOwners = historicalMarkdownOwners(loaded.model, historicalRecords);
169
- const commitChanges = pairCommittedRecordRenames(loaded.root, commit, committedChangedEntries(loaded.root, commit));
209
+ const parents = historyContext.parentsByCommit.get(commit) || [];
210
+ const parent = parents[0] || null;
211
+ const recordsBefore = parent ? recordStateAtCommit(historyContext, loaded.model, parent) : null;
212
+ const recordsAfter = recordStateAtCommit(historyContext, loaded.model, commit);
213
+ const changes = historyContext.changesByCommit.get(commit) || [];
214
+ const commitChanges = pairCommittedRecordRenames(changes, recordsBefore, recordsAfter);
170
215
  const commitPaths = [...new Set(commitChanges.flatMap(({ beforePath, afterPath }) => (
171
216
  [beforePath, afterPath].filter(Boolean)
172
217
  )))];
218
+ const changedMarkdownPathsByRecordId = new Map();
219
+ for (const changed of commitPaths.filter((path) => path.endsWith(".md"))) {
220
+ const owners = new Set([
221
+ markdownOwnerAtPath(recordsAfter, changed)?.record.id,
222
+ markdownOwnerAtPath(recordsBefore, changed)?.record.id
223
+ ].filter(Boolean));
224
+ for (const ownerId of owners) {
225
+ if (!changedMarkdownPathsByRecordId.has(ownerId)) changedMarkdownPathsByRecordId.set(ownerId, []);
226
+ changedMarkdownPathsByRecordId.get(ownerId).push(changed);
227
+ }
228
+ }
229
+ const changeContexts = [];
173
230
  const commitExamined = new Set();
174
231
  for (const change of commitChanges) {
175
232
  const path = change.afterPath || change.beforePath;
176
- const currentEntry = currentByPath.get(path) || markdownOwners.get(path) || markdownOwnersBefore.get(path);
233
+ const currentEntry = recordAtPath(recordsAfter, path)
234
+ || markdownOwnerAtPath(recordsAfter, path)
235
+ || markdownOwnerAtPath(recordsBefore, path);
177
236
  const previousRecordPath = change.beforePath?.endsWith(".json")
178
237
  ? change.beforePath
179
238
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
@@ -181,28 +240,96 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
181
240
  ? change.afterPath
182
241
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
183
242
  const recordPath = currentRecordPath || previousRecordPath;
184
- const previous = readRevisionRecord(loaded.root, `${commit}^`, previousRecordPath);
185
- const current = readRevisionRecord(loaded.root, commit, currentRecordPath);
243
+ const previous = recordAtPath(recordsBefore, previousRecordPath)?.record || null;
244
+ const current = recordAtPath(recordsAfter, currentRecordPath)?.record || null;
186
245
  const record = current || previous;
187
246
  if (!record || commitExamined.has(`${record.type}:${record.id}`)) continue;
188
247
  commitExamined.add(`${record.type}:${record.id}`);
189
- const changedMarkdownPaths = commitPaths.filter((changed) => (
190
- markdownOwners.get(changed)?.record.id === record.id
191
- || markdownOwnersBefore.get(changed)?.record.id === record.id
192
- ));
248
+ const changedMarkdownPaths = changedMarkdownPathsByRecordId.get(record.id) || [];
193
249
  const markdownChanged = changedMarkdownPaths.length > 0;
250
+ const alternateParents = parents.slice(1).map((alternateRevision) => {
251
+ const alternateState = recordStateAtCommit(historyContext, loaded.model, alternateRevision);
252
+ const alternateEntry = recordAtId(alternateState, record.id);
253
+ return {
254
+ revision: alternateRevision,
255
+ recordPath: alternateEntry?.path || null,
256
+ markdownPaths: changedMarkdownPaths
257
+ };
258
+ });
259
+ for (const [revision, sourcePath] of [
260
+ [commit, currentRecordPath],
261
+ [parent, previousRecordPath],
262
+ ...changedMarkdownPaths.flatMap((changed) => [[commit, changed], [parent, changed]]),
263
+ ...alternateParents.flatMap((alternate) => [
264
+ [alternate.revision, alternate.recordPath],
265
+ ...alternate.markdownPaths.map((markdownPath) => [alternate.revision, markdownPath])
266
+ ])
267
+ ]) {
268
+ if (revision && sourcePath) historicalRequests.set(`${revision}\0${sourcePath}`, { revision, relativePath: sourcePath });
269
+ }
270
+ changeContexts.push({
271
+ path,
272
+ record,
273
+ recordPath: currentRecordPath || previousRecordPath,
274
+ previous,
275
+ current,
276
+ currentRecordPath,
277
+ previousRecordPath,
278
+ changedMarkdownPaths,
279
+ markdownChanged,
280
+ alternateParents
281
+ });
282
+ }
283
+ committedContexts.push({ commit, parent, changeContexts });
284
+ }
285
+ const historicalRequestList = [...historicalRequests.values()];
286
+ const historicalSources = getFilesAtRevisions(loaded.root, historicalRequestList, {
287
+ batchSize: 512,
288
+ maxRequests: MAX_RECONCILIATION_HISTORY_REQUESTS,
289
+ maxTotalBytes: MAX_RECONCILIATION_HISTORY_BYTES,
290
+ deadline: historyDeadline
291
+ });
292
+ const sourceByRevisionAndPath = new Map(historicalRequestList.map((request, index) => [
293
+ `${request.revision}\0${request.relativePath}`,
294
+ historicalSources[index] || ""
295
+ ]));
296
+
297
+ for (const { commit, parent, changeContexts } of committedContexts) {
298
+ for (const change of changeContexts) {
299
+ const {
300
+ path,
301
+ record,
302
+ recordPath,
303
+ previous,
304
+ current,
305
+ currentRecordPath,
306
+ previousRecordPath,
307
+ changedMarkdownPaths,
308
+ markdownChanged,
309
+ alternateParents
310
+ } = change;
194
311
  const currentSource = [
195
- readRevisionSource(loaded.root, commit, currentRecordPath),
196
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, commit, changed))
312
+ historicalSource(sourceByRevisionAndPath, commit, currentRecordPath),
313
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, commit, changed))
197
314
  ].join("\n");
198
315
  const beforeSource = [
199
- readRevisionSource(loaded.root, `${commit}^`, previousRecordPath),
200
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, `${commit}^`, changed))
316
+ historicalSource(sourceByRevisionAndPath, parent, previousRecordPath),
317
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, parent, changed))
201
318
  ].join("\n");
319
+ const repeatsParentState = alternateParents.some((alternate) => {
320
+ const alternateSource = [
321
+ historicalSource(sourceByRevisionAndPath, alternate.revision, alternate.recordPath),
322
+ ...alternate.markdownPaths.map((markdownPath) => (
323
+ historicalSource(sourceByRevisionAndPath, alternate.revision, markdownPath)
324
+ ))
325
+ ].join("\n");
326
+ return alternateSource === currentSource;
327
+ });
328
+ if (repeatsParentState) continue;
202
329
  for (const transition of TRANSITIONS[record.type] || []) {
203
330
  if (!transition.applies(previous, current, { markdownChanged })) continue;
204
331
  const fingerprintInput = {
205
- baseRevision: parentRevision(loaded.root, commit),
332
+ baseRevision: parent || commit,
206
333
  eventType: transition.eventType,
207
334
  subjectId: record.id,
208
335
  path: recordPath,
@@ -215,7 +342,7 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
215
342
  const legacyWorkingFingerprint = transitionFingerprint({
216
343
  ...fingerprintInput,
217
344
  beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
218
- readRevisionSource(loaded.root, `${commit}^`, changed)
345
+ readRevisionSource(loaded.root, `${commit}^`, changed, historyIndex)
219
346
  ))].join("\n")
220
347
  });
221
348
  const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
@@ -237,7 +364,13 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
237
364
  includeDismissed: options.includeDismissed,
238
365
  committedRecordIds,
239
366
  timezone: loaded.workspace.timezone,
240
- recordsForEvent
367
+ recordsForEvent,
368
+ currentIndex: currentTransitionIndex,
369
+ consumeMatchEvaluation,
370
+ indexFor: (records) => {
371
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
372
+ return transitionIndexes.get(records);
373
+ }
241
374
  })) filteredCandidates.push(candidate);
242
375
  }
243
376
  }
@@ -252,14 +385,12 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
252
385
  const currentRecordPath = change.afterPath?.endsWith(".json")
253
386
  ? change.afterPath
254
387
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
255
- const previous = readHeadRecord(loaded.root, previousRecordPath);
388
+ const previous = readHeadRecord(loaded.root, previousRecordPath, baseRevision);
256
389
  const current = currentRecordPath ? currentByPath.get(currentRecordPath)?.record || currentEntry?.record || null : null;
257
390
  const record = current || previous;
258
391
  if (!record || examined.has(`${record.type}:${record.id}`)) continue;
259
392
  examined.add(`${record.type}:${record.id}`);
260
- const changedMarkdownPaths = changedPaths.filter((changed) => (
261
- currentMarkdownOwners.get(changed)?.record.id === record.id
262
- ));
393
+ const changedMarkdownPaths = workingMarkdownPathsByRecordId.get(record.id) || [];
263
394
  const markdownChanged = changedMarkdownPaths.length > 0;
264
395
  const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
265
396
  try {
@@ -268,9 +399,9 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
268
399
  return "";
269
400
  }
270
401
  }));
271
- const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
402
+ const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed, baseRevision));
272
403
  const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
273
- const beforeSource = [readHeadSource(loaded.root, previousRecordPath), ...previousMarkdown].join("\n");
404
+ const beforeSource = [readHeadSource(loaded.root, previousRecordPath, baseRevision), ...previousMarkdown].join("\n");
274
405
  for (const transition of TRANSITIONS[record.type] || []) {
275
406
  if (!transition.applies(previous, current, { markdownChanged })) continue;
276
407
  const fingerprintInput = {
@@ -297,13 +428,19 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
297
428
  includeDismissed: options.includeDismissed,
298
429
  committedRecordIds,
299
430
  timezone: loaded.workspace.timezone,
300
- recordsForEvent
431
+ recordsForEvent,
432
+ currentIndex: currentTransitionIndex,
433
+ consumeMatchEvaluation,
434
+ indexFor: (records) => {
435
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
436
+ return transitionIndexes.get(records);
437
+ }
301
438
  })) filteredCandidates.push(candidate);
302
439
  }
303
440
  }
304
441
  const result = {
305
442
  contractVersion: 1,
306
- gitRevision: gitRevision(loaded.root),
443
+ gitRevision: baseRevision,
307
444
  changedPaths,
308
445
  candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
309
446
  .sort((a, b) => a.id.localeCompare(b.id))
@@ -340,42 +477,27 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
340
477
  };
341
478
  }
342
479
 
343
- function reconciliationCommits(root) {
344
- const commits = lines(runGit(root, ["log", "--reverse", "--format=%H", "--", "data"]));
345
- const baseline = commits.findIndex((commit) => {
346
- const workspace = readRevisionRecord(root, commit, "data/workspace.json");
480
+ function reconciliationCommits(index, model) {
481
+ const commits = index.commits.filter((commit) => {
482
+ const parent = index.parentsByCommit.get(commit)?.[0] || null;
483
+ if (!parent) return false;
484
+ const workspace = recordAtId(recordStateAtCommit(index, model, parent), "workspace")?.record;
347
485
  return Number(workspace?.dataModelVersion) >= 3;
348
486
  });
349
- return baseline < 0 ? [] : commits.slice(baseline + 1);
350
- }
351
-
352
- function committedChangedEntries(root, commit) {
353
- return parseNameStatus(runGit(root, [
354
- "diff-tree",
355
- "--root",
356
- "--no-commit-id",
357
- "--name-status",
358
- "-M",
359
- "-r",
360
- commit,
361
- "--",
362
- "data"
363
- ])).filter(({ beforePath, afterPath }) => (
364
- beforePath?.startsWith("data/") || afterPath?.startsWith("data/")
365
- ));
487
+ return { commits };
366
488
  }
367
489
 
368
- function pairCommittedRecordRenames(root, commit, changes) {
490
+ function pairCommittedRecordRenames(changes, recordsBefore, recordsAfter) {
369
491
  const deleted = changes.filter(({ beforePath, afterPath }) => beforePath?.endsWith(".json") && !afterPath);
370
492
  const added = changes.filter(({ beforePath, afterPath }) => !beforePath && afterPath?.endsWith(".json"));
371
493
  const paired = new Set();
372
494
  const replacements = [];
373
495
  for (const removed of deleted) {
374
- const previous = readRevisionRecord(root, `${commit}^`, removed.beforePath);
496
+ const previous = recordAtPath(recordsBefore, removed.beforePath)?.record;
375
497
  if (!previous?.type || !previous?.id) continue;
376
498
  const addition = added.find((candidate) => {
377
499
  if (paired.has(candidate)) return false;
378
- const current = readRevisionRecord(root, commit, candidate.afterPath);
500
+ const current = recordAtPath(recordsAfter, candidate.afterPath)?.record;
379
501
  return current?.type === previous.type && current?.id === previous.id;
380
502
  });
381
503
  if (!addition) continue;
@@ -386,6 +508,302 @@ function pairCommittedRecordRenames(root, commit, changes) {
386
508
  return [...changes.filter((change) => !paired.has(change)), ...replacements];
387
509
  }
388
510
 
511
+ function recordStateAtCommit(index, model, commit) {
512
+ if (index.recordStatesByCommit.has(commit)) return index.recordStatesByCommit.get(commit);
513
+ const lineage = [];
514
+ let cursor = commit;
515
+ while (cursor && !index.recordStatesByCommit.has(cursor)) {
516
+ lineage.push(cursor);
517
+ cursor = index.parentsByCommit.get(cursor)?.[0] || null;
518
+ }
519
+ let state = cursor ? index.recordStatesByCommit.get(cursor) : null;
520
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
521
+ const changedAt = lineage[position];
522
+ state = extendRecordState(index, model, changedAt, state);
523
+ index.recordStatesByCommit.set(changedAt, state);
524
+ }
525
+ return index.recordStatesByCommit.get(commit);
526
+ }
527
+
528
+ function extendRecordState(index, model, commit, parentState) {
529
+ const changes = index.changesByCommit.get(commit) || [];
530
+ if (!changes.length) return parentState;
531
+ const state = {
532
+ parent: parentState,
533
+ depth: (parentState?.depth || 0) + 1,
534
+ recordsById: new Map(),
535
+ recordsByPath: new Map(),
536
+ markdownOwnersByPath: new Map(),
537
+ identitiesById: new Map(),
538
+ snapshot: null
539
+ };
540
+ const changedRecordsByPath = new Map(
541
+ [...(index.recordsByCommit.get(commit) || new Map()).values()].map((item) => [item.path, item])
542
+ );
543
+ const workspaceChanges = [...changedRecordsByPath.values()].filter(({ path, record }) => (
544
+ path === "data/workspace.json" || record.id === "workspace" || record.type === "workspace"
545
+ ));
546
+ if (workspaceChanges.length > 1) {
547
+ throw new Error(`Git history contains multiple Workspace records at ${commit.slice(0, 12)}.`);
548
+ }
549
+ const workspaceChange = workspaceChanges[0] || null;
550
+ let historicalModel = parentState ? parentState.model : model;
551
+ if (workspaceChange) {
552
+ if (
553
+ workspaceChange.path !== "data/workspace.json"
554
+ || workspaceChange.record.id !== "workspace"
555
+ || workspaceChange.record.type !== "workspace"
556
+ ) {
557
+ throw new Error(`Git history contains an invalid Workspace identity or location at ${commit.slice(0, 12)}.`);
558
+ }
559
+ if (!Object.hasOwn(workspaceChange.record, "dataModelVersion")) {
560
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
561
+ }
562
+ const version = workspaceChange.record.dataModelVersion;
563
+ if (typeof version !== "string" || (version !== "1" && !SUPPORTED_MODEL_VERSIONS.includes(version))) {
564
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
565
+ }
566
+ if (version === "1") {
567
+ historicalModel = null;
568
+ } else {
569
+ historicalModel = loadModel(version);
570
+ assertHistoricalRecordValid(
571
+ workspaceChange.record,
572
+ historicalModel,
573
+ workspaceChange.path.replace(/^data\//, "")
574
+ );
575
+ }
576
+ }
577
+ state.model = historicalModel;
578
+ const modelTransition = Boolean(
579
+ historicalModel
580
+ && parentState?.model?.modelVersion !== historicalModel.modelVersion
581
+ );
582
+ const removedPaths = new Set(changes.filter(({ beforePath, afterPath }) => (
583
+ beforePath?.endsWith(".json") && beforePath !== afterPath
584
+ )).map(({ beforePath }) => beforePath));
585
+ for (const change of changes) {
586
+ if (!change.beforePath?.endsWith(".json") && !change.afterPath?.endsWith(".json")) continue;
587
+ const previous = recordAtPath(parentState, change.beforePath);
588
+ if (previous) {
589
+ for (const markdown of parentState?.model ? markdownEntries(parentState.model, previous.record) : []) {
590
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, null);
591
+ }
592
+ }
593
+ if (change.beforePath?.endsWith(".json") && change.beforePath !== change.afterPath) {
594
+ state.recordsByPath.set(change.beforePath, null);
595
+ if (previous?.record.id) {
596
+ const replacement = [...changedRecordsByPath.values()].find(({ record }) => record.id === previous.record.id);
597
+ if (!replacement) {
598
+ state.recordsById.set(previous.record.id, null);
599
+ const identity = identityAtId(parentState, previous.record.id);
600
+ if (identity) state.identitiesById.set(previous.record.id, { ...identity, active: false });
601
+ }
602
+ }
603
+ }
604
+ if (change.afterPath?.endsWith(".json")) {
605
+ const current = changedRecordsByPath.get(change.afterPath) || null;
606
+ if (!current || (historicalModel && !historicalModel.resources[current.record.type])) {
607
+ throw new Error(`Git history contains an unsupported data record at ${commit.slice(0, 12)}:${change.afterPath}.`);
608
+ }
609
+ if (historicalModel && RECONCILIATION_HISTORY_TYPES.has(current.record.type)) {
610
+ assertHistoricalRecordValid(current.record, historicalModel, change.afterPath.replace(/^data\//, ""));
611
+ }
612
+ const priorAtIdentity = previous || recordAtId(parentState, current.record.id);
613
+ const allowedTypeTransition = priorAtIdentity && allowsHistoricalTypeTransition(
614
+ parentState?.model,
615
+ historicalModel,
616
+ priorAtIdentity.record.type,
617
+ current.record.type
618
+ );
619
+ if (priorAtIdentity && priorAtIdentity.record.type !== current.record.type) {
620
+ if (!allowedTypeTransition) {
621
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
622
+ }
623
+ assertHistoricalRecordValid(
624
+ priorAtIdentity.record,
625
+ parentState.model,
626
+ priorAtIdentity.path.replace(/^data\//, "")
627
+ );
628
+ assertHistoricalRecordValid(
629
+ current.record,
630
+ historicalModel,
631
+ change.afterPath.replace(/^data\//, "")
632
+ );
633
+ }
634
+ if (previous && (
635
+ previous.record.id !== current.record.id
636
+ || (previous.record.type !== current.record.type && !allowedTypeTransition)
637
+ )) {
638
+ throw new Error(`Git history changes immutable record identity at ${commit.slice(0, 12)}:${change.afterPath}.`);
639
+ }
640
+ const existing = recordAtId(parentState, current.record.id);
641
+ const priorIdentity = identityAtId(parentState, current.record.id);
642
+ if (priorIdentity && !priorIdentity.active) {
643
+ const sameIdentityRestore = priorIdentity.path === change.afterPath
644
+ && priorIdentity.type === current.record.type;
645
+ if (!sameIdentityRestore) {
646
+ throw new Error(`Git history reuses deleted record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
647
+ }
648
+ }
649
+ if (existing && existing.path !== change.afterPath && !removedPaths.has(existing.path)) {
650
+ throw new Error(`Git history reuses record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
651
+ }
652
+ if (existing && existing.record.type !== current.record.type && !allowedTypeTransition) {
653
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
654
+ }
655
+ state.recordsByPath.set(change.afterPath, current);
656
+ if (current?.record.id) {
657
+ state.recordsById.set(current.record.id, current);
658
+ state.identitiesById.set(current.record.id, priorIdentity
659
+ ? { ...priorIdentity, type: current.record.type, record: current.record, path: change.afterPath, active: true }
660
+ : { type: current.record.type, introducedAt: commit, record: current.record, path: change.afterPath, active: true });
661
+ const entry = { ...current, relativePath: current.path.replace(/^data\//, "") };
662
+ for (const markdown of historicalModel ? markdownEntries(historicalModel, current.record) : []) {
663
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, entry);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ const activeWorkspace = recordAtPath(state, "data/workspace.json");
669
+ if (
670
+ !activeWorkspace
671
+ || activeWorkspace.record.id !== "workspace"
672
+ || activeWorkspace.record.type !== "workspace"
673
+ ) {
674
+ throw new Error(`Git history has no active canonical Workspace at ${commit.slice(0, 12)}.`);
675
+ }
676
+ if (modelTransition) {
677
+ for (const entry of materializeRecordState(state)) {
678
+ if (!RECONCILIATION_HISTORY_TYPES.has(entry.record.type)) continue;
679
+ assertHistoricalRecordValid(entry.record, historicalModel, entry.path.replace(/^data\//, ""));
680
+ }
681
+ }
682
+ if (state.depth % RECORD_STATE_CHECKPOINT_INTERVAL === 0) state.snapshot = snapshotRecordState(state);
683
+ return state;
684
+ }
685
+
686
+ function recordAtPath(state, path) {
687
+ if (!path) return null;
688
+ for (let current = state; current; current = current.parent) {
689
+ if (current.snapshot) {
690
+ const item = current.snapshot.recordsByPath.get(path);
691
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
692
+ }
693
+ if (!current.recordsByPath.has(path)) continue;
694
+ const item = current.recordsByPath.get(path);
695
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
696
+ }
697
+ return null;
698
+ }
699
+
700
+ function recordAtId(state, id) {
701
+ if (!id) return null;
702
+ for (let current = state; current; current = current.parent) {
703
+ if (current.snapshot) return current.snapshot.recordsById.get(id) || null;
704
+ if (!current.recordsById.has(id)) continue;
705
+ return current.recordsById.get(id);
706
+ }
707
+ return null;
708
+ }
709
+
710
+ function markdownOwnerAtPath(state, path) {
711
+ if (!path) return null;
712
+ for (let current = state; current; current = current.parent) {
713
+ if (current.snapshot) return current.snapshot.markdownOwnersByPath.get(path) || null;
714
+ if (current.markdownOwnersByPath.has(path)) return current.markdownOwnersByPath.get(path);
715
+ }
716
+ return null;
717
+ }
718
+
719
+ function identityAtId(state, id) {
720
+ if (!id) return null;
721
+ for (let current = state; current; current = current.parent) {
722
+ if (current.snapshot) return current.snapshot.identitiesById.get(id) || null;
723
+ if (current.identitiesById.has(id)) return current.identitiesById.get(id);
724
+ }
725
+ return null;
726
+ }
727
+
728
+ function snapshotRecordState(state) {
729
+ const snapshot = {
730
+ recordsById: new Map(),
731
+ recordsByPath: new Map(),
732
+ markdownOwnersByPath: new Map(),
733
+ identitiesById: new Map()
734
+ };
735
+ const seen = Object.fromEntries(Object.keys(snapshot).map((key) => [key, new Set()]));
736
+ for (let current = state; current; current = current.parent) {
737
+ for (const key of Object.keys(snapshot)) {
738
+ const source = current[key];
739
+ if (source) {
740
+ for (const [itemKey, value] of source) {
741
+ if (seen[key].has(itemKey)) continue;
742
+ seen[key].add(itemKey);
743
+ if (value) snapshot[key].set(itemKey, value);
744
+ }
745
+ }
746
+ if (current !== state && current.snapshot) {
747
+ for (const [itemKey, value] of current.snapshot[key]) {
748
+ if (seen[key].has(itemKey)) continue;
749
+ seen[key].add(itemKey);
750
+ if (value) snapshot[key].set(itemKey, value);
751
+ }
752
+ }
753
+ }
754
+ if (current !== state && current.snapshot) break;
755
+ }
756
+ return snapshot;
757
+ }
758
+
759
+ function validateMergeIdentities(index, model) {
760
+ const snapshots = new Map();
761
+ let checks = 0;
762
+ for (const [commit, parents] of index.parentsByCommit) {
763
+ if (parents.length < 2) continue;
764
+ const identities = new Map();
765
+ for (const parent of parents) {
766
+ const state = recordStateAtCommit(index, model, parent);
767
+ let snapshot = snapshots.get(state);
768
+ if (!snapshot) {
769
+ snapshot = state.snapshot || snapshotRecordState(state);
770
+ snapshots.set(state, snapshot);
771
+ }
772
+ checks += snapshot.identitiesById.size;
773
+ if (checks > MAX_MERGE_IDENTITY_CHECKS) {
774
+ throw new Error("Git merge identity history is too large to reconcile safely.");
775
+ }
776
+ for (const [id, identity] of snapshot.identitiesById) {
777
+ const previous = identities.get(id);
778
+ if (previous && (
779
+ previous.type !== identity.type
780
+ || previous.introducedAt !== identity.introducedAt
781
+ )) {
782
+ throw new Error(`Git history reuses record ID "${id}" across merge branches at ${commit.slice(0, 12)}.`);
783
+ }
784
+ identities.set(id, identity);
785
+ }
786
+ }
787
+ }
788
+ }
789
+
790
+ function materializeRecordState(state) {
791
+ const records = new Map();
792
+ const seen = new Set();
793
+ for (let current = state; current; current = current.parent) {
794
+ for (const [id, item] of current.recordsById) {
795
+ if (seen.has(id)) continue;
796
+ seen.add(id);
797
+ if (item) records.set(id, item);
798
+ }
799
+ }
800
+ return [...records.values()];
801
+ }
802
+
803
+ function historicalSource(sources, revision, path) {
804
+ return revision && path ? sources.get(`${revision}\0${path}`) || "" : "";
805
+ }
806
+
389
807
  function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
390
808
  const byId = new Map(records.map((record) => [record.id, record]));
391
809
  return records.filter((record) => {
@@ -401,17 +819,22 @@ function readRevisionRecord(root, revision, path) {
401
819
  if (!path) return null;
402
820
  try {
403
821
  return JSON.parse(readRevisionSource(root, revision, path));
404
- } catch {
822
+ } catch (error) {
823
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
405
824
  return null;
406
825
  }
407
826
  }
408
827
 
409
- function readRevisionSource(root, revision, path) {
828
+ function readRevisionSource(root, revision, path, historyIndex = null) {
410
829
  if (!path) return "";
411
830
  try {
412
- const commit = runGit(root, ["rev-parse", `${revision}^{commit}`]);
831
+ const parentMatch = String(revision).match(/^([a-f0-9]{40})\^$/i);
832
+ const commit = parentMatch && historyIndex
833
+ ? historyIndex.parentsByCommit.get(parentMatch[1])?.[0] || null
834
+ : /^[a-f0-9]{40}$/i.test(String(revision)) ? String(revision) : runGit(root, ["rev-parse", `${revision}^{commit}`]);
413
835
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
414
- } catch {
836
+ } catch (error) {
837
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
415
838
  return "";
416
839
  }
417
840
  }
@@ -492,30 +915,84 @@ export async function dismissReconciliation(input = process.cwd(), options = {})
492
915
  }
493
916
 
494
917
  function transitionHandled(resources, eventIds, fingerprints, transition) {
495
- return resources.some((item) => (
496
- item.type === "obligation-event"
497
- ? (() => {
498
- if (!eventIds.includes(item.id) && !fingerprints.includes(item.transitionFingerprint)) return false;
499
- const records = transition.recordsForEvent?.(item) || resources;
500
- const event = records.find((record) => record.id === item.id) || item;
501
- return (eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
502
- && obligationEventHandlesTransition(event, records, transition.eventType, transition.subjectId, resources);
503
- })()
504
- : !transition.includeDismissed && reconciliationDismissalMatches(item, {
918
+ const currentIndex = transition.currentIndex || indexTransitionResources(resources);
919
+ const matchingEvents = new Set();
920
+ for (const eventId of eventIds) {
921
+ const event = currentIndex.eventsById.get(eventId);
922
+ if (event) matchingEvents.add(event);
923
+ }
924
+ for (const fingerprint of fingerprints) {
925
+ for (const event of currentIndex.eventsByFingerprint.get(fingerprint) || []) {
926
+ transition.consumeMatchEvaluation?.();
927
+ matchingEvents.add(event);
928
+ }
929
+ }
930
+ for (const item of matchingEvents) {
931
+ const records = transition.recordsForEvent?.(item) || resources;
932
+ const historicalIndex = transition.indexFor?.(records) || indexTransitionResources(records);
933
+ const event = historicalIndex.byId.get(item.id) || item;
934
+ if ((eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
935
+ && obligationEventHandlesTransition(
936
+ event,
937
+ records,
938
+ transition.eventType,
939
+ transition.subjectId,
940
+ resources,
941
+ historicalIndex,
942
+ currentIndex
943
+ )) return true;
944
+ }
945
+ if (transition.includeDismissed) return false;
946
+ const dismissal = currentIndex.byId.get(`reconciliation-dismissal-${fingerprints[0]}`);
947
+ return Boolean(dismissal && reconciliationDismissalMatches(dismissal, {
505
948
  transitionFingerprint: fingerprints[0],
506
949
  eventType: transition.eventType,
507
950
  subject: { id: transition.subjectId }
508
951
  }, resources, {
509
- requireActiveReviewer: !transition.committedRecordIds.has(item.id),
510
- timezone: transition.timezone
952
+ requireActiveReviewer: !transition.committedRecordIds.has(dismissal.id),
953
+ timezone: transition.timezone,
954
+ peopleById: currentIndex.peopleById
511
955
  })
512
- ));
956
+ );
513
957
  }
514
958
 
515
- function obligationEventHandlesTransition(event, records, eventType, subjectId, currentRecords = records) {
959
+ function indexTransitionResources(resources) {
960
+ const index = {
961
+ byId: new Map(),
962
+ peopleById: new Map(),
963
+ eventsById: new Map(),
964
+ eventsByFingerprint: new Map(),
965
+ actionsBySourceAndObligation: new Map()
966
+ };
967
+ for (const record of resources) {
968
+ index.byId.set(record.id, record);
969
+ if (record.type === "person") index.peopleById.set(record.id, record);
970
+ if (record.type === "obligation-event") {
971
+ index.eventsById.set(record.id, record);
972
+ if (record.transitionFingerprint) {
973
+ if (!index.eventsByFingerprint.has(record.transitionFingerprint)) index.eventsByFingerprint.set(record.transitionFingerprint, []);
974
+ index.eventsByFingerprint.get(record.transitionFingerprint).push(record);
975
+ }
976
+ }
977
+ if (record.type === "action-item" && record.sourceResourceId && record.obligationId) {
978
+ index.actionsBySourceAndObligation.set(`${record.sourceResourceId}\0${record.obligationId}`, record);
979
+ }
980
+ }
981
+ return index;
982
+ }
983
+
984
+ function obligationEventHandlesTransition(
985
+ event,
986
+ records,
987
+ eventType,
988
+ subjectId,
989
+ currentRecords = records,
990
+ historicalIndex = indexTransitionResources(records),
991
+ currentIndex = indexTransitionResources(currentRecords)
992
+ ) {
516
993
  if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
517
994
  if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
518
- const currentEvent = currentRecords.find((record) => record.id === event.id);
995
+ const currentEvent = currentIndex.byId.get(event.id);
519
996
  if (
520
997
  currentEvent?.type !== "obligation-event"
521
998
  || currentEvent.eventType !== eventType
@@ -529,38 +1006,34 @@ function obligationEventHandlesTransition(event, records, eventType, subjectId,
529
1006
  const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
530
1007
  if (!requiredObligationIds.length) return false;
531
1008
  return requiredObligationIds.every((obligationId) => {
532
- const historicalAction = records.find((record) => (
533
- record.type === "action-item"
534
- && record.sourceResourceId === event.id
535
- && record.obligationId === obligationId
536
- && record.status !== "canceled"
537
- ));
1009
+ const historicalAction = historicalIndex.actionsBySourceAndObligation.get(`${event.id}\0${obligationId}`);
1010
+ const currentAction = historicalAction ? currentIndex.byId.get(historicalAction.id) : null;
538
1011
  return (event.obligationIds || []).includes(obligationId)
539
1012
  && (currentEvent.obligationIds || []).includes(obligationId)
540
1013
  && historicalAction
541
- && currentRecords.some((record) => (
542
- record.type === "action-item"
543
- && record.id === historicalAction.id
544
- && record.sourceResourceId === event.id
545
- && record.obligationId === obligationId
546
- && record.status !== "canceled"
547
- ));
1014
+ && historicalAction.status !== "canceled"
1015
+ && currentAction?.type === "action-item"
1016
+ && currentAction.sourceResourceId === event.id
1017
+ && currentAction.obligationId === obligationId
1018
+ && currentAction.status !== "canceled";
548
1019
  });
549
1020
  }
550
1021
 
551
1022
  export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
552
1023
  if (record?.type !== "reconciliation-dismissal") return false;
553
1024
  const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
1025
+ const reviewer = reviewers.length === 1
1026
+ ? options.peopleById?.get(reviewers[0]) || resources.find((item) => (
1027
+ item.type === "person" && item.id === reviewers[0]
1028
+ ))
1029
+ : null;
554
1030
  return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
555
1031
  && record.transitionFingerprint === candidate.transitionFingerprint
556
1032
  && record.eventType === candidate.eventType
557
1033
  && record.subjectResourceId === candidate.subject.id
558
1034
  && reviewers.length === 1
559
- && resources.some((item) => (
560
- item.type === "person"
561
- && item.id === reviewers[0]
562
- && (!options.requireActiveReviewer || item.status === "active")
563
- ))
1035
+ && reviewer
1036
+ && (!options.requireActiveReviewer || reviewer.status === "active")
564
1037
  && /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
565
1038
  && (!options.requireActiveReviewer
566
1039
  || record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
@@ -572,12 +1045,14 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
572
1045
  const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
573
1046
  if (!dismissals.length) return;
574
1047
  const raw = await planReconciliation(loaded, { includeHandled: true });
1048
+ const candidateByFingerprint = new Map(raw.candidates.map((candidate) => [
1049
+ candidate.transitionFingerprint,
1050
+ candidate
1051
+ ]));
575
1052
  const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
576
1053
  const seen = new Set();
577
1054
  for (const entry of dismissals) {
578
- const candidate = raw.candidates.find(({ transitionFingerprint }) => (
579
- transitionFingerprint === entry.record.transitionFingerprint
580
- ));
1055
+ const candidate = candidateByFingerprint.get(entry.record.transitionFingerprint);
581
1056
  if (
582
1057
  !candidate
583
1058
  || seen.has(entry.record.transitionFingerprint)
@@ -598,10 +1073,6 @@ export async function validateReconciliationDismissals(loaded, diagnostics) {
598
1073
  return raw;
599
1074
  }
600
1075
 
601
- function parentRevision(root, commit) {
602
- return runGit(root, ["rev-parse", `${commit}^`]) || commit;
603
- }
604
-
605
1076
  function findCandidate(plan, options) {
606
1077
  return plan.candidates.find(({ id, transitionFingerprint }) => (
607
1078
  id === options.candidateId
@@ -610,15 +1081,15 @@ function findCandidate(plan, options) {
610
1081
  ));
611
1082
  }
612
1083
 
613
- function gitChangedEntries(root) {
614
- const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
615
- const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
1084
+ function gitChangedEntries(root, deadline, baseRevision = "HEAD") {
1085
+ const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", baseRevision, "--", "data"], deadline));
1086
+ const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"], deadline))
616
1087
  .map((path) => ({ beforePath: null, afterPath: path }));
617
1088
  return [...tracked, ...untracked]
618
1089
  .filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
619
1090
  }
620
1091
 
621
- function pairWorkingRecordRenames(root, changes, currentByPath) {
1092
+ function pairWorkingRecordRenames(root, changes, currentByPath, baseRevision = null) {
622
1093
  const additions = new Map();
623
1094
  for (const change of changes) {
624
1095
  if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
@@ -629,7 +1100,7 @@ function pairWorkingRecordRenames(root, changes, currentByPath) {
629
1100
  const result = [];
630
1101
  for (const change of changes) {
631
1102
  if (!change.afterPath && change.beforePath?.endsWith(".json")) {
632
- const record = readHeadRecord(root, change.beforePath);
1103
+ const record = readHeadRecord(root, change.beforePath, baseRevision);
633
1104
  const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
634
1105
  if (addition) {
635
1106
  paired.add(addition);
@@ -655,32 +1126,42 @@ function parseNameStatus(value) {
655
1126
  }).filter(Boolean);
656
1127
  }
657
1128
 
658
- function readHeadRecord(root, path) {
1129
+ function readHeadRecord(root, path, revision = null) {
659
1130
  if (!path) return null;
660
1131
  try {
661
- return JSON.parse(readHeadSource(root, path));
662
- } catch {
1132
+ return JSON.parse(readHeadSource(root, path, revision));
1133
+ } catch (error) {
1134
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
663
1135
  return null;
664
1136
  }
665
1137
  }
666
1138
 
667
- function readHeadSource(root, path) {
1139
+ function readHeadSource(root, path, revision = null) {
668
1140
  try {
669
- const commit = gitRevision(root);
1141
+ const commit = revision || gitRevision(root);
670
1142
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
671
- } catch {
1143
+ } catch (error) {
1144
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
672
1145
  return "";
673
1146
  }
674
1147
  }
675
1148
 
676
- function gitRevision(root) {
677
- return runGit(root, ["rev-parse", "HEAD"]) || null;
1149
+ function gitRevision(root, deadline) {
1150
+ return runGit(root, ["rev-parse", "HEAD"], deadline) || null;
678
1151
  }
679
1152
 
680
- function runGit(root, args) {
1153
+ function runGit(root, args, deadline) {
681
1154
  try {
682
- return runGitCommandSync(root, args);
683
- } catch {
1155
+ const timeoutMs = deadline ? Math.floor(deadline - performance.now()) : undefined;
1156
+ if (deadline && timeoutMs <= 0) throw new Error("Git reconciliation history exceeded its cumulative deadline.");
1157
+ return runGitCommandSync(root, args, timeoutMs ? { timeoutMs } : {});
1158
+ } catch (error) {
1159
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1160
+ if (deadline && performance.now() >= deadline) {
1161
+ const timeoutError = new Error("Git reconciliation history exceeded its cumulative deadline.", { cause: error });
1162
+ timeoutError.code = "FILEGRC_HISTORY_DEADLINE";
1163
+ throw timeoutError;
1164
+ }
684
1165
  return "";
685
1166
  }
686
1167
  }
@@ -715,22 +1196,3 @@ function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp
715
1196
  const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
716
1197
  return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
717
1198
  }
718
-
719
- function historicalMarkdownOwners(model, recordsById) {
720
- const owners = new Map();
721
- for (const { record, path } of recordsById.values()) {
722
- const entry = { record, relativePath: path.replace(/^data\//, "") };
723
- for (const markdown of markdownEntries(model, record)) owners.set(`data/${markdown.path}`, entry);
724
- }
725
- return owners;
726
- }
727
-
728
- function recordsAtRevision(root, revision) {
729
- const records = new Map();
730
- for (const path of lines(runGit(root, ["ls-tree", "-r", "--name-only", revision, "--", "data"]))) {
731
- if (!path.endsWith(".json")) continue;
732
- const record = readRevisionRecord(root, revision, path);
733
- if (record?.id) records.set(record.id, { record, path });
734
- }
735
- return records;
736
- }