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