@1agh/maude 0.53.1 → 0.54.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.
Files changed (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,305 @@
1
+ // One writer at a time on the workspace checkout — Cloud Phase 27 D2.
2
+ //
3
+ // THE PROBLEM THIS EXISTS FOR. A cell is two processes over one working tree.
4
+ // The hub commits autosaves, bundles backups and (at boot) clones the checkout;
5
+ // the studio writes canvas source and runs the browser's own git verbs —
6
+ // commit, discard, branch, checkout, fold, pull. Neither could see the other,
7
+ // and the phase's preserved dissent says exactly what that costs: "the 3 a.m.
8
+ // event is not a 500 — it is a tenant's canvas lost to a half-staged commit or
9
+ // a checkout under a live writer, in a cell whose /health still says 200."
10
+ //
11
+ // WHY GIT'S OWN LOCK WAS NOT ENOUGH, AND WHAT HAD TO CHANGE FIRST. `index.lock`
12
+ // only serializes processes that take it, and the studio did not: it runs
13
+ // isomorphic-git for the write paths, which keeps an in-PROCESS async lock and
14
+ // writes `.git/index` directly. Two engines, one index, no shared lock — so the
15
+ // first half of D2 is `MAUDE_USE_SYSTEM_GIT=1` in a cell (studio-child.mjs), and
16
+ // this file is the second half.
17
+ //
18
+ // WHY A SECOND LOCK ON TOP OF `index.lock`. Two reasons, and both matter:
19
+ //
20
+ // 1. `index.lock` FAILS rather than waits. Two commits racing gives one of
21
+ // them "Unable to create '.git/index.lock': File exists" — loud, but a
22
+ // failed autosave commit is a history that quietly stops. Here the loser
23
+ // WAITS, which is what an autosave wants.
24
+ // 2. `index.lock` is held per git INVOCATION, and the dangerous unit is a
25
+ // SEQUENCE: `add` then `commit` is two invocations, and a `checkout`
26
+ // landing between them is precisely the half-staged commit. A lock the
27
+ // caller holds across the whole sequence is the only thing that closes it.
28
+ //
29
+ // WHAT IT DELIBERATELY DOES NOT COVER. Ordinary file writes are not locked.
30
+ // They are atomic per file (tmp + rename everywhere in this codebase), so a
31
+ // `git add` racing a canvas write stages the old bytes or the new ones, never
32
+ // half of either, and the next quiescence commits the rest. Locking every
33
+ // keystroke-debounced write against a 3-second commit cycle would buy nothing
34
+ // and cost the editor its latency. What is locked is the operations that
35
+ // REWRITE the tree or the index — those are the ones that lose work.
36
+ //
37
+ // The hub's tree-rewriting operations (`seedRepo`, `restoreRepo`, rehydrate)
38
+ // are all cold-start, before the studio is serving — verified, not assumed:
39
+ // `restoreLatest` is called only from `rehydrate.mjs`, which the cell entrypoint
40
+ // runs as its own process before the hub starts, and `seedRepo` runs once in
41
+ // `startWorkspaceAgent` against an empty directory. That is why this is a lock
42
+ // and not a lock plus a quiesce RPC: there is no live hub operation for the
43
+ // studio to be quiesced FOR. If one is ever added, it needs the RPC too, and
44
+ // this comment is where to notice that.
45
+
46
+ import {
47
+ closeSync,
48
+ mkdirSync,
49
+ openSync,
50
+ readFileSync,
51
+ renameSync,
52
+ statSync,
53
+ unlinkSync,
54
+ utimesSync,
55
+ writeSync,
56
+ } from 'node:fs';
57
+ import path from 'node:path';
58
+
59
+ /** Where the lock lives. Inside `.git/` because it is git state, and beside
60
+ * `index.lock` because that is where anyone debugging a stuck repo looks. It
61
+ * is NOT named `index.lock` — faking git's own lock would make git itself
62
+ * refuse to run, which is a cure considerably worse than the disease. */
63
+ export const REPO_LOCK_FILE = 'maude-repo.lock';
64
+
65
+ /** Past this, a holder is assumed dead. Deliberately shorter than the 60 s
66
+ * `/health` staleness window: that one answers "should a human worry", this one
67
+ * answers "may I proceed", and waiting a minute to autosave is its own failure.
68
+ * Every operation under this lock is seconds at most; a clone is not (it runs
69
+ * before anything else exists to contend with it). */
70
+ export const STALE_LOCK_MS = 30_000;
71
+
72
+ /** How long a caller waits before giving up. Long enough to outlast any commit
73
+ * or checkout, short enough that a wedged holder surfaces as an error rather
74
+ * than a hang. */
75
+ export const DEFAULT_WAIT_MS = 15_000;
76
+
77
+ /** How often a holder refreshes its lock's mtime. Comfortably inside
78
+ * STALE_LOCK_MS, so a live holder is never mistaken for a dead one. */
79
+ export const HEARTBEAT_MS = 5_000;
80
+
81
+ export interface RepoLockOptions {
82
+ /** Milliseconds to wait for the lock before throwing. */
83
+ waitMs?: number;
84
+ /** Test seam. */
85
+ now?: () => number;
86
+ sleep?: (ms: number) => Promise<void>;
87
+ log?: Pick<Console, 'warn' | 'log'>;
88
+ }
89
+
90
+ export interface LockHolder {
91
+ pid: number;
92
+ /** Who and what — `hub:autocommit`, `studio:checkout`. Read by a human. */
93
+ holder: string;
94
+ at: number;
95
+ /** Distinguishes OUR lock from one a stale-steal handed to somebody else. */
96
+ token: string;
97
+ }
98
+
99
+ function lockPathFor(repoRoot: string): string {
100
+ return path.join(repoRoot, '.git', REPO_LOCK_FILE);
101
+ }
102
+
103
+ function readHolder(file: string): LockHolder | null {
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
106
+ return typeof parsed?.pid === 'number' ? (parsed as LockHolder) : null;
107
+ } catch {
108
+ // Unreadable or half-written: treat as an unknown holder rather than as
109
+ // absent. Staleness still reaps it; guessing "nobody" would not.
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Is the process that wrote this lock still alive?
116
+ *
117
+ * Both contenders live in the same container (and on a desktop, the same
118
+ * machine), so signal 0 is a real answer rather than a guess. An unknown pid is
119
+ * treated as ALIVE — an unreadable lock file must not become a licence to
120
+ * steal; that is what the staleness window is for.
121
+ */
122
+ function holderAlive(holder: LockHolder | null): boolean {
123
+ if (!holder) return true;
124
+ try {
125
+ process.kill(holder.pid, 0);
126
+ return true;
127
+ } catch (err) {
128
+ // EPERM means it exists and belongs to another user — alive.
129
+ return (err as NodeJS.ErrnoException)?.code === 'EPERM';
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Take the lock, run `fn`, release it — even when `fn` throws.
135
+ *
136
+ * A repo with no `.git` yet runs UNLOCKED, on purpose: there is nothing to
137
+ * serialize before the checkout exists, and the only code that runs then is the
138
+ * boot-time seed, single-process by construction. Creating `.git` here to hold
139
+ * a lock would make this function a repo initializer, which is the last thing
140
+ * it should be.
141
+ */
142
+ export async function withRepoLock<T>(
143
+ repoRoot: string,
144
+ holder: string,
145
+ fn: () => Promise<T>,
146
+ opts: RepoLockOptions = {}
147
+ ): Promise<T> {
148
+ const {
149
+ waitMs = DEFAULT_WAIT_MS,
150
+ now = () => Date.now(),
151
+ sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
152
+ log = console,
153
+ } = opts;
154
+
155
+ const gitDir = path.join(repoRoot, '.git');
156
+ try {
157
+ if (!statSync(gitDir).isDirectory()) return await fn();
158
+ } catch {
159
+ return await fn(); // no repo yet — see above
160
+ }
161
+
162
+ const file = lockPathFor(repoRoot);
163
+ const token = `${process.pid}-${now()}-${Math.random().toString(36).slice(2, 10)}`;
164
+ const deadline = now() + waitMs;
165
+ let delay = 20;
166
+
167
+ for (;;) {
168
+ if (tryAcquire(file, holder, token, now)) break;
169
+
170
+ const current = readHolder(file);
171
+ const age = ageOf(file, now);
172
+ // Steal only from a holder that is demonstrably gone, or one that has held
173
+ // it past every plausible operation. A cell that crashed mid-commit must
174
+ // not need a human to unwedge it.
175
+ if (age !== null && (age > STALE_LOCK_MS || !holderAlive(current))) {
176
+ // STEAL BY RENAME, NOT BY UNLINK — the steal has to be a
177
+ // compare-and-swap or it is a second way to lose mutual exclusion.
178
+ //
179
+ // An unconditional `unlink` here loses it outright: two waiters both see
180
+ // A as stale, the first unlinks and acquires, and the second — still
181
+ // acting on its read of A — unlinks the FIRST's fresh lock and acquires
182
+ // too. Both then run their critical section over one git index, which is
183
+ // precisely the half-staged commit this lock exists to prevent,
184
+ // reintroduced by its own recovery path.
185
+ //
186
+ // `rename` is the fix because it FAILS when the source is already gone:
187
+ // exactly one waiter can move the stale file aside, and every other gets
188
+ // ENOENT and goes back around the loop. Comparing the holder's token
189
+ // before an unlink was tried first and rejected — it narrows the window
190
+ // rather than closing it, and no honest test could tell it apart from the
191
+ // unfixed version, which is how the weakness came to light.
192
+ const sidecar = `${file}.stale-${token}`;
193
+ try {
194
+ renameSync(file, sidecar);
195
+ } catch {
196
+ continue; // another waiter won the steal; re-read and try again
197
+ }
198
+ log.warn?.(
199
+ `[repo-lock] stole a ${Math.round(age / 1000)}s lock from ${safeLabel(current?.holder)}` +
200
+ `${holderAlive(current) ? '' : ' (process gone)'}`
201
+ );
202
+ try {
203
+ unlinkSync(sidecar);
204
+ } catch {
205
+ /* already gone — the lock path is free either way */
206
+ }
207
+ continue;
208
+ }
209
+
210
+ if (now() >= deadline) {
211
+ throw new Error(
212
+ `repo lock held by ${current?.holder ?? 'an unreadable holder'} (pid ${current?.pid ?? '?'}) ` +
213
+ `for ${age === null ? '?' : Math.round(age / 1000)}s — ${holder} gave up after ${Math.round(waitMs / 1000)}s`
214
+ );
215
+ }
216
+ await sleep(delay);
217
+ delay = Math.min(delay * 2, 250);
218
+ }
219
+
220
+ // KEEP THE LOCK LOOKING ALIVE WHILE IT IS.
221
+ //
222
+ // The staleness test is the file's mtime, stamped once at acquisition, so
223
+ // "held for 30 s" and "the holder died 30 s ago" were the same observation.
224
+ // That was defensible when everything under this lock was a local `commit`;
225
+ // D2 put `pull`, `fold` and `resolve` under it too, and those do NETWORK I/O
226
+ // on a container's cold connection. Without this, the next waiter steals the
227
+ // lock out from under a running merge.
228
+ const beat = setInterval(() => {
229
+ try {
230
+ const held = readHolder(file);
231
+ if (held?.token !== token) return; // not ours any more — nothing to refresh
232
+ const stamp = new Date();
233
+ utimesSync(file, stamp, stamp);
234
+ } catch {
235
+ /* the file is gone or unreadable; the release below is still correct */
236
+ }
237
+ }, HEARTBEAT_MS);
238
+ beat.unref?.();
239
+
240
+ try {
241
+ return await fn();
242
+ } finally {
243
+ clearInterval(beat);
244
+ release(file, token);
245
+ }
246
+ }
247
+
248
+ /** Bound a holder label before it reaches a log line. `readHolder` parses
249
+ * attacker-plantable JSON, and an unescaped newline in a log is a forged log
250
+ * entry. */
251
+ function safeLabel(holder: string | undefined): string {
252
+ if (!holder) return 'an unreadable holder';
253
+ const clean = holder.replace(/[^\w:.-]/g, '').slice(0, 64);
254
+ return clean || 'an unreadable holder';
255
+ }
256
+
257
+ /** `wx` is the whole mechanism: create-or-fail is atomic on every filesystem
258
+ * this runs on, which is what makes this a lock rather than a suggestion. */
259
+ function tryAcquire(file: string, holder: string, token: string, now: () => number): boolean {
260
+ let fd: number | null = null;
261
+ try {
262
+ mkdirSync(path.dirname(file), { recursive: true });
263
+ fd = openSync(file, 'wx');
264
+ writeSync(
265
+ fd,
266
+ JSON.stringify({ pid: process.pid, holder, at: now(), token } satisfies LockHolder)
267
+ );
268
+ return true;
269
+ } catch {
270
+ return false;
271
+ } finally {
272
+ if (fd !== null) {
273
+ try {
274
+ closeSync(fd);
275
+ } catch {
276
+ /* nothing useful to do */
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ function ageOf(file: string, now: () => number): number | null {
283
+ try {
284
+ return Math.max(0, now() - statSync(file).mtimeMs);
285
+ } catch {
286
+ return null; // vanished between attempts — not stale, just gone
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Release, but only OUR lock.
292
+ *
293
+ * If a stale-steal already handed it to somebody else, unlinking here would
294
+ * pull the lock out from under a live holder — the one way a lock can be worse
295
+ * than no lock at all.
296
+ */
297
+ function release(file: string, token: string): void {
298
+ const current = readHolder(file);
299
+ if (current && current.token !== token) return;
300
+ try {
301
+ unlinkSync(file);
302
+ } catch {
303
+ /* already gone */
304
+ }
305
+ }
@@ -31,7 +31,16 @@ import { isAbsolute, join, relative, sep } from 'node:path';
31
31
  import git from 'isomorphic-git';
32
32
  import http from 'isomorphic-git/http/node';
33
33
 
34
- const USE_SYSTEM_GIT = /^(1|true|on|yes)$/i.test(process.env.MAUDE_USE_SYSTEM_GIT ?? '');
34
+ import { withRepoLock } from './repo-lock.ts';
35
+
36
+ // Read LIVE, not as a module-load const — the same reason `noSystemGit()` below
37
+ // is a function, and a sharper one since Cloud Phase 27 D2: a CELL now runs with
38
+ // this forced on (studio-child.mjs), so the system-git write paths stopped being
39
+ // an opt-in escape hatch and became what every cloud tenant's saves go through.
40
+ // A module-load const cannot be scoped around a test, which is how those paths
41
+ // came to have no coverage at all while the iso ones had plenty.
42
+ const systemGitForced = (): boolean =>
43
+ /^(1|true|on|yes)$/i.test(process.env.MAUDE_USE_SYSTEM_GIT ?? '');
35
44
  // DDR-133 (DDR-107 end-state): auto-prefer a detected system `git` for the NETWORK
36
45
  // paths (gitFetchRemote / remoteAheadBehind — native fetch is instant + uses the
37
46
  // user's own credential helper / SSH agent) AND the READ paths (status / list-
@@ -54,7 +63,7 @@ let systemGitProbe: Promise<boolean> | undefined;
54
63
  * and cheap. The probe NEVER relaxes the DDR-131 transport gate: callers classify
55
64
  * the remote URL first; this only picks which engine runs an already-vetted op. */
56
65
  function systemGitAvailable(): Promise<boolean> {
57
- if (USE_SYSTEM_GIT) return Promise.resolve(true);
66
+ if (systemGitForced()) return Promise.resolve(true);
58
67
  if (noSystemGit()) return Promise.resolve(false);
59
68
  if (!systemGitProbe) {
60
69
  systemGitProbe = runGit(process.cwd(), ['--version'], undefined, 4000)
@@ -64,6 +73,33 @@ function systemGitAvailable(): Promise<boolean> {
64
73
  return systemGitProbe;
65
74
  }
66
75
 
76
+ // ── one writer at a time (Cloud Phase 27 D2) ─────────────────────────────────
77
+ //
78
+ // In a CELL this process shares the checkout with the hub, which commits
79
+ // autosaves on its own clock. Every verb below either rewrites the working tree
80
+ // or the index, so each one runs under the cross-process advisory lock — held
81
+ // for the WHOLE verb, not per git invocation, because the dangerous unit is a
82
+ // sequence (`checkout main` → `merge` → `branch -D` in a fold, `add` → `commit`
83
+ // in the autocommit). On a desktop the lock is uncontended and costs a file
84
+ // create.
85
+ //
86
+ // A verb that cannot get the lock REFUSES in its own shape rather than throwing:
87
+ // these results reach a panel, and "somebody else is saving" is a sentence a
88
+ // designer can act on, where a 500 is not.
89
+ const REPO_BUSY = 'Somebody else is saving this project right now — try again in a moment.';
90
+
91
+ function underRepoLock<T>(
92
+ dir: string,
93
+ op: string,
94
+ fn: () => Promise<T>,
95
+ busy: () => T
96
+ ): Promise<T> {
97
+ return withRepoLock(dir, `studio:${op}`, fn).catch((err) => {
98
+ console.warn(`[git] ${op} could not take the repo lock: ${(err as Error).message}`);
99
+ return busy();
100
+ });
101
+ }
102
+
67
103
  const TIMED_OUT = Symbol('maude-git-timeout');
68
104
  /** Race `p` against a timeout. Returns `TIMED_OUT` if the deadline wins; a late
69
105
  * rejection from the losing promise is swallowed so it never surfaces as an
@@ -207,9 +243,16 @@ function underPrefix(filepath: string, prefix: string): boolean {
207
243
  * transport) → NOT hidden here.
208
244
  * - `_comments/` → hub-sync-only (DDR-102 CRDT) → HIDDEN, so it never
209
245
  * double-transports through git. */
210
- function isMaudeRuntimeState(p: string): boolean {
246
+ /** Exported for the test that guards the D3 per-member sibling: three separate
247
+ * lists have to agree on what runtime state IS, and they silently did not. */
248
+ export function isMaudeRuntimeState(p: string): boolean {
211
249
  return (
212
- /(^|\/)_(?:server|active|sync|preflight|locator|export-history|generate-history)\.json$/.test(
250
+ // The optional `.<session>` segment is Cloud Phase 27 D3: `_active.json`
251
+ // becomes `_active.<sessionKey>.json` per member in a cell. Without it each
252
+ // member's open tabs and selection showed as untracked to EVERYONE, a
253
+ // "Save all" staged them, and a push published them — one person's place in
254
+ // the project, in the tenant's remote.
255
+ /(^|\/)_(?:server|active|sync|preflight|locator|export-history|generate-history)(?:\.[A-Za-z0-9_-]{1,64})?\.json$/.test(
213
256
  p
214
257
  ) ||
215
258
  /(^|\/)_server\.(?:lock|log)$/.test(p) ||
@@ -402,7 +445,24 @@ function classifyPorcelain(xy: string): GitFileState | null {
402
445
  * checked; an empty/undefined list means "Save all" (every changed file under
403
446
  * `designPrefix`). Each file is staged add-or-remove based on its workdir
404
447
  * presence, then one commit lands. Returns the new sha. */
405
- export async function gitCommit(
448
+ export function gitCommit(
449
+ dir: string,
450
+ message: string,
451
+ files?: string[],
452
+ opts: { designPrefix?: string } = {}
453
+ ): Promise<GitCommitResult> {
454
+ return underRepoLock(
455
+ dir,
456
+ 'commit',
457
+ () => commitLocked(dir, message, files, opts),
458
+ () => ({
459
+ ok: false,
460
+ error: REPO_BUSY,
461
+ })
462
+ );
463
+ }
464
+
465
+ async function commitLocked(
406
466
  dir: string,
407
467
  message: string,
408
468
  files?: string[],
@@ -426,7 +486,7 @@ export async function gitCommit(
426
486
  selected = status.files;
427
487
  }
428
488
 
429
- return USE_SYSTEM_GIT ? commitSystem(dir, msg, selected) : commitIso(dir, msg, selected);
489
+ return systemGitForced() ? commitSystem(dir, msg, selected) : commitIso(dir, msg, selected);
430
490
  }
431
491
 
432
492
  async function commitIso(
@@ -479,7 +539,23 @@ export interface GitDiscardResult {
479
539
  * A tracked file (modified/deleted) is restored from HEAD; an untracked file is
480
540
  * deleted (it has no HEAD version to restore). Destructive by intent; the UI
481
541
  * confirms first. Each path is the endpoint-validated repo-relative form. */
482
- export async function gitDiscard(
542
+ export function gitDiscard(
543
+ dir: string,
544
+ files: string[],
545
+ opts: { designPrefix?: string } = {}
546
+ ): Promise<GitDiscardResult> {
547
+ return underRepoLock(
548
+ dir,
549
+ 'discard',
550
+ () => discardLocked(dir, files, opts),
551
+ () => ({
552
+ ok: false,
553
+ error: REPO_BUSY,
554
+ })
555
+ );
556
+ }
557
+
558
+ async function discardLocked(
483
559
  dir: string,
484
560
  files: string[],
485
561
  opts: { designPrefix?: string } = {}
@@ -496,7 +572,7 @@ export async function gitDiscard(
496
572
  for (const f of targets) {
497
573
  if (byPath.get(f) === 'untracked') {
498
574
  await fs.promises.rm(join(dir, f), { force: true });
499
- } else if (USE_SYSTEM_GIT) {
575
+ } else if (systemGitForced()) {
500
576
  const r = await runGit(dir, ['checkout', 'HEAD', '--', f]);
501
577
  if (r.code !== 0) return { ok: false, error: r.stderr.trim() || 'discard failed' };
502
578
  } else {
@@ -731,7 +807,19 @@ export interface GitBranchResult {
731
807
 
732
808
  /** Create a new draft off HEAD and switch to it. The name is validated against the
733
809
  * same dash-led / charset guard as every other positional (defense-in-depth). */
734
- export async function gitCreateBranch(dir: string, name: string): Promise<GitBranchResult> {
810
+ export function gitCreateBranch(dir: string, name: string): Promise<GitBranchResult> {
811
+ return underRepoLock(
812
+ dir,
813
+ 'branch',
814
+ () => createBranchLocked(dir, name),
815
+ () => ({
816
+ ok: false,
817
+ error: REPO_BUSY,
818
+ })
819
+ );
820
+ }
821
+
822
+ async function createBranchLocked(dir: string, name: string): Promise<GitBranchResult> {
735
823
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
736
824
  if (!isSafeGitPositional(name))
737
825
  return { ok: false, error: "That draft name has characters we can't use." };
@@ -739,7 +827,7 @@ export async function gitCreateBranch(dir: string, name: string): Promise<GitBra
739
827
  const existing = await gitListBranches(dir);
740
828
  if (existing.some((b) => b.name === name))
741
829
  return { ok: false, error: 'A draft with that name already exists.' };
742
- if (USE_SYSTEM_GIT) {
830
+ if (systemGitForced()) {
743
831
  const r = await runGit(dir, ['checkout', '-b', name]);
744
832
  if (r.code !== 0)
745
833
  return { ok: false, error: r.stderr.trim() || 'Could not create the draft.' };
@@ -755,11 +843,23 @@ export async function gitCreateBranch(dir: string, name: string): Promise<GitBra
755
843
  /** Switch to an existing draft (or back to the Shared version). A dirty tree that
756
844
  * would be clobbered surfaces a plain "Save your changes first" rather than a
757
845
  * raw git error. */
758
- export async function gitCheckout(dir: string, name: string): Promise<GitBranchResult> {
846
+ export function gitCheckout(dir: string, name: string): Promise<GitBranchResult> {
847
+ return underRepoLock(
848
+ dir,
849
+ 'checkout',
850
+ () => checkoutLocked(dir, name),
851
+ () => ({
852
+ ok: false,
853
+ error: REPO_BUSY,
854
+ })
855
+ );
856
+ }
857
+
858
+ async function checkoutLocked(dir: string, name: string): Promise<GitBranchResult> {
759
859
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
760
860
  if (!isSafeGitPositional(name)) return { ok: false, error: 'Invalid draft name.' };
761
861
  try {
762
- if (USE_SYSTEM_GIT) {
862
+ if (systemGitForced()) {
763
863
  // System git DWIMs `git checkout <name>` into a tracking branch when <name>
764
864
  // exists on exactly one remote, so the local + remote-only cases share a path.
765
865
  const r = await runGit(dir, ['checkout', name]);
@@ -826,7 +926,24 @@ export interface GitFoldResult {
826
926
  * the draft. A content conflict or a rejected publish surfaces the plain "Get latest
827
927
  * first" path (no 3-way merge UI). The local draft is removed ONLY after a clean
828
928
  * publish, so a rejected publish leaves a recoverable state. */
829
- export async function gitFoldDraft(
929
+ export function gitFoldDraft(
930
+ dir: string,
931
+ draftName: string,
932
+ token: string | undefined,
933
+ opts: { remote?: string } = {}
934
+ ): Promise<GitFoldResult> {
935
+ return underRepoLock(
936
+ dir,
937
+ 'fold',
938
+ () => foldLocked(dir, draftName, token, opts),
939
+ () => ({
940
+ ok: false,
941
+ error: REPO_BUSY,
942
+ })
943
+ );
944
+ }
945
+
946
+ async function foldLocked(
830
947
  dir: string,
831
948
  draftName: string,
832
949
  token: string | undefined,
@@ -868,7 +985,7 @@ export async function gitFoldDraft(
868
985
  // locally; there's no PR host. Unchanged pre-PR-flow behavior.
869
986
  // Merge the draft into the Shared version (FF when possible, else a merge commit).
870
987
  try {
871
- if (USE_SYSTEM_GIT) {
988
+ if (systemGitForced()) {
872
989
  const co = await runGit(dir, ['checkout', shared]);
873
990
  if (co.code !== 0) return { ok: false, error: 'Save your changes before adding the draft.' };
874
991
  const mg = await runGit(dir, ['merge', draftName]);
@@ -896,7 +1013,7 @@ export async function gitFoldDraft(
896
1013
  await git.checkout({ fs, dir, ref: shared, force: true });
897
1014
  }
898
1015
  } catch (e) {
899
- if (!USE_SYSTEM_GIT) {
1016
+ if (!systemGitForced()) {
900
1017
  try {
901
1018
  await git.checkout({ fs, dir, ref: draftName, force: true });
902
1019
  } catch {
@@ -928,7 +1045,7 @@ export async function gitFoldDraft(
928
1045
 
929
1046
  // The draft's work is now in the Shared version — remove the draft (local only).
930
1047
  try {
931
- if (USE_SYSTEM_GIT) await runGit(dir, ['branch', '-D', draftName]);
1048
+ if (systemGitForced()) await runGit(dir, ['branch', '-D', draftName]);
932
1049
  else await git.deleteBranch({ fs, dir, ref: draftName });
933
1050
  } catch {
934
1051
  /* non-fatal: the fold + publish succeeded; a leftover draft ref is harmless */
@@ -1011,7 +1128,7 @@ export async function gitPush(
1011
1128
  case 'iso':
1012
1129
  return pushIso(dir, token, remote, opts.ref);
1013
1130
  case 'legacy':
1014
- return USE_SYSTEM_GIT
1131
+ return systemGitForced()
1015
1132
  ? pushSystem(dir, token, remote, opts.ref)
1016
1133
  : pushIso(dir, token, remote, opts.ref);
1017
1134
  }
@@ -1141,7 +1258,23 @@ function isTransportError(blob: string): boolean {
1141
1258
  // ── pull (Get latest) ─────────────────────────────────────────────────────
1142
1259
 
1143
1260
  /** Get latest. Same optional-token model as gitPush (see its doc comment). */
1144
- export async function gitPull(
1261
+ export function gitPull(
1262
+ dir: string,
1263
+ token: string | undefined,
1264
+ opts: { remote?: string; ref?: string } = {}
1265
+ ): Promise<GitPullResult> {
1266
+ return underRepoLock(
1267
+ dir,
1268
+ 'pull',
1269
+ () => pullLocked(dir, token, opts),
1270
+ () => ({
1271
+ ok: false,
1272
+ error: REPO_BUSY,
1273
+ })
1274
+ );
1275
+ }
1276
+
1277
+ async function pullLocked(
1145
1278
  dir: string,
1146
1279
  token: string | undefined,
1147
1280
  opts: { remote?: string; ref?: string } = {}
@@ -1160,7 +1293,7 @@ export async function gitPull(
1160
1293
  case 'iso':
1161
1294
  return pullIso(dir, token, remote, opts.ref);
1162
1295
  case 'legacy':
1163
- return USE_SYSTEM_GIT
1296
+ return systemGitForced()
1164
1297
  ? pullSystem(dir, token, remote, opts.ref)
1165
1298
  : pullIso(dir, token, remote, opts.ref);
1166
1299
  }
@@ -1351,7 +1484,24 @@ export async function gitFetchRemote(
1351
1484
  * • `both` — take theirs AND save ours as a "<name> (mine)<ext>" copy (the
1352
1485
  * DiffView zero-data-loss default).
1353
1486
  * Produces the two-parent merge commit so a subsequent Publish fast-forwards. */
1354
- export async function gitResolve(
1487
+ export function gitResolve(
1488
+ dir: string,
1489
+ choice: ResolveChoice,
1490
+ token: string | undefined,
1491
+ opts: { remote?: string; ref?: string } = {}
1492
+ ): Promise<GitResolveResult> {
1493
+ return underRepoLock(
1494
+ dir,
1495
+ 'resolve',
1496
+ () => resolveLocked(dir, choice, token, opts),
1497
+ () => ({
1498
+ ok: false,
1499
+ error: REPO_BUSY,
1500
+ })
1501
+ );
1502
+ }
1503
+
1504
+ async function resolveLocked(
1355
1505
  dir: string,
1356
1506
  choice: ResolveChoice,
1357
1507
  token: string | undefined,
@@ -1360,7 +1510,7 @@ export async function gitResolve(
1360
1510
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
1361
1511
  if (choice !== 'mine' && choice !== 'theirs' && choice !== 'both')
1362
1512
  return { ok: false, error: 'Pick how to resolve: keep mine, theirs, or both.' };
1363
- return USE_SYSTEM_GIT
1513
+ return systemGitForced()
1364
1514
  ? resolveSystem(dir, choice, opts.remote || 'origin', opts.ref)
1365
1515
  : resolveIso(dir, choice, token, opts.remote || 'origin', opts.ref);
1366
1516
  }