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/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);