amalgm 0.1.158 → 0.1.159

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.
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // One owner per file (workspace-sync.md §1): a git-tracked file syncs
4
+ // through git, and the doc rail must refuse to promote it. Untracked
5
+ // files inside a repo and files outside any repo have no git owner and
6
+ // promote as before.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { execFile } = require('child_process');
14
+ const { promisify } = require('util');
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-promotion-ownership-test-'));
19
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
20
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
21
+ process.env.AMALGM_COMPUTER_ID = 'machine-test';
22
+ process.env.AMALGM_DEVICE_ID = 'device-machine-test';
23
+
24
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
25
+ fs.writeFileSync(
26
+ path.join(process.env.AMALGM_DIR, 'computer.json'),
27
+ JSON.stringify({ computer_id: 'machine-test', user_id: '11111111-1111-4111-8111-111111111111' }),
28
+ );
29
+
30
+ const { closeLocalDb } = require('../state/db');
31
+ const { handlePromotionStart } = require('../state/shared-rest');
32
+
33
+ test.after(() => {
34
+ closeLocalDb();
35
+ fs.rmSync(tempRoot, { recursive: true, force: true });
36
+ });
37
+
38
+ async function run(cwd, ...args) {
39
+ await execFileAsync(
40
+ 'git',
41
+ ['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
42
+ { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
43
+ );
44
+ }
45
+
46
+ /** Run the handler, capture what it would send over the wire. */
47
+ async function promote(filePath) {
48
+ let sent;
49
+ await handlePromotionStart({ path: filePath }, (statusCode, payload) => {
50
+ sent = { statusCode, payload };
51
+ });
52
+ return sent;
53
+ }
54
+
55
+ test('a git-tracked file is refused: git owns it', async () => {
56
+ const repo = path.join(tempRoot, 'repo');
57
+ fs.mkdirSync(repo, { recursive: true });
58
+ await run(repo, 'init', '-b', 'main');
59
+ const tracked = path.join(repo, 'README.md');
60
+ fs.writeFileSync(tracked, '# tracked\n');
61
+ await run(repo, 'add', '.');
62
+ await run(repo, 'commit', '-m', 'initial');
63
+
64
+ const { statusCode, payload } = await promote(tracked);
65
+ assert.equal(statusCode, 409);
66
+ assert.equal(payload.code, 'file_git_owned');
67
+ assert.match(payload.error, /tracked by the repository at /);
68
+ assert.ok(payload.error.includes(fs.realpathSync(repo)), 'the error names the owning repo');
69
+ });
70
+
71
+ test('an untracked file inside a repo has no git owner and promotes', async () => {
72
+ const repo = path.join(tempRoot, 'repo');
73
+ const untracked = path.join(repo, 'scratch-notes.md');
74
+ fs.writeFileSync(untracked, '# scratch\n');
75
+
76
+ const { statusCode, payload } = await promote(untracked);
77
+ assert.equal(statusCode, 202, JSON.stringify(payload));
78
+ });
79
+
80
+ test('a file outside any repository promotes', async () => {
81
+ const loose = path.join(tempRoot, 'loose.md');
82
+ fs.writeFileSync(loose, '# loose\n');
83
+
84
+ const { statusCode, payload } = await promote(loose);
85
+ assert.equal(statusCode, 202, JSON.stringify(payload));
86
+ });
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFile } = require('child_process');
9
+ const { promisify } = require('util');
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-cards-store-test-'));
14
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
15
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
16
+ process.env.AMALGM_COMPUTER_ID = 'computer-test';
17
+
18
+ const { closeLocalDb, openLocalDb } = require('../state/db');
19
+ const { upsertWorkspace, removeWorkspace } = require('../workspace/store');
20
+ const {
21
+ listRepoCards,
22
+ refreshRepoCards,
23
+ repoCardServiceStats,
24
+ startRepoCardService,
25
+ stopRepoCardService,
26
+ } = require('../workspace/cards');
27
+
28
+ test.after(() => {
29
+ stopRepoCardService();
30
+ closeLocalDb();
31
+ fs.rmSync(tempRoot, { recursive: true, force: true });
32
+ });
33
+
34
+ async function run(cwd, ...args) {
35
+ await execFileAsync(
36
+ 'git',
37
+ ['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
38
+ { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
39
+ );
40
+ }
41
+
42
+ async function makeRepo(name) {
43
+ const dir = path.join(tempRoot, name);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ await run(dir, 'init', '-b', 'main');
46
+ fs.writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`);
47
+ await run(dir, 'add', '.');
48
+ await run(dir, 'commit', '-m', 'initial');
49
+ return dir;
50
+ }
51
+
52
+ function cardEvents() {
53
+ return openLocalDb()
54
+ .prepare(`SELECT * FROM event_log WHERE resource = 'repo-cards' ORDER BY seq`)
55
+ .all();
56
+ }
57
+
58
+ function findCard(cards, dir) {
59
+ const suffix = path.sep + path.basename(dir);
60
+ return cards.find((card) => card.path.endsWith(suffix));
61
+ }
62
+
63
+ test('refresh cards the tracked world once, and a no-change refresh writes nothing', async () => {
64
+ const projects = path.join(tempRoot, 'projects');
65
+ fs.mkdirSync(projects, { recursive: true });
66
+ const repo = await makeRepo('projects/app');
67
+ upsertWorkspace({ path: projects, name: 'Projects', source: 'computer' });
68
+
69
+ const first = await refreshRepoCards();
70
+ assert.equal(first.stats.changed, 1);
71
+ assert.equal(first.rootErrors.length, 0);
72
+ assert.equal(first.cards.length, 1);
73
+ assert.equal(first.cards[0].path, await fs.promises.realpath(repo));
74
+ assert.equal(first.cards[0].health, 'healthy');
75
+ assert.equal(first.cards[0].machine, 'computer-test');
76
+ assert.equal(first.cards[0].generation, 1);
77
+ const eventsAfterFirst = cardEvents();
78
+ assert.equal(eventsAfterFirst.length, 1);
79
+ assert.match(eventsAfterFirst[0].id, /^rpl_/);
80
+
81
+ const second = await refreshRepoCards();
82
+ assert.equal(second.stats.changed, 0);
83
+ assert.equal(second.cards[0].generation, 1);
84
+ assert.equal(second.cards[0].updatedAt, first.cards[0].updatedAt);
85
+ assert.equal(cardEvents().length, eventsAfterFirst.length);
86
+ });
87
+
88
+ test('a 4,000-file branch switch is exactly one generation bump and one event', async () => {
89
+ const repo = path.join(tempRoot, 'projects/big');
90
+ fs.mkdirSync(repo, { recursive: true });
91
+ await run(repo, 'init', '-b', 'main');
92
+ fs.writeFileSync(path.join(repo, 'README.md'), '# big\n');
93
+ await run(repo, 'add', '.');
94
+ await run(repo, 'commit', '-m', 'initial');
95
+ await run(repo, 'checkout', '-b', 'heavy');
96
+ for (let i = 0; i < 4000; i++) {
97
+ const dir = path.join(repo, 'gen', String(i % 40));
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ fs.writeFileSync(path.join(dir, `file-${i}.txt`), `${i}\n`);
100
+ }
101
+ await run(repo, 'add', '.');
102
+ await run(repo, 'commit', '-m', 'four thousand files');
103
+
104
+ const before = await refreshRepoCards();
105
+ const bigBefore = findCard(before.cards, repo);
106
+ const eventsBefore = cardEvents().length;
107
+
108
+ await run(repo, 'checkout', 'main');
109
+ const after = await refreshRepoCards();
110
+ const bigAfter = findCard(after.cards, repo);
111
+
112
+ assert.equal(bigAfter.replica, bigBefore.replica);
113
+ assert.equal(bigAfter.generation, bigBefore.generation + 1);
114
+ assert.equal(after.stats.changed, 1);
115
+ assert.equal(cardEvents().length, eventsBefore + 1);
116
+ assert.equal(bigAfter.head.branch, 'main');
117
+ assert.ok(JSON.stringify(bigAfter).length < 2048, 'a card stays small no matter the checkout size');
118
+ });
119
+
120
+ test('two workspaces reaching the same repo still mean one card', async () => {
121
+ const repo = path.join(tempRoot, 'projects/app');
122
+ upsertWorkspace({ path: repo, name: 'App direct', source: 'computer' });
123
+
124
+ const { cards } = await refreshRepoCards();
125
+ assert.equal(cards.filter((card) => card.path === fs.realpathSync(repo)).length, 1);
126
+ removeWorkspace(repo);
127
+ await refreshRepoCards();
128
+ });
129
+
130
+ test('a vanished repo goes missing — the card survives to say so, and returns healthy', async () => {
131
+ const repo = await makeRepo('projects/vanishing');
132
+ const parked = path.join(tempRoot, 'parked-vanishing');
133
+ const { cards } = await refreshRepoCards();
134
+ const card = findCard(cards, repo);
135
+ assert.equal(card.health, 'healthy');
136
+
137
+ fs.renameSync(repo, parked);
138
+ const gone = await refreshRepoCards();
139
+ const missing = findCard(gone.cards, repo);
140
+ assert.equal(missing.health, 'missing');
141
+ assert.equal(missing.replica, card.replica);
142
+ assert.equal(missing.generation, card.generation + 1);
143
+ assert.equal(missing.head.branch, 'main', 'the last good observation rides with the health');
144
+ assert.equal(gone.stats.healthChanged, 1);
145
+ assert.equal(gone.stats.removed, 0);
146
+
147
+ fs.renameSync(parked, repo);
148
+ const back = await refreshRepoCards();
149
+ const healthy = findCard(back.cards, repo);
150
+ assert.equal(healthy.health, 'healthy');
151
+ assert.equal(healthy.replica, card.replica, 'the same checkout at the same git dir is the same replica');
152
+ });
153
+
154
+ test('a de-gitted repo goes broken and heals when its .git returns', async () => {
155
+ const repo = await makeRepo('projects/degit');
156
+ await refreshRepoCards();
157
+
158
+ const gitDir = path.join(repo, '.git');
159
+ const parked = path.join(tempRoot, 'parked-degit');
160
+ fs.renameSync(gitDir, parked);
161
+ const brokenPass = await refreshRepoCards();
162
+ const broken = findCard(brokenPass.cards, repo);
163
+ assert.equal(broken.health, 'broken');
164
+
165
+ fs.renameSync(parked, gitDir);
166
+ const healed = await refreshRepoCards();
167
+ assert.equal(findCard(healed.cards, repo).health, 'healthy');
168
+ });
169
+
170
+ test('untracking is the only human act that removes cards', async () => {
171
+ const repo = await makeRepo('solo-tracked');
172
+ upsertWorkspace({ path: repo, name: 'Solo', source: 'computer' });
173
+ const { cards } = await refreshRepoCards();
174
+ const card = findCard(cards, repo);
175
+ assert.equal(card.health, 'healthy');
176
+
177
+ removeWorkspace(repo);
178
+ const eventsBefore = cardEvents().length;
179
+ const after = await refreshRepoCards();
180
+ assert.equal(findCard(after.cards, repo), undefined);
181
+ assert.equal(after.stats.removed, 1);
182
+ const deleteEvent = cardEvents().at(-1);
183
+ assert.equal(deleteEvent.op, 'delete');
184
+ assert.equal(deleteEvent.id, card.replica);
185
+ assert.equal(cardEvents().length, eventsBefore + 1);
186
+ });
187
+
188
+ test('git settling a worktree removal removes its card — the primary stays', async () => {
189
+ const repo = await makeRepo('projects/wt-owner');
190
+ const worktreePath = path.join(tempRoot, 'outside-wt');
191
+ await run(repo, 'worktree', 'add', worktreePath, '-b', 'ride-along');
192
+
193
+ const { cards } = await refreshRepoCards();
194
+ const linked = cards.find((card) => card.path === fs.realpathSync(worktreePath));
195
+ assert.equal(linked.kind, 'linked-worktree');
196
+
197
+ await run(repo, 'worktree', 'remove', worktreePath);
198
+ const after = await refreshRepoCards();
199
+ assert.equal(after.cards.some((card) => card.replica === linked.replica), false);
200
+ assert.equal(after.stats.removed, 1);
201
+ assert.equal(findCard(after.cards, repo).health, 'healthy');
202
+ });
203
+
204
+ test('one unreadable root cannot blind the map or settle deletions', async () => {
205
+ const rootA = path.join(tempRoot, 'root-a');
206
+ const rootB = path.join(tempRoot, 'root-b');
207
+ fs.mkdirSync(rootA, { recursive: true });
208
+ fs.mkdirSync(rootB, { recursive: true });
209
+ const repoA = await makeRepo('root-a/alpha');
210
+ const repoB = await makeRepo('root-b/beta');
211
+ upsertWorkspace({ path: rootA, name: 'Root A', source: 'computer' });
212
+ upsertWorkspace({ path: rootB, name: 'Root B', source: 'computer' });
213
+ const seeded = await refreshRepoCards();
214
+ assert.ok(findCard(seeded.cards, repoA));
215
+ assert.ok(findCard(seeded.cards, repoB));
216
+
217
+ fs.rmSync(rootA, { recursive: true, force: true });
218
+ const blinded = await refreshRepoCards();
219
+ assert.equal(blinded.rootErrors.length, 1);
220
+ assert.equal(blinded.stats.removed, 0, 'a blind pass never settles an untrack');
221
+ assert.equal(findCard(blinded.cards, repoA).health, 'missing');
222
+ assert.equal(findCard(blinded.cards, repoB).health, 'healthy');
223
+
224
+ removeWorkspace(rootA);
225
+ removeWorkspace(rootB);
226
+ const cleaned = await refreshRepoCards();
227
+ assert.equal(findCard(cleaned.cards, repoA), undefined, 'untracking settles even a missing card');
228
+ assert.equal(findCard(cleaned.cards, repoB), undefined);
229
+ });
230
+
231
+ test('concurrent refresh calls coalesce: one pass runs, one follow-up settles everyone', async () => {
232
+ const before = repoCardServiceStats().refreshes;
233
+ const results = await Promise.all([
234
+ refreshRepoCards(),
235
+ refreshRepoCards(),
236
+ refreshRepoCards(),
237
+ refreshRepoCards(),
238
+ refreshRepoCards(),
239
+ ]);
240
+ const after = repoCardServiceStats().refreshes;
241
+ assert.ok(after - before <= 2, `five concurrent calls ran ${after - before} passes`);
242
+ for (const result of results) assert.ok(Array.isArray(result.cards));
243
+ });
244
+
245
+ test('losing one of two coverings never turns a vanished repo into an erasure', async () => {
246
+ // A repo registered directly AND through its parent folder stays covered
247
+ // when the direct registration goes away. Deleting the repo after that
248
+ // must read as `missing` — the surviving covering still tracks it.
249
+ const garage = path.join(tempRoot, 'garage');
250
+ fs.mkdirSync(garage, { recursive: true });
251
+ const repo = await makeRepo('garage/coupe');
252
+ upsertWorkspace({ path: repo, name: 'Coupe direct', source: 'computer' });
253
+ upsertWorkspace({ path: garage, name: 'Garage', source: 'computer' });
254
+ const seeded = await refreshRepoCards();
255
+ const card = findCard(seeded.cards, repo);
256
+ assert.equal(card.health, 'healthy');
257
+
258
+ removeWorkspace(repo);
259
+ await refreshRepoCards();
260
+ const recordedRoot = openLocalDb()
261
+ .prepare('SELECT root FROM repo_cards WHERE replica_id = ?')
262
+ .get(card.replica).root;
263
+ assert.equal(recordedRoot, garage, 'coverage bookkeeping follows the surviving registration');
264
+
265
+ fs.rmSync(repo, { recursive: true, force: true });
266
+ const gone = await refreshRepoCards();
267
+ assert.equal(gone.rootErrors.length, 0);
268
+ assert.equal(gone.stats.removed, 0, 'a covered repo must never be deleted for vanishing');
269
+ assert.equal(findCard(gone.cards, repo).health, 'missing');
270
+
271
+ removeWorkspace(garage);
272
+ const settled = await refreshRepoCards();
273
+ assert.equal(findCard(settled.cards, repo), undefined, 'untracking still settles the card');
274
+ });
275
+
276
+ test('coverage shift and deletion inside a single pass still reads as missing', async () => {
277
+ // Same story, but the registration change and the deletion both land
278
+ // between two refreshes — no intermediate pass ever repaired the
279
+ // bookkeeping. The absence decision must judge coverage against the
280
+ // registrations of today, not against what was recorded at write time.
281
+ const depot = path.join(tempRoot, 'depot');
282
+ fs.mkdirSync(depot, { recursive: true });
283
+ const repo = await makeRepo('depot/wagon');
284
+ // Named to sort ahead of 'Depot' (roots scan in name order), so the
285
+ // direct registration is the one recorded as the card's covering root.
286
+ upsertWorkspace({ path: repo, name: 'A wagon, directly', source: 'computer' });
287
+ upsertWorkspace({ path: depot, name: 'Depot', source: 'computer' });
288
+ const seeded = await refreshRepoCards();
289
+ assert.equal(findCard(seeded.cards, repo).health, 'healthy');
290
+
291
+ removeWorkspace(repo);
292
+ fs.rmSync(repo, { recursive: true, force: true });
293
+ const gone = await refreshRepoCards();
294
+ assert.equal(gone.rootErrors.length, 0);
295
+ assert.equal(gone.stats.removed, 0);
296
+ assert.equal(findCard(gone.cards, repo).health, 'missing');
297
+
298
+ removeWorkspace(depot);
299
+ const settled = await refreshRepoCards();
300
+ assert.equal(findCard(settled.cards, repo), undefined);
301
+ });
302
+
303
+ test('the live service hears a commit through the git-dir doorbell', async () => {
304
+ const repo = await makeRepo('projects/belled');
305
+ const projects = path.join(tempRoot, 'projects');
306
+ upsertWorkspace({ path: projects, name: 'Projects', source: 'computer' });
307
+ startRepoCardService();
308
+ await refreshRepoCards();
309
+ const before = findCard(listRepoCards(), repo);
310
+ assert.equal(before.health, 'healthy');
311
+
312
+ fs.writeFileSync(path.join(repo, 'ding.txt'), 'ding\n');
313
+ await run(repo, 'add', '.');
314
+ await run(repo, 'commit', '-m', 'ring the bell');
315
+
316
+ const deadline = Date.now() + 5_000;
317
+ let after = findCard(listRepoCards(), repo);
318
+ while (after.head.oid === before.head.oid && Date.now() < deadline) {
319
+ await new Promise((resolve) => setTimeout(resolve, 100));
320
+ after = findCard(listRepoCards(), repo);
321
+ }
322
+ stopRepoCardService();
323
+
324
+ assert.notEqual(after.head.oid, before.head.oid, 'the commit must surface without anyone calling refresh');
325
+ assert.ok(repoCardServiceStats().doorbellRings >= 1);
326
+ });