filegrc 0.3.2 → 0.3.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/README.md +2 -2
- package/model/v1.json +8 -5
- package/package.json +1 -1
- package/src/build.js +3 -1
- package/src/cli.js +19 -7
- package/src/favicon.js +3 -108
- package/src/favicon.png +0 -0
- package/src/git-name.js +16 -0
- package/src/git.js +499 -1
- package/src/index.js +4 -1
- package/src/logo-mark-white.png +0 -0
- package/src/mutation.js +4 -1
- package/src/server.js +89 -20
- package/src/startup.js +5 -0
- package/src/state.js +7 -2
- package/src/validate.js +2 -0
- package/src/web.js +85 -17
package/src/git.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { execFileSync } 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 { isSafeGitName } from "./git-name.js";
|
|
2
6
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
3
7
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
4
8
|
import { validateWorkspace } from "./validate.js";
|
|
9
|
+
import { loadWorkspace } from "./workspace.js";
|
|
10
|
+
|
|
11
|
+
const lastSuccessfulSynchronizations = new Map();
|
|
5
12
|
|
|
6
13
|
export function getGitSummary(input = process.cwd()) {
|
|
7
14
|
const root = resolveWorkspaceRoot(input);
|
|
@@ -120,6 +127,175 @@ export async function pushWorkspace(input = process.cwd()) {
|
|
|
120
127
|
return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
|
|
131
|
+
const root = resolveWorkspaceRoot(input);
|
|
132
|
+
const config = await getRepositoryConfig(root);
|
|
133
|
+
const gitSummary = getGitSummary(root);
|
|
134
|
+
if (config.mode !== "trunk") {
|
|
135
|
+
return {
|
|
136
|
+
mode: "manual",
|
|
137
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
138
|
+
remote: config.remote,
|
|
139
|
+
developmentOverride: false,
|
|
140
|
+
status: "manual",
|
|
141
|
+
label: "Manual Git",
|
|
142
|
+
writesAllowed: !options.readOnly,
|
|
143
|
+
currentCommit: gitSummary.commit,
|
|
144
|
+
upstreamCommit: null,
|
|
145
|
+
ahead: null,
|
|
146
|
+
behind: null,
|
|
147
|
+
pendingCommits: [],
|
|
148
|
+
pendingCommitsFilegrcOnly: null,
|
|
149
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
150
|
+
message: "Browser writes stay local until a user commits and synchronizes them."
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const details = inspectTrunkRepository(root, config, gitSummary);
|
|
155
|
+
const developmentOverride = options.allowNonAuthoritativeWrites === true;
|
|
156
|
+
if (developmentOverride) {
|
|
157
|
+
return {
|
|
158
|
+
...details,
|
|
159
|
+
developmentOverride: true,
|
|
160
|
+
writesAllowed: !options.readOnly,
|
|
161
|
+
status: "not-synced",
|
|
162
|
+
label: "Not synced",
|
|
163
|
+
message: "Development override is active. Browser writes stay local and FileGRC will not commit or push them."
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...details,
|
|
168
|
+
developmentOverride: false,
|
|
169
|
+
writesAllowed: !options.readOnly && details.writesAllowed
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runBrowserMutation(input, options, task) {
|
|
174
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
175
|
+
const config = await getRepositoryConfig(root);
|
|
176
|
+
if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
|
|
177
|
+
return task(root);
|
|
178
|
+
}
|
|
179
|
+
return runTrunkMutationUnlocked(root, config, options, task);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function retryBrowserSync(input = process.cwd(), options = {}) {
|
|
184
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
185
|
+
const config = await getRepositoryConfig(root);
|
|
186
|
+
if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
|
|
187
|
+
if (options.allowNonAuthoritativeWrites === true) {
|
|
188
|
+
throw new Error("Retry sync is disabled while the development write override is active.");
|
|
189
|
+
}
|
|
190
|
+
const before = requireTrunkPreconditions(root, config, { allowAhead: true });
|
|
191
|
+
fetchConfiguredRemote(root, config.remote);
|
|
192
|
+
const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
193
|
+
if (synchronized.behind > 0 && synchronized.ahead > 0) {
|
|
194
|
+
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.");
|
|
195
|
+
}
|
|
196
|
+
if (synchronized.behind > 0) {
|
|
197
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
198
|
+
}
|
|
199
|
+
const ready = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
200
|
+
if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
|
|
201
|
+
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.");
|
|
202
|
+
}
|
|
203
|
+
if (ready.ahead > 0) pushConfiguredBranch(root, config);
|
|
204
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
205
|
+
if (after.ahead !== 0 || after.behind !== 0) {
|
|
206
|
+
throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
|
|
207
|
+
}
|
|
208
|
+
const synchronizedAt = new Date().toISOString();
|
|
209
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
210
|
+
return {
|
|
211
|
+
commit: after.currentCommit,
|
|
212
|
+
shortCommit: after.currentCommit?.slice(0, 8) ?? null,
|
|
213
|
+
branch: config.authoritativeBranch,
|
|
214
|
+
upstream: after.upstream,
|
|
215
|
+
synchronizedAt,
|
|
216
|
+
retriedCommits: before.ahead ?? 0
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
222
|
+
requireTrunkPreconditions(root, config);
|
|
223
|
+
fetchConfiguredRemote(root, config.remote);
|
|
224
|
+
let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
225
|
+
if (synchronized.ahead > 0 && synchronized.behind > 0) {
|
|
226
|
+
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.");
|
|
227
|
+
}
|
|
228
|
+
if (synchronized.ahead > 0) {
|
|
229
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
230
|
+
}
|
|
231
|
+
if (synchronized.behind > 0) {
|
|
232
|
+
fastForwardConfiguredBranch(root, synchronized.upstream);
|
|
233
|
+
synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
234
|
+
}
|
|
235
|
+
if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
|
|
236
|
+
throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let result;
|
|
240
|
+
let subject;
|
|
241
|
+
try {
|
|
242
|
+
result = await task(root);
|
|
243
|
+
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
244
|
+
const validation = await validateWorkspace(root);
|
|
245
|
+
if (!validation.ok) {
|
|
246
|
+
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
|
|
247
|
+
}
|
|
248
|
+
assertNoOutsideWorktreeChanges(root);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
try {
|
|
251
|
+
await rollbackWorkspaceChanges(root);
|
|
252
|
+
} catch (rollbackError) {
|
|
253
|
+
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.`);
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!getGitSummary(root).changes.length) {
|
|
259
|
+
throw new Error("The browser action did not change any FileGRC workspace files.");
|
|
260
|
+
}
|
|
261
|
+
if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
|
|
262
|
+
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.");
|
|
263
|
+
}
|
|
264
|
+
gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
|
|
265
|
+
assertNoOutsideWorktreeChanges(root, false);
|
|
266
|
+
assertOnlyWorkspaceFilesStaged(root);
|
|
267
|
+
try {
|
|
268
|
+
gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const committed = getGitSummary(root);
|
|
274
|
+
let pushError = null;
|
|
275
|
+
try {
|
|
276
|
+
pushConfiguredBranch(root, config);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
pushError = `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`;
|
|
279
|
+
}
|
|
280
|
+
const after = inspectTrunkRepository(root, config, getGitSummary(root));
|
|
281
|
+
let synchronizedAt = null;
|
|
282
|
+
if (!pushError && after.ahead === 0 && after.behind === 0) {
|
|
283
|
+
synchronizedAt = new Date().toISOString();
|
|
284
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
...result,
|
|
288
|
+
synchronization: {
|
|
289
|
+
status: pushError ? "not-synced" : "synced",
|
|
290
|
+
commit: committed.commit,
|
|
291
|
+
shortCommit: committed.shortCommit,
|
|
292
|
+
upstream: after.upstream,
|
|
293
|
+
synchronizedAt,
|
|
294
|
+
pushError
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
123
299
|
async function commitWorkspaceUnlocked(root, message) {
|
|
124
300
|
const subject = String(message ?? "").trim();
|
|
125
301
|
if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
|
|
@@ -233,6 +409,299 @@ function syncReadySummary(root, action) {
|
|
|
233
409
|
return summary;
|
|
234
410
|
}
|
|
235
411
|
|
|
412
|
+
async function getRepositoryConfig(root) {
|
|
413
|
+
const loaded = await loadWorkspace(root);
|
|
414
|
+
const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
|
|
415
|
+
const mode = renderer?.repositoryMode === "trunk" ? "trunk" : "manual";
|
|
416
|
+
const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch, "main");
|
|
417
|
+
const remote = cleanGitName(renderer?.repositoryRemote, "origin");
|
|
418
|
+
return {
|
|
419
|
+
mode,
|
|
420
|
+
authoritativeBranch,
|
|
421
|
+
remote,
|
|
422
|
+
configurationError: !isSafeGitName(authoritativeBranch)
|
|
423
|
+
? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
|
|
424
|
+
: !isSafeGitName(remote)
|
|
425
|
+
? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
|
|
426
|
+
: null
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
|
|
431
|
+
const base = {
|
|
432
|
+
mode: "trunk",
|
|
433
|
+
authoritativeBranch: config.authoritativeBranch,
|
|
434
|
+
remote: config.remote,
|
|
435
|
+
currentCommit: summary.commit,
|
|
436
|
+
upstreamCommit: null,
|
|
437
|
+
upstream: summary.upstream,
|
|
438
|
+
ahead: null,
|
|
439
|
+
behind: null,
|
|
440
|
+
pendingCommits: [],
|
|
441
|
+
pendingCommitsFilegrcOnly: null,
|
|
442
|
+
lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
|
|
443
|
+
wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
|
|
444
|
+
operationInProgress: summary.available ? repositoryOperation(root) : null,
|
|
445
|
+
writesAllowed: false
|
|
446
|
+
};
|
|
447
|
+
if (config.configurationError) {
|
|
448
|
+
return {
|
|
449
|
+
...base,
|
|
450
|
+
status: "git-setup-required",
|
|
451
|
+
label: "Git setup required",
|
|
452
|
+
message: config.configurationError
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (!summary.available) {
|
|
456
|
+
return {
|
|
457
|
+
...base,
|
|
458
|
+
status: "git-setup-required",
|
|
459
|
+
label: "Git setup required",
|
|
460
|
+
message: "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (summary.branch !== config.authoritativeBranch) {
|
|
464
|
+
return {
|
|
465
|
+
...base,
|
|
466
|
+
status: "read-only-checkout",
|
|
467
|
+
label: "Read-only checkout",
|
|
468
|
+
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."
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (!summary.remotes.includes(config.remote)) {
|
|
472
|
+
return {
|
|
473
|
+
...base,
|
|
474
|
+
status: "git-setup-required",
|
|
475
|
+
label: "Git setup required",
|
|
476
|
+
message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
|
|
480
|
+
if (summary.upstream !== expectedUpstream) {
|
|
481
|
+
return {
|
|
482
|
+
...base,
|
|
483
|
+
status: "git-setup-required",
|
|
484
|
+
label: "Git setup required",
|
|
485
|
+
message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const upstreamCommit = tryGit(root, ["rev-parse", expectedUpstream]) || null;
|
|
489
|
+
const counts = upstreamCommit ? aheadBehind(root, expectedUpstream) : { ahead: null, behind: null };
|
|
490
|
+
const pendingCommits = counts.ahead > 0 ? commitsAhead(root, expectedUpstream) : [];
|
|
491
|
+
const pendingCommitsFilegrcOnly = counts.ahead > 0 ? commitsOnlyTouchWorkspace(root, expectedUpstream) : true;
|
|
492
|
+
const details = {
|
|
493
|
+
...base,
|
|
494
|
+
upstreamCommit,
|
|
495
|
+
ahead: counts.ahead,
|
|
496
|
+
behind: counts.behind,
|
|
497
|
+
pendingCommits,
|
|
498
|
+
pendingCommitsFilegrcOnly
|
|
499
|
+
};
|
|
500
|
+
if (base.operationInProgress) {
|
|
501
|
+
return {
|
|
502
|
+
...details,
|
|
503
|
+
status: "not-synced",
|
|
504
|
+
label: "Not synced",
|
|
505
|
+
message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (!base.wholeWorktreeClean) {
|
|
509
|
+
return {
|
|
510
|
+
...details,
|
|
511
|
+
status: "not-synced",
|
|
512
|
+
label: "Not synced",
|
|
513
|
+
message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (counts.ahead === null || counts.behind === null) {
|
|
517
|
+
return {
|
|
518
|
+
...details,
|
|
519
|
+
status: "git-setup-required",
|
|
520
|
+
label: "Git setup required",
|
|
521
|
+
message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (counts.ahead > 0 || counts.behind > 0) {
|
|
525
|
+
const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
|
|
526
|
+
return {
|
|
527
|
+
...details,
|
|
528
|
+
status: "not-synced",
|
|
529
|
+
label: "Not synced",
|
|
530
|
+
message: external
|
|
531
|
+
? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
|
|
532
|
+
: counts.ahead > 0 && counts.behind > 0
|
|
533
|
+
? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
|
|
534
|
+
: counts.ahead > 0
|
|
535
|
+
? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
|
|
536
|
+
: "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing.",
|
|
537
|
+
writesAllowed: counts.ahead === 0 && counts.behind > 0,
|
|
538
|
+
retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
...details,
|
|
543
|
+
status: "synced",
|
|
544
|
+
label: "Synced",
|
|
545
|
+
message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
|
|
546
|
+
writesAllowed: true,
|
|
547
|
+
retrySafe: false
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function requireTrunkPreconditions(root, config, options = {}) {
|
|
552
|
+
const summary = getGitSummary(root);
|
|
553
|
+
const state = inspectTrunkRepository(root, config, summary);
|
|
554
|
+
if (config.configurationError) throw new Error(state.message);
|
|
555
|
+
if (!summary.available) throw new Error(state.message);
|
|
556
|
+
if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
|
|
557
|
+
if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
|
|
558
|
+
if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
|
|
559
|
+
if (state.operationInProgress) throw new Error(state.message);
|
|
560
|
+
if (!state.wholeWorktreeClean) throw new Error(state.message);
|
|
561
|
+
if (!options.allowAhead && state.ahead > 0) {
|
|
562
|
+
throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
|
|
563
|
+
}
|
|
564
|
+
return state;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function fetchConfiguredRemote(root, remote) {
|
|
568
|
+
gitForWrite(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function fastForwardConfiguredBranch(root, upstream) {
|
|
572
|
+
gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function pushConfiguredBranch(root, config) {
|
|
576
|
+
gitForWrite(
|
|
577
|
+
root,
|
|
578
|
+
["push", "--porcelain", "--", config.remote, `HEAD:refs/heads/${config.authoritativeBranch}`],
|
|
579
|
+
`push ${config.authoritativeBranch} to ${config.remote}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function wholeWorktreeClean(root) {
|
|
584
|
+
return git(root, ["status", "--porcelain=v1"]) === "";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function repositoryOperation(root) {
|
|
588
|
+
for (const [name, gitPath] of [
|
|
589
|
+
["merge", "MERGE_HEAD"],
|
|
590
|
+
["rebase", "rebase-merge"],
|
|
591
|
+
["rebase", "rebase-apply"],
|
|
592
|
+
["cherry-pick", "CHERRY_PICK_HEAD"]
|
|
593
|
+
]) {
|
|
594
|
+
const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
|
|
595
|
+
if (path && existsSync(resolve(root, path))) return name;
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function aheadBehind(root, upstream) {
|
|
601
|
+
const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
602
|
+
const [ahead, behind] = output.split(/\s+/).map(Number);
|
|
603
|
+
return Number.isInteger(ahead) && Number.isInteger(behind)
|
|
604
|
+
? { ahead, behind }
|
|
605
|
+
: { ahead: null, behind: null };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function commitsAhead(root, upstream) {
|
|
609
|
+
return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
|
|
610
|
+
const [commit, subject] = line.split("\x1f");
|
|
611
|
+
return { commit, shortCommit: commit.slice(0, 8), subject };
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function commitsOnlyTouchWorkspace(root, upstream) {
|
|
616
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
617
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
618
|
+
const commits = lines(tryGit(root, ["rev-list", `${upstream}..HEAD`]));
|
|
619
|
+
return commits.every((commit) => {
|
|
620
|
+
const paths = nulFields(tryGitRaw(topLevel, [
|
|
621
|
+
"diff-tree",
|
|
622
|
+
"--no-commit-id",
|
|
623
|
+
"--name-only",
|
|
624
|
+
"-z",
|
|
625
|
+
"-r",
|
|
626
|
+
"--root",
|
|
627
|
+
commit
|
|
628
|
+
]));
|
|
629
|
+
return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
|
|
634
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
635
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
636
|
+
const paths = statusPaths(topLevel);
|
|
637
|
+
if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
638
|
+
throw new Error(rollbackExpected
|
|
639
|
+
? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
|
|
640
|
+
: "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.");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function assertOnlyWorkspaceFilesStaged(root) {
|
|
645
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
646
|
+
const prefix = relative(topLevel, root).split(sep).join("/");
|
|
647
|
+
const staged = nulFields(tryGitRaw(topLevel, [
|
|
648
|
+
"diff",
|
|
649
|
+
"--cached",
|
|
650
|
+
"--name-only",
|
|
651
|
+
"-z",
|
|
652
|
+
"--diff-filter=ACDMRTUXB"
|
|
653
|
+
]));
|
|
654
|
+
if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
|
|
655
|
+
if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
|
|
656
|
+
throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function rollbackWorkspaceChanges(root) {
|
|
661
|
+
gitForWrite(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], "roll back the FileGRC workspace change");
|
|
662
|
+
const untracked = nulFields(tryGitRaw(root, ["ls-files", "-z", "--others", "--exclude-standard", "--", "."]));
|
|
663
|
+
for (const path of untracked) {
|
|
664
|
+
const absolute = resolve(root, path);
|
|
665
|
+
if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
|
|
666
|
+
await rm(absolute, { force: true });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function statusPaths(topLevel) {
|
|
671
|
+
const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
672
|
+
if (!output) return [];
|
|
673
|
+
const fields = nulFields(output);
|
|
674
|
+
const paths = [];
|
|
675
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
676
|
+
const field = fields[index];
|
|
677
|
+
if (!/^[ MADRCU?!]{2} /.test(field)) {
|
|
678
|
+
paths.push(field);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const status = field.slice(0, 2);
|
|
682
|
+
paths.push(field.slice(3));
|
|
683
|
+
if (/[RC]/.test(status) && fields[index + 1] !== undefined) paths.push(fields[++index]);
|
|
684
|
+
}
|
|
685
|
+
return paths;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function pathInsideWorkspace(path, prefix) {
|
|
689
|
+
return !prefix || path === prefix || path.startsWith(`${prefix}/`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function generatedCommitMessage(value) {
|
|
693
|
+
const subject = String(value ?? "")
|
|
694
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
695
|
+
.replace(/\s+/g, " ")
|
|
696
|
+
.trim();
|
|
697
|
+
return (subject || "Update FileGRC workspace").slice(0, 200);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function cleanGitName(value, fallback) {
|
|
701
|
+
const normalized = String(value ?? fallback).trim();
|
|
702
|
+
return normalized || fallback;
|
|
703
|
+
}
|
|
704
|
+
|
|
236
705
|
function parseLogLine(line) {
|
|
237
706
|
if (!line) return null;
|
|
238
707
|
const [commit, timestamp, author, subject] = line.split("\x1f");
|
|
@@ -261,6 +730,28 @@ function tryGit(cwd, args) {
|
|
|
261
730
|
}
|
|
262
731
|
}
|
|
263
732
|
|
|
733
|
+
function tryGitRaw(cwd, args) {
|
|
734
|
+
try {
|
|
735
|
+
return gitRaw(cwd, args);
|
|
736
|
+
} catch {
|
|
737
|
+
return "";
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function gitRaw(cwd, args) {
|
|
742
|
+
return execFileSync("git", args, {
|
|
743
|
+
cwd,
|
|
744
|
+
encoding: "utf8",
|
|
745
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
746
|
+
timeout: 10_000,
|
|
747
|
+
maxBuffer: 20_000_000
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function nulFields(source) {
|
|
752
|
+
return source ? source.split("\0").filter(Boolean) : [];
|
|
753
|
+
}
|
|
754
|
+
|
|
264
755
|
function gitForWrite(cwd, args, action = "create the commit") {
|
|
265
756
|
try {
|
|
266
757
|
return execFileSync("git", args, {
|
|
@@ -276,11 +767,18 @@ function gitForWrite(cwd, args, action = "create the commit") {
|
|
|
276
767
|
}
|
|
277
768
|
}).trim();
|
|
278
769
|
} catch (error) {
|
|
279
|
-
const message = error.stderr?.trim() || error.stdout?.trim() || error.message;
|
|
770
|
+
const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
|
|
280
771
|
throw new Error(`Git could not ${action}. ${message}`);
|
|
281
772
|
}
|
|
282
773
|
}
|
|
283
774
|
|
|
775
|
+
export function sanitizeGitErrorMessage(value) {
|
|
776
|
+
return String(value || "Git returned no error detail.")
|
|
777
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
|
|
778
|
+
.replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
|
|
779
|
+
.replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
|
|
780
|
+
}
|
|
781
|
+
|
|
284
782
|
function tryGitForWrite(cwd, args) {
|
|
285
783
|
try {
|
|
286
784
|
gitForWrite(cwd, args);
|
package/src/index.js
CHANGED
|
@@ -18,11 +18,14 @@ export {
|
|
|
18
18
|
export {
|
|
19
19
|
commitAndPushWorkspace,
|
|
20
20
|
commitWorkspace,
|
|
21
|
+
getBrowserRepositoryState,
|
|
21
22
|
getFileHistory,
|
|
22
23
|
getGitSummary,
|
|
23
24
|
getWorkspaceHistories,
|
|
24
25
|
pullWorkspace,
|
|
25
|
-
pushWorkspace
|
|
26
|
+
pushWorkspace,
|
|
27
|
+
retryBrowserSync,
|
|
28
|
+
runBrowserMutation
|
|
26
29
|
} from "./git.js";
|
|
27
30
|
export { generateModelDocumentation } from "./model-docs.js";
|
|
28
31
|
export { renderMarkdown } from "./markdown.js";
|
|
Binary file
|
package/src/mutation.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
2
3
|
|
|
3
4
|
const mutationQueues = new Map();
|
|
5
|
+
const activeMutation = new AsyncLocalStorage();
|
|
4
6
|
|
|
5
7
|
export function serializeWorkspaceMutation(input, task) {
|
|
6
8
|
const root = resolveWorkspaceRoot(input);
|
|
9
|
+
if (activeMutation.getStore() === root) return task(root);
|
|
7
10
|
const previous = mutationQueues.get(root) ?? Promise.resolve();
|
|
8
|
-
const run = previous.catch(() => {}).then(() => task(root));
|
|
11
|
+
const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root)));
|
|
9
12
|
let tracked;
|
|
10
13
|
tracked = run.finally(() => {
|
|
11
14
|
if (mutationQueues.get(root) === tracked) mutationQueues.delete(root);
|