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