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