@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.
Files changed (46) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +43 -3
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/client/app.jsx +208 -25
  5. package/apps/studio/client/export-center.jsx +10 -2
  6. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  7. package/apps/studio/client/styles/4-components.css +40 -0
  8. package/apps/studio/cloud-build.ts +208 -0
  9. package/apps/studio/collab/room.ts +43 -7
  10. package/apps/studio/context.ts +12 -5
  11. package/apps/studio/dist/client.bundle.js +1007 -1007
  12. package/apps/studio/dist/comment-mount.js +2 -2
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/git/repo-lock.ts +305 -0
  15. package/apps/studio/git/service.ts +171 -21
  16. package/apps/studio/http.ts +145 -12
  17. package/apps/studio/inspect.ts +66 -6
  18. package/apps/studio/server.ts +114 -17
  19. package/apps/studio/session-scope.ts +101 -0
  20. package/apps/studio/sync/autocommit.ts +84 -46
  21. package/apps/studio/sync/index.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  23. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  24. package/apps/studio/test/cloud-build.test.ts +95 -0
  25. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  26. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  27. package/apps/studio/test/git-system-engine.test.ts +135 -0
  28. package/apps/studio/test/read-only-gate.test.ts +8 -0
  29. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  30. package/apps/studio/test/repo-lock.test.ts +350 -0
  31. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  32. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  33. package/apps/studio/test/session-scope.test.ts +100 -0
  34. package/apps/studio/test/shell-importmap.test.ts +8 -1
  35. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  36. package/apps/studio/test/sync-hardening.test.ts +75 -0
  37. package/apps/studio/use-collab.tsx +21 -1
  38. package/apps/studio/whats-new.json +36 -0
  39. package/apps/studio/ws.ts +44 -8
  40. package/cli/commands/kg.mjs +49 -19
  41. package/cli/lib/gitignore-block.mjs +3 -0
  42. package/package.json +11 -9
  43. package/plugins/design/dependencies.json +1 -1
  44. package/plugins/design/templates/_shell.html +66 -32
  45. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  46. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,194 @@
1
+ // Unit: the read-only role gate on collab sync writes — the cloud collab lane
2
+ // (RCA issue-cloud-live-collaboration-dead).
3
+ //
4
+ // A cell's proxy opens the canvas iframe's collab socket at the member's real
5
+ // role; a viewer's socket arrives with readOnly=true. The room mirrors the role
6
+ // matrix at the WS door the same way the inspector channel does for
7
+ // comments-patch/delete: a read-only conn receives everything and publishes
8
+ // awareness (presence for viewers is the point of the channel), but its sync
9
+ // writes are gated to the COMMENT lane — the one write a viewer holds
10
+ // (`viewer.comment === true`). Composes with, never replaces, the DDR-122
11
+ // canvas-realm body-lane gate (test/collab-origin-gate.test.ts).
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import * as encoding from 'lib0/encoding';
16
+ import { writeUpdate } from 'y-protocols/sync';
17
+ import * as Y from 'yjs';
18
+
19
+ import { MESSAGE_SYNC } from '../collab/protocol.ts';
20
+ import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
21
+
22
+ function makeConn(
23
+ id: string,
24
+ opts: { realm?: 'main' | 'canvas'; readOnly?: boolean } = {}
25
+ ): RoomConn & { recv: Uint8Array[] } {
26
+ const recv: Uint8Array[] = [];
27
+ return {
28
+ id,
29
+ ...(opts.realm ? { realm: opts.realm } : {}),
30
+ ...(opts.readOnly !== undefined ? { readOnly: opts.readOnly } : {}),
31
+ send(payload: Uint8Array) {
32
+ recv.push(payload);
33
+ },
34
+ recv,
35
+ };
36
+ }
37
+
38
+ function makeCallbacks(): RoomCallbacks {
39
+ return {
40
+ seed() {},
41
+ persistJson() {},
42
+ persistBinary() {},
43
+ };
44
+ }
45
+
46
+ function syncUpdateFrame(update: Uint8Array): Uint8Array {
47
+ const encoder = encoding.createEncoder();
48
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
49
+ writeUpdate(encoder, update);
50
+ return encoding.toUint8Array(encoder);
51
+ }
52
+
53
+ function peerDelta(room: { doc: Y.Doc }, mutate: (doc: Y.Doc) => void): Uint8Array {
54
+ const peer = new Y.Doc();
55
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(room.doc));
56
+ const before = Y.encodeStateVector(peer);
57
+ mutate(peer);
58
+ return Y.encodeStateAsUpdate(peer, before);
59
+ }
60
+
61
+ describe('room read-only gate — cloud viewer sockets', () => {
62
+ test('a read-only canvas conn may write COMMENTS (the viewer capability)', async () => {
63
+ const room = createRoom('viewer-comments', makeCallbacks());
64
+ const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
65
+ await room.connect(viewer);
66
+
67
+ room.receive(
68
+ viewer,
69
+ syncUpdateFrame(
70
+ peerDelta(room, (d) => d.getArray('comments').push([{ id: 'c1', text: 'a viewer note' }]))
71
+ )
72
+ );
73
+
74
+ expect(room.doc.getArray('comments').toArray()).toEqual([{ id: 'c1', text: 'a viewer note' }]);
75
+ expect(room.gateRefusals()).toBe(0);
76
+ await room.destroy();
77
+ });
78
+
79
+ test('a read-only conn may NOT write annotations — refused, doc untouched, not broadcast', async () => {
80
+ const room = createRoom('viewer-annotations', makeCallbacks());
81
+ const editor = makeConn('editor', { realm: 'canvas', readOnly: false });
82
+ const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
83
+ await room.connect(editor);
84
+ await room.connect(viewer);
85
+ editor.recv.length = 0;
86
+ viewer.recv.length = 0;
87
+
88
+ room.receive(
89
+ viewer,
90
+ syncUpdateFrame(peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg/>')))
91
+ );
92
+
93
+ expect(room.doc.getMap('annotations').get('svg')).toBeUndefined();
94
+ expect(room.gateRefusals()).toBe(1);
95
+ expect(editor.recv.length).toBe(0);
96
+ // The refused peer got a corrective full-state frame instead.
97
+ expect(viewer.recv.length).toBe(1);
98
+ await room.destroy();
99
+ });
100
+
101
+ test('a read-only conn may not write body lanes either — even on the main realm', async () => {
102
+ const room = createRoom('viewer-body', makeCallbacks());
103
+ const viewer = makeConn('viewer', { realm: 'main', readOnly: true });
104
+ await room.connect(viewer);
105
+
106
+ room.receive(
107
+ viewer,
108
+ syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'PWNED')))
109
+ );
110
+
111
+ expect(room.doc.getText('html').toString()).toBe('');
112
+ expect(room.gateRefusals()).toBe(1);
113
+ await room.destroy();
114
+ });
115
+
116
+ test('a WRITABLE canvas conn (an owner through the cloud lane) still annotates', async () => {
117
+ // The reason the role claim exists at all: annotations are exactly what the
118
+ // cloud complaint was about, and they need an editor-or-better socket.
119
+ const room = createRoom('owner-annotates', makeCallbacks());
120
+ const owner = makeConn('owner', { realm: 'canvas', readOnly: false });
121
+ await room.connect(owner);
122
+
123
+ room.receive(
124
+ owner,
125
+ syncUpdateFrame(
126
+ peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg><rect/></svg>'))
127
+ )
128
+ );
129
+
130
+ expect(room.doc.getMap('annotations').get('svg')).toBe('<svg><rect/></svg>');
131
+ expect(room.gateRefusals()).toBe(0);
132
+ await room.destroy();
133
+ });
134
+
135
+ test('awareness frames from a read-only conn pass — presence for viewers is the point', async () => {
136
+ const room = createRoom('viewer-presence', makeCallbacks());
137
+ const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
138
+ await room.connect(viewer);
139
+
140
+ const peerDoc = new Y.Doc();
141
+ const { Awareness, encodeAwarenessUpdate } = await import('y-protocols/awareness');
142
+ const peerAwareness = new Awareness(peerDoc);
143
+ peerAwareness.setLocalState({ name: 'viewing member' });
144
+ const encoder = encoding.createEncoder();
145
+ encoding.writeVarUint(encoder, 1 /* MESSAGE_AWARENESS */);
146
+ encoding.writeVarUint8Array(
147
+ encoder,
148
+ encodeAwarenessUpdate(peerAwareness, [peerAwareness.clientID])
149
+ );
150
+ room.receive(viewer, encoding.toUint8Array(encoder));
151
+
152
+ expect(room.awareness.getStates().get(peerAwareness.clientID)).toEqual({
153
+ name: 'viewing member',
154
+ });
155
+ expect(room.gateRefusals()).toBe(0);
156
+ await room.destroy();
157
+ });
158
+
159
+ test('a mixed frame (comment + annotation) from a read-only conn is refused wholesale', async () => {
160
+ const room = createRoom('viewer-mixed', makeCallbacks());
161
+ const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
162
+ await room.connect(viewer);
163
+
164
+ room.receive(
165
+ viewer,
166
+ syncUpdateFrame(
167
+ peerDelta(room, (d) => {
168
+ d.getArray('comments').push([{ id: 'decoy' }]);
169
+ d.getMap('annotations').set('svg', '<svg/>');
170
+ })
171
+ )
172
+ );
173
+
174
+ expect(room.gateRefusals()).toBe(1);
175
+ expect(room.doc.getArray('comments').length).toBe(0);
176
+ expect(room.doc.getMap('annotations').get('svg')).toBeUndefined();
177
+ await room.destroy();
178
+ });
179
+
180
+ test('absent readOnly defaults to writable — pre-existing loopback callers unchanged', async () => {
181
+ const room = createRoom('legacy-writable', makeCallbacks());
182
+ const legacy = makeConn('legacy', { realm: 'canvas' });
183
+ await room.connect(legacy);
184
+
185
+ room.receive(
186
+ legacy,
187
+ syncUpdateFrame(peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg/>')))
188
+ );
189
+
190
+ expect(room.doc.getMap('annotations').get('svg')).toBe('<svg/>');
191
+ expect(room.gateRefusals()).toBe(0);
192
+ await room.destroy();
193
+ });
194
+ });
@@ -0,0 +1,135 @@
1
+ // The engine a CELL actually runs — Cloud Phase 27 D2.
2
+ //
3
+ // WHY THIS FILE EXISTS. `MAUDE_USE_SYSTEM_GIT=1` used to be an escape hatch
4
+ // nobody set, so `commitSystem` and the system-git halves of checkout / branch /
5
+ // discard / fold had no coverage at all while their isomorphic-git twins had
6
+ // plenty. D2 makes every cloud tenant's saves go through exactly those halves
7
+ // (studio-child.mjs pins it, because two git engines over one index is not a
8
+ // race a careful caller can avoid — it is two programs writing one file).
9
+ //
10
+ // Shipping that flip while its code path was the untested one would be trading a
11
+ // known bug for an unmeasured one. These are the same assertions the iso engine
12
+ // already gets, run against the engine the cloud uses.
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ import {
20
+ gitCheckout,
21
+ gitCommit,
22
+ gitCreateBranch,
23
+ gitDiscard,
24
+ gitListBranches,
25
+ gitStatus,
26
+ } from '../git/service.ts';
27
+
28
+ let dir: string;
29
+ let saved: string | undefined;
30
+
31
+ function run(args: string[], cwd: string) {
32
+ const r = Bun.spawnSync(['git', ...args], { cwd });
33
+ return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
34
+ }
35
+
36
+ beforeEach(() => {
37
+ saved = process.env.MAUDE_USE_SYSTEM_GIT;
38
+ // The whole point: the flag is read LIVE, so it can be scoped to this file
39
+ // instead of leaking into every other suite through a module-load const.
40
+ process.env.MAUDE_USE_SYSTEM_GIT = '1';
41
+
42
+ dir = mkdtempSync(join(tmpdir(), 'maude-system-git-'));
43
+ run(['init', '-b', 'main'], dir);
44
+ run(['config', 'user.name', 'Test'], dir);
45
+ run(['config', 'user.email', 'test@example.com'], dir);
46
+ mkdirSync(join(dir, '.design', 'ui'), { recursive: true });
47
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 1;\n');
48
+ run(['add', '-A'], dir);
49
+ run(['commit', '-m', 'seed'], dir);
50
+ });
51
+
52
+ afterEach(() => {
53
+ if (saved === undefined) {
54
+ delete process.env.MAUDE_USE_SYSTEM_GIT;
55
+ } else process.env.MAUDE_USE_SYSTEM_GIT = saved;
56
+ rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ describe('the system-git engine a cell runs on', () => {
60
+ test('a save commits exactly the selected file and nothing else', async () => {
61
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 2;\n');
62
+ writeFileSync(join(dir, '.design', 'ui', 'Other.tsx'), 'export const other = 1;\n');
63
+
64
+ const res = await gitCommit(dir, 'update home', ['.design/ui/Home.tsx'], {
65
+ designPrefix: '.design',
66
+ });
67
+ expect(res.ok).toBe(true);
68
+
69
+ // The other file is still uncommitted — "Save selected" must mean selected.
70
+ const after = await gitStatus(dir, { designPrefix: '.design' });
71
+ expect(after.files.map((f) => f.path)).toEqual(['.design/ui/Other.tsx']);
72
+ });
73
+
74
+ test('save-all commits every change in scope', async () => {
75
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 3;\n');
76
+ writeFileSync(join(dir, '.design', 'ui', 'New.tsx'), 'export const n = 1;\n');
77
+
78
+ const res = await gitCommit(dir, 'save all', undefined, { designPrefix: '.design' });
79
+ expect(res.ok).toBe(true);
80
+ expect((await gitStatus(dir, { designPrefix: '.design' })).clean).toBe(true);
81
+ });
82
+
83
+ test('nothing to save is a refusal, not an empty commit', async () => {
84
+ const before = run(['rev-list', '--count', 'HEAD'], dir).stdout.trim();
85
+ const res = await gitCommit(dir, 'nothing', undefined, { designPrefix: '.design' });
86
+ expect(res.ok).toBe(false);
87
+ expect(run(['rev-list', '--count', 'HEAD'], dir).stdout.trim()).toBe(before);
88
+ });
89
+
90
+ test('discard restores a tracked file and deletes an untracked one', async () => {
91
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'wrecked\n');
92
+ writeFileSync(join(dir, '.design', 'ui', 'Scratch.tsx'), 'temporary\n');
93
+
94
+ const res = await gitDiscard(dir, ['.design/ui/Home.tsx', '.design/ui/Scratch.tsx'], {
95
+ designPrefix: '.design',
96
+ });
97
+ expect(res.ok).toBe(true);
98
+ expect(readFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'utf8')).toBe(
99
+ 'export const v = 1;\n'
100
+ );
101
+ expect(existsSync(join(dir, '.design', 'ui', 'Scratch.tsx'))).toBe(false);
102
+ });
103
+
104
+ test('a draft is created, switched to, and switched back', async () => {
105
+ const made = await gitCreateBranch(dir, 'nav-redesign');
106
+ expect(made.ok).toBe(true);
107
+ expect(run(['rev-parse', '--abbrev-ref', 'HEAD'], dir).stdout.trim()).toBe('nav-redesign');
108
+
109
+ const back = await gitCheckout(dir, 'main');
110
+ expect(back.ok).toBe(true);
111
+ expect(run(['rev-parse', '--abbrev-ref', 'HEAD'], dir).stdout.trim()).toBe('main');
112
+
113
+ expect((await gitListBranches(dir)).map((b) => b.name).sort()).toEqual([
114
+ 'main',
115
+ 'nav-redesign',
116
+ ]);
117
+ });
118
+
119
+ test('switching with unsaved changes says so instead of losing them', async () => {
120
+ await gitCreateBranch(dir, 'draft');
121
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 99;\n');
122
+ await gitCommit(dir, 'draft edit', undefined, { designPrefix: '.design' });
123
+ await gitCheckout(dir, 'main');
124
+
125
+ // Now dirty on main in a way the switch would clobber.
126
+ writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 1000;\n');
127
+ const res = await gitCheckout(dir, 'draft');
128
+ expect(res.ok).toBe(false);
129
+ expect(res.error).toContain('Save your changes');
130
+ // The unsaved bytes are still there — the refusal is the point.
131
+ expect(readFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'utf8')).toBe(
132
+ 'export const v = 1000;\n'
133
+ );
134
+ });
135
+ });
@@ -76,6 +76,14 @@ describe('read-only gate — viewer role refuses project writes locally', () =>
76
76
  ['/_api/fs-mkdir', 'POST'],
77
77
  ['/_api/annotations', 'PUT'],
78
78
  ['/_api/acp/chat', 'POST'],
79
+ // Cloud Phase 27 D2 — both USED to be on READ_ONLY_ALLOWED_WRITES, on the
80
+ // reasoning that "viewing another branch is not changing one". Both
81
+ // rewrite the working TREE, and the tree is shared: a viewer switching
82
+ // branches replaces the files under whoever is mid-edit. The cell's proxy
83
+ // refuses them too, but this is the gate the hub-linked desktop and the
84
+ // self-host path consult, where no manifest runs at all.
85
+ ['/_api/git/checkout', 'POST'],
86
+ ['/_api/git/pull', 'POST'],
79
87
  ] as const) {
80
88
  const r = await fetch(base + path, { method, body: '{}' });
81
89
  expect(`${path} ${r.status}`).toBe(`${path} 403`);
@@ -0,0 +1,146 @@
1
+ // Cloud Phase 27 D2 — the concurrency test the plan asks for by name:
2
+ // "a concurrency test that runs an autocommit against a live canvas write."
3
+ //
4
+ // REAL GIT, REAL FILES, REAL CONTENTION. Everything else in this suite injects
5
+ // its git runner, which is right for asserting what the code MEANS to do and
6
+ // useless for asserting what two processes over one index actually DO. The
7
+ // failure this exists to catch does not reproduce against a fake: it is
8
+ // `.git/index.lock` colliding, or a commit landing between another commit's
9
+ // `add` and its `commit`.
10
+ //
11
+ // What is asserted, in the order it matters:
12
+ // 1. NOTHING IS LOST. Every byte written during the storm is either committed
13
+ // or still in the working tree at the end. That is the tenant's data.
14
+ // 2. THE REPO IS NOT CORRUPT. `git fsck` and a clean `git status` afterwards.
15
+ // 3. NO LOCK IS LEFT BEHIND. Neither git's nor ours — a leaked lock is a
16
+ // history that silently stops.
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { REPO_LOCK_FILE, withRepoLock } from '../git/repo-lock.ts';
24
+ import { createAutoCommit } from '../sync/autocommit.ts';
25
+
26
+ /** The same shape the cell uses on both sides: shell out to system git. */
27
+ async function run(args: string[], { cwd }: { cwd: string }) {
28
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
29
+ const [stdout, stderr] = await Promise.all([
30
+ new Response(proc.stdout).text(),
31
+ new Response(proc.stderr).text(),
32
+ ]);
33
+ return { code: await proc.exited, stdout, stderr };
34
+ }
35
+
36
+ async function makeRepo(): Promise<string> {
37
+ const root = mkdtempSync(join(tmpdir(), 'maude-repo-concurrency-'));
38
+ await run(['init', '-b', 'main'], { cwd: root });
39
+ await run(['config', 'user.name', 'Test'], { cwd: root });
40
+ await run(['config', 'user.email', 'test@example.com'], { cwd: root });
41
+ mkdirSync(join(root, '.design', 'ui'), { recursive: true });
42
+ writeFileSync(join(root, '.design', 'ui', 'seed.tsx'), 'export const seed = 0;\n');
43
+ await run(['add', '-A'], { cwd: root });
44
+ await run(['commit', '-m', 'seed'], { cwd: root });
45
+ return root;
46
+ }
47
+
48
+ /** Atomic, exactly like every real writer in this codebase. */
49
+ function writeCanvas(root: string, rel: string, body: string): void {
50
+ const abs = join(root, rel);
51
+ const tmp = `${abs}.tmp`;
52
+ writeFileSync(tmp, body);
53
+ Bun.spawnSync(['mv', tmp, abs]);
54
+ }
55
+
56
+ describe('two writers, one checkout', () => {
57
+ test('an autocommit against a live canvas write loses nothing and corrupts nothing', async () => {
58
+ const root = await makeRepo();
59
+ try {
60
+ const auto = createAutoCommit({
61
+ repoRoot: root,
62
+ run,
63
+ debounceMs: 5,
64
+ log: { warn() {}, error() {}, log() {} },
65
+ });
66
+
67
+ // The storm: twelve rounds of "a designer typed, the doc flushed to disk,
68
+ // the hub noticed" — with a SECOND writer (the studio's own git verbs,
69
+ // here standing in as a lock-taking committer) cutting in throughout.
70
+ const rounds = 12;
71
+ const expected = new Map<string, string>();
72
+ const flights: Promise<unknown>[] = [];
73
+
74
+ for (let i = 0; i < rounds; i++) {
75
+ const rel = `.design/ui/canvas-${i % 4}.tsx`;
76
+ const body = `export const v = ${i};\n`;
77
+ writeCanvas(root, rel, body);
78
+ expected.set(rel, body);
79
+ auto.note(rel, { name: 'Alice', email: 'alice@example.com' });
80
+
81
+ if (i % 3 === 0) {
82
+ // The other process, doing what it does: taking the lock for a whole
83
+ // sequence and running git inside it.
84
+ flights.push(
85
+ withRepoLock(root, 'studio:commit', async () => {
86
+ await run(['add', '--', '.design'], { cwd: root });
87
+ await run(['commit', '-m', `studio ${i}`, '--allow-empty'], { cwd: root });
88
+ })
89
+ );
90
+ }
91
+ if (i % 2 === 0) flights.push(auto.flush());
92
+ await new Promise((r) => setTimeout(r, 3));
93
+ }
94
+
95
+ await Promise.all(flights);
96
+ await auto.flush();
97
+ auto.stop();
98
+
99
+ // 1. NOTHING IS LOST — every last write is on disk with the bytes we wrote.
100
+ for (const [rel, body] of expected) {
101
+ expect(readFileSync(join(root, rel), 'utf8')).toBe(body);
102
+ }
103
+
104
+ // 2. THE REPO IS NOT CORRUPT.
105
+ const fsck = await run(['fsck', '--no-progress'], { cwd: root });
106
+ expect(fsck.code).toBe(0);
107
+ const log = await run(['log', '--oneline'], { cwd: root });
108
+ expect(log.code).toBe(0);
109
+ expect(log.stdout.trim().split('\n').length).toBeGreaterThan(1);
110
+
111
+ // 3. NO LOCK LEFT BEHIND — neither git's nor ours.
112
+ expect(existsSync(join(root, '.git', 'index.lock'))).toBe(false);
113
+ expect(existsSync(join(root, '.git', REPO_LOCK_FILE))).toBe(false);
114
+ } finally {
115
+ rmSync(root, { recursive: true, force: true });
116
+ }
117
+ }, 30_000);
118
+
119
+ test('a commit sequence is never interleaved by another writer', async () => {
120
+ // The half-staged commit, precisely. Without a lock held ACROSS the
121
+ // sequence, the second writer's commit lands between the first's `add` and
122
+ // its `commit` and takes the staged file with it. `index.lock` cannot stop
123
+ // this — it is released the moment `add` returns.
124
+ const root = await makeRepo();
125
+ try {
126
+ const order: string[] = [];
127
+ const sequence = (name: string) =>
128
+ withRepoLock(root, `studio:${name}`, async () => {
129
+ order.push(`${name}:add`);
130
+ await run(['add', '-A'], { cwd: root });
131
+ await new Promise((r) => setTimeout(r, 25));
132
+ order.push(`${name}:commit`);
133
+ await run(['commit', '-m', name, '--allow-empty'], { cwd: root });
134
+ });
135
+
136
+ writeCanvas(root, '.design/ui/a.tsx', 'export const a = 1;\n');
137
+ await Promise.all([sequence('one'), sequence('two')]);
138
+
139
+ // add/commit/add/commit — never add/add/commit/commit.
140
+ expect(order[1]).toBe(order[0].replace(':add', ':commit'));
141
+ expect(order[3]).toBe(order[2].replace(':add', ':commit'));
142
+ } finally {
143
+ rmSync(root, { recursive: true, force: true });
144
+ }
145
+ }, 20_000);
146
+ });