filegrc 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { existsSync } from "node:fs";
3
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { rm } from "node:fs/promises";
5
+ import { devNull } from "node:os";
5
6
  import { relative, resolve, sep } from "node:path";
6
7
  import { performance } from "node:perf_hooks";
7
8
  import { isSafeGitName } from "./git-name.js";
@@ -13,6 +14,10 @@ import { loadWorkspace } from "./workspace.js";
13
14
 
14
15
  const lastSuccessfulSynchronizations = new Map();
15
16
  const workspaceHistoryCache = new Map();
17
+ const dataRecordHistoryIndexCache = new Map();
18
+ const historicalFileCache = new Map();
19
+ const reachableDataAncestryCache = new Map();
20
+ const dataHistoryContextCache = new WeakMap();
16
21
  const backgroundSynchronizations = new Map();
17
22
  const browserRemotePrefetches = new Map();
18
23
  const browserRemotePrefetchPromises = new Map();
@@ -24,6 +29,38 @@ const GIT_REMOTE_TIMEOUT_MS = 30_000;
24
29
  const GIT_MAX_OUTPUT_BYTES = 20_000_000;
25
30
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
26
31
 
32
+ function gitEnvironment(overrides = {}) {
33
+ return {
34
+ ...Object.fromEntries(
35
+ Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_"))
36
+ ),
37
+ ...overrides,
38
+ GIT_NO_REPLACE_OBJECTS: "1",
39
+ GIT_GRAFT_FILE: devNull,
40
+ GIT_NO_LAZY_FETCH: "1",
41
+ GIT_ASKPASS: "",
42
+ SSH_ASKPASS: "",
43
+ SSH_ASKPASS_REQUIRE: "never",
44
+ GIT_CONFIG_COUNT: "8",
45
+ GIT_CONFIG_KEY_0: "core.fsmonitor",
46
+ GIT_CONFIG_VALUE_0: "false",
47
+ GIT_CONFIG_KEY_1: "core.hooksPath",
48
+ GIT_CONFIG_VALUE_1: devNull,
49
+ GIT_CONFIG_KEY_2: "protocol.ext.allow",
50
+ GIT_CONFIG_VALUE_2: "never",
51
+ GIT_CONFIG_KEY_3: "protocol.allow",
52
+ GIT_CONFIG_VALUE_3: "never",
53
+ GIT_CONFIG_KEY_4: "protocol.http.allow",
54
+ GIT_CONFIG_VALUE_4: "always",
55
+ GIT_CONFIG_KEY_5: "protocol.https.allow",
56
+ GIT_CONFIG_VALUE_5: "always",
57
+ GIT_CONFIG_KEY_6: "protocol.ssh.allow",
58
+ GIT_CONFIG_VALUE_6: "always",
59
+ GIT_CONFIG_KEY_7: "protocol.file.allow",
60
+ GIT_CONFIG_VALUE_7: "always"
61
+ };
62
+ }
63
+
27
64
  export class GitOperationError extends Error {
28
65
  constructor(kind, operation, detail, options = {}) {
29
66
  const prefix = kind === "missing-executable"
@@ -45,6 +82,7 @@ export function getGitSummary(input = process.cwd()) {
45
82
  const root = resolveWorkspaceRoot(input);
46
83
  try {
47
84
  const topLevel = measureTimingSync("git-discovery", () => git(root, ["rev-parse", "--show-toplevel"]));
85
+ assertNoWorkspaceContentFilters(root);
48
86
  const status = git(root, ["status", "--porcelain=v1", "--", "."]);
49
87
  const commit = tryGit(root, ["rev-parse", "HEAD"]) || null;
50
88
  const branch = tryGit(root, ["symbolic-ref", "--short", "HEAD"]) || null;
@@ -77,11 +115,14 @@ export function getFileHistory(input, relativePath, limit = 50) {
77
115
  const root = resolveWorkspaceRoot(input);
78
116
  if (!isSafeDataGitPath(relativePath)) return null;
79
117
  try {
118
+ const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
119
+ ? []
120
+ : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
80
121
  const output = git(root, [
81
122
  "log",
82
123
  "--follow",
83
- `--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`,
84
- "--format=%H%x1f%aI%x1f%an%x1f%s",
124
+ ...countArgs,
125
+ "--format=%H%x1f%cI%x1f%an%x1f%s",
85
126
  "--",
86
127
  relativePath
87
128
  ]);
@@ -92,6 +133,305 @@ export function getFileHistory(input, relativePath, limit = 50) {
92
133
  }
93
134
  }
94
135
 
136
+ export function getFileHistoryWithPaths(input, relativePath, limit = 50) {
137
+ const root = resolveWorkspaceRoot(input);
138
+ if (!isSafeDataGitPath(relativePath)) return null;
139
+ try {
140
+ const countArgs = Number(limit) >= Number.MAX_SAFE_INTEGER
141
+ ? []
142
+ : [`--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`];
143
+ const output = gitRaw(root, [
144
+ "log",
145
+ "--follow",
146
+ "-z",
147
+ ...countArgs,
148
+ "--format=%H%x00%cI%x00%an%x00%s%x00",
149
+ "--name-status",
150
+ "-M",
151
+ "--",
152
+ relativePath
153
+ ]);
154
+ if (!output) return [];
155
+ const tokens = output.split("\0");
156
+ let trackedPath = relativePath;
157
+ const history = [];
158
+ let index = 0;
159
+ while (index < tokens.length) {
160
+ while (index < tokens.length && !/^[a-f0-9]{40}$/i.test(tokens[index].trim())) index += 1;
161
+ if (index >= tokens.length) break;
162
+ const summary = {
163
+ commit: tokens[index++].trim(),
164
+ timestamp: tokens[index++] || "",
165
+ author: tokens[index++] || "",
166
+ subject: tokens[index++] || ""
167
+ };
168
+ history.push({ ...summary, path: trackedPath });
169
+ while (index < tokens.length && !/^[a-f0-9]{40}$/i.test(tokens[index].trim())) {
170
+ const status = tokens[index++].trim();
171
+ if (!status) continue;
172
+ const oldPath = tokens[index++] || "";
173
+ if (status.startsWith("R") || status.startsWith("C")) {
174
+ const newPath = tokens[index++] || "";
175
+ if (status.startsWith("R") && newPath === trackedPath) trackedPath = oldPath;
176
+ }
177
+ }
178
+ }
179
+ return history;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ export function getFilePathAtRevision(input, relativePath, revision) {
186
+ const root = resolveWorkspaceRoot(input);
187
+ if (!isSafeDataGitPath(relativePath) || !/^[a-f0-9]{40}$/i.test(String(revision || ""))) return null;
188
+ const history = getFileHistoryWithPaths(root, relativePath, Number.MAX_SAFE_INTEGER) || [];
189
+ const summary = history.find(({ commit }) => commit === revision)
190
+ || history.find(({ commit }) => isDataHistoryAncestor(root, commit, revision));
191
+ return summary?.path || null;
192
+ }
193
+
194
+ export function getDataCommitHistory(input) {
195
+ const root = resolveWorkspaceRoot(input);
196
+ try {
197
+ return lines(git(root, ["log", "--reverse", "--format=%H", "--", "data"]));
198
+ } catch {
199
+ return [];
200
+ }
201
+ }
202
+
203
+ export function getChangedDataJsonFilesAtRevision(input, revision) {
204
+ if (!/^[a-f0-9]{40}$/i.test(String(revision))) return [];
205
+ const root = resolveWorkspaceRoot(input);
206
+ try {
207
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
208
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
209
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
210
+ const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
211
+ const output = git(root, [
212
+ "diff-tree", "--root", "--no-commit-id", "--name-status", "-M", "-m", "-r", revision, "--", dataPrefix
213
+ ]);
214
+ const paths = [];
215
+ for (const line of lines(output)) {
216
+ const [status, first, second] = line.split("\t");
217
+ if (status === "D") continue;
218
+ const repositoryPath = status?.startsWith("R") || status?.startsWith("C") ? second : first;
219
+ if (!repositoryPath?.startsWith(`${dataPrefix}/`) || !repositoryPath.endsWith(".json")) continue;
220
+ paths.push(workspacePrefix ? repositoryPath.slice(workspacePrefix.length + 1) : repositoryPath);
221
+ }
222
+ return [...new Set(paths)];
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ export function getRecordIdentityHistory(input, id) {
229
+ return getRecordIdentityHistories(input, [id]).get(id) || [];
230
+ }
231
+
232
+ export function getRecordIdentityHistories(input, ids) {
233
+ const index = getDataRecordHistoryIndex(input);
234
+ return new Map([...new Set(ids)].map((id) => [id, index.historiesById.get(id) || []]));
235
+ }
236
+
237
+ export function getDataRecordHistoryIndex(input) {
238
+ const root = resolveWorkspaceRoot(input);
239
+ const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
240
+ const cached = dataRecordHistoryIndexCache.get(root);
241
+ if (cached?.head === head) return cached;
242
+ const changes = [];
243
+ const shallow = head && tryGit(root, ["rev-parse", "--is-shallow-repository"]) === "true";
244
+ let available = Boolean(head) && !shallow;
245
+ let error = !head
246
+ ? new Error("Git history is unavailable because the workspace has no committed HEAD.")
247
+ : shallow ? new Error("Git history is shallow.") : null;
248
+ try {
249
+ if (available) {
250
+ const output = gitRaw(root, [
251
+ "log", "--reverse", "-m", "-z", "--relative",
252
+ "--format=%H%x00%cI%x00%an%x00%s%x00", "--name-status", "-M", "--", "data"
253
+ ]);
254
+ changes.push(...parseDataRecordHistory(output));
255
+ }
256
+ } catch (cause) {
257
+ available = false;
258
+ error = cause;
259
+ }
260
+ const sources = available
261
+ ? getFilesAtRevisions(
262
+ root,
263
+ changes.map(({ summary, path }) => ({ revision: summary.commit, relativePath: path })),
264
+ { batchSize: 512 }
265
+ )
266
+ : [];
267
+ if (head && sources.some((source) => source === null)) {
268
+ available = false;
269
+ error ||= new Error("Git could not read every historical data record.");
270
+ }
271
+ const recordsByCommit = new Map();
272
+ const historiesById = new Map();
273
+ for (let index = 0; index < changes.length; index += 1) {
274
+ const { summary, path } = changes[index];
275
+ try {
276
+ const record = JSON.parse(sources[index]);
277
+ if (!record?.id) continue;
278
+ if (!recordsByCommit.has(summary.commit)) recordsByCommit.set(summary.commit, new Map());
279
+ recordsByCommit.get(summary.commit).set(record.id, { record, path });
280
+ if (!historiesById.has(record.id)) historiesById.set(record.id, []);
281
+ historiesById.get(record.id).push({ ...summary, path });
282
+ } catch {
283
+ // Ignore malformed historical files.
284
+ }
285
+ }
286
+ for (const [id, history] of historiesById) {
287
+ history.reverse();
288
+ const seen = new Set();
289
+ historiesById.set(id, history.filter(({ commit, path }) => {
290
+ const key = `${commit}\u0000${path}`;
291
+ if (seen.has(key)) return false;
292
+ seen.add(key);
293
+ return true;
294
+ }));
295
+ }
296
+ const result = {
297
+ head,
298
+ available,
299
+ error,
300
+ commits: [...new Set(changes.map(({ summary }) => summary.commit))],
301
+ recordsByCommit,
302
+ historiesById
303
+ };
304
+ if (available) {
305
+ dataRecordHistoryIndexCache.set(root, result);
306
+ while (dataRecordHistoryIndexCache.size > 16) dataRecordHistoryIndexCache.delete(dataRecordHistoryIndexCache.keys().next().value);
307
+ }
308
+ return result;
309
+ }
310
+
311
+ function parseDataRecordHistory(output) {
312
+ const fields = output.split("\0");
313
+ const changes = [];
314
+ let index = 0;
315
+ while (index < fields.length) {
316
+ while (fields[index] === "") index += 1;
317
+ const commit = fields[index++];
318
+ if (commit === undefined) break;
319
+ if (!/^[a-f0-9]{40}$/i.test(commit)) throw new Error("Git returned an invalid data-history commit.");
320
+ const timestamp = fields[index++];
321
+ const author = fields[index++];
322
+ const subject = fields[index++];
323
+ if (timestamp === undefined || author === undefined || subject === undefined) {
324
+ throw new Error("Git returned an incomplete data-history header.");
325
+ }
326
+ const summary = { commit, shortCommit: commit.slice(0, 8), timestamp, author, subject };
327
+ while (index < fields.length) {
328
+ while (fields[index] === "") index += 1;
329
+ const rawStatus = fields[index];
330
+ if (rawStatus === undefined || /^[a-f0-9]{40}$/i.test(rawStatus)) break;
331
+ const status = rawStatus.replace(/^\n+/, "");
332
+ if (!/^(?:[ACDMRTUXB]|R\d{1,3}|C\d{1,3})$/.test(status)) {
333
+ throw new Error("Git returned an invalid data-history status.");
334
+ }
335
+ index += 1;
336
+ const first = fields[index++];
337
+ const renamed = status.startsWith("R") || status.startsWith("C");
338
+ const second = renamed ? fields[index++] : null;
339
+ if (!first || (renamed && !second)) throw new Error("Git returned an incomplete data-history path.");
340
+ if (status === "D") continue;
341
+ const path = renamed ? second : first;
342
+ if (path.startsWith("data/") && path.endsWith(".json")) {
343
+ if (!isSafeDataGitPath(path)) throw new Error("Git returned an unsafe data-history path.");
344
+ changes.push({ summary, path });
345
+ }
346
+ }
347
+ }
348
+ return changes;
349
+ }
350
+
351
+ export function isGitAncestor(input, ancestor, descendant) {
352
+ if (!/^[a-f0-9]{40}$/i.test(String(ancestor)) || !/^[a-f0-9]{40}$/i.test(String(descendant))) return false;
353
+ const root = resolveWorkspaceRoot(input);
354
+ try {
355
+ execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
356
+ cwd: root,
357
+ stdio: "ignore",
358
+ timeout: 10_000,
359
+ env: gitEnvironment()
360
+ });
361
+ return true;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ export function isDataHistoryAncestor(input, ancestor, descendant) {
368
+ const context = input && typeof input === "object" ? input : null;
369
+ const root = resolveWorkspaceRoot(context?.root || input);
370
+ let index = context ? dataHistoryContextCache.get(context) : null;
371
+ if (!index) {
372
+ index = getDataRecordHistoryIndex(root);
373
+ if (context) dataHistoryContextCache.set(context, index);
374
+ }
375
+ if (
376
+ !index.available
377
+ || (descendant !== index.head && !index.commits.includes(descendant))
378
+ ) return isGitAncestor(root, ancestor, descendant);
379
+ const key = `${root}\0${ancestor}\0${descendant}`;
380
+ if (reachableDataAncestryCache.has(key)) return reachableDataAncestryCache.get(key);
381
+ const result = isGitAncestor(root, ancestor, descendant);
382
+ reachableDataAncestryCache.set(key, result);
383
+ while (reachableDataAncestryCache.size > 20_000) {
384
+ reachableDataAncestryCache.delete(reachableDataAncestryCache.keys().next().value);
385
+ }
386
+ return result;
387
+ }
388
+
389
+ export function getFileBufferAtRevision(input, revision, relativePath) {
390
+ if (!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)) return null;
391
+ const root = resolveWorkspaceRoot(input);
392
+ try {
393
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
394
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
395
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
396
+ const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
397
+ return execFileSync("git", ["show", `${revision}:${repositoryPath}`], {
398
+ cwd: root,
399
+ stdio: ["ignore", "pipe", "ignore"],
400
+ timeout: 10_000,
401
+ maxBuffer: 20_000_000,
402
+ env: gitEnvironment()
403
+ });
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ export function getFileObjectIdAtRevision(input, revision, relativePath) {
410
+ if (!/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)) return null;
411
+ const root = resolveWorkspaceRoot(input);
412
+ try {
413
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
414
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
415
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return null;
416
+ const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
417
+ const objectId = git(root, ["rev-parse", `${revision}:${repositoryPath}`]);
418
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ export function getWorkingFileObjectId(input, relativePath) {
425
+ if (!isSafeDataGitPath(relativePath)) return null;
426
+ const root = resolveWorkspaceRoot(input);
427
+ try {
428
+ const objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
429
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+
95
435
  export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
96
436
  const root = resolveWorkspaceRoot(input);
97
437
  const wanted = new Set(relativePaths);
@@ -136,7 +476,7 @@ export function getFileAtRevision(input, revision, relativePath) {
136
476
  return getFilesAtRevisions(input, [{ revision, relativePath }])[0];
137
477
  }
138
478
 
139
- export function getFilesAtRevisions(input, requests) {
479
+ export function getFilesAtRevisions(input, requests, options = {}) {
140
480
  const root = resolveWorkspaceRoot(input);
141
481
  const invalid = Array.isArray(requests) && requests.find(({ revision, relativePath } = {}) => (
142
482
  !/^[a-f0-9]{40}$/i.test(String(revision)) || !isSafeDataGitPath(relativePath)
@@ -145,33 +485,72 @@ export function getFilesAtRevisions(input, requests) {
145
485
  throw new Error("Historical file exports require a Git commit and a data/ path.");
146
486
  }
147
487
  if (!requests.length) return [];
488
+ const results = new Array(requests.length);
489
+ const missing = [];
490
+ requests.forEach((request, index) => {
491
+ const key = `${root}\0${request.revision}\0${request.relativePath}`;
492
+ if (historicalFileCache.has(key)) {
493
+ results[index] = historicalFileCache.get(key);
494
+ historicalFileCache.delete(key);
495
+ historicalFileCache.set(key, results[index]);
496
+ } else {
497
+ missing.push({ ...request, index, key });
498
+ }
499
+ });
500
+ if (!missing.length) return results;
148
501
  try {
149
502
  const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
150
503
  const workspacePrefix = relative(topLevel, root).split(sep).join("/");
151
504
  if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return requests.map(() => null);
152
- const results = [];
153
- for (let offset = 0; offset < requests.length; offset += 4) {
154
- const batch = requests.slice(offset, offset + 4);
505
+ const batchSize = Math.max(1, Math.min(Number(options.batchSize) || 4, 512));
506
+ for (let offset = 0; offset < missing.length; offset += batchSize) {
507
+ const batch = missing.slice(offset, offset + batchSize);
155
508
  const specifications = batch.map(({ revision, relativePath }) => {
156
509
  const repositoryPath = workspacePrefix ? `${workspacePrefix}/${relativePath}` : relativePath;
157
510
  return `${revision}:${repositoryPath}`;
158
511
  });
512
+ let values;
159
513
  try {
160
514
  const output = measureTimingSync("git-history-export", () => execFileSync("git", ["cat-file", "--batch"], {
161
515
  cwd: root,
162
516
  input: `${specifications.join("\n")}\n`,
163
517
  stdio: ["pipe", "pipe", "ignore"],
164
518
  timeout: 10_000,
165
- maxBuffer: 80_000_000
519
+ maxBuffer: 80_000_000,
520
+ env: gitEnvironment()
166
521
  }));
167
- results.push(...parseBatchObjects(output, batch.length));
522
+ values = parseBatchObjects(output, batch.length);
168
523
  } catch {
169
- results.push(...specifications.map((specification) => readHistoricalFile(root, specification)));
524
+ values = specifications.map((specification) => readHistoricalFile(root, specification));
525
+ }
526
+ batch.forEach(({ index, key }, batchIndex) => {
527
+ const value = values[batchIndex];
528
+ results[index] = value;
529
+ if (value !== null && Buffer.byteLength(value, "utf8") <= 65_536) historicalFileCache.set(key, value);
530
+ });
531
+ while (historicalFileCache.size > 2_048) {
532
+ historicalFileCache.delete(historicalFileCache.keys().next().value);
170
533
  }
171
534
  }
172
535
  return results;
173
536
  } catch {
174
- return requests.map(() => null);
537
+ return results.map((value) => value ?? null);
538
+ }
539
+ }
540
+
541
+ export function getDataFilesAtRevision(input, revision) {
542
+ if (!/^[a-f0-9]{40}$/i.test(String(revision))) return [];
543
+ const root = resolveWorkspaceRoot(input);
544
+ try {
545
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
546
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
547
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) return [];
548
+ const dataPrefix = workspacePrefix ? `${workspacePrefix}/data` : "data";
549
+ return lines(git(root, ["ls-tree", "-r", "--name-only", revision, "--", dataPrefix]))
550
+ .filter((path) => path.startsWith(`${dataPrefix}/`) && path.endsWith(".json"))
551
+ .map((path) => workspacePrefix ? path.slice(workspacePrefix.length + 1) : path);
552
+ } catch {
553
+ return [];
175
554
  }
176
555
  }
177
556
 
@@ -182,7 +561,8 @@ function readHistoricalFile(root, specification) {
182
561
  encoding: "utf8",
183
562
  stdio: ["ignore", "pipe", "ignore"],
184
563
  timeout: 10_000,
185
- maxBuffer: 20_000_000
564
+ maxBuffer: 20_000_000,
565
+ env: gitEnvironment()
186
566
  }));
187
567
  } catch {
188
568
  return null;
@@ -275,6 +655,7 @@ export function getRepositorySnapshot(input = process.cwd(), options = {}) {
275
655
  export async function getWorkspaceRevisionSnapshot(input = process.cwd()) {
276
656
  const root = resolveWorkspaceRoot(input);
277
657
  try {
658
+ assertNoWorkspaceContentFilters(root);
278
659
  const source = await measureTiming("repository-revision", () => runGitCommand(root, [
279
660
  "status",
280
661
  "--porcelain=v2",
@@ -312,6 +693,7 @@ async function buildRepositorySnapshot(root) {
312
693
  }
313
694
  const [topLevel, gitDirectory] = repositoryPaths.split("\n");
314
695
  try {
696
+ assertNoWorkspaceContentFilters(root);
315
697
  const [status, remotes] = await Promise.all([
316
698
  runGitCommand(topLevel, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], {
317
699
  operation: "inspect repository status"
@@ -466,7 +848,7 @@ async function prefetchBrowserRemoteCoalesced(root, options) {
466
848
  if (!prepared.repository) return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
467
849
 
468
850
  // Fetch updates only remote-tracking refs, so it does not occupy the source mutation queue.
469
- await fetchConfiguredRemote(root, prepared.config.remote);
851
+ await fetchConfiguredRemote(root, prepared.config);
470
852
  return serializeWorkspaceMutation(root, async () => {
471
853
  const summary = await getRepositorySnapshot(root, { fresh: true });
472
854
  if (!summary.available) throw summary.error;
@@ -503,19 +885,26 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
503
885
  throw new Error("Retry sync is disabled while the development write override is active.");
504
886
  }
505
887
  const before = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
506
- await fetchConfiguredRemote(root, config.remote);
888
+ await fetchConfiguredRemote(root, config);
507
889
  const synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
508
890
  if (synchronized.behind > 0 && synchronized.ahead > 0) {
509
891
  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.");
510
892
  }
511
893
  if (synchronized.behind > 0) {
512
- await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
894
+ throw new Error("The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before retrying sync.");
513
895
  }
514
896
  const ready = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
515
897
  if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
516
898
  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.");
517
899
  }
518
- if (ready.ahead > 0) await pushConfiguredBranch(root, config);
900
+ if (ready.ahead > 0) {
901
+ const pushed = await pushConfiguredBranch(root, config, ready.currentCommit, ready.upstreamCommit);
902
+ if (pushed.trackingError) {
903
+ const committed = { commit: ready.currentCommit, shortCommit: ready.currentCommit.slice(0, 8) };
904
+ recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
905
+ throw new Error(pushed.trackingError);
906
+ }
907
+ }
519
908
  const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
520
909
  if (after.ahead !== 0 || after.behind !== 0) {
521
910
  throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
@@ -538,7 +927,7 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
538
927
  const beforeFetch = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
539
928
  let synchronized = beforeFetch;
540
929
  if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
541
- await fetchConfiguredRemote(root, config.remote);
930
+ await fetchConfiguredRemote(root, config);
542
931
  synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
543
932
  }
544
933
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
@@ -548,8 +937,7 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
548
937
  throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
549
938
  }
550
939
  if (synchronized.behind > 0) {
551
- await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
552
- synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
940
+ throw new Error("The authoritative branch is behind its upstream. Fast-forward it with Git, then reload before making a browser change.");
553
941
  }
554
942
  if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
555
943
  throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
@@ -558,29 +946,29 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
558
946
  let result;
559
947
  let subject;
560
948
  let validationProof;
949
+ let validatedManifest;
561
950
  try {
562
951
  result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root, {
563
952
  repositorySnapshot: synchronized
564
953
  })));
565
954
  subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
955
+ assertNoIgnoredAuthoritativeFiles(root);
956
+ const beforeValidation = workspaceByteManifest(root);
566
957
  const validation = await validateWorkspace(root);
567
958
  if (!validation.ok) {
568
- throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
959
+ throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`);
569
960
  }
570
961
  validationProof = options?.includeValidationProof === false
571
962
  ? null
572
963
  : {
573
964
  validation,
574
965
  fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
575
- };
966
+ };
967
+ validatedManifest = workspaceByteManifest(root);
968
+ assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
576
969
  await assertNoOutsideWorktreeChangesAsync(root);
577
970
  } catch (error) {
578
- try {
579
- await rollbackWorkspaceChangesAsync(root);
580
- } catch (rollbackError) {
581
- 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.`);
582
- }
583
- throw error;
971
+ throw new Error(`${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`);
584
972
  }
585
973
 
586
974
  const changed = Boolean(await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
@@ -602,37 +990,39 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
602
990
  if (!changed) {
603
991
  throw new Error("The browser action did not change any FileGRC workspace files.");
604
992
  }
605
- let identity;
606
- try {
607
- identity = await runGitCommand(root, ["config", "--get-regexp", "^user\\.(name|email)$"], {
608
- operation: "read the Git user identity"
609
- });
610
- } catch (error) {
611
- if (error instanceof GitOperationError && error.kind === "command-failure") identity = "";
612
- else throw error;
613
- }
614
- if (!/^user\.name\s+.+$/m.test(identity) || !/^user\.email\s+.+$/m.test(identity)) {
615
- 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.");
616
- }
617
- await measureTiming("stage", () => runGitCommand(root, ["add", "--all", "--", "."], {
618
- operation: "stage the FileGRC workspace change",
619
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
620
- }));
621
- await assertNoOutsideWorktreeChangesAsync(root, false);
622
- await assertOnlyWorkspaceFilesStagedAsync(root);
993
+ assertValidGitIdentity(root);
994
+ assertNoWorkspaceContentFilters(root);
995
+ await assertNoOutsideWorktreeChangesAsync(root);
996
+ let commit;
997
+ let indexReconciled;
623
998
  try {
624
- await measureTiming("commit", () => runGitCommand(root, ["commit", "-m", subject, "--", "."], {
625
- operation: "create the FileGRC browser commit",
626
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
627
- }));
999
+ const expectedRef = `refs/heads/${config.authoritativeBranch}`;
1000
+ assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
1001
+ validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1002
+ ({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
1003
+ expectedParent: synchronized.currentCommit,
1004
+ expectedRef
1005
+ })));
628
1006
  } catch (error) {
629
1007
  throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
630
1008
  }
631
1009
 
632
- const commit = await runGitCommand(root, ["rev-parse", "HEAD"], {
633
- operation: "read the FileGRC browser commit"
634
- });
635
- const committed = { commit, shortCommit: commit.slice(0, 8) };
1010
+ const committed = { commit, shortCommit: commit.slice(0, 8), upstreamCommit: synchronized.upstreamCommit };
1011
+ if (!indexReconciled) {
1012
+ const error = new Error("FileGRC created the commit, but the shared Git index changed or could not be reconciled afterward. Review and reconcile the Git index before syncing.");
1013
+ recordBackgroundPushFailure(root, committed, error);
1014
+ return withValidationProof({
1015
+ ...result,
1016
+ synchronization: {
1017
+ status: "not-synced",
1018
+ commit: committed.commit,
1019
+ shortCommit: committed.shortCommit,
1020
+ upstream: synchronized.upstream,
1021
+ synchronizedAt: null,
1022
+ pushError: backgroundSynchronizations.get(root)?.error ?? error.message
1023
+ }
1024
+ }, validationProof);
1025
+ }
636
1026
  queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
637
1027
  return withValidationProof({
638
1028
  ...result,
@@ -682,7 +1072,10 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
682
1072
  });
683
1073
  const start = async () => {
684
1074
  try {
685
- const ready = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
1075
+ const ready = await requireTrunkPreconditionsAsync(root, config, {
1076
+ allowAhead: true,
1077
+ backgroundCommit: committed.commit
1078
+ });
686
1079
  if (ready.currentCommit !== committed.commit) {
687
1080
  throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
688
1081
  }
@@ -705,18 +1098,26 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
705
1098
  async function finishBackgroundPush(root, config, committed) {
706
1099
  const started = performance.now();
707
1100
  let outcome = "failed";
1101
+ let remotePushed = false;
708
1102
  try {
709
- await pushConfiguredBranch(root, config, committed.commit);
1103
+ const pushed = await pushConfiguredBranch(root, config, committed.commit, committed.upstreamCommit);
1104
+ remotePushed = pushed.remotePushed;
1105
+ if (pushed.trackingError) {
1106
+ recordBackgroundTrackingFailure(root, committed, pushed.trackingError);
1107
+ outcome = "pushed-tracking-stale";
1108
+ return;
1109
+ }
710
1110
  const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }), { ignoreBackground: true });
711
1111
  if (after.ahead !== 0 || after.behind !== 0) {
712
1112
  throw new Error("The authoritative branch is still not synchronized after the background push.");
713
1113
  }
714
1114
  const synchronizedAt = new Date().toISOString();
715
1115
  lastSuccessfulSynchronizations.set(root, synchronizedAt);
716
- backgroundSynchronizations.delete(root);
1116
+ deleteBackgroundSynchronizationIfCurrent(root, committed.commit);
717
1117
  outcome = "synced";
718
1118
  } catch (error) {
719
- recordBackgroundPushFailure(root, committed, error);
1119
+ if (remotePushed) recordBackgroundRemoteSuccessFailure(root, committed, error.message);
1120
+ else recordBackgroundPushFailure(root, committed, error);
720
1121
  } finally {
721
1122
  if (timingEnabled()) {
722
1123
  console.error(`[filegrc timing] ${JSON.stringify({
@@ -729,14 +1130,47 @@ async function finishBackgroundPush(root, config, committed) {
729
1130
  }
730
1131
 
731
1132
  function recordBackgroundPushFailure(root, committed, error) {
732
- backgroundSynchronizations.set(root, {
1133
+ setBackgroundSynchronizationIfCurrent(root, committed, {
733
1134
  status: "failed",
734
1135
  commit: committed.commit,
735
1136
  shortCommit: committed.shortCommit,
736
1137
  startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
737
1138
  finishedAt: new Date().toISOString(),
1139
+ remotePushed: false,
738
1140
  error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`
1141
+ }, { allowMissing: true });
1142
+ }
1143
+
1144
+ function recordBackgroundTrackingFailure(root, committed, error) {
1145
+ recordBackgroundRemoteSuccessFailure(root, committed, error);
1146
+ }
1147
+
1148
+ function recordBackgroundRemoteSuccessFailure(root, committed, error) {
1149
+ setBackgroundSynchronizationIfCurrent(root, committed, {
1150
+ status: "failed",
1151
+ commit: committed.commit,
1152
+ shortCommit: committed.shortCommit,
1153
+ startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
1154
+ finishedAt: new Date().toISOString(),
1155
+ remotePushed: true,
1156
+ error: `${error} The remote accepted the exact FileGRC commit. Fetch with Git to reconcile local state; do not retry the push.`
1157
+ }, { allowMissing: true });
1158
+ }
1159
+
1160
+ function setBackgroundSynchronizationIfCurrent(root, committed, next, options = {}) {
1161
+ const current = backgroundSynchronizations.get(root);
1162
+ if (current && current.commit !== committed.commit) return false;
1163
+ if (!current && options.allowMissing !== true) return false;
1164
+ backgroundSynchronizations.set(root, {
1165
+ ...next,
1166
+ remotePushed: current?.remotePushed === true || next.remotePushed === true
739
1167
  });
1168
+ return true;
1169
+ }
1170
+
1171
+ function deleteBackgroundSynchronizationIfCurrent(root, commit) {
1172
+ if (backgroundSynchronizations.get(root)?.commit !== commit) return false;
1173
+ return backgroundSynchronizations.delete(root);
740
1174
  }
741
1175
 
742
1176
  async function commitWorkspaceUnlocked(root, message) {
@@ -744,31 +1178,47 @@ async function commitWorkspaceUnlocked(root, message) {
744
1178
  if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
745
1179
  throw new Error("Commit messages must be one line from 1 through 200 characters.");
746
1180
  }
747
- const validation = await validateWorkspace(root);
748
- if (!validation.ok) {
749
- throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before committing.`);
750
- }
1181
+ assertNoWorkspaceContentFilters(root);
751
1182
  const before = getGitSummary(root);
752
1183
  if (!before.available) throw new Error("Git history is unavailable for this workspace.");
753
1184
  if (!before.branch) throw new Error("Check out a branch before creating a browser commit.");
1185
+ assertNoGitOperationInProgress(root);
754
1186
  if (before.clean) throw new Error("The workspace has no changes to commit.");
755
1187
  if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
756
1188
  throw new Error("Configure git user.name and user.email before committing.");
757
1189
  }
758
- gitForWrite(root, ["add", "--all", "--", "."]);
759
- gitForWrite(root, ["commit", "-m", subject, "--", "."]);
1190
+ assertValidGitIdentity(root);
1191
+ assertNoIgnoredAuthoritativeFiles(root);
1192
+ const beforeValidation = workspaceByteManifest(root);
1193
+ const validation = await validateWorkspace(root);
1194
+ if (!validation.ok) {
1195
+ throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before committing.`);
1196
+ }
1197
+ let validatedManifest = workspaceByteManifest(root);
1198
+ assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1199
+ const commitOptions = {
1200
+ expectedParent: before.commit,
1201
+ expectedRef: `refs/heads/${before.branch}`
1202
+ };
1203
+ assertExpectedCheckout(root, commitOptions.expectedRef, commitOptions.expectedParent);
1204
+ validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1205
+ const committed = await commitValidatedIndexAsync(root, subject, validatedManifest, commitOptions);
760
1206
  const after = getGitSummary(root);
761
1207
  return {
762
- commit: after.commit,
763
- shortCommit: after.shortCommit,
1208
+ commit: committed.commit,
1209
+ shortCommit: committed.commit.slice(0, 8),
1210
+ indexReconciled: committed.indexReconciled,
764
1211
  subject: after.lastCommit?.subject || subject
765
1212
  };
766
1213
  }
767
1214
 
768
1215
  async function commitAndPushWorkspaceUnlocked(root, message) {
769
1216
  const before = getGitSummary(root);
1217
+ const target = before.upstream || before.remotes?.length
1218
+ ? captureManualPushTarget(root, before)
1219
+ : null;
770
1220
  const committed = await commitWorkspaceUnlocked(root, message);
771
- if (!before.upstream && !before.remotes?.length) {
1221
+ if (!target) {
772
1222
  return {
773
1223
  ...committed,
774
1224
  pushed: false,
@@ -776,12 +1226,18 @@ async function commitAndPushWorkspaceUnlocked(root, message) {
776
1226
  };
777
1227
  }
778
1228
  try {
779
- const pushed = await pushWorkspaceUnlocked(root);
1229
+ const pushed = await pushCapturedWorkspaceCommit(root, {
1230
+ before,
1231
+ target,
1232
+ commit: committed.commit
1233
+ });
780
1234
  return {
781
1235
  ...committed,
782
1236
  pushed: true,
783
1237
  pushSkipped: false,
784
- upstream: pushed.upstream
1238
+ upstream: pushed.upstream,
1239
+ trackingConfigured: pushed.trackingConfigured,
1240
+ trackingError: pushed.trackingError
785
1241
  };
786
1242
  } catch (error) {
787
1243
  return {
@@ -795,14 +1251,23 @@ async function commitAndPushWorkspaceUnlocked(root, message) {
795
1251
 
796
1252
  async function pullWorkspaceUnlocked(root) {
797
1253
  const before = syncReadySummary(root, "pull");
1254
+ assertNoGitOperationInProgress(root);
798
1255
  if (!before.upstream) {
799
1256
  throw new Error("This branch has no upstream branch. Push it first or configure an upstream with Git.");
800
1257
  }
801
- try {
802
- gitForWrite(root, ["pull", "--rebase", "--no-autostash"], "pull with rebase");
803
- } catch (error) {
804
- tryGitForWrite(root, ["rebase", "--abort"]);
805
- throw error;
1258
+ const remote = before.upstream.split("/")[0];
1259
+ const branch = before.upstream.slice(remote.length + 1);
1260
+ const url = assertSafeAutomaticTransport(root, remote);
1261
+ gitForWrite(root, exactFetchArgs({ remote, branch, url }), `fetch ${remote} before checking incoming commits`);
1262
+ const target = git(root, ["rev-parse", "--verify", `${before.upstream}^{commit}`]);
1263
+ const incomingCommits = lines(git(root, ["rev-list", "--reverse", `${before.commit}..${target}`]));
1264
+ assertCommitsInsideWorkspace(root, incomingCommits);
1265
+ for (const commit of incomingCommits) {
1266
+ assertNoCommitWorkspaceContentFilters(root, commit);
1267
+ }
1268
+ assertExpectedCheckout(root, `refs/heads/${before.branch}`, before.commit);
1269
+ if (incomingCommits.length) {
1270
+ throw new Error("FileGRC fetched incoming commits but did not integrate them. Reconcile the branch with Git, then reload FileGRC.");
806
1271
  }
807
1272
  const after = getGitSummary(root);
808
1273
  return {
@@ -816,14 +1281,56 @@ async function pullWorkspaceUnlocked(root) {
816
1281
 
817
1282
  async function pushWorkspaceUnlocked(root) {
818
1283
  const before = syncReadySummary(root, "push");
1284
+ assertNoGitOperationInProgress(root);
1285
+ const target = captureManualPushTarget(root, before);
819
1286
  const validation = await validateWorkspace(root);
820
1287
  if (!validation.ok) {
821
1288
  throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before pushing.`);
822
1289
  }
1290
+ return pushCapturedWorkspaceCommit(root, { before, target, commit: before.commit });
1291
+ }
1292
+
1293
+ async function pushCapturedWorkspaceCommit(root, { before, target, commit }) {
1294
+ assertExpectedCheckout(root, `refs/heads/${before.branch}`, commit);
1295
+ await gitForWriteAsync(root, exactPushArgs(target, commit), "push", {
1296
+ expectedCheckout: {
1297
+ expectedRef: `refs/heads/${before.branch}`,
1298
+ expectedCommit: commit
1299
+ }
1300
+ });
1301
+ let trackingError = reconcileRemoteTracking(root, target, commit);
1302
+ if (!before.upstream && !trackingError) {
1303
+ try {
1304
+ assertExpectedCheckout(root, `refs/heads/${before.branch}`, commit);
1305
+ gitForWrite(root, ["branch", "--set-upstream-to", `${target.remote}/${before.branch}`, before.branch], "configure the pushed branch upstream");
1306
+ } catch (error) {
1307
+ trackingError = error.message;
1308
+ }
1309
+ }
1310
+ return {
1311
+ commit,
1312
+ shortCommit: commit.slice(0, 8),
1313
+ branch: before.branch,
1314
+ upstream: before.upstream || (trackingError ? null : `${target.remote}/${before.branch}`),
1315
+ trackingConfigured: Boolean(before.upstream) || !trackingError,
1316
+ trackingError
1317
+ };
1318
+ }
1319
+
1320
+ function captureManualPushTarget(root, before) {
1321
+ let remote;
1322
+ let destination;
823
1323
  if (before.upstream) {
824
- gitForWrite(root, ["push"], "push");
1324
+ remote = tryGit(root, ["config", "--get", `branch.${before.branch}.remote`]);
1325
+ destination = tryGit(root, ["config", "--get", `branch.${before.branch}.merge`]);
1326
+ const destinationBranch = destination?.startsWith("refs/heads/")
1327
+ ? destination.slice("refs/heads/".length)
1328
+ : "";
1329
+ if (!isSafeGitName(remote) || !isSafeGitName(destinationBranch) || before.upstream !== `${remote}/${destinationBranch}`) {
1330
+ throw new Error("The current branch has an unsafe or unsupported upstream configuration. Configure a normal remote branch with Git before pushing.");
1331
+ }
825
1332
  } else {
826
- const remote = before.remotes.includes("origin")
1333
+ remote = before.remotes.includes("origin")
827
1334
  ? "origin"
828
1335
  : before.remotes.length === 1
829
1336
  ? before.remotes[0]
@@ -833,17 +1340,43 @@ async function pushWorkspaceUnlocked(root) {
833
1340
  ? "This branch has no upstream and the repository has multiple remotes. Configure an upstream with Git."
834
1341
  : "This repository has no Git remote. Add one before pushing.");
835
1342
  }
836
- gitForWrite(root, ["push", "--set-upstream", "--", remote, "HEAD"], "push");
1343
+ destination = `refs/heads/${before.branch}`;
837
1344
  }
838
- const after = getGitSummary(root);
1345
+ const destinationBranch = destination.slice("refs/heads/".length);
839
1346
  return {
840
- commit: after.commit,
841
- shortCommit: after.shortCommit,
842
- branch: after.branch,
843
- upstream: after.upstream
1347
+ remote,
1348
+ destination,
1349
+ destinationBranch,
1350
+ trackingRef: `refs/remotes/${remote}/${destinationBranch}`,
1351
+ expectedTrackingCommit: before.upstream ? tryGit(root, ["rev-parse", "--verify", before.upstream]) : null,
1352
+ url: assertSafeAutomaticTransport(root, remote, { push: true })
844
1353
  };
845
1354
  }
846
1355
 
1356
+ function reconcileRemoteTracking(root, target, commit) {
1357
+ try {
1358
+ gitForWrite(root, [
1359
+ "update-ref", "-m", "FileGRC exact push",
1360
+ target.trackingRef,
1361
+ commit,
1362
+ target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
1363
+ ], "reconcile the remote-tracking branch after a successful push");
1364
+ return null;
1365
+ } catch (error) {
1366
+ return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
1367
+ }
1368
+ }
1369
+
1370
+ function exactPushArgs(target, commit) {
1371
+ const expected = target.expectedTrackingCommit || "";
1372
+ return [
1373
+ "-c", "push.pushOption=",
1374
+ "push", "--porcelain", "--no-follow-tags", "--recurse-submodules=no", "--no-signed", "--no-push-option",
1375
+ `--force-with-lease=${target.destination}:${expected}`,
1376
+ "--", target.url, `${commit}:${target.destination}`
1377
+ ];
1378
+ }
1379
+
847
1380
  function syncReadySummary(root, action) {
848
1381
  const summary = getGitSummary(root);
849
1382
  if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so filegrc cannot ${action}.`);
@@ -894,6 +1427,7 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
894
1427
  shortCommit: background.shortCommit,
895
1428
  startedAt: background.startedAt,
896
1429
  finishedAt: background.finishedAt ?? null,
1430
+ remotePushed: background.remotePushed === true,
897
1431
  error: background.error
898
1432
  } : null,
899
1433
  writesAllowed: false
@@ -989,9 +1523,11 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
989
1523
  const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
990
1524
  const backgroundFailure = background?.status === "failed"
991
1525
  && background.commit === summary.commit
992
- && counts.ahead > 0
993
- && counts.behind === 0
994
- && pendingCommitsFilegrcOnly
1526
+ && (background.remotePushed === true || (
1527
+ counts.ahead > 0
1528
+ && counts.behind === 0
1529
+ && pendingCommitsFilegrcOnly
1530
+ ))
995
1531
  ? background.error
996
1532
  : null;
997
1533
  return {
@@ -1004,9 +1540,9 @@ function inspectTrunkRepository(root, config, summary, options = {}) {
1004
1540
  ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
1005
1541
  : counts.ahead > 0
1006
1542
  ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
1007
- : "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing."),
1008
- writesAllowed: counts.ahead === 0 && counts.behind > 0,
1009
- retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly,
1543
+ : "The authoritative branch is behind upstream. Fast-forward it with Git, then reload FileGRC."),
1544
+ writesAllowed: false,
1545
+ retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly && background?.remotePushed !== true,
1010
1546
  backgroundSyncError: backgroundFailure
1011
1547
  };
1012
1548
  }
@@ -1028,6 +1564,12 @@ async function requireTrunkPreconditionsAsync(root, config, options = {}) {
1028
1564
  if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
1029
1565
  if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
1030
1566
  if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
1567
+ if (
1568
+ state.backgroundSynchronization?.status === "syncing"
1569
+ && state.backgroundSynchronization.commit !== options.backgroundCommit
1570
+ ) {
1571
+ throw new Error("A FileGRC background push is still finalizing. Wait for it to finish before making another browser change.");
1572
+ }
1031
1573
  if (state.operationInProgress) throw new Error(state.message);
1032
1574
  if (!state.wholeWorktreeClean) throw new Error(state.message);
1033
1575
  if (!options.allowAhead && state.ahead > 0) {
@@ -1036,26 +1578,140 @@ async function requireTrunkPreconditionsAsync(root, config, options = {}) {
1036
1578
  return state;
1037
1579
  }
1038
1580
 
1039
- async function fetchConfiguredRemote(root, remote) {
1040
- return measureTiming("fetch", () => gitForWriteAsync(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`));
1581
+ async function fetchConfiguredRemote(root, config) {
1582
+ const url = assertSafeAutomaticTransport(root, config.remote);
1583
+ return measureTiming("fetch", () => gitForWriteAsync(
1584
+ root,
1585
+ exactFetchArgs({ remote: config.remote, branch: config.authoritativeBranch, url }),
1586
+ `fetch ${config.remote}`
1587
+ ));
1588
+ }
1589
+
1590
+ function exactFetchArgs({ remote, branch, url }) {
1591
+ return [
1592
+ "fetch", "--prune", "--no-prune-tags", "--no-tags", "--recurse-submodules=no",
1593
+ "--", url, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`
1594
+ ];
1041
1595
  }
1042
1596
 
1043
- function fastForwardConfiguredBranchAsync(root, upstream) {
1044
- return runGitCommand(root, ["merge", "--ff-only", "--", upstream], {
1045
- operation: `fast-forward from ${upstream}`,
1046
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
1047
- });
1597
+ function assertCommitsInsideWorkspace(root, commits) {
1598
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
1599
+ const prefix = relative(topLevel, root).split(sep).join("/");
1600
+ if (prefix === ".." || prefix.startsWith("../")) throw new Error("The FileGRC workspace is outside its Git repository.");
1601
+ if (!prefix) return;
1602
+ for (const commit of commits) {
1603
+ const changed = nulFields(gitRaw(topLevel, [
1604
+ "diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-m", "-r", commit, "--"
1605
+ ]));
1606
+ const outside = changed.filter((path) => !pathInsideWorkspace(path, prefix));
1607
+ if (outside.length) {
1608
+ throw new Error(`FileGRC will not rebase commit ${commit.slice(0, 12)} because it changes files outside this nested workspace: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? "…" : ""}. Reconcile the whole repository with Git.`);
1609
+ }
1610
+ }
1048
1611
  }
1049
1612
 
1050
- async function pushConfiguredBranch(root, config, source = "HEAD") {
1051
- return measureTiming("push", () => gitForWriteAsync(
1613
+ async function pushConfiguredBranch(root, config, source, expectedTrackingCommit) {
1614
+ if (!/^[a-f0-9]{40}$/i.test(source)) {
1615
+ throw new Error("FileGRC requires an exact commit ID before pushing the authoritative branch.");
1616
+ }
1617
+ const url = assertSafeAutomaticTransport(root, config.remote, { push: true });
1618
+ const target = {
1619
+ remote: config.remote,
1620
+ url,
1621
+ destination: `refs/heads/${config.authoritativeBranch}`,
1622
+ trackingRef: `refs/remotes/${config.remote}/${config.authoritativeBranch}`,
1623
+ expectedTrackingCommit
1624
+ };
1625
+ await measureTiming("push", () => gitForWriteAsync(
1052
1626
  root,
1053
- ["push", "--porcelain", "--", config.remote, `${source}:refs/heads/${config.authoritativeBranch}`],
1054
- `push ${config.authoritativeBranch} to ${config.remote}`
1627
+ exactPushArgs(target, source),
1628
+ `push ${config.authoritativeBranch} to ${config.remote}`,
1629
+ {
1630
+ expectedCheckout: {
1631
+ expectedRef: `refs/heads/${config.authoritativeBranch}`,
1632
+ expectedCommit: source
1633
+ }
1634
+ }
1055
1635
  ));
1636
+ const trackingError = await reconcileRemoteTrackingAsync(root, target, source);
1637
+ return { remotePushed: true, trackingError };
1056
1638
  }
1057
1639
 
1058
- async function assertNoOutsideWorktreeChangesAsync(root, rollbackExpected = true) {
1640
+ async function reconcileRemoteTrackingAsync(root, target, commit) {
1641
+ try {
1642
+ await gitForWriteAsync(root, [
1643
+ "update-ref", "-m", "FileGRC exact push",
1644
+ target.trackingRef,
1645
+ commit,
1646
+ target.expectedTrackingCommit || "0000000000000000000000000000000000000000"
1647
+ ], "reconcile the remote-tracking branch after a successful push");
1648
+ return null;
1649
+ } catch (error) {
1650
+ return `${error.message} The exact commit was pushed, but the local remote-tracking branch needs reconciliation with Git.`;
1651
+ }
1652
+ }
1653
+
1654
+ function assertSafeAutomaticTransport(root, remote, options = {}) {
1655
+ assertNoRepositoryExecutableGitConfig(root);
1656
+ const url = tryGit(root, ["remote", "get-url", ...(options.push ? ["--push"] : []), "--", remote]);
1657
+ const safe = url
1658
+ && !/[\0\r\n]/.test(url)
1659
+ && (
1660
+ /^(?:https?|ssh|file):\/\//i.test(url)
1661
+ || /^(?:\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(url)
1662
+ || /^[^\s/@:]+@[^\s/:]+:.+$/.test(url)
1663
+ );
1664
+ if (!safe) {
1665
+ throw new Error(`FileGRC will not use the configured remote "${remote}" because its URL scheme is not allowed for automatic fetch or push.`);
1666
+ }
1667
+ return url;
1668
+ }
1669
+
1670
+ function assertNoRepositoryExecutableGitConfig(root) {
1671
+ assertWorkspaceInsideGitWorktree(root);
1672
+ const pattern = "^(core\\.(sshcommand|gitproxy|askpass|alternaterefscommand|worktree|sparsecheckout.*)|credential\\.(helper|.*\\.helper)|fetch\\.bundleuri|filter\\..*\\.(clean|smudge|process)|merge\\..*\\.driver|http\\..*|remote\\..*\\.(proxy|uploadpack|receivepack)|url\\..*\\.(insteadof|pushinsteadof)|protocol\\..*\\.allow)$";
1673
+ const scopes = ["--local"];
1674
+ if (gitOptionalMatch(root, ["config", "--local", "--type=bool", "--get", "extensions.worktreeConfig"]) === "true") {
1675
+ scopes.push("--worktree");
1676
+ }
1677
+ const executableConfiguration = scopes
1678
+ .map((scope) => gitOptionalMatch(root, ["config", scope, "--get-regexp", pattern]))
1679
+ .find(Boolean);
1680
+ if (executableConfiguration) {
1681
+ throw new Error("FileGRC will not run managed Git synchronization while repository-local executable transport, filter, merge, HTTP, credential, URL rewrite, or protocol configuration is present. Move trusted user and transport settings to normal user-level Git configuration and remove the local override.");
1682
+ }
1683
+ assertNoHiddenIndexEntries(root);
1684
+ }
1685
+
1686
+ function assertWorkspaceInsideGitWorktree(root) {
1687
+ const workspace = realpathSync(root);
1688
+ const topLevel = realpathSync(git(root, ["rev-parse", "--show-toplevel"]));
1689
+ if (workspace !== topLevel && !workspace.startsWith(`${topLevel}${sep}`)) {
1690
+ throw new Error("FileGRC will not run managed Git writes because repository configuration redirects the Git worktree outside this workspace. Remove core.worktree and reopen the authoritative checkout.");
1691
+ }
1692
+ }
1693
+
1694
+ function assertExpectedCheckout(root, expectedRef, expectedCommit) {
1695
+ if (!checkoutMatches(root, expectedRef, expectedCommit)) {
1696
+ throw new Error("The checked-out Git branch or commit changed while FileGRC prepared the operation. Review the worktree and return to the expected branch before trying again.");
1697
+ }
1698
+ }
1699
+
1700
+ function checkoutMatches(root, expectedRef, expectedCommit) {
1701
+ return tryGit(root, ["symbolic-ref", "--quiet", "HEAD"]) === expectedRef
1702
+ && tryGit(root, ["rev-parse", "HEAD"]) === expectedCommit;
1703
+ }
1704
+
1705
+ function assertValidGitIdentity(root) {
1706
+ try {
1707
+ git(root, ["var", "GIT_AUTHOR_IDENT"]);
1708
+ git(root, ["var", "GIT_COMMITTER_IDENT"]);
1709
+ } catch {
1710
+ throw new Error("Configure valid git user.name and git user.email values before FileGRC creates commits. The saved files remain uncommitted and later browser changes are blocked.");
1711
+ }
1712
+ }
1713
+
1714
+ async function assertNoOutsideWorktreeChangesAsync(root) {
1059
1715
  const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
1060
1716
  operation: "locate the repository before checking worktree changes"
1061
1717
  })).trim();
@@ -1065,44 +1721,269 @@ async function assertNoOutsideWorktreeChangesAsync(root, rollbackExpected = true
1065
1721
  });
1066
1722
  const paths = statusPathsFromRaw(output);
1067
1723
  if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
1068
- throw new Error(rollbackExpected
1069
- ? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
1070
- : "Files outside this FileGRC workspace changed while the browser action was being staged. The saved FileGRC files remain uncommitted and later browser mutations are blocked; reconcile the Git worktree.");
1724
+ throw new Error("Files outside this FileGRC workspace changed while the browser action was running. FileGRC preserved the current worktree; reconcile the Git diff before another browser mutation.");
1071
1725
  }
1072
1726
  }
1073
1727
 
1074
- async function assertOnlyWorkspaceFilesStagedAsync(root) {
1075
- const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
1076
- operation: "locate the repository before checking staged files"
1077
- })).trim();
1078
- const prefix = relative(topLevel, root).split(sep).join("/");
1079
- const output = await runGitCommand(topLevel, [
1080
- "diff", "--cached", "--name-only", "-z", "--diff-filter=ACDMRTUXB"
1081
- ], { operation: "inspect staged FileGRC files" });
1082
- const staged = nulFields(output);
1083
- if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
1084
- if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
1085
- throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
1728
+ function assertNoWorkspaceContentFilters(root) {
1729
+ const paths = [...new Set(nulFields(gitRaw(root, [
1730
+ "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
1731
+ ])))];
1732
+ for (let offset = 0; offset < paths.length; offset += 256) {
1733
+ const batch = paths.slice(offset, offset + 256);
1734
+ const attributes = nulFields(gitRaw(root, ["check-attr", "-z", "filter", "merge", "--", ...batch]));
1735
+ for (let index = 0; index < attributes.length; index += 3) {
1736
+ const [path, attribute, value] = attributes.slice(index, index + 3);
1737
+ if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
1738
+ throw new Error(`FileGRC will not commit "${path}" because Git content filter "${value}" could change or execute while staging authoritative files. Remove the filter attribute and review the resulting diff.`);
1739
+ }
1740
+ if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
1741
+ throw new Error(`FileGRC will not manage "${path}" because Git merge driver "${value}" could execute while synchronizing authoritative files. Remove the named merge attribute and review the resulting diff.`);
1742
+ }
1743
+ }
1086
1744
  }
1087
1745
  }
1088
1746
 
1089
- async function rollbackWorkspaceChangesAsync(root) {
1090
- await runGitCommand(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], {
1091
- operation: "roll back the FileGRC workspace change",
1092
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
1747
+ function assertNoCommitWorkspaceContentFilters(root, commit) {
1748
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
1749
+ const gitDirectory = git(root, ["rev-parse", "--absolute-git-dir"]);
1750
+ const prefix = relative(topLevel, root).split(sep).join("/");
1751
+ if (prefix === ".." || prefix.startsWith("../")) {
1752
+ throw new Error("The FileGRC workspace is outside its Git repository.");
1753
+ }
1754
+ const exactCommit = git(root, ["rev-parse", "--verify", `${commit}^{commit}`]);
1755
+ const paths = nulFields(gitRaw(topLevel, [
1756
+ "ls-tree", "-r", "-z", exactCommit, "--", prefix || "."
1757
+ ])).map((entry) => {
1758
+ const separator = entry.indexOf("\t");
1759
+ const metadata = separator === -1 ? [] : entry.slice(0, separator).split(" ");
1760
+ const path = separator === -1 ? "" : entry.slice(separator + 1);
1761
+ const [mode, type, objectId] = metadata;
1762
+ if (!/^(?:100644|100755)$/.test(mode || "") || type !== "blob" || !/^[a-f0-9]+$/i.test(objectId || "") || !path) {
1763
+ throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because its workspace tree contains a non-regular entry. Replace symlinks and submodules with regular, reviewable files before synchronizing.`);
1764
+ }
1765
+ return path;
1093
1766
  });
1094
- const untracked = nulFields(await runGitCommand(
1095
- root,
1096
- ["ls-files", "-z", "--others", "--exclude-standard", "--", "."],
1097
- { operation: "list untracked FileGRC files" }
1098
- ));
1099
- for (const path of untracked) {
1767
+ const indexFile = resolve(gitDirectory, `filegrc-inspection-index-${randomUUID()}`);
1768
+ try {
1769
+ gitForWrite(topLevel, ["read-tree", exactCommit], "inspect the incoming Git tree", { gitIndexFile: indexFile });
1770
+ for (let offset = 0; offset < paths.length; offset += 256) {
1771
+ const batch = paths.slice(offset, offset + 256);
1772
+ const attributes = nulFields(gitRaw(topLevel, ["check-attr", "-z", "--cached", "filter", "merge", "--", ...batch], {
1773
+ gitIndexFile: indexFile
1774
+ }));
1775
+ for (let index = 0; index < attributes.length; index += 3) {
1776
+ const [path, attribute, value] = attributes.slice(index, index + 3);
1777
+ if (attribute === "filter" && value && !["unspecified", "unset"].includes(value)) {
1778
+ throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git content filter "${value}". Remove the filter attribute in a reviewed Git change before synchronizing.`);
1779
+ }
1780
+ if (attribute === "merge" && value && !["unspecified", "unset", "set"].includes(value)) {
1781
+ throw new Error(`FileGRC will not check out incoming commit ${exactCommit.slice(0, 12)} because "${path}" uses Git merge driver "${value}". Remove the named merge attribute in a reviewed Git change before synchronizing.`);
1782
+ }
1783
+ }
1784
+ }
1785
+ } finally {
1786
+ rmSync(indexFile, { force: true });
1787
+ rmSync(`${indexFile}.lock`, { force: true });
1788
+ }
1789
+ }
1790
+
1791
+ function assertNoIgnoredAuthoritativeFiles(root) {
1792
+ const ignored = nulFields(gitRaw(root, [
1793
+ "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", "data"
1794
+ ]));
1795
+ if (ignored.length) {
1796
+ throw new Error(`FileGRC will not commit while authoritative data files are ignored by Git: ${ignored.slice(0, 5).join(", ")}${ignored.length > 5 ? "…" : ""}. Remove the ignore rule and review the complete staged diff.`);
1797
+ }
1798
+ }
1799
+
1800
+ function workspaceByteManifest(root) {
1801
+ assertNoHiddenIndexEntries(root);
1802
+ const paths = [...new Set(nulFields(gitRaw(root, [
1803
+ "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
1804
+ ])))].sort();
1805
+ const workspace = realpathSync(root);
1806
+ return new Map(paths.map((path) => {
1807
+ if (!path || path.startsWith("/") || path.split("/").includes("..") || /[\0\r\n]/.test(path)) {
1808
+ throw new Error("Git reported an unsafe workspace path. Reconcile the worktree before committing.");
1809
+ }
1100
1810
  const absolute = resolve(root, path);
1101
- if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
1102
- await rm(absolute, { force: true });
1811
+ let stat;
1812
+ try {
1813
+ stat = lstatSync(absolute);
1814
+ } catch (error) {
1815
+ if (error?.code === "ENOENT") return [path, { objectId: null, mode: null }];
1816
+ throw error;
1817
+ }
1818
+ if (stat.isSymbolicLink()) {
1819
+ throw new Error(`FileGRC will not commit symbolic link "${path}". Store authoritative content as a regular file and review its target explicitly.`);
1820
+ }
1821
+ if (!stat.isFile()) {
1822
+ throw new Error(`FileGRC will not commit non-file workspace entry "${path}".`);
1823
+ }
1824
+ const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
1825
+ try {
1826
+ const opened = fstatSync(descriptor);
1827
+ const resolved = realpathSync(absolute);
1828
+ const current = lstatSync(absolute);
1829
+ if (
1830
+ !opened.isFile()
1831
+ || (resolved !== workspace && !resolved.startsWith(`${workspace}${sep}`))
1832
+ || current.isSymbolicLink()
1833
+ || !current.isFile()
1834
+ || opened.dev !== current.dev
1835
+ || opened.ino !== current.ino
1836
+ ) {
1837
+ throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
1838
+ }
1839
+ const bytes = readFileSync(descriptor);
1840
+ const objectId = hashWorkspaceBytes(root, bytes);
1841
+ return [path, { objectId, mode: opened.mode & 0o111 ? "100755" : "100644", bytes }];
1842
+ } finally {
1843
+ closeSync(descriptor);
1844
+ }
1845
+ }));
1846
+ }
1847
+
1848
+ function assertNoHiddenIndexEntries(root) {
1849
+ const hidden = nulFields(gitRaw(root, ["ls-files", "-v", "-z", "--cached", "--", "."]))
1850
+ .filter((entry) => entry[0] === "S" || entry[0] === entry[0]?.toLowerCase());
1851
+ if (hidden.length) {
1852
+ const paths = hidden.slice(0, 5).map((entry) => entry.slice(2));
1853
+ throw new Error(`FileGRC will not run managed Git operations while index entries use skip-worktree or assume-unchanged: ${paths.join(", ")}${hidden.length > 5 ? "…" : ""}. Clear those Git index flags and review the complete workspace diff.`);
1854
+ }
1855
+ }
1856
+
1857
+ function hashWorkspaceBytes(root, bytes, write = false) {
1858
+ return execFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
1859
+ cwd: root,
1860
+ input: bytes,
1861
+ encoding: "utf8",
1862
+ stdio: ["pipe", "pipe", "ignore"],
1863
+ timeout: 30_000,
1864
+ maxBuffer: 20_000_000,
1865
+ env: gitEnvironment()
1866
+ }).trim();
1867
+ }
1868
+
1869
+ function writeWorkspaceManifestObjects(root, manifest) {
1870
+ return new Map([...manifest].map(([path, value]) => (
1871
+ value.objectId
1872
+ ? [path, { ...value, objectId: hashWorkspaceBytes(root, value.bytes, true) }]
1873
+ : [path, value]
1874
+ )));
1875
+ }
1876
+
1877
+ function assertWorkspaceManifestEqual(expected, current) {
1878
+ if (!workspaceManifestsEqual(expected, current)) {
1879
+ throw new Error("Workspace files changed while FileGRC was validating them. Review the concurrent edit and try the action again.");
1880
+ }
1881
+ }
1882
+
1883
+ function workspaceManifestsEqual(expected, current) {
1884
+ return expected.size === current.size
1885
+ && [...expected].every(([path, value]) => {
1886
+ const other = current.get(path);
1887
+ return other && value.objectId === other.objectId && value.mode === other.mode;
1888
+ });
1889
+ }
1890
+
1891
+ async function commitValidatedIndexAsync(root, subject, manifest, options) {
1892
+ assertWorkspaceInsideGitWorktree(root);
1893
+ assertNoGitOperationInProgress(root);
1894
+ const parent = options.expectedParent;
1895
+ const ref = options.expectedRef;
1896
+ assertExpectedCheckout(root, ref, parent);
1897
+ const gitDirectory = await runGitCommand(root, ["rev-parse", "--absolute-git-dir"], { operation: "locate the repository index" });
1898
+ const sharedIndex = resolve(root, await runGitCommand(root, ["rev-parse", "--git-path", "index"], { operation: "locate the shared repository index" }));
1899
+ const expectedSharedIndex = readFileSync(sharedIndex);
1900
+ const indexFile = resolve(gitDirectory, `filegrc-index-${randomUUID()}`);
1901
+ const sharedUpdateIndex = resolve(gitDirectory, `filegrc-shared-index-${randomUUID()}`);
1902
+ let indexReconciled = false;
1903
+ try {
1904
+ writeFileSync(sharedUpdateIndex, expectedSharedIndex);
1905
+ await applyManifestToIndexAsync(root, manifest, sharedUpdateIndex);
1906
+ await runGitCommand(root, ["read-tree", parent], { operation: "initialize the private FileGRC index", gitIndexFile: indexFile });
1907
+ await applyManifestToIndexAsync(root, manifest, indexFile);
1908
+ const tree = await runGitCommand(root, ["write-tree"], { operation: "capture the validated FileGRC tree", gitIndexFile: indexFile });
1909
+ const commit = await runGitCommand(root, ["commit-tree", tree, "-p", parent, "-m", subject], {
1910
+ operation: "create the validated FileGRC browser commit",
1911
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS
1912
+ });
1913
+ assertNoGitOperationInProgress(root);
1914
+ await runGitCommand(root, ["update-ref", "-m", subject, ref, commit, parent], {
1915
+ operation: "advance the authoritative branch to the validated FileGRC commit",
1916
+ expectedCheckout: { expectedRef: ref, expectedCommit: parent },
1917
+ expectedNoOperation: true
1918
+ });
1919
+ if (!gitOperationInProgress(root) && checkoutMatches(root, ref, commit)) {
1920
+ indexReconciled = replaceSharedIndexIfUnchanged(sharedIndex, sharedUpdateIndex, expectedSharedIndex);
1921
+ }
1922
+ return { commit, indexReconciled: indexReconciled ?? false };
1923
+ } finally {
1924
+ await Promise.all([
1925
+ rm(indexFile, { force: true }),
1926
+ rm(`${indexFile}.lock`, { force: true }),
1927
+ rm(sharedUpdateIndex, { force: true }),
1928
+ rm(`${sharedUpdateIndex}.lock`, { force: true })
1929
+ ].map((cleanup) => cleanup.catch(() => undefined)));
1103
1930
  }
1104
1931
  }
1105
1932
 
1933
+ function replaceSharedIndexIfUnchanged(sharedIndex, privateIndex, expected) {
1934
+ const lock = `${sharedIndex}.lock`;
1935
+ let descriptor;
1936
+ let installed = false;
1937
+ let ownsLock = false;
1938
+ try {
1939
+ descriptor = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666);
1940
+ ownsLock = true;
1941
+ if (!readFileSync(sharedIndex).equals(expected)) return false;
1942
+ writeFileSync(descriptor, readFileSync(privateIndex));
1943
+ fsyncSync(descriptor);
1944
+ closeSync(descriptor);
1945
+ descriptor = undefined;
1946
+ renameSync(lock, sharedIndex);
1947
+ installed = true;
1948
+ return true;
1949
+ } catch {
1950
+ return false;
1951
+ } finally {
1952
+ if (descriptor !== undefined) {
1953
+ try { closeSync(descriptor); } catch { /* Best-effort cleanup after reconciliation. */ }
1954
+ }
1955
+ if (ownsLock && !installed) {
1956
+ try { rmSync(lock, { force: true }); } catch { /* Best-effort cleanup after reconciliation. */ }
1957
+ }
1958
+ }
1959
+ }
1960
+
1961
+ async function applyManifestToIndexAsync(root, manifest, indexFile) {
1962
+ const options = { operation: "write the validated FileGRC index", ...(indexFile ? { gitIndexFile: indexFile } : {}) };
1963
+ for (const args of manifestIndexCommands(root, manifest)) await runGitCommand(root, args, options);
1964
+ }
1965
+
1966
+ function manifestIndexCommands(root, manifest) {
1967
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
1968
+ const prefix = relative(topLevel, root).split(sep).join("/");
1969
+ if (prefix === ".." || prefix.startsWith("../")) throw new Error("The FileGRC workspace is outside its Git repository.");
1970
+ const additions = [];
1971
+ const deletions = [];
1972
+ for (const [path, value] of manifest) {
1973
+ const repositoryPath = prefix ? `${prefix}/${path}` : path;
1974
+ if (value.objectId) additions.push(`${value.mode},${value.objectId},${repositoryPath}`);
1975
+ else deletions.push(repositoryPath);
1976
+ }
1977
+ const commands = [];
1978
+ for (let offset = 0; offset < additions.length; offset += 128) {
1979
+ commands.push(["update-index", "--add", ...additions.slice(offset, offset + 128).flatMap((entry) => ["--cacheinfo", entry])]);
1980
+ }
1981
+ for (let offset = 0; offset < deletions.length; offset += 256) {
1982
+ commands.push(["update-index", "--force-remove", "--", ...deletions.slice(offset, offset + 256)]);
1983
+ }
1984
+ return commands;
1985
+ }
1986
+
1106
1987
  function statusPathsFromRaw(output) {
1107
1988
  if (!output) return [];
1108
1989
  const fields = nulFields(output);
@@ -1229,13 +2110,28 @@ function repositoryOperationFromDirectory(gitDirectory) {
1229
2110
  ["merge", "MERGE_HEAD"],
1230
2111
  ["rebase", "rebase-merge"],
1231
2112
  ["rebase", "rebase-apply"],
1232
- ["cherry-pick", "CHERRY_PICK_HEAD"]
2113
+ ["cherry-pick", "CHERRY_PICK_HEAD"],
2114
+ ["sequencer", "sequencer"],
2115
+ ["revert", "REVERT_HEAD"],
2116
+ ["bisect", "BISECT_START"]
1233
2117
  ]) {
1234
2118
  if (existsSync(resolve(gitDirectory, path))) return name;
1235
2119
  }
1236
2120
  return null;
1237
2121
  }
1238
2122
 
2123
+ function assertNoGitOperationInProgress(root) {
2124
+ const operation = gitOperationInProgress(root);
2125
+ if (operation) {
2126
+ throw new Error(`A Git ${operation} is already in progress. Finish or abort it with Git before using FileGRC synchronization.`);
2127
+ }
2128
+ }
2129
+
2130
+ function gitOperationInProgress(root) {
2131
+ const gitDirectory = tryGit(root, ["rev-parse", "--absolute-git-dir"]);
2132
+ return repositoryOperationFromDirectory(gitDirectory);
2133
+ }
2134
+
1239
2135
  export function setGitCommandInterceptorForTests(interceptor) {
1240
2136
  if (interceptor !== null && typeof interceptor !== "function") {
1241
2137
  throw new TypeError("The Git command interceptor must be a function or null.");
@@ -1257,7 +2153,19 @@ export function runGitCommand(cwd, args, options = {}) {
1257
2153
  return runGitCommandNative(cwd, args, options);
1258
2154
  }
1259
2155
 
2156
+ export function runGitCommandSync(cwd, args) {
2157
+ return git(resolveWorkspaceRoot(cwd), args);
2158
+ }
2159
+
1260
2160
  function runGitCommandNative(cwd, args, options = {}) {
2161
+ if (options.expectedCheckout) {
2162
+ assertExpectedCheckout(
2163
+ cwd,
2164
+ options.expectedCheckout.expectedRef,
2165
+ options.expectedCheckout.expectedCommit
2166
+ );
2167
+ }
2168
+ if (options.expectedNoOperation) assertNoGitOperationInProgress(cwd);
1261
2169
  const operation = options.operation || "run a Git command";
1262
2170
  const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
1263
2171
  const timeoutMs = Math.max(1, Number(configuredTimeout) || GIT_DEFAULT_TIMEOUT_MS);
@@ -1267,13 +2175,13 @@ function runGitCommandNative(cwd, args, options = {}) {
1267
2175
  cwd,
1268
2176
  stdio: ["ignore", "pipe", "pipe"],
1269
2177
  detached: process.platform !== "win32",
1270
- env: {
1271
- ...process.env,
2178
+ env: gitEnvironment({
2179
+ ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
1272
2180
  GIT_TERMINAL_PROMPT: "0",
1273
2181
  GIT_ASKPASS: "",
1274
2182
  SSH_ASKPASS: "",
1275
2183
  GIT_MERGE_AUTOEDIT: "no"
1276
- }
2184
+ })
1277
2185
  });
1278
2186
  const stdout = [];
1279
2187
  const stderr = [];
@@ -1359,7 +2267,8 @@ function git(cwd, args) {
1359
2267
  encoding: "utf8",
1360
2268
  stdio: ["ignore", "pipe", "ignore"],
1361
2269
  timeout: 10_000,
1362
- maxBuffer: 20_000_000
2270
+ maxBuffer: 20_000_000,
2271
+ env: gitEnvironment()
1363
2272
  }).trim());
1364
2273
  }
1365
2274
 
@@ -1371,6 +2280,15 @@ function tryGit(cwd, args) {
1371
2280
  }
1372
2281
  }
1373
2282
 
2283
+ function gitOptionalMatch(cwd, args) {
2284
+ try {
2285
+ return git(cwd, args);
2286
+ } catch (error) {
2287
+ if (error?.status === 1) return "";
2288
+ throw error;
2289
+ }
2290
+ }
2291
+
1374
2292
  function tryGitRaw(cwd, args) {
1375
2293
  try {
1376
2294
  return gitRaw(cwd, args);
@@ -1379,13 +2297,14 @@ function tryGitRaw(cwd, args) {
1379
2297
  }
1380
2298
  }
1381
2299
 
1382
- function gitRaw(cwd, args) {
2300
+ function gitRaw(cwd, args, options = {}) {
1383
2301
  return execFileSync("git", args, {
1384
2302
  cwd,
1385
2303
  encoding: "utf8",
1386
2304
  stdio: ["ignore", "pipe", "ignore"],
1387
2305
  timeout: 10_000,
1388
- maxBuffer: 20_000_000
2306
+ maxBuffer: 20_000_000,
2307
+ env: gitEnvironment(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {})
1389
2308
  });
1390
2309
  }
1391
2310
 
@@ -1393,19 +2312,20 @@ function nulFields(source) {
1393
2312
  return source ? source.split("\0").filter(Boolean) : [];
1394
2313
  }
1395
2314
 
1396
- function gitForWrite(cwd, args, action = "create the commit") {
2315
+ function gitForWrite(cwd, args, action = "create the commit", options = {}) {
1397
2316
  try {
2317
+ assertWorkspaceInsideGitWorktree(cwd);
1398
2318
  return execFileSync("git", args, {
1399
2319
  cwd,
1400
2320
  encoding: "utf8",
1401
2321
  stdio: ["ignore", "pipe", "pipe"],
1402
2322
  timeout: 30_000,
1403
2323
  maxBuffer: 20_000_000,
1404
- env: {
1405
- ...process.env,
2324
+ env: gitEnvironment({
2325
+ ...(options.gitIndexFile ? { GIT_INDEX_FILE: options.gitIndexFile } : {}),
1406
2326
  GIT_TERMINAL_PROMPT: "0",
1407
2327
  GIT_MERGE_AUTOEDIT: "no"
1408
- }
2328
+ })
1409
2329
  }).trim();
1410
2330
  } catch (error) {
1411
2331
  const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
@@ -1413,25 +2333,20 @@ function gitForWrite(cwd, args, action = "create the commit") {
1413
2333
  }
1414
2334
  }
1415
2335
 
1416
- async function gitForWriteAsync(cwd, args, action = "update the repository") {
2336
+ async function gitForWriteAsync(cwd, args, action = "update the repository", options = {}) {
2337
+ assertWorkspaceInsideGitWorktree(cwd);
1417
2338
  return runGitCommand(cwd, args, {
1418
2339
  operation: action,
1419
- timeoutMs: GIT_REMOTE_TIMEOUT_MS
2340
+ timeoutMs: GIT_REMOTE_TIMEOUT_MS,
2341
+ ...options
1420
2342
  });
1421
2343
  }
1422
2344
 
1423
2345
  export function sanitizeGitErrorMessage(value) {
1424
2346
  const sanitized = String(value || "Git returned no error detail.")
1425
2347
  .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
1426
- .replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
2348
+ .replace(/([?&][^=&#\s]+)=([^&#\s]*)/g, "$1=[redacted]")
2349
+ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s#]+)#[^\s]*/gi, "$1#[redacted]")
1427
2350
  .replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
1428
2351
  return sanitized.length > 8_000 ? `${sanitized.slice(0, 8_000)}…` : sanitized;
1429
2352
  }
1430
-
1431
- function tryGitForWrite(cwd, args) {
1432
- try {
1433
- gitForWrite(cwd, args);
1434
- } catch {
1435
- // Best-effort cleanup after a failed remote operation.
1436
- }
1437
- }