@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.
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +75 -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 +910 -910
- 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 +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- 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-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -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/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -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,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`);
|
|
@@ -128,3 +136,53 @@ describe('read-only gate — viewer role refuses project writes locally', () =>
|
|
|
128
136
|
expect(r.status).not.toBe(403);
|
|
129
137
|
}, 30000);
|
|
130
138
|
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Cloud Phase 27 — the one write a viewer holds.
|
|
142
|
+
//
|
|
143
|
+
// The role matrix has said `viewer.comment === true` since Phase 25 C4 and the
|
|
144
|
+
// People page promises it in those words. This list did not have it, so a
|
|
145
|
+
// viewer's comment was allowed by the authority (the cell's proxy) and then
|
|
146
|
+
// refused by the defence-in-depth layer behind it. Found by commenting as a
|
|
147
|
+
// viewer against a real cell, not by reading either file.
|
|
148
|
+
|
|
149
|
+
describe('a viewer may comment — the matrix says so, and so must this gate', () => {
|
|
150
|
+
test('comment + reply pass; annotate still does not', async () => {
|
|
151
|
+
const { base } = await boot('viewer');
|
|
152
|
+
|
|
153
|
+
const comment = await fetch(`${base}/_comments`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', text: 'looks off to me' }),
|
|
157
|
+
});
|
|
158
|
+
expect(comment.status).not.toBe(403);
|
|
159
|
+
|
|
160
|
+
const reply = await fetch(`${base}/_api/comments/abc123/reply`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'content-type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ text: 'agreed' }),
|
|
164
|
+
});
|
|
165
|
+
expect(reply.status).not.toBe(403);
|
|
166
|
+
|
|
167
|
+
// An annotation is drawn ON the design and versioned with it — the matrix
|
|
168
|
+
// files it with `edit`, and that distinction has to survive this change.
|
|
169
|
+
const annotate = await fetch(`${base}/_api/annotations`, {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', svg: '<svg/>' }),
|
|
173
|
+
});
|
|
174
|
+
expect(annotate.status).toBe(403);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the reply pattern does not open the whole comment namespace', async () => {
|
|
178
|
+
const { base } = await boot('viewer');
|
|
179
|
+
for (const path of [
|
|
180
|
+
'/_api/comments/abc/delete',
|
|
181
|
+
'/_api/comments/abc/reply/../../edit-text',
|
|
182
|
+
'/_api/comments//reply',
|
|
183
|
+
]) {
|
|
184
|
+
const res = await fetch(`${base}${path}`, { method: 'POST' });
|
|
185
|
+
expect(res.status).not.toBe(200);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -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
|
+
});
|