filegrc 0.3.3 → 0.4.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/README.md +17 -7
- package/model/index.js +37 -3
- package/model/v1.json +89 -52
- package/model/v2.json +8022 -0
- package/package.json +1 -1
- package/src/agent.js +36 -8
- package/src/audit-preparation.js +63 -60
- package/src/cli.js +176 -113
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git-name.js +16 -0
- package/src/git.js +702 -6
- package/src/index.js +9 -6
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +46 -1
- package/src/obligations.js +108 -84
- package/src/parties.js +17 -2
- package/src/program-path.js +31 -58
- package/src/program-readiness.js +142 -106
- package/src/resource-status.js +17 -0
- package/src/server.js +175 -36
- package/src/setup.js +27 -28
- package/src/state.js +93 -27
- package/src/timing.js +41 -0
- package/src/validate.js +611 -43
- package/src/web.js +586 -141
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/git.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
6
|
+
import { isSafeGitName } from "./git-name.js";
|
|
7
|
+
import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
|
|
3
8
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
4
|
-
import {
|
|
9
|
+
import { measureTiming, measureTimingSync, timingEnabled } from "./timing.js";
|
|
10
|
+
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
11
|
+
import { loadWorkspace } from "./workspace.js";
|
|
12
|
+
|
|
13
|
+
const lastSuccessfulSynchronizations = new Map();
|
|
14
|
+
const workspaceHistoryCache = new Map();
|
|
15
|
+
const backgroundSynchronizations = new Map();
|
|
16
|
+
export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
|
|
5
17
|
|
|
6
18
|
export function getGitSummary(input = process.cwd()) {
|
|
7
19
|
const root = resolveWorkspaceRoot(input);
|
|
@@ -58,6 +70,13 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
|
|
|
58
70
|
const wanted = new Set(relativePaths);
|
|
59
71
|
const histories = new Map([...wanted].map((path) => [path, []]));
|
|
60
72
|
if (!wanted.size) return histories;
|
|
73
|
+
const head = tryGit(root, ["rev-parse", "HEAD"]) || null;
|
|
74
|
+
const cached = workspaceHistoryCache.get(root);
|
|
75
|
+
if (cached?.head === head && cached.limitPerFile === limitPerFile) {
|
|
76
|
+
for (const path of wanted) histories.set(path, cached.histories.get(path) ?? []);
|
|
77
|
+
return histories;
|
|
78
|
+
}
|
|
79
|
+
const allHistories = new Map();
|
|
61
80
|
try {
|
|
62
81
|
const output = git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"]);
|
|
63
82
|
for (const block of output.split("\x1e")) {
|
|
@@ -65,13 +84,16 @@ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
|
|
|
65
84
|
if (lines.length < 2) continue;
|
|
66
85
|
const commit = parseLogLine(lines[0]);
|
|
67
86
|
for (const path of lines.slice(1)) {
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
if (!allHistories.has(path)) allHistories.set(path, []);
|
|
88
|
+
const history = allHistories.get(path);
|
|
89
|
+
if (history.length < limitPerFile) history.push(commit);
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
92
|
} catch {
|
|
73
93
|
// An uncommitted workspace has no history yet.
|
|
74
94
|
}
|
|
95
|
+
workspaceHistoryCache.set(root, { head, limitPerFile, histories: allHistories });
|
|
96
|
+
for (const path of wanted) histories.set(path, allHistories.get(path) ?? []);
|
|
75
97
|
return histories;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -120,6 +142,262 @@ export async function pushWorkspace(input = process.cwd()) {
|
|
|
120
142
|
return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
|
|
121
143
|
}
|
|
122
144
|
|
|
145
|
+
export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
|
|
146
|
+
const root = resolveWorkspaceRoot(input);
|
|
147
|
+
const config = await getRepositoryConfig(root);
|
|
148
|
+
const gitSummary = getGitSummary(root);
|
|
149
|
+
if (config.mode !== "trunk") {
|
|
150
|
+
return {
|
|
151
|
+
mode: "manual",
|
|
152
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
153
|
+
remote: config.remote,
|
|
154
|
+
developmentOverride: false,
|
|
155
|
+
status: "manual",
|
|
156
|
+
label: "Manual Git",
|
|
157
|
+
writesAllowed: !options.readOnly,
|
|
158
|
+
currentCommit: gitSummary.commit,
|
|
159
|
+
upstreamCommit: null,
|
|
160
|
+
ahead: null,
|
|
161
|
+
behind: null,
|
|
162
|
+
pendingCommits: [],
|
|
163
|
+
pendingCommitsFilegrcOnly: null,
|
|
164
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
165
|
+
message: "Browser writes stay local until a user commits and synchronizes them."
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const details = inspectTrunkRepository(root, config, gitSummary);
|
|
170
|
+
const developmentOverride = options.allowNonAuthoritativeWrites === true;
|
|
171
|
+
if (developmentOverride) {
|
|
172
|
+
return {
|
|
173
|
+
...details,
|
|
174
|
+
developmentOverride: true,
|
|
175
|
+
writesAllowed: !options.readOnly,
|
|
176
|
+
status: "not-synced",
|
|
177
|
+
label: "Not synced",
|
|
178
|
+
message: "Development override is active. Browser writes stay local and FileGRC will not commit or push them."
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...details,
|
|
183
|
+
developmentOverride: false,
|
|
184
|
+
writesAllowed: !options.readOnly && details.writesAllowed
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function runBrowserMutation(input, options, task) {
|
|
189
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
190
|
+
const config = await getRepositoryConfig(root);
|
|
191
|
+
if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
|
|
192
|
+
return task(root);
|
|
193
|
+
}
|
|
194
|
+
return runTrunkMutationUnlocked(root, config, options, task);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
199
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
200
|
+
const config = await getRepositoryConfig(root);
|
|
201
|
+
if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
|
|
202
|
+
if (backgroundSynchronizations.get(root)?.status === "syncing") {
|
|
203
|
+
throw new Error("A FileGRC background push is already in progress. Wait for it to finish before retrying sync.");
|
|
204
|
+
}
|
|
205
|
+
if (options.allowNonAuthoritativeWrites === true) {
|
|
206
|
+
throw new Error("Retry sync is disabled while the development write override is active.");
|
|
207
|
+
}
|
|
208
|
+
const before = requireTrunkPreconditions(root, config, { allowAhead: true });
|
|
209
|
+
await fetchConfiguredRemote(root, config.remote);
|
|
210
|
+
const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
211
|
+
if (synchronized.behind > 0 && synchronized.ahead > 0) {
|
|
212
|
+
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.");
|
|
213
|
+
}
|
|
214
|
+
if (synchronized.behind > 0) {
|
|
215
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
216
|
+
}
|
|
217
|
+
const ready = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
218
|
+
if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
|
|
219
|
+
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.");
|
|
220
|
+
}
|
|
221
|
+
if (ready.ahead > 0) await pushConfiguredBranch(root, config);
|
|
222
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
223
|
+
if (after.ahead !== 0 || after.behind !== 0) {
|
|
224
|
+
throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
|
|
225
|
+
}
|
|
226
|
+
const synchronizedAt = new Date().toISOString();
|
|
227
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
228
|
+
backgroundSynchronizations.delete(root);
|
|
229
|
+
return {
|
|
230
|
+
commit: after.currentCommit,
|
|
231
|
+
shortCommit: after.currentCommit?.slice(0, 8) ?? null,
|
|
232
|
+
branch: config.authoritativeBranch,
|
|
233
|
+
upstream: after.upstream,
|
|
234
|
+
synchronizedAt,
|
|
235
|
+
retriedCommits: before.ahead ?? 0
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
241
|
+
requireTrunkPreconditions(root, config);
|
|
242
|
+
await fetchConfiguredRemote(root, config.remote);
|
|
243
|
+
let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
244
|
+
if (synchronized.ahead > 0 && synchronized.behind > 0) {
|
|
245
|
+
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.");
|
|
246
|
+
}
|
|
247
|
+
if (synchronized.ahead > 0) {
|
|
248
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
249
|
+
}
|
|
250
|
+
if (synchronized.behind > 0) {
|
|
251
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
252
|
+
synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
253
|
+
}
|
|
254
|
+
if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
|
|
255
|
+
throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let result;
|
|
259
|
+
let subject;
|
|
260
|
+
let validationProof;
|
|
261
|
+
try {
|
|
262
|
+
result = await withDeferredWorkspaceValidation(() => task(root));
|
|
263
|
+
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
264
|
+
const validation = await validateWorkspace(root);
|
|
265
|
+
if (!validation.ok) {
|
|
266
|
+
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
|
|
267
|
+
}
|
|
268
|
+
validationProof = {
|
|
269
|
+
validation,
|
|
270
|
+
fingerprint: (await fingerprintWorkspace(validation.loaded)).fingerprint
|
|
271
|
+
};
|
|
272
|
+
assertNoOutsideWorktreeChanges(root);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
try {
|
|
275
|
+
await rollbackWorkspaceChanges(root);
|
|
276
|
+
} catch (rollbackError) {
|
|
277
|
+
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.`);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!getGitSummary(root).changes.length && options?.allowNoChanges === true) {
|
|
283
|
+
return withValidationProof({
|
|
284
|
+
...result,
|
|
285
|
+
synchronization: {
|
|
286
|
+
status: "unchanged",
|
|
287
|
+
commit: synchronized.currentCommit,
|
|
288
|
+
shortCommit: synchronized.currentCommit?.slice(0, 8) ?? null,
|
|
289
|
+
upstream: synchronized.upstream,
|
|
290
|
+
synchronizedAt: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
291
|
+
pushError: null
|
|
292
|
+
}
|
|
293
|
+
}, validationProof);
|
|
294
|
+
}
|
|
295
|
+
if (!getGitSummary(root).changes.length) {
|
|
296
|
+
throw new Error("The browser action did not change any FileGRC workspace files.");
|
|
297
|
+
}
|
|
298
|
+
if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
|
|
299
|
+
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.");
|
|
300
|
+
}
|
|
301
|
+
gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
|
|
302
|
+
assertNoOutsideWorktreeChanges(root, false);
|
|
303
|
+
assertOnlyWorkspaceFilesStaged(root);
|
|
304
|
+
try {
|
|
305
|
+
measureTimingSync("commit", () => {
|
|
306
|
+
gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const committed = getGitSummary(root);
|
|
313
|
+
queueBackgroundPush(root, config, committed, options?.backgroundPushDelayMs);
|
|
314
|
+
return withValidationProof({
|
|
315
|
+
...result,
|
|
316
|
+
synchronization: {
|
|
317
|
+
status: "syncing",
|
|
318
|
+
commit: committed.commit,
|
|
319
|
+
shortCommit: committed.shortCommit,
|
|
320
|
+
upstream: synchronized.upstream,
|
|
321
|
+
synchronizedAt: null,
|
|
322
|
+
pushError: null
|
|
323
|
+
}
|
|
324
|
+
}, validationProof);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function withValidationProof(result, proof) {
|
|
328
|
+
if (result && typeof result === "object") {
|
|
329
|
+
Object.defineProperty(result, BROWSER_VALIDATION, { value: proof });
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function queueBackgroundPush(root, config, committed, delayMs = 0) {
|
|
335
|
+
backgroundSynchronizations.set(root, {
|
|
336
|
+
status: "syncing",
|
|
337
|
+
commit: committed.commit,
|
|
338
|
+
shortCommit: committed.shortCommit,
|
|
339
|
+
startedAt: new Date().toISOString(),
|
|
340
|
+
error: null
|
|
341
|
+
});
|
|
342
|
+
const start = () => {
|
|
343
|
+
try {
|
|
344
|
+
const ready = requireTrunkPreconditions(root, config, { allowAhead: true });
|
|
345
|
+
if (ready.currentCommit !== committed.commit) {
|
|
346
|
+
throw new Error("The authoritative branch changed after FileGRC created its browser commit. FileGRC did not push it.");
|
|
347
|
+
}
|
|
348
|
+
if (ready.behind > 0) {
|
|
349
|
+
throw new Error("The authoritative branch changed upstream after FileGRC created its browser commit. FileGRC did not push it.");
|
|
350
|
+
}
|
|
351
|
+
if (ready.ahead < 1 || !ready.pendingCommitsFilegrcOnly) {
|
|
352
|
+
throw new Error("The pending commits are no longer limited to this FileGRC workspace. FileGRC did not push them.");
|
|
353
|
+
}
|
|
354
|
+
void finishBackgroundPush(root, config, committed);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
recordBackgroundPushFailure(root, committed, error);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const delay = Math.max(0, Math.min(Number(delayMs) || 0, 30_000));
|
|
360
|
+
if (delay) setTimeout(start, delay);
|
|
361
|
+
else setImmediate(start);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function finishBackgroundPush(root, config, committed) {
|
|
365
|
+
const started = performance.now();
|
|
366
|
+
let outcome = "failed";
|
|
367
|
+
try {
|
|
368
|
+
await pushConfiguredBranch(root, config, committed.commit);
|
|
369
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root), { ignoreBackground: true });
|
|
370
|
+
if (after.ahead !== 0 || after.behind !== 0) {
|
|
371
|
+
throw new Error("The authoritative branch is still not synchronized after the background push.");
|
|
372
|
+
}
|
|
373
|
+
const synchronizedAt = new Date().toISOString();
|
|
374
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
375
|
+
backgroundSynchronizations.delete(root);
|
|
376
|
+
outcome = "synced";
|
|
377
|
+
} catch (error) {
|
|
378
|
+
recordBackgroundPushFailure(root, committed, error);
|
|
379
|
+
} finally {
|
|
380
|
+
if (timingEnabled()) {
|
|
381
|
+
console.error(`[filegrc timing] ${JSON.stringify({
|
|
382
|
+
operation: "background-sync",
|
|
383
|
+
push: { count: 1, durationMs: performance.now() - started },
|
|
384
|
+
outcome
|
|
385
|
+
})}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function recordBackgroundPushFailure(root, committed, error) {
|
|
391
|
+
backgroundSynchronizations.set(root, {
|
|
392
|
+
status: "failed",
|
|
393
|
+
commit: committed.commit,
|
|
394
|
+
shortCommit: committed.shortCommit,
|
|
395
|
+
startedAt: backgroundSynchronizations.get(root)?.startedAt ?? null,
|
|
396
|
+
finishedAt: new Date().toISOString(),
|
|
397
|
+
error: `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
123
401
|
async function commitWorkspaceUnlocked(root, message) {
|
|
124
402
|
const subject = String(message ?? "").trim();
|
|
125
403
|
if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
|
|
@@ -233,6 +511,328 @@ function syncReadySummary(root, action) {
|
|
|
233
511
|
return summary;
|
|
234
512
|
}
|
|
235
513
|
|
|
514
|
+
async function getRepositoryConfig(root) {
|
|
515
|
+
const loaded = await loadWorkspace(root);
|
|
516
|
+
const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
|
|
517
|
+
const mode = renderer?.repositoryMode;
|
|
518
|
+
const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
|
|
519
|
+
const remote = cleanGitName(renderer?.repositoryRemote);
|
|
520
|
+
return {
|
|
521
|
+
mode,
|
|
522
|
+
authoritativeBranch,
|
|
523
|
+
remote,
|
|
524
|
+
configurationError: !["trunk", "manual"].includes(mode)
|
|
525
|
+
? "Repository mode is missing or invalid. Run the model migration or update renderer settings."
|
|
526
|
+
: !isSafeGitName(authoritativeBranch)
|
|
527
|
+
? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
|
|
528
|
+
: !isSafeGitName(remote)
|
|
529
|
+
? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
|
|
530
|
+
: null
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function inspectTrunkRepository(root, config, summary = getGitSummary(root), options = {}) {
|
|
535
|
+
const background = options.ignoreBackground ? null : backgroundSynchronizations.get(root);
|
|
536
|
+
const base = {
|
|
537
|
+
mode: "trunk",
|
|
538
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
539
|
+
remote: config.remote,
|
|
540
|
+
currentCommit: summary.commit,
|
|
541
|
+
upstreamCommit: null,
|
|
542
|
+
upstream: summary.upstream,
|
|
543
|
+
ahead: null,
|
|
544
|
+
behind: null,
|
|
545
|
+
pendingCommits: [],
|
|
546
|
+
pendingCommitsFilegrcOnly: null,
|
|
547
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
548
|
+
wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
|
|
549
|
+
operationInProgress: summary.available ? repositoryOperation(root) : null,
|
|
550
|
+
backgroundSynchronization: background ? {
|
|
551
|
+
status: background.status,
|
|
552
|
+
commit: background.commit,
|
|
553
|
+
shortCommit: background.shortCommit,
|
|
554
|
+
startedAt: background.startedAt,
|
|
555
|
+
finishedAt: background.finishedAt ?? null,
|
|
556
|
+
error: background.error
|
|
557
|
+
} : null,
|
|
558
|
+
writesAllowed: false
|
|
559
|
+
};
|
|
560
|
+
if (config.configurationError) {
|
|
561
|
+
return {
|
|
562
|
+
...base,
|
|
563
|
+
status: "git-setup-required",
|
|
564
|
+
label: "Git setup required",
|
|
565
|
+
message: config.configurationError
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (!summary.available) {
|
|
569
|
+
return {
|
|
570
|
+
...base,
|
|
571
|
+
status: "git-setup-required",
|
|
572
|
+
label: "Git setup required",
|
|
573
|
+
message: "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (summary.branch !== config.authoritativeBranch) {
|
|
577
|
+
return {
|
|
578
|
+
...base,
|
|
579
|
+
status: "read-only-checkout",
|
|
580
|
+
label: "Read-only checkout",
|
|
581
|
+
message: "This checkout is not the authoritative FileGRC branch. You can review the program here, but browser changes are disabled. Run FileGRC from the main checkout or use the explicit development override."
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (!summary.remotes.includes(config.remote)) {
|
|
585
|
+
return {
|
|
586
|
+
...base,
|
|
587
|
+
status: "git-setup-required",
|
|
588
|
+
label: "Git setup required",
|
|
589
|
+
message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
|
|
593
|
+
if (summary.upstream !== expectedUpstream) {
|
|
594
|
+
return {
|
|
595
|
+
...base,
|
|
596
|
+
status: "git-setup-required",
|
|
597
|
+
label: "Git setup required",
|
|
598
|
+
message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const upstreamCommit = tryGit(root, ["rev-parse", expectedUpstream]) || null;
|
|
602
|
+
const counts = upstreamCommit ? aheadBehind(root, expectedUpstream) : { ahead: null, behind: null };
|
|
603
|
+
const pendingCommits = counts.ahead > 0 ? commitsAhead(root, expectedUpstream) : [];
|
|
604
|
+
const pendingCommitsFilegrcOnly = counts.ahead > 0 ? commitsOnlyTouchWorkspace(root, expectedUpstream) : true;
|
|
605
|
+
const details = {
|
|
606
|
+
...base,
|
|
607
|
+
upstreamCommit,
|
|
608
|
+
ahead: counts.ahead,
|
|
609
|
+
behind: counts.behind,
|
|
610
|
+
pendingCommits,
|
|
611
|
+
pendingCommitsFilegrcOnly
|
|
612
|
+
};
|
|
613
|
+
if (base.operationInProgress) {
|
|
614
|
+
return {
|
|
615
|
+
...details,
|
|
616
|
+
status: "not-synced",
|
|
617
|
+
label: "Not synced",
|
|
618
|
+
message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
if (!base.wholeWorktreeClean) {
|
|
622
|
+
return {
|
|
623
|
+
...details,
|
|
624
|
+
status: "not-synced",
|
|
625
|
+
label: "Not synced",
|
|
626
|
+
message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (background?.status === "syncing" && background.commit === summary.commit) {
|
|
630
|
+
return {
|
|
631
|
+
...details,
|
|
632
|
+
status: "syncing",
|
|
633
|
+
label: "Syncing",
|
|
634
|
+
message: `The FileGRC commit ${background.shortCommit} is saved locally and is being pushed to ${expectedUpstream}.`,
|
|
635
|
+
writesAllowed: false,
|
|
636
|
+
retrySafe: false
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (counts.ahead === null || counts.behind === null) {
|
|
640
|
+
return {
|
|
641
|
+
...details,
|
|
642
|
+
status: "git-setup-required",
|
|
643
|
+
label: "Git setup required",
|
|
644
|
+
message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (counts.ahead > 0 || counts.behind > 0) {
|
|
648
|
+
const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
|
|
649
|
+
const backgroundFailure = background?.status === "failed"
|
|
650
|
+
&& background.commit === summary.commit
|
|
651
|
+
&& counts.ahead > 0
|
|
652
|
+
&& counts.behind === 0
|
|
653
|
+
&& pendingCommitsFilegrcOnly
|
|
654
|
+
? background.error
|
|
655
|
+
: null;
|
|
656
|
+
return {
|
|
657
|
+
...details,
|
|
658
|
+
status: "not-synced",
|
|
659
|
+
label: "Not synced",
|
|
660
|
+
message: backgroundFailure || (external
|
|
661
|
+
? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
|
|
662
|
+
: counts.ahead > 0 && counts.behind > 0
|
|
663
|
+
? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
|
|
664
|
+
: counts.ahead > 0
|
|
665
|
+
? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
|
|
666
|
+
: "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing."),
|
|
667
|
+
writesAllowed: counts.ahead === 0 && counts.behind > 0,
|
|
668
|
+
retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly,
|
|
669
|
+
backgroundSyncError: backgroundFailure
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
...details,
|
|
674
|
+
status: "synced",
|
|
675
|
+
label: "Synced",
|
|
676
|
+
message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
|
|
677
|
+
writesAllowed: true,
|
|
678
|
+
retrySafe: false
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function requireTrunkPreconditions(root, config, options = {}) {
|
|
683
|
+
const summary = getGitSummary(root);
|
|
684
|
+
const state = inspectTrunkRepository(root, config, summary);
|
|
685
|
+
if (config.configurationError) throw new Error(state.message);
|
|
686
|
+
if (!summary.available) throw new Error(state.message);
|
|
687
|
+
if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
|
|
688
|
+
if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
|
|
689
|
+
if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
|
|
690
|
+
if (state.operationInProgress) throw new Error(state.message);
|
|
691
|
+
if (!state.wholeWorktreeClean) throw new Error(state.message);
|
|
692
|
+
if (!options.allowAhead && state.ahead > 0) {
|
|
693
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
694
|
+
}
|
|
695
|
+
return state;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function fetchConfiguredRemote(root, remote) {
|
|
699
|
+
return measureTiming("fetch", () => gitForWriteAsync(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function fastForwardConfiguredBranch(root, upstream) {
|
|
703
|
+
gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function pushConfiguredBranch(root, config, source = "HEAD") {
|
|
707
|
+
return measureTiming("push", () => gitForWriteAsync(
|
|
708
|
+
root,
|
|
709
|
+
["push", "--porcelain", "--", config.remote, `${source}:refs/heads/${config.authoritativeBranch}`],
|
|
710
|
+
`push ${config.authoritativeBranch} to ${config.remote}`
|
|
711
|
+
));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function wholeWorktreeClean(root) {
|
|
715
|
+
return git(root, ["status", "--porcelain=v1"]) === "";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function repositoryOperation(root) {
|
|
719
|
+
for (const [name, gitPath] of [
|
|
720
|
+
["merge", "MERGE_HEAD"],
|
|
721
|
+
["rebase", "rebase-merge"],
|
|
722
|
+
["rebase", "rebase-apply"],
|
|
723
|
+
["cherry-pick", "CHERRY_PICK_HEAD"]
|
|
724
|
+
]) {
|
|
725
|
+
const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
|
|
726
|
+
if (path && existsSync(resolve(root, path))) return name;
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function aheadBehind(root, upstream) {
|
|
732
|
+
const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
733
|
+
const [ahead, behind] = output.split(/\s+/).map(Number);
|
|
734
|
+
return Number.isInteger(ahead) && Number.isInteger(behind)
|
|
735
|
+
? { ahead, behind }
|
|
736
|
+
: { ahead: null, behind: null };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function commitsAhead(root, upstream) {
|
|
740
|
+
return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
|
|
741
|
+
const [commit, subject] = line.split("\x1f");
|
|
742
|
+
return { commit, shortCommit: commit.slice(0, 8), subject };
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function commitsOnlyTouchWorkspace(root, upstream) {
|
|
747
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
748
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
749
|
+
const commits = lines(tryGit(root, ["rev-list", `${upstream}..HEAD`]));
|
|
750
|
+
return commits.every((commit) => {
|
|
751
|
+
const paths = nulFields(tryGitRaw(topLevel, [
|
|
752
|
+
"diff-tree",
|
|
753
|
+
"--no-commit-id",
|
|
754
|
+
"--name-only",
|
|
755
|
+
"-z",
|
|
756
|
+
"-r",
|
|
757
|
+
"--root",
|
|
758
|
+
commit
|
|
759
|
+
]));
|
|
760
|
+
return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
|
|
765
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
766
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
767
|
+
const paths = statusPaths(topLevel);
|
|
768
|
+
if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
769
|
+
throw new Error(rollbackExpected
|
|
770
|
+
? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
|
|
771
|
+
: "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.");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function assertOnlyWorkspaceFilesStaged(root) {
|
|
776
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
777
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
778
|
+
const staged = nulFields(tryGitRaw(topLevel, [
|
|
779
|
+
"diff",
|
|
780
|
+
"--cached",
|
|
781
|
+
"--name-only",
|
|
782
|
+
"-z",
|
|
783
|
+
"--diff-filter=ACDMRTUXB"
|
|
784
|
+
]));
|
|
785
|
+
if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
|
|
786
|
+
if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
787
|
+
throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function rollbackWorkspaceChanges(root) {
|
|
792
|
+
gitForWrite(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], "roll back the FileGRC workspace change");
|
|
793
|
+
const untracked = nulFields(tryGitRaw(root, ["ls-files", "-z", "--others", "--exclude-standard", "--", "."]));
|
|
794
|
+
for (const path of untracked) {
|
|
795
|
+
const absolute = resolve(root, path);
|
|
796
|
+
if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
|
|
797
|
+
await rm(absolute, { force: true });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function statusPaths(topLevel) {
|
|
802
|
+
const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
803
|
+
if (!output) return [];
|
|
804
|
+
const fields = nulFields(output);
|
|
805
|
+
const paths = [];
|
|
806
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
807
|
+
const field = fields[index];
|
|
808
|
+
if (!/^[ MADRCU?!]{2} /.test(field)) {
|
|
809
|
+
paths.push(field);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const status = field.slice(0, 2);
|
|
813
|
+
paths.push(field.slice(3));
|
|
814
|
+
if (/[RC]/.test(status) && fields[index + 1] !== undefined) paths.push(fields[++index]);
|
|
815
|
+
}
|
|
816
|
+
return paths;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function pathInsideWorkspace(path, prefix) {
|
|
820
|
+
return !prefix || path === prefix || path.startsWith(`${prefix}/`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function generatedCommitMessage(value) {
|
|
824
|
+
const subject = String(value ?? "")
|
|
825
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
826
|
+
.replace(/\s+/g, " ")
|
|
827
|
+
.trim();
|
|
828
|
+
return (subject || "Update FileGRC workspace").slice(0, 200);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function cleanGitName(value, fallback) {
|
|
832
|
+
const normalized = String(value ?? fallback).trim();
|
|
833
|
+
return normalized || fallback;
|
|
834
|
+
}
|
|
835
|
+
|
|
236
836
|
function parseLogLine(line) {
|
|
237
837
|
if (!line) return null;
|
|
238
838
|
const [commit, timestamp, author, subject] = line.split("\x1f");
|
|
@@ -261,6 +861,28 @@ function tryGit(cwd, args) {
|
|
|
261
861
|
}
|
|
262
862
|
}
|
|
263
863
|
|
|
864
|
+
function tryGitRaw(cwd, args) {
|
|
865
|
+
try {
|
|
866
|
+
return gitRaw(cwd, args);
|
|
867
|
+
} catch {
|
|
868
|
+
return "";
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function gitRaw(cwd, args) {
|
|
873
|
+
return execFileSync("git", args, {
|
|
874
|
+
cwd,
|
|
875
|
+
encoding: "utf8",
|
|
876
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
877
|
+
timeout: 10_000,
|
|
878
|
+
maxBuffer: 20_000_000
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function nulFields(source) {
|
|
883
|
+
return source ? source.split("\0").filter(Boolean) : [];
|
|
884
|
+
}
|
|
885
|
+
|
|
264
886
|
function gitForWrite(cwd, args, action = "create the commit") {
|
|
265
887
|
try {
|
|
266
888
|
return execFileSync("git", args, {
|
|
@@ -276,11 +898,85 @@ function gitForWrite(cwd, args, action = "create the commit") {
|
|
|
276
898
|
}
|
|
277
899
|
}).trim();
|
|
278
900
|
} catch (error) {
|
|
279
|
-
const message = error.stderr?.trim() || error.stdout?.trim() || error.message;
|
|
901
|
+
const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
|
|
280
902
|
throw new Error(`Git could not ${action}. ${message}`);
|
|
281
903
|
}
|
|
282
904
|
}
|
|
283
905
|
|
|
906
|
+
async function gitForWriteAsync(cwd, args, action = "update the repository") {
|
|
907
|
+
return new Promise((resolve, reject) => {
|
|
908
|
+
const child = spawn("git", args, {
|
|
909
|
+
cwd,
|
|
910
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
911
|
+
detached: process.platform !== "win32",
|
|
912
|
+
env: {
|
|
913
|
+
...process.env,
|
|
914
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
915
|
+
GIT_MERGE_AUTOEDIT: "no"
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
const stdout = [];
|
|
919
|
+
const stderr = [];
|
|
920
|
+
let size = 0;
|
|
921
|
+
let timedOut = false;
|
|
922
|
+
let forceKillTimer = null;
|
|
923
|
+
const terminate = (signal) => {
|
|
924
|
+
try {
|
|
925
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
926
|
+
else child.kill(signal);
|
|
927
|
+
} catch {
|
|
928
|
+
try {
|
|
929
|
+
child.kill(signal);
|
|
930
|
+
} catch {
|
|
931
|
+
// The process may have exited between the timeout and termination.
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
const timer = setTimeout(() => {
|
|
936
|
+
timedOut = true;
|
|
937
|
+
terminate("SIGTERM");
|
|
938
|
+
forceKillTimer = setTimeout(() => terminate("SIGKILL"), 2_000);
|
|
939
|
+
}, 30_000);
|
|
940
|
+
child.stdout.on("data", (chunk) => {
|
|
941
|
+
size += chunk.length;
|
|
942
|
+
if (size <= 20_000_000) stdout.push(chunk);
|
|
943
|
+
});
|
|
944
|
+
child.stderr.on("data", (chunk) => {
|
|
945
|
+
size += chunk.length;
|
|
946
|
+
if (size <= 20_000_000) stderr.push(chunk);
|
|
947
|
+
});
|
|
948
|
+
child.once("error", (error) => {
|
|
949
|
+
clearTimeout(timer);
|
|
950
|
+
clearTimeout(forceKillTimer);
|
|
951
|
+
reject(new Error(`Git could not ${action}. ${sanitizeGitErrorMessage(error.message)}`));
|
|
952
|
+
});
|
|
953
|
+
child.once("close", (code) => {
|
|
954
|
+
clearTimeout(timer);
|
|
955
|
+
clearTimeout(forceKillTimer);
|
|
956
|
+
const output = Buffer.concat(stdout).toString("utf8").trim();
|
|
957
|
+
const errorOutput = Buffer.concat(stderr).toString("utf8").trim();
|
|
958
|
+
if (code === 0 && !timedOut && size <= 20_000_000) {
|
|
959
|
+
resolve(output);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const detail = size > 20_000_000
|
|
963
|
+
? "Git output exceeded 20 MB."
|
|
964
|
+
: timedOut
|
|
965
|
+
? "Git timed out after 30 seconds."
|
|
966
|
+
: errorOutput || output || `Git exited with status ${code}.`;
|
|
967
|
+
const message = sanitizeGitErrorMessage(detail);
|
|
968
|
+
reject(new Error(`Git could not ${action}. ${message}`));
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export function sanitizeGitErrorMessage(value) {
|
|
974
|
+
return String(value || "Git returned no error detail.")
|
|
975
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
|
|
976
|
+
.replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
|
|
977
|
+
.replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
|
|
978
|
+
}
|
|
979
|
+
|
|
284
980
|
function tryGitForWrite(cwd, args) {
|
|
285
981
|
try {
|
|
286
982
|
gitForWrite(cwd, args);
|