filegrc 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/collection-review.js +2 -21
- package/src/collection-scope.js +24 -0
- package/src/git.js +431 -182
- package/src/index.js +1 -0
- package/src/server.js +9 -6
- package/src/state.js +20 -5
- package/src/validate.js +5 -17
- package/src/web.js +7 -3
package/package.json
CHANGED
package/src/collection-review.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
2
3
|
import { applyResourceBatch } from "./files.js";
|
|
3
4
|
import { getGitSummary } from "./git.js";
|
|
4
5
|
import { loadWorkspace } from "./workspace.js";
|
|
5
|
-
import {
|
|
6
|
+
import { resolveProgram, selectedRequirementIds } from "./program.js";
|
|
6
7
|
|
|
7
8
|
export function collectionRevision(loaded, resourceType, options = {}) {
|
|
8
9
|
const program = resolveProgram(loaded, options.programId);
|
|
@@ -200,26 +201,6 @@ export async function applyCollectionReview(input = process.cwd(), options = {})
|
|
|
200
201
|
};
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
function scopedCollectionRecords(loaded, resourceType, program) {
|
|
204
|
-
if (String(loaded.model.modelVersion) !== "4") {
|
|
205
|
-
return loaded.resources.filter((record) => record.type === resourceType);
|
|
206
|
-
}
|
|
207
|
-
const components = programComponents(loaded, program);
|
|
208
|
-
const componentIds = new Set(components.map(({ id }) => id));
|
|
209
|
-
const selected = {
|
|
210
|
-
system: new Set(program.systemIds || []),
|
|
211
|
-
component: componentIds,
|
|
212
|
-
framework: new Set(program.frameworkIds || []),
|
|
213
|
-
vendor: new Set(components.map(({ vendorId }) => vendorId).filter(Boolean)),
|
|
214
|
-
asset: new Set(loaded.resources.filter((record) => (
|
|
215
|
-
record.type === "asset" && (record.componentIds || []).some((id) => componentIds.has(id))
|
|
216
|
-
)).map(({ id }) => id))
|
|
217
|
-
}[resourceType];
|
|
218
|
-
return loaded.resources.filter((record) => (
|
|
219
|
-
record.type === resourceType && (!selected || selected.has(record.id))
|
|
220
|
-
));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
204
|
function requiredType(loaded, value) {
|
|
224
205
|
const resourceType = String(value || "").trim();
|
|
225
206
|
if (!loaded.model.collectionReviews?.[resourceType]) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { programComponents } from "./program.js";
|
|
2
|
+
|
|
3
|
+
export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
4
|
+
if (String(loaded.model.modelVersion) !== "4") {
|
|
5
|
+
return loaded.resources.filter((record) => record.type === resourceType);
|
|
6
|
+
}
|
|
7
|
+
if (resourceType === "vendor") {
|
|
8
|
+
return loaded.resources.filter((record) => record.type === "vendor");
|
|
9
|
+
}
|
|
10
|
+
const scopedProgram = program || {};
|
|
11
|
+
const components = programComponents(loaded, scopedProgram);
|
|
12
|
+
const componentIds = new Set(components.map(({ id }) => id));
|
|
13
|
+
const selected = {
|
|
14
|
+
system: new Set(scopedProgram.systemIds || []),
|
|
15
|
+
component: componentIds,
|
|
16
|
+
framework: new Set(scopedProgram.frameworkIds || []),
|
|
17
|
+
asset: new Set(loaded.resources.filter((record) => (
|
|
18
|
+
record.type === "asset" && (record.componentIds || []).some((id) => componentIds.has(id))
|
|
19
|
+
)).map(({ id }) => id))
|
|
20
|
+
}[resourceType];
|
|
21
|
+
return loaded.resources.filter((record) => (
|
|
22
|
+
record.type === resourceType && (!selected || selected.has(record.id))
|
|
23
|
+
));
|
|
24
|
+
}
|
package/src/git.js
CHANGED
|
@@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks";
|
|
|
7
7
|
import { isSafeGitName } from "./git-name.js";
|
|
8
8
|
import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
|
|
9
9
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
10
|
-
import { measureTiming,
|
|
10
|
+
import { measureTiming, recordTiming, timingEnabled } from "./timing.js";
|
|
11
11
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
12
12
|
import { loadWorkspace } from "./workspace.js";
|
|
13
13
|
|
|
@@ -15,9 +15,32 @@ const lastSuccessfulSynchronizations = new Map();
|
|
|
15
15
|
const workspaceHistoryCache = new Map();
|
|
16
16
|
const backgroundSynchronizations = new Map();
|
|
17
17
|
const browserRemotePrefetches = new Map();
|
|
18
|
+
const browserRemotePrefetchPromises = new Map();
|
|
19
|
+
const repositorySnapshotPromises = new Map();
|
|
20
|
+
let gitCommandInterceptor = null;
|
|
18
21
|
const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
|
|
22
|
+
const GIT_DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
|
+
const GIT_REMOTE_TIMEOUT_MS = 30_000;
|
|
24
|
+
const GIT_MAX_OUTPUT_BYTES = 20_000_000;
|
|
19
25
|
export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
|
|
20
26
|
|
|
27
|
+
export class GitOperationError extends Error {
|
|
28
|
+
constructor(kind, operation, detail, options = {}) {
|
|
29
|
+
const prefix = kind === "missing-executable"
|
|
30
|
+
? "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
|
|
31
|
+
: kind === "timeout"
|
|
32
|
+
? `Git timed out while trying to ${operation}.`
|
|
33
|
+
: kind === "invalid-repository"
|
|
34
|
+
? `Git could not ${operation} because this workspace is not in a valid Git repository.`
|
|
35
|
+
: `Git could not ${operation}.`;
|
|
36
|
+
super(detail ? `${prefix} ${sanitizeGitErrorMessage(detail)}` : prefix, options);
|
|
37
|
+
this.name = "GitOperationError";
|
|
38
|
+
this.kind = kind;
|
|
39
|
+
this.operation = operation;
|
|
40
|
+
this.code = options.code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
export function getGitSummary(input = process.cwd()) {
|
|
22
45
|
const root = resolveWorkspaceRoot(input);
|
|
23
46
|
try {
|
|
@@ -145,10 +168,111 @@ export async function pushWorkspace(input = process.cwd()) {
|
|
|
145
168
|
return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
|
|
146
169
|
}
|
|
147
170
|
|
|
171
|
+
export function getRepositorySnapshot(input = process.cwd(), options = {}) {
|
|
172
|
+
const root = resolveWorkspaceRoot(input);
|
|
173
|
+
if (!options.fresh && repositorySnapshotPromises.has(root)) {
|
|
174
|
+
recordTiming("repository-snapshot-reused", 0);
|
|
175
|
+
return repositorySnapshotPromises.get(root);
|
|
176
|
+
}
|
|
177
|
+
const snapshot = buildRepositorySnapshot(root).finally(() => {
|
|
178
|
+
if (repositorySnapshotPromises.get(root) === snapshot) repositorySnapshotPromises.delete(root);
|
|
179
|
+
});
|
|
180
|
+
repositorySnapshotPromises.set(root, snapshot);
|
|
181
|
+
return snapshot;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function buildRepositorySnapshot(root) {
|
|
185
|
+
let repositoryPaths;
|
|
186
|
+
try {
|
|
187
|
+
repositoryPaths = await runGitCommand(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir"], {
|
|
188
|
+
operation: "locate the repository"
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return unavailableSnapshot(error);
|
|
192
|
+
}
|
|
193
|
+
const [topLevel, gitDirectory] = repositoryPaths.split("\n");
|
|
194
|
+
try {
|
|
195
|
+
const [status, remotes] = await Promise.all([
|
|
196
|
+
runGitCommand(topLevel, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], {
|
|
197
|
+
operation: "inspect repository status"
|
|
198
|
+
}),
|
|
199
|
+
runGitCommand(root, ["remote"], { operation: "list repository remotes" })
|
|
200
|
+
]);
|
|
201
|
+
const parsed = parsePorcelainV2(status, topLevel, root);
|
|
202
|
+
const last = parsed.commit
|
|
203
|
+
? parseLogLine(await runGitCommand(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"], {
|
|
204
|
+
operation: "read the latest commit"
|
|
205
|
+
}))
|
|
206
|
+
: null;
|
|
207
|
+
let upstreamCommit = null;
|
|
208
|
+
let pendingCommits = [];
|
|
209
|
+
let pendingCommitsFilegrcOnly = parsed.ahead === 0 ? true : null;
|
|
210
|
+
if (parsed.upstream) {
|
|
211
|
+
upstreamCommit = (await runGitCommand(root, ["rev-parse", parsed.upstream], {
|
|
212
|
+
operation: `resolve upstream ${parsed.upstream}`
|
|
213
|
+
})).trim() || null;
|
|
214
|
+
}
|
|
215
|
+
if (parsed.ahead > 0 && parsed.upstream) {
|
|
216
|
+
const pending = await runGitCommand(root, [
|
|
217
|
+
"log",
|
|
218
|
+
"--format=%x1e%H%x1f%s",
|
|
219
|
+
"--name-only",
|
|
220
|
+
`${parsed.upstream}..HEAD`
|
|
221
|
+
], { operation: `inspect commits ahead of ${parsed.upstream}` });
|
|
222
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
223
|
+
const commits = parsePendingCommitPaths(pending);
|
|
224
|
+
pendingCommits = commits.map(({ commit, subject }) => ({
|
|
225
|
+
commit,
|
|
226
|
+
shortCommit: commit.slice(0, 8),
|
|
227
|
+
subject
|
|
228
|
+
}));
|
|
229
|
+
pendingCommitsFilegrcOnly = commits.every(({ paths }) => (
|
|
230
|
+
paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix))
|
|
231
|
+
));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
available: true,
|
|
235
|
+
root: topLevel,
|
|
236
|
+
gitDirectory,
|
|
237
|
+
commit: parsed.commit,
|
|
238
|
+
shortCommit: parsed.commit?.slice(0, 8) ?? "no commits",
|
|
239
|
+
branch: parsed.branch,
|
|
240
|
+
upstream: parsed.upstream,
|
|
241
|
+
remotes: lines(remotes),
|
|
242
|
+
clean: parsed.workspaceChanges.length === 0,
|
|
243
|
+
changes: parsed.workspaceChanges,
|
|
244
|
+
wholeWorktreeClean: parsed.allChanges.length === 0,
|
|
245
|
+
operationInProgress: repositoryOperationFromDirectory(gitDirectory),
|
|
246
|
+
upstreamCommit,
|
|
247
|
+
ahead: parsed.ahead,
|
|
248
|
+
behind: parsed.behind,
|
|
249
|
+
pendingCommits,
|
|
250
|
+
pendingCommitsFilegrcOnly,
|
|
251
|
+
lastCommit: last,
|
|
252
|
+
invocationCount: 3 + (parsed.commit ? 1 : 0) + (parsed.upstream ? 1 : 0) + (parsed.ahead > 0 ? 1 : 0)
|
|
253
|
+
};
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return unavailableSnapshot(error, { root: topLevel, gitDirectory });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function unavailableSnapshot(error, extra = {}) {
|
|
260
|
+
return {
|
|
261
|
+
available: false,
|
|
262
|
+
clean: null,
|
|
263
|
+
changes: [],
|
|
264
|
+
error,
|
|
265
|
+
message: error instanceof GitOperationError
|
|
266
|
+
? error.message
|
|
267
|
+
: "Git history is unavailable. Commit the workspace to enable audit metadata.",
|
|
268
|
+
...extra
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
148
272
|
export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
|
|
149
273
|
const root = resolveWorkspaceRoot(input);
|
|
150
274
|
const config = await getRepositoryConfig(root);
|
|
151
|
-
const gitSummary =
|
|
275
|
+
const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
|
|
152
276
|
if (config.mode !== "trunk") {
|
|
153
277
|
return {
|
|
154
278
|
mode: "manual",
|
|
@@ -199,20 +323,42 @@ export async function runBrowserMutation(input, options, task) {
|
|
|
199
323
|
}
|
|
200
324
|
|
|
201
325
|
export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
|
|
202
|
-
|
|
326
|
+
const root = resolveWorkspaceRoot(input);
|
|
327
|
+
const key = `${root}\0${options.allowNonAuthoritativeWrites === true}`;
|
|
328
|
+
if (browserRemotePrefetchPromises.has(key)) {
|
|
329
|
+
recordTiming("prefetch-coalesced", 0);
|
|
330
|
+
return browserRemotePrefetchPromises.get(key);
|
|
331
|
+
}
|
|
332
|
+
const prefetch = prefetchBrowserRemoteCoalesced(root, options).finally(() => {
|
|
333
|
+
if (browserRemotePrefetchPromises.get(key) === prefetch) browserRemotePrefetchPromises.delete(key);
|
|
334
|
+
});
|
|
335
|
+
browserRemotePrefetchPromises.set(key, prefetch);
|
|
336
|
+
return prefetch;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function prefetchBrowserRemoteCoalesced(root, options) {
|
|
340
|
+
const prepared = await serializeWorkspaceMutation(root, async () => {
|
|
203
341
|
const config = await getRepositoryConfig(root);
|
|
204
|
-
if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
|
|
205
|
-
|
|
342
|
+
if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) return { config, repository: null };
|
|
343
|
+
const repository = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
|
|
344
|
+
return { config, repository };
|
|
345
|
+
});
|
|
346
|
+
if (!prepared.repository) return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
|
|
347
|
+
|
|
348
|
+
// Fetch updates only remote-tracking refs, so it does not occupy the source mutation queue.
|
|
349
|
+
await fetchConfiguredRemote(root, prepared.config.remote);
|
|
350
|
+
return serializeWorkspaceMutation(root, async () => {
|
|
351
|
+
const summary = await getRepositorySnapshot(root, { fresh: true });
|
|
352
|
+
if (!summary.available) throw summary.error;
|
|
353
|
+
if (summary.commit !== prepared.repository.currentCommit) {
|
|
354
|
+
throw new Error("The authoritative branch changed while FileGRC checked its remote. Reload and try again.");
|
|
206
355
|
}
|
|
207
|
-
|
|
208
|
-
await fetchConfiguredRemote(root, config.remote);
|
|
209
|
-
const summary = getGitSummary(root);
|
|
210
|
-
const repository = inspectTrunkRepository(root, config, summary);
|
|
356
|
+
const repository = inspectTrunkRepository(root, prepared.config, summary);
|
|
211
357
|
const fetchedAt = new Date().toISOString();
|
|
212
358
|
const token = randomUUID();
|
|
213
359
|
browserRemotePrefetches.set(root, {
|
|
214
360
|
token,
|
|
215
|
-
remote: config.remote,
|
|
361
|
+
remote: prepared.config.remote,
|
|
216
362
|
currentCommit: summary.commit,
|
|
217
363
|
upstreamCommit: repository.upstreamCommit,
|
|
218
364
|
fetchedAt: Date.parse(fetchedAt)
|
|
@@ -236,21 +382,21 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
|
236
382
|
if (options.allowNonAuthoritativeWrites === true) {
|
|
237
383
|
throw new Error("Retry sync is disabled while the development write override is active.");
|
|
238
384
|
}
|
|
239
|
-
const before =
|
|
385
|
+
const before = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
|
|
240
386
|
await fetchConfiguredRemote(root, config.remote);
|
|
241
|
-
const synchronized = inspectTrunkRepository(root, config,
|
|
387
|
+
const synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
|
|
242
388
|
if (synchronized.behind > 0 && synchronized.ahead > 0) {
|
|
243
389
|
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.");
|
|
244
390
|
}
|
|
245
391
|
if (synchronized.behind > 0) {
|
|
246
|
-
|
|
392
|
+
await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
|
|
247
393
|
}
|
|
248
|
-
const ready = inspectTrunkRepository(root, config,
|
|
394
|
+
const ready = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
|
|
249
395
|
if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
|
|
250
396
|
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.");
|
|
251
397
|
}
|
|
252
398
|
if (ready.ahead > 0) await pushConfiguredBranch(root, config);
|
|
253
|
-
const after = inspectTrunkRepository(root, config,
|
|
399
|
+
const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
|
|
254
400
|
if (after.ahead !== 0 || after.behind !== 0) {
|
|
255
401
|
throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
|
|
256
402
|
}
|
|
@@ -269,11 +415,11 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
|
269
415
|
}
|
|
270
416
|
|
|
271
417
|
async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
272
|
-
const beforeFetch =
|
|
418
|
+
const beforeFetch = await measureTiming("git-preconditions", () => requireTrunkPreconditionsAsync(root, config));
|
|
273
419
|
let synchronized = beforeFetch;
|
|
274
420
|
if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
|
|
275
421
|
await fetchConfiguredRemote(root, config.remote);
|
|
276
|
-
synchronized = inspectTrunkRepository(root, config,
|
|
422
|
+
synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
|
|
277
423
|
}
|
|
278
424
|
if (synchronized.ahead > 0 && synchronized.behind > 0) {
|
|
279
425
|
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.");
|
|
@@ -282,8 +428,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
282
428
|
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
283
429
|
}
|
|
284
430
|
if (synchronized.behind > 0) {
|
|
285
|
-
|
|
286
|
-
synchronized = inspectTrunkRepository(root, config,
|
|
431
|
+
await fastForwardConfiguredBranchAsync(root, synchronized.upstream);
|
|
432
|
+
synchronized = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }));
|
|
287
433
|
}
|
|
288
434
|
if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
|
|
289
435
|
throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
|
|
@@ -293,7 +439,9 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
293
439
|
let subject;
|
|
294
440
|
let validationProof;
|
|
295
441
|
try {
|
|
296
|
-
result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root
|
|
442
|
+
result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root, {
|
|
443
|
+
repositorySnapshot: synchronized
|
|
444
|
+
})));
|
|
297
445
|
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
298
446
|
const validation = await validateWorkspace(root);
|
|
299
447
|
if (!validation.ok) {
|
|
@@ -305,17 +453,19 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
305
453
|
validation,
|
|
306
454
|
fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
|
|
307
455
|
};
|
|
308
|
-
|
|
456
|
+
await assertNoOutsideWorktreeChangesAsync(root);
|
|
309
457
|
} catch (error) {
|
|
310
458
|
try {
|
|
311
|
-
await
|
|
459
|
+
await rollbackWorkspaceChangesAsync(root);
|
|
312
460
|
} catch (rollbackError) {
|
|
313
461
|
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.`);
|
|
314
462
|
}
|
|
315
463
|
throw error;
|
|
316
464
|
}
|
|
317
465
|
|
|
318
|
-
const changed =
|
|
466
|
+
const changed = Boolean(await runGitCommand(root, ["status", "--porcelain=v1", "--", "."], {
|
|
467
|
+
operation: "check the FileGRC workspace change"
|
|
468
|
+
}));
|
|
319
469
|
if (!changed && options?.allowNoChanges === true) {
|
|
320
470
|
return withValidationProof({
|
|
321
471
|
...result,
|
|
@@ -332,23 +482,37 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
332
482
|
if (!changed) {
|
|
333
483
|
throw new Error("The browser action did not change any FileGRC workspace files.");
|
|
334
484
|
}
|
|
335
|
-
|
|
485
|
+
let identity;
|
|
486
|
+
try {
|
|
487
|
+
identity = await runGitCommand(root, ["config", "--get-regexp", "^user\\.(name|email)$"], {
|
|
488
|
+
operation: "read the Git user identity"
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error instanceof GitOperationError && error.kind === "command-failure") identity = "";
|
|
492
|
+
else throw error;
|
|
493
|
+
}
|
|
494
|
+
if (!/^user\.name\s+.+$/m.test(identity) || !/^user\.email\s+.+$/m.test(identity)) {
|
|
336
495
|
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.");
|
|
337
496
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
497
|
+
await measureTiming("stage", () => runGitCommand(root, ["add", "--all", "--", "."], {
|
|
498
|
+
operation: "stage the FileGRC workspace change",
|
|
499
|
+
timeoutMs: GIT_REMOTE_TIMEOUT_MS
|
|
500
|
+
}));
|
|
501
|
+
await assertNoOutsideWorktreeChangesAsync(root, false);
|
|
502
|
+
await assertOnlyWorkspaceFilesStagedAsync(root);
|
|
343
503
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
504
|
+
await measureTiming("commit", () => runGitCommand(root, ["commit", "-m", subject, "--", "."], {
|
|
505
|
+
operation: "create the FileGRC browser commit",
|
|
506
|
+
timeoutMs: GIT_REMOTE_TIMEOUT_MS
|
|
507
|
+
}));
|
|
347
508
|
} catch (error) {
|
|
348
509
|
throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
|
|
349
510
|
}
|
|
350
511
|
|
|
351
|
-
const
|
|
512
|
+
const commit = await runGitCommand(root, ["rev-parse", "HEAD"], {
|
|
513
|
+
operation: "read the FileGRC browser commit"
|
|
514
|
+
});
|
|
515
|
+
const committed = { commit, shortCommit: commit.slice(0, 8) };
|
|
352
516
|
queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
|
|
353
517
|
return withValidationProof({
|
|
354
518
|
...result,
|
|
@@ -396,9 +560,9 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
|
|
|
396
560
|
startedAt: new Date().toISOString(),
|
|
397
561
|
error: null
|
|
398
562
|
});
|
|
399
|
-
const start = () => {
|
|
563
|
+
const start = async () => {
|
|
400
564
|
try {
|
|
401
|
-
const ready =
|
|
565
|
+
const ready = await requireTrunkPreconditionsAsync(root, config, { allowAhead: true });
|
|
402
566
|
if (ready.currentCommit !== committed.commit) {
|
|
403
567
|
throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
|
|
404
568
|
}
|
|
@@ -408,7 +572,7 @@ function queueBackgroundPush(root, config, committed, delayMs = 0) {
|
|
|
408
572
|
if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
|
|
409
573
|
throw new Error("The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.");
|
|
410
574
|
}
|
|
411
|
-
|
|
575
|
+
await finishBackgroundPush(root, config, committed);
|
|
412
576
|
} catch (error) {
|
|
413
577
|
recordBackgroundPushFailure(root, committed, error);
|
|
414
578
|
}
|
|
@@ -423,7 +587,7 @@ async function finishBackgroundPush(root, config, committed) {
|
|
|
423
587
|
let outcome = "failed";
|
|
424
588
|
try {
|
|
425
589
|
await pushConfiguredBranch(root, config, committed.commit);
|
|
426
|
-
const after = inspectTrunkRepository(root, config,
|
|
590
|
+
const after = inspectTrunkRepository(root, config, await getRepositorySnapshot(root, { fresh: true }), { ignoreBackground: true });
|
|
427
591
|
if (after.ahead !== 0 || after.behind !== 0) {
|
|
428
592
|
throw new Error("The authoritative branch is still not synchronized after the background push.");
|
|
429
593
|
}
|
|
@@ -588,7 +752,7 @@ async function getRepositoryConfig(root) {
|
|
|
588
752
|
};
|
|
589
753
|
}
|
|
590
754
|
|
|
591
|
-
function inspectTrunkRepository(root, config, summary
|
|
755
|
+
function inspectTrunkRepository(root, config, summary, options = {}) {
|
|
592
756
|
const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
|
|
593
757
|
const base = {
|
|
594
758
|
mode: "trunk",
|
|
@@ -602,8 +766,8 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
|
|
|
602
766
|
pendingCommits: [],
|
|
603
767
|
pendingCommitsFilegrcOnly: null,
|
|
604
768
|
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
605
|
-
wholeWorktreeClean: summary.available ? wholeWorktreeClean
|
|
606
|
-
operationInProgress: summary.available ?
|
|
769
|
+
wholeWorktreeClean: summary.available ? summary.wholeWorktreeClean : null,
|
|
770
|
+
operationInProgress: summary.available ? summary.operationInProgress : null,
|
|
607
771
|
backgroundSynchronization: background ? {
|
|
608
772
|
status: background.status,
|
|
609
773
|
commit: background.commit,
|
|
@@ -625,9 +789,9 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
|
|
|
625
789
|
if (!summary.available) {
|
|
626
790
|
return {
|
|
627
791
|
...base,
|
|
628
|
-
status: "git-setup-required",
|
|
629
|
-
label: "Git setup required",
|
|
630
|
-
message: "Git is unavailable
|
|
792
|
+
status: summary.error?.kind === "missing-executable" ? "git-setup-required" : "git-error",
|
|
793
|
+
label: summary.error?.kind === "missing-executable" ? "Git setup required" : "Git error",
|
|
794
|
+
message: summary.message || "Git history is unavailable for this workspace."
|
|
631
795
|
};
|
|
632
796
|
}
|
|
633
797
|
if (summary.branch !== config.authoritativeBranch) {
|
|
@@ -655,10 +819,10 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
|
|
|
655
819
|
message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
|
|
656
820
|
};
|
|
657
821
|
}
|
|
658
|
-
const upstreamCommit =
|
|
659
|
-
const counts =
|
|
660
|
-
const pendingCommits =
|
|
661
|
-
const pendingCommitsFilegrcOnly =
|
|
822
|
+
const upstreamCommit = summary.upstreamCommit;
|
|
823
|
+
const counts = { ahead: summary.ahead, behind: summary.behind };
|
|
824
|
+
const pendingCommits = summary.pendingCommits;
|
|
825
|
+
const pendingCommitsFilegrcOnly = summary.pendingCommitsFilegrcOnly;
|
|
662
826
|
const details = {
|
|
663
827
|
...base,
|
|
664
828
|
upstreamCommit,
|
|
@@ -736,11 +900,11 @@ function inspectTrunkRepository(root, config, summary = getGitSummary(root), opt
|
|
|
736
900
|
};
|
|
737
901
|
}
|
|
738
902
|
|
|
739
|
-
function
|
|
740
|
-
const summary =
|
|
903
|
+
async function requireTrunkPreconditionsAsync(root, config, options = {}) {
|
|
904
|
+
const summary = await getRepositorySnapshot(root, { fresh: true });
|
|
741
905
|
const state = inspectTrunkRepository(root, config, summary);
|
|
742
906
|
if (config.configurationError) throw new Error(state.message);
|
|
743
|
-
if (!summary.available) throw new Error(state.message);
|
|
907
|
+
if (!summary.available) throw summary.error || new Error(state.message);
|
|
744
908
|
if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
|
|
745
909
|
if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
|
|
746
910
|
if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
|
|
@@ -756,8 +920,11 @@ async function fetchConfiguredRemote(root, remote) {
|
|
|
756
920
|
return measureTiming("fetch", () => gitForWriteAsync(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`));
|
|
757
921
|
}
|
|
758
922
|
|
|
759
|
-
function
|
|
760
|
-
|
|
923
|
+
function fastForwardConfiguredBranchAsync(root, upstream) {
|
|
924
|
+
return runGitCommand(root, ["merge", "--ff-only", "--", upstream], {
|
|
925
|
+
operation: `fast-forward from ${upstream}`,
|
|
926
|
+
timeoutMs: GIT_REMOTE_TIMEOUT_MS
|
|
927
|
+
});
|
|
761
928
|
}
|
|
762
929
|
|
|
763
930
|
async function pushConfiguredBranch(root, config, source = "HEAD") {
|
|
@@ -768,60 +935,15 @@ async function pushConfiguredBranch(root, config, source = "HEAD") {
|
|
|
768
935
|
));
|
|
769
936
|
}
|
|
770
937
|
|
|
771
|
-
function
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
function repositoryOperation(root) {
|
|
776
|
-
for (const [name, gitPath] of [
|
|
777
|
-
["merge", "MERGE_HEAD"],
|
|
778
|
-
["rebase", "rebase-merge"],
|
|
779
|
-
["rebase", "rebase-apply"],
|
|
780
|
-
["cherry-pick", "CHERRY_PICK_HEAD"]
|
|
781
|
-
]) {
|
|
782
|
-
const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
|
|
783
|
-
if (path && existsSync(resolve(root, path))) return name;
|
|
784
|
-
}
|
|
785
|
-
return null;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
function aheadBehind(root, upstream) {
|
|
789
|
-
const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
790
|
-
const [ahead, behind] = output.split(/\s+/).map(Number);
|
|
791
|
-
return Number.isInteger(ahead) && Number.isInteger(behind)
|
|
792
|
-
? { ahead, behind }
|
|
793
|
-
: { ahead: null, behind: null };
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function commitsAhead(root, upstream) {
|
|
797
|
-
return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
|
|
798
|
-
const [commit, subject] = line.split("\x1f");
|
|
799
|
-
return { commit, shortCommit: commit.slice(0, 8), subject };
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function commitsOnlyTouchWorkspace(root, upstream) {
|
|
804
|
-
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
938
|
+
async function assertNoOutsideWorktreeChangesAsync(root, rollbackExpected = true) {
|
|
939
|
+
const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
|
|
940
|
+
operation: "locate the repository before checking worktree changes"
|
|
941
|
+
})).trim();
|
|
805
942
|
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
const paths = nulFields(tryGitRaw(topLevel, [
|
|
809
|
-
"diff-tree",
|
|
810
|
-
"--no-commit-id",
|
|
811
|
-
"--name-only",
|
|
812
|
-
"-z",
|
|
813
|
-
"-r",
|
|
814
|
-
"--root",
|
|
815
|
-
commit
|
|
816
|
-
]));
|
|
817
|
-
return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
|
|
943
|
+
const output = await runGitCommand(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
944
|
+
operation: "inspect worktree changes"
|
|
818
945
|
});
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
|
|
822
|
-
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
823
|
-
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
824
|
-
const paths = statusPaths(topLevel);
|
|
946
|
+
const paths = statusPathsFromRaw(output);
|
|
825
947
|
if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
826
948
|
throw new Error(rollbackExpected
|
|
827
949
|
? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
|
|
@@ -829,25 +951,31 @@ function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
|
|
|
829
951
|
}
|
|
830
952
|
}
|
|
831
953
|
|
|
832
|
-
function
|
|
833
|
-
const topLevel =
|
|
954
|
+
async function assertOnlyWorkspaceFilesStagedAsync(root) {
|
|
955
|
+
const topLevel = (await runGitCommand(root, ["rev-parse", "--show-toplevel"], {
|
|
956
|
+
operation: "locate the repository before checking staged files"
|
|
957
|
+
})).trim();
|
|
834
958
|
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
835
|
-
const
|
|
836
|
-
"diff",
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
"-z",
|
|
840
|
-
"--diff-filter=ACDMRTUXB"
|
|
841
|
-
]));
|
|
959
|
+
const output = await runGitCommand(topLevel, [
|
|
960
|
+
"diff", "--cached", "--name-only", "-z", "--diff-filter=ACDMRTUXB"
|
|
961
|
+
], { operation: "inspect staged FileGRC files" });
|
|
962
|
+
const staged = nulFields(output);
|
|
842
963
|
if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
|
|
843
964
|
if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
844
965
|
throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
|
|
845
966
|
}
|
|
846
967
|
}
|
|
847
968
|
|
|
848
|
-
async function
|
|
849
|
-
|
|
850
|
-
|
|
969
|
+
async function rollbackWorkspaceChangesAsync(root) {
|
|
970
|
+
await runGitCommand(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], {
|
|
971
|
+
operation: "roll back the FileGRC workspace change",
|
|
972
|
+
timeoutMs: GIT_REMOTE_TIMEOUT_MS
|
|
973
|
+
});
|
|
974
|
+
const untracked = nulFields(await runGitCommand(
|
|
975
|
+
root,
|
|
976
|
+
["ls-files", "-z", "--others", "--exclude-standard", "--", "."],
|
|
977
|
+
{ operation: "list untracked FileGRC files" }
|
|
978
|
+
));
|
|
851
979
|
for (const path of untracked) {
|
|
852
980
|
const absolute = resolve(root, path);
|
|
853
981
|
if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
|
|
@@ -855,8 +983,7 @@ async function rollbackWorkspaceChanges(root) {
|
|
|
855
983
|
}
|
|
856
984
|
}
|
|
857
985
|
|
|
858
|
-
function
|
|
859
|
-
const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
986
|
+
function statusPathsFromRaw(output) {
|
|
860
987
|
if (!output) return [];
|
|
861
988
|
const fields = nulFields(output);
|
|
862
989
|
const paths = [];
|
|
@@ -900,6 +1027,187 @@ function lines(source) {
|
|
|
900
1027
|
return source ? source.split("\n").filter(Boolean) : [];
|
|
901
1028
|
}
|
|
902
1029
|
|
|
1030
|
+
function parsePorcelainV2(source, topLevel, root) {
|
|
1031
|
+
const fields = source.split("\0").filter(Boolean);
|
|
1032
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
1033
|
+
let commit = null;
|
|
1034
|
+
let branch = null;
|
|
1035
|
+
let upstream = null;
|
|
1036
|
+
let ahead = null;
|
|
1037
|
+
let behind = null;
|
|
1038
|
+
const allChanges = [];
|
|
1039
|
+
const workspaceChanges = [];
|
|
1040
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
1041
|
+
const field = fields[index];
|
|
1042
|
+
if (field.startsWith("# branch.oid ")) commit = field.slice(13) === "(initial)" ? null : field.slice(13);
|
|
1043
|
+
else if (field.startsWith("# branch.head ")) branch = field.slice(14) === "(detached)" ? null : field.slice(14);
|
|
1044
|
+
else if (field.startsWith("# branch.upstream ")) upstream = field.slice(18);
|
|
1045
|
+
else if (field.startsWith("# branch.ab ")) {
|
|
1046
|
+
const match = /\+(\d+) -(\d+)/.exec(field);
|
|
1047
|
+
if (match) [ahead, behind] = match.slice(1).map(Number);
|
|
1048
|
+
} else if (/^[12u?!] /.test(field)) {
|
|
1049
|
+
const path = porcelainV2Path(field);
|
|
1050
|
+
allChanges.push(field);
|
|
1051
|
+
const originalPath = field.startsWith("2 ") ? fields[index + 1] : null;
|
|
1052
|
+
if (pathInsideWorkspace(path, prefix) || originalPath && pathInsideWorkspace(originalPath, prefix)) {
|
|
1053
|
+
workspaceChanges.push(field);
|
|
1054
|
+
}
|
|
1055
|
+
if (originalPath) index += 1;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { commit, branch, upstream, ahead, behind, allChanges, workspaceChanges };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function porcelainV2Path(field) {
|
|
1062
|
+
if (field.startsWith("? ") || field.startsWith("! ")) return field.slice(2);
|
|
1063
|
+
const requiredSpaces = field.startsWith("2 ") ? 9 : field.startsWith("u ") ? 10 : 8;
|
|
1064
|
+
let offset = 0;
|
|
1065
|
+
for (let count = 0; count < requiredSpaces; count += 1) {
|
|
1066
|
+
offset = field.indexOf(" ", offset) + 1;
|
|
1067
|
+
if (!offset) return "";
|
|
1068
|
+
}
|
|
1069
|
+
return field.slice(offset);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function parsePendingCommitPaths(source) {
|
|
1073
|
+
return source.split("\x1e").flatMap((block) => {
|
|
1074
|
+
const [header, ...paths] = block.trim().split("\n").filter(Boolean);
|
|
1075
|
+
if (!header) return [];
|
|
1076
|
+
const [commit, subject] = header.split("\x1f");
|
|
1077
|
+
return [{ commit, subject, paths }];
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function repositoryOperationFromDirectory(gitDirectory) {
|
|
1082
|
+
if (!gitDirectory) return null;
|
|
1083
|
+
for (const [name, path] of [
|
|
1084
|
+
["merge", "MERGE_HEAD"],
|
|
1085
|
+
["rebase", "rebase-merge"],
|
|
1086
|
+
["rebase", "rebase-apply"],
|
|
1087
|
+
["cherry-pick", "CHERRY_PICK_HEAD"]
|
|
1088
|
+
]) {
|
|
1089
|
+
if (existsSync(resolve(gitDirectory, path))) return name;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export function setGitCommandInterceptorForTests(interceptor) {
|
|
1095
|
+
if (interceptor !== null && typeof interceptor !== "function") {
|
|
1096
|
+
throw new TypeError("The Git command interceptor must be a function or null.");
|
|
1097
|
+
}
|
|
1098
|
+
const previous = gitCommandInterceptor;
|
|
1099
|
+
gitCommandInterceptor = interceptor;
|
|
1100
|
+
return () => { gitCommandInterceptor = previous; };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export function runGitCommand(cwd, args, options = {}) {
|
|
1104
|
+
if (gitCommandInterceptor) {
|
|
1105
|
+
return Promise.resolve().then(() => gitCommandInterceptor({
|
|
1106
|
+
cwd,
|
|
1107
|
+
args: [...args],
|
|
1108
|
+
options: { ...options },
|
|
1109
|
+
run: () => runGitCommandNative(cwd, args, options)
|
|
1110
|
+
}));
|
|
1111
|
+
}
|
|
1112
|
+
return runGitCommandNative(cwd, args, options);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function runGitCommandNative(cwd, args, options = {}) {
|
|
1116
|
+
const operation = options.operation || "run a Git command";
|
|
1117
|
+
const configuredTimeout = options.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
|
|
1118
|
+
const timeoutMs = Math.max(1, Number(configuredTimeout) || GIT_DEFAULT_TIMEOUT_MS);
|
|
1119
|
+
const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || GIT_MAX_OUTPUT_BYTES);
|
|
1120
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
1121
|
+
const child = spawn("git", args, {
|
|
1122
|
+
cwd,
|
|
1123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1124
|
+
detached: process.platform !== "win32",
|
|
1125
|
+
env: {
|
|
1126
|
+
...process.env,
|
|
1127
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1128
|
+
GIT_ASKPASS: "",
|
|
1129
|
+
SSH_ASKPASS: "",
|
|
1130
|
+
GIT_MERGE_AUTOEDIT: "no"
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
const stdout = [];
|
|
1134
|
+
const stderr = [];
|
|
1135
|
+
let outputSize = 0;
|
|
1136
|
+
let settled = false;
|
|
1137
|
+
let timedOut = false;
|
|
1138
|
+
let outputExceeded = false;
|
|
1139
|
+
let forceKillTimer;
|
|
1140
|
+
const terminate = (signal) => {
|
|
1141
|
+
try {
|
|
1142
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
1143
|
+
else child.kill(signal);
|
|
1144
|
+
} catch {
|
|
1145
|
+
try { child.kill(signal); } catch { /* The child already exited. */ }
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
const stop = () => {
|
|
1149
|
+
terminate("SIGTERM");
|
|
1150
|
+
forceKillTimer = setTimeout(() => terminate("SIGKILL"), 1_000);
|
|
1151
|
+
forceKillTimer.unref?.();
|
|
1152
|
+
};
|
|
1153
|
+
const timer = setTimeout(() => {
|
|
1154
|
+
timedOut = true;
|
|
1155
|
+
stop();
|
|
1156
|
+
}, timeoutMs);
|
|
1157
|
+
timer.unref?.();
|
|
1158
|
+
const collect = (target, chunk) => {
|
|
1159
|
+
outputSize += chunk.length;
|
|
1160
|
+
if (outputSize <= maxOutputBytes) target.push(chunk);
|
|
1161
|
+
else if (!outputExceeded) {
|
|
1162
|
+
outputExceeded = true;
|
|
1163
|
+
stop();
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
child.stdout.on("data", (chunk) => collect(stdout, chunk));
|
|
1167
|
+
child.stderr.on("data", (chunk) => collect(stderr, chunk));
|
|
1168
|
+
child.once("error", (error) => {
|
|
1169
|
+
if (settled) return;
|
|
1170
|
+
settled = true;
|
|
1171
|
+
clearTimeout(timer);
|
|
1172
|
+
clearTimeout(forceKillTimer);
|
|
1173
|
+
rejectCommand(new GitOperationError(
|
|
1174
|
+
error.code === "ENOENT" ? "missing-executable" : "command-failure",
|
|
1175
|
+
operation,
|
|
1176
|
+
error.code === "ENOENT" ? "" : error.message,
|
|
1177
|
+
{ cause: error, code: error.code }
|
|
1178
|
+
));
|
|
1179
|
+
});
|
|
1180
|
+
child.once("close", (code, signal) => {
|
|
1181
|
+
if (settled) return;
|
|
1182
|
+
settled = true;
|
|
1183
|
+
clearTimeout(timer);
|
|
1184
|
+
clearTimeout(forceKillTimer);
|
|
1185
|
+
const output = Buffer.concat(stdout).toString("utf8");
|
|
1186
|
+
const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
|
|
1187
|
+
if (code === 0 && !timedOut && !outputExceeded) return resolveCommand(output.trim());
|
|
1188
|
+
const detail = outputExceeded
|
|
1189
|
+
? `Git output exceeded ${maxOutputBytes} bytes.`
|
|
1190
|
+
: timedOut
|
|
1191
|
+
? `The operation exceeded ${timeoutMs} ms and the process group was terminated.`
|
|
1192
|
+
: errorOutput || `Git exited with status ${code ?? signal ?? "unknown"}.`;
|
|
1193
|
+
const kind = timedOut
|
|
1194
|
+
? "timeout"
|
|
1195
|
+
: /not a git repository|outside repository/i.test(errorOutput)
|
|
1196
|
+
? "invalid-repository"
|
|
1197
|
+
: "command-failure";
|
|
1198
|
+
rejectCommand(new GitOperationError(kind, operation, detail));
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function tryGitAsync(cwd, args, operation) {
|
|
1204
|
+
try {
|
|
1205
|
+
return await runGitCommand(cwd, args, { operation });
|
|
1206
|
+
} catch {
|
|
1207
|
+
return "";
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
903
1211
|
function git(cwd, args) {
|
|
904
1212
|
return execFileSync("git", args, {
|
|
905
1213
|
cwd,
|
|
@@ -961,77 +1269,18 @@ function gitForWrite(cwd, args, action = "create the commit") {
|
|
|
961
1269
|
}
|
|
962
1270
|
|
|
963
1271
|
async function gitForWriteAsync(cwd, args, action = "update the repository") {
|
|
964
|
-
return
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
968
|
-
detached: process.platform !== "win32",
|
|
969
|
-
env: {
|
|
970
|
-
...process.env,
|
|
971
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
972
|
-
GIT_MERGE_AUTOEDIT: "no"
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
const stdout = [];
|
|
976
|
-
const stderr = [];
|
|
977
|
-
let size = 0;
|
|
978
|
-
let timedOut = false;
|
|
979
|
-
let forceKillTimer = null;
|
|
980
|
-
const terminate = (signal) => {
|
|
981
|
-
try {
|
|
982
|
-
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
983
|
-
else child.kill(signal);
|
|
984
|
-
} catch {
|
|
985
|
-
try {
|
|
986
|
-
child.kill(signal);
|
|
987
|
-
} catch {
|
|
988
|
-
// The process may have exited between the timeout and termination.
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
};
|
|
992
|
-
const timer = setTimeout(() => {
|
|
993
|
-
timedOut = true;
|
|
994
|
-
terminate("SIGTERM");
|
|
995
|
-
forceKillTimer = setTimeout(() => terminate("SIGKILL"), 2_000);
|
|
996
|
-
}, 30_000);
|
|
997
|
-
child.stdout.on("data", (chunk) => {
|
|
998
|
-
size += chunk.length;
|
|
999
|
-
if (size <= 20_000_000) stdout.push(chunk);
|
|
1000
|
-
});
|
|
1001
|
-
child.stderr.on("data", (chunk) => {
|
|
1002
|
-
size += chunk.length;
|
|
1003
|
-
if (size <= 20_000_000) stderr.push(chunk);
|
|
1004
|
-
});
|
|
1005
|
-
child.once("error", (error) => {
|
|
1006
|
-
clearTimeout(timer);
|
|
1007
|
-
clearTimeout(forceKillTimer);
|
|
1008
|
-
reject(new Error(`Git could not ${action}. ${sanitizeGitErrorMessage(error.message)}`));
|
|
1009
|
-
});
|
|
1010
|
-
child.once("close", (code) => {
|
|
1011
|
-
clearTimeout(timer);
|
|
1012
|
-
clearTimeout(forceKillTimer);
|
|
1013
|
-
const output = Buffer.concat(stdout).toString("utf8").trim();
|
|
1014
|
-
const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
|
|
1015
|
-
if (code === 0 && !timedOut && size <= 20_000_000) {
|
|
1016
|
-
resolve(output);
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
const detail = size > 20_000_000
|
|
1020
|
-
? "Git output exceeded 20 MB."
|
|
1021
|
-
: timedOut
|
|
1022
|
-
? "Git timed out after 30 seconds."
|
|
1023
|
-
: errorOutput || output || `Git exited with status ${code}.`;
|
|
1024
|
-
const message = sanitizeGitErrorMessage(detail);
|
|
1025
|
-
reject(new Error(`Git could not ${action}. ${message}`));
|
|
1026
|
-
});
|
|
1272
|
+
return runGitCommand(cwd, args, {
|
|
1273
|
+
operation: action,
|
|
1274
|
+
timeoutMs: GIT_REMOTE_TIMEOUT_MS
|
|
1027
1275
|
});
|
|
1028
1276
|
}
|
|
1029
1277
|
|
|
1030
1278
|
export function sanitizeGitErrorMessage(value) {
|
|
1031
|
-
|
|
1279
|
+
const sanitized = String(value || "Git returned no error detail.")
|
|
1032
1280
|
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
|
|
1033
1281
|
.replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
|
|
1034
1282
|
.replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
|
|
1283
|
+
return sanitized.length > 8_000 ? `${sanitized.slice(0, 8_000)}…` : sanitized;
|
|
1035
1284
|
}
|
|
1036
1285
|
|
|
1037
1286
|
function tryGitForWrite(cwd, args) {
|
package/src/index.js
CHANGED
package/src/server.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
commitAndPushWorkspace,
|
|
24
24
|
getBrowserRepositoryState,
|
|
25
25
|
getFileHistory,
|
|
26
|
-
|
|
26
|
+
getRepositorySnapshot,
|
|
27
27
|
prefetchBrowserRemote,
|
|
28
28
|
pullWorkspace,
|
|
29
29
|
pushWorkspace,
|
|
@@ -174,8 +174,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
174
174
|
message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`,
|
|
175
175
|
fastResponse: prefersFastMutation(request),
|
|
176
176
|
prefetchToken
|
|
177
|
-
}, () => applyApplicabilityReview(input, {
|
|
177
|
+
}, (_root, mutationContext) => applyApplicabilityReview(input, {
|
|
178
178
|
...reviewPayload,
|
|
179
|
+
scopeRevision: mutationContext?.repositorySnapshot?.currentCommit,
|
|
179
180
|
confirmed: payload.confirmed === true
|
|
180
181
|
})));
|
|
181
182
|
}
|
|
@@ -189,8 +190,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
189
190
|
message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`,
|
|
190
191
|
fastResponse: prefersFastMutation(request),
|
|
191
192
|
prefetchToken
|
|
192
|
-
}, () => applyCollectionReview(input, {
|
|
193
|
+
}, (_root, mutationContext) => applyCollectionReview(input, {
|
|
193
194
|
...reviewPayload,
|
|
195
|
+
scopeRevision: mutationContext?.repositorySnapshot?.currentCommit,
|
|
194
196
|
confirmed: payload.confirmed === true
|
|
195
197
|
})));
|
|
196
198
|
}
|
|
@@ -351,10 +353,11 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
351
353
|
return json(response, 200, { ...result, state });
|
|
352
354
|
}
|
|
353
355
|
if (request.method === "GET" && url.pathname === "/api/git/sync-status") {
|
|
356
|
+
const git = { ...await getRepositorySnapshot(input) };
|
|
354
357
|
const repository = await getBrowserRepositoryState(input, {
|
|
355
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
358
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
359
|
+
repositorySnapshot: git
|
|
356
360
|
});
|
|
357
|
-
const git = getGitSummary(input);
|
|
358
361
|
delete git.root;
|
|
359
362
|
return json(response, 200, {
|
|
360
363
|
repository,
|
|
@@ -705,7 +708,7 @@ function statusFor(error) {
|
|
|
705
708
|
if (/exceeds 25 MB/i.test(error.message)) return 413;
|
|
706
709
|
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
707
710
|
if (/already exists|target file already exists/i.test(error.message)) return 409;
|
|
708
|
-
if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
|
|
711
|
+
if (/Git could not|Git is unavailable|Git timed out|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
|
|
709
712
|
if (/not found|ENOENT/i.test(error.message)) return 404;
|
|
710
713
|
if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
|
|
711
714
|
return 500;
|
package/src/state.js
CHANGED
|
@@ -2,10 +2,10 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
4
4
|
import { assessCollectionReviews } from "./collection-review.js";
|
|
5
|
-
import { getBrowserRepositoryState,
|
|
5
|
+
import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js";
|
|
6
6
|
import { renderMarkdown } from "./markdown.js";
|
|
7
7
|
import { planObligations } from "./obligations.js";
|
|
8
|
-
import { resolveDataPath } from "./paths.js";
|
|
8
|
+
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
9
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
11
11
|
import { currentCalendarDate } from "./time.js";
|
|
@@ -16,9 +16,23 @@ import { measureTiming } from "./timing.js";
|
|
|
16
16
|
|
|
17
17
|
const renderedMarkdownCache = new Map();
|
|
18
18
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
19
|
+
const appStatePromises = new Map();
|
|
19
20
|
|
|
20
21
|
export async function createAppState(input = process.cwd(), options = {}) {
|
|
21
|
-
|
|
22
|
+
const key = JSON.stringify([
|
|
23
|
+
resolveWorkspaceRoot(input),
|
|
24
|
+
options.readOnly === true,
|
|
25
|
+
options.allowNonAuthoritativeWrites === true,
|
|
26
|
+
options.includeDetails !== false,
|
|
27
|
+
options.asOf ?? null,
|
|
28
|
+
options.now ?? null
|
|
29
|
+
]);
|
|
30
|
+
if (!options.validationProof && appStatePromises.has(key)) return appStatePromises.get(key);
|
|
31
|
+
const promise = serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options)).finally(() => {
|
|
32
|
+
if (appStatePromises.get(key) === promise) appStatePromises.delete(key);
|
|
33
|
+
});
|
|
34
|
+
if (!options.validationProof) appStatePromises.set(key, promise);
|
|
35
|
+
return promise;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
async function createAppStateUnlocked(input, options) {
|
|
@@ -47,11 +61,12 @@ async function createAppStateUnlocked(input, options) {
|
|
|
47
61
|
}
|
|
48
62
|
});
|
|
49
63
|
|
|
50
|
-
const git =
|
|
64
|
+
const git = { ...await measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root)) };
|
|
51
65
|
delete git.root;
|
|
52
66
|
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded.root, {
|
|
53
67
|
readOnly: options.readOnly,
|
|
54
|
-
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
68
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
69
|
+
repositorySnapshot: git
|
|
55
70
|
}));
|
|
56
71
|
const workspace = loaded.workspace ?? {
|
|
57
72
|
dataModelVersion: loaded.model.modelVersion,
|
package/src/validate.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
4
|
import { getResourceDefinition } from "../model/index.js";
|
|
5
|
+
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
5
6
|
import { isSafeGitName } from "./git-name.js";
|
|
6
7
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
7
8
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
@@ -78,7 +79,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
78
79
|
if (record.type === "control") validateControlComponents(record, byId, displayPath, diagnostics);
|
|
79
80
|
if (record.type === "audit") validateAuditSubservices(record, byId, displayPath, diagnostics);
|
|
80
81
|
if (record.type === "collection-review") {
|
|
81
|
-
validateCollectionReview(record, loaded
|
|
82
|
+
validateCollectionReview(record, loaded, byId, displayPath, diagnostics);
|
|
82
83
|
}
|
|
83
84
|
if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
84
85
|
if (record.type === "action-item") {
|
|
@@ -158,8 +159,9 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
158
159
|
};
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
function validateCollectionReview(record,
|
|
162
|
+
function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
162
163
|
if (record.status !== "active") return;
|
|
164
|
+
const { model } = loaded;
|
|
163
165
|
const configuration = model.collectionReviews?.[record.resourceType];
|
|
164
166
|
if (!configuration) return;
|
|
165
167
|
const allowedDecisions = configuration.decisions || ["complete"];
|
|
@@ -174,21 +176,7 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
|
|
|
174
176
|
const program = String(model.modelVersion) === "4"
|
|
175
177
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
176
178
|
: null;
|
|
177
|
-
const
|
|
178
|
-
const componentIds = new Set(resources.filter((candidate) => (
|
|
179
|
-
candidate.type === "component"
|
|
180
|
-
&& (candidate.systemUses || []).some(({ systemId }) => programSystemIds.has(systemId))
|
|
181
|
-
)).map(({ id }) => id));
|
|
182
|
-
const selectedIds = {
|
|
183
|
-
system: programSystemIds,
|
|
184
|
-
component: componentIds,
|
|
185
|
-
framework: new Set(program?.frameworkIds || []),
|
|
186
|
-
vendor: new Set(resources.filter(({ id }) => componentIds.has(id)).map(({ vendorId }) => vendorId).filter(Boolean)),
|
|
187
|
-
asset: new Set(resources.filter(({ type, componentIds: ids }) => type === "asset" && (ids || []).some((id) => componentIds.has(id))).map(({ id }) => id))
|
|
188
|
-
}[record.resourceType];
|
|
189
|
-
const recordCount = resources.filter(({ type, id }) => (
|
|
190
|
-
type === record.resourceType && (!program || !selectedIds || selectedIds.has(id))
|
|
191
|
-
)).length;
|
|
179
|
+
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
192
180
|
if (!recordCount && record.decision === "complete") {
|
|
193
181
|
diagnostics.push(error(
|
|
194
182
|
"invalid-collection-review-decision",
|
package/src/web.js
CHANGED
|
@@ -512,6 +512,7 @@ function openCollectionReviewDialog(type) {
|
|
|
512
512
|
rationale: form.elements.rationale.value.trim(),
|
|
513
513
|
reviewedByIds: [form.elements.reviewerId.value],
|
|
514
514
|
reviewedOn: form.elements.reviewedOn.value,
|
|
515
|
+
scopeRevision: state.git?.commit || undefined,
|
|
515
516
|
[v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
|
|
516
517
|
expectedRevision: assessment.reviewRevision || undefined
|
|
517
518
|
};
|
|
@@ -519,6 +520,7 @@ function openCollectionReviewDialog(type) {
|
|
|
519
520
|
try {
|
|
520
521
|
saveStatus.textContent = "Validating and saving…";
|
|
521
522
|
const prefetch = await repositoryPrefetch;
|
|
523
|
+
if (prefetch?.error) throw prefetch.error;
|
|
522
524
|
const response = await localFetch("/api/collection-review", {
|
|
523
525
|
method: "POST",
|
|
524
526
|
headers: { "content-type": "application/json", prefer: "respond-async" },
|
|
@@ -1509,6 +1511,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1509
1511
|
decisions,
|
|
1510
1512
|
reviewedByIds: [form.elements.reviewerId.value],
|
|
1511
1513
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1514
|
+
scopeRevision: state.git?.commit || undefined,
|
|
1512
1515
|
expectedRevisions: Object.fromEntries([
|
|
1513
1516
|
...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
|
|
1514
1517
|
...(type === "requirement" && String(state.model.modelVersion) === "4"
|
|
@@ -1532,6 +1535,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1532
1535
|
} else {
|
|
1533
1536
|
saveStatus.textContent = "Validating and saving…";
|
|
1534
1537
|
const prefetch = await repositoryPrefetch;
|
|
1538
|
+
if (prefetch?.error) throw prefetch.error;
|
|
1535
1539
|
const response = await localFetch("/api/applicability-review", {
|
|
1536
1540
|
method: "POST",
|
|
1537
1541
|
headers: { "content-type": "application/json", prefer: "respond-async" },
|
|
@@ -4147,9 +4151,9 @@ function prefetchRepositoryForReview(status) {
|
|
|
4147
4151
|
status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
4148
4152
|
return result;
|
|
4149
4153
|
})
|
|
4150
|
-
.catch(() => {
|
|
4151
|
-
status.textContent = "Repository
|
|
4152
|
-
return
|
|
4154
|
+
.catch((error) => {
|
|
4155
|
+
status.textContent = "Repository check failed";
|
|
4156
|
+
return { error };
|
|
4153
4157
|
});
|
|
4154
4158
|
}
|
|
4155
4159
|
|