@1agh/maude 0.53.2 → 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.
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +1007 -1007
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (!
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|