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.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +43 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +35 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +175 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +41 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +20 -1
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +130 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +86 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +326 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +401 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +148 -0
- package/runtime/scripts/amalgm-mcp/tools/shared-live-smoke.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/card.js +199 -0
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +437 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +286 -0
- package/runtime/scripts/amalgm-mcp/workspace/identity.js +88 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +308 -0
|
@@ -0,0 +1,401 @@
|
|
|
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-repo-cards-test-'));
|
|
14
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
15
|
+
process.env.AMALGM_COMPUTER_ID = 'computer-test';
|
|
16
|
+
|
|
17
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
18
|
+
const {
|
|
19
|
+
buildRepoCard,
|
|
20
|
+
cardFingerprint,
|
|
21
|
+
discoverWorktrees,
|
|
22
|
+
remoteIdentity,
|
|
23
|
+
} = require('../workspace/card');
|
|
24
|
+
const { classifyDirectory } = require('../workspace/git');
|
|
25
|
+
|
|
26
|
+
test.after(() => {
|
|
27
|
+
closeLocalDb();
|
|
28
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function run(cwd, ...args) {
|
|
32
|
+
await execFileAsync(
|
|
33
|
+
'git',
|
|
34
|
+
[
|
|
35
|
+
'-c', 'user.email=test@amalgm.test',
|
|
36
|
+
'-c', 'user.name=Amalgm Test',
|
|
37
|
+
'-c', 'commit.gpgsign=false',
|
|
38
|
+
'-c', 'protocol.file.allow=always',
|
|
39
|
+
...args,
|
|
40
|
+
],
|
|
41
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function write(dir, file, content) {
|
|
46
|
+
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(dir, file), content);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let repoCounter = 0;
|
|
51
|
+
async function makeRepo(name, { commit = true } = {}) {
|
|
52
|
+
const dir = path.join(tempRoot, name);
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
await run(dir, 'init', '-b', 'main');
|
|
55
|
+
if (commit) {
|
|
56
|
+
write(dir, 'README.md', `# ${name} ${repoCounter++}\n`);
|
|
57
|
+
await run(dir, 'add', '.');
|
|
58
|
+
await run(dir, 'commit', '-m', 'initial');
|
|
59
|
+
}
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('remoteIdentity folds every clone style onto one https identity', () => {
|
|
64
|
+
const canonical = 'https://github.com/Org/Repo';
|
|
65
|
+
assert.equal(remoteIdentity('git@github.com:Org/Repo.git'), canonical);
|
|
66
|
+
assert.equal(remoteIdentity('https://github.com/Org/Repo.git'), canonical);
|
|
67
|
+
assert.equal(remoteIdentity('https://user:token@GitHub.com/Org/Repo/'), canonical);
|
|
68
|
+
assert.equal(remoteIdentity('ssh://git@github.com:2222/Org/Repo.git'), canonical);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('remoteIdentity never lets a secret or a machine-local name survive', () => {
|
|
72
|
+
// Credentials and tokens are stripped wherever they hide.
|
|
73
|
+
assert.equal(
|
|
74
|
+
remoteIdentity('https://ci:SECRET123@github.com/org/repo.git'),
|
|
75
|
+
'https://github.com/org/repo',
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
remoteIdentity('https://github.com/org/repo?access_token=SECRET123'),
|
|
79
|
+
'https://github.com/org/repo',
|
|
80
|
+
);
|
|
81
|
+
assert.equal(
|
|
82
|
+
remoteIdentity('https://github.com/org/repo.git#token=SECRET123'),
|
|
83
|
+
'https://github.com/org/repo',
|
|
84
|
+
);
|
|
85
|
+
// Machine-local remotes identify nothing off this machine.
|
|
86
|
+
assert.equal(remoteIdentity('file:///Users/dev/repo'), null);
|
|
87
|
+
assert.equal(remoteIdentity('/Users/dev/repo'), null);
|
|
88
|
+
assert.equal(remoteIdentity('../sibling/repo'), null);
|
|
89
|
+
assert.equal(remoteIdentity('~/repos/thing'), null);
|
|
90
|
+
// ssh aliases resolve through this machine's own ssh config.
|
|
91
|
+
assert.equal(remoteIdentity('github-work:org/repo.git'), null);
|
|
92
|
+
assert.equal(remoteIdentity('git@bastion:org/repo.git'), null);
|
|
93
|
+
// scp-like absolute paths on a real host keep their identity.
|
|
94
|
+
assert.equal(remoteIdentity('git@build.example.com:/srv/git/repo.git'), 'https://build.example.com/srv/git/repo');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a clean repo cards with exact head, clean counts, and full identity', async () => {
|
|
98
|
+
const dir = await makeRepo('clean');
|
|
99
|
+
await run(dir, 'remote', 'add', 'origin', 'git@github.com:amalgm/clean.git');
|
|
100
|
+
|
|
101
|
+
const card = await buildRepoCard(dir);
|
|
102
|
+
assert.equal(card.card, 'repo-card@2');
|
|
103
|
+
assert.equal(card.machine, 'computer-test');
|
|
104
|
+
assert.match(card.replica, /^rpl_[A-Za-z0-9_-]+$/);
|
|
105
|
+
assert.equal(card.path, await fs.promises.realpath(dir));
|
|
106
|
+
assert.equal(card.kind, 'primary');
|
|
107
|
+
assert.equal(card.repo.remote, 'https://github.com/amalgm/clean');
|
|
108
|
+
assert.match(card.repo.rootCommit, /^[0-9a-f]{40}$/);
|
|
109
|
+
assert.equal(card.head.branch, 'main');
|
|
110
|
+
assert.match(card.head.oid, /^[0-9a-f]{40}$/);
|
|
111
|
+
assert.equal(card.head.detached, false);
|
|
112
|
+
assert.deepEqual(card.dirty, { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, submodulesChanged: 0 });
|
|
113
|
+
assert.equal(card.operation, 'none');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('observation never runs repo-configured programs, and repo config cannot hide files', async () => {
|
|
117
|
+
const dir = await makeRepo('hostile');
|
|
118
|
+
const spy = path.join(tempRoot, 'fsmonitor-ran');
|
|
119
|
+
const hook = path.join(tempRoot, 'fsmonitor-spy.sh');
|
|
120
|
+
fs.writeFileSync(hook, `#!/bin/sh\ntouch "${spy}"\necho '/'\n`, { mode: 0o755 });
|
|
121
|
+
await run(dir, 'config', 'core.fsmonitor', hook);
|
|
122
|
+
await run(dir, 'config', 'status.showUntrackedFiles', 'no');
|
|
123
|
+
write(dir, 'loose.txt', 'x\n');
|
|
124
|
+
|
|
125
|
+
const card = await buildRepoCard(dir);
|
|
126
|
+
assert.equal(fs.existsSync(spy), false, 'core.fsmonitor must never execute during observation');
|
|
127
|
+
assert.equal(card.dirty.untracked, 1, 'repo config must not hide untracked files from the count');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('replica identity is stable per checkout and new after a move', async () => {
|
|
131
|
+
const dir = await makeRepo('identity');
|
|
132
|
+
const first = await buildRepoCard(dir);
|
|
133
|
+
const second = await buildRepoCard(dir);
|
|
134
|
+
assert.equal(first.replica, second.replica);
|
|
135
|
+
|
|
136
|
+
const movedDir = path.join(tempRoot, 'identity-moved');
|
|
137
|
+
fs.renameSync(dir, movedDir);
|
|
138
|
+
const moved = await buildRepoCard(movedDir);
|
|
139
|
+
assert.notEqual(moved.replica, first.replica, 'a moved checkout is a new replica');
|
|
140
|
+
assert.equal(moved.repo.rootCommit, first.repo.rootCommit, 'same history keeps its identity anchor');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('two worktrees of one repo are two replicas sharing one root commit', async () => {
|
|
144
|
+
const dir = await makeRepo('twin');
|
|
145
|
+
const worktreePath = path.join(tempRoot, 'twin-wt');
|
|
146
|
+
await run(dir, 'worktree', 'add', worktreePath, '-b', 'feature');
|
|
147
|
+
|
|
148
|
+
const main = await buildRepoCard(dir);
|
|
149
|
+
const linked = await buildRepoCard(worktreePath);
|
|
150
|
+
assert.equal(main.kind, 'primary');
|
|
151
|
+
assert.equal(linked.kind, 'linked-worktree');
|
|
152
|
+
assert.equal(linked.head.branch, 'feature');
|
|
153
|
+
assert.notEqual(main.replica, linked.replica);
|
|
154
|
+
assert.equal(main.repo.rootCommit, linked.repo.rootCommit);
|
|
155
|
+
|
|
156
|
+
const fromMain = await discoverWorktrees(dir);
|
|
157
|
+
const fromLinked = await discoverWorktrees(worktreePath);
|
|
158
|
+
const expected = [await fs.promises.realpath(dir), await fs.promises.realpath(worktreePath)].sort();
|
|
159
|
+
assert.deepEqual(fromMain.worktrees, expected);
|
|
160
|
+
assert.deepEqual(fromLinked.worktrees, expected);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the root commit is read from the replica cache, not re-walked per observation', async () => {
|
|
164
|
+
const dir = await makeRepo('anchored');
|
|
165
|
+
const first = await buildRepoCard(dir);
|
|
166
|
+
const sentinel = 'cafe'.repeat(10);
|
|
167
|
+
openLocalDb()
|
|
168
|
+
.prepare('UPDATE repo_replicas SET root_commit = ? WHERE replica_id = ?')
|
|
169
|
+
.run(sentinel, first.replica);
|
|
170
|
+
|
|
171
|
+
const second = await buildRepoCard(dir);
|
|
172
|
+
assert.equal(second.repo.rootCommit, sentinel);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a card is for the worktree root no matter which subdirectory was asked about', async () => {
|
|
176
|
+
const dir = await makeRepo('subdir');
|
|
177
|
+
write(dir, 'src/lib/deep.txt', 'x\n');
|
|
178
|
+
await run(dir, 'add', '.');
|
|
179
|
+
await run(dir, 'commit', '-m', 'deep');
|
|
180
|
+
|
|
181
|
+
const card = await buildRepoCard(path.join(dir, 'src/lib'));
|
|
182
|
+
assert.equal(card.path, await fs.promises.realpath(dir));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('dirty counts are git status entries: renames once, directories once, dual-state files twice', async () => {
|
|
186
|
+
const dir = await makeRepo('dirty');
|
|
187
|
+
write(dir, 'tracked.txt', 'v1\n');
|
|
188
|
+
await run(dir, 'add', '.');
|
|
189
|
+
await run(dir, 'commit', '-m', 'base');
|
|
190
|
+
|
|
191
|
+
write(dir, 'staged.txt', 'staged\n');
|
|
192
|
+
await run(dir, 'add', 'staged.txt');
|
|
193
|
+
write(dir, 'tracked.txt', 'v2\n');
|
|
194
|
+
write(dir, 'loose-1.txt', 'x\n');
|
|
195
|
+
write(dir, 'loose-2.txt', 'y\n');
|
|
196
|
+
|
|
197
|
+
const card = await buildRepoCard(dir);
|
|
198
|
+
assert.deepEqual(card.dirty, { staged: 1, unstaged: 1, untracked: 2, conflicted: 0, submodulesChanged: 0 });
|
|
199
|
+
|
|
200
|
+
// An untracked directory of many files is one status entry, not a census.
|
|
201
|
+
write(dir, 'newdir/a.txt', 'a\n');
|
|
202
|
+
write(dir, 'newdir/b.txt', 'b\n');
|
|
203
|
+
const withDir = await buildRepoCard(dir);
|
|
204
|
+
assert.equal(withDir.dirty.untracked, 3);
|
|
205
|
+
|
|
206
|
+
// A file staged and then edited again counts on both sides.
|
|
207
|
+
write(dir, 'staged.txt', 'staged then edited\n');
|
|
208
|
+
const dual = await buildRepoCard(dir);
|
|
209
|
+
assert.equal(dual.dirty.staged, 1);
|
|
210
|
+
assert.equal(dual.dirty.unstaged, 2);
|
|
211
|
+
|
|
212
|
+
await run(dir, 'mv', 'README.md', 'RENAMED.md');
|
|
213
|
+
const renamed = await buildRepoCard(dir);
|
|
214
|
+
assert.equal(renamed.dirty.staged, 2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('a detached HEAD cards with the exact commit and no branch', async () => {
|
|
218
|
+
const dir = await makeRepo('detached');
|
|
219
|
+
await run(dir, 'checkout', '--detach');
|
|
220
|
+
|
|
221
|
+
const card = await buildRepoCard(dir);
|
|
222
|
+
assert.equal(card.head.branch, null);
|
|
223
|
+
assert.equal(card.head.detached, true);
|
|
224
|
+
assert.match(card.head.oid, /^[0-9a-f]{40}$/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('an unborn repo cards with null oid and null root commit', async () => {
|
|
228
|
+
const dir = await makeRepo('unborn', { commit: false });
|
|
229
|
+
|
|
230
|
+
const card = await buildRepoCard(dir);
|
|
231
|
+
assert.equal(card.head.branch, 'main');
|
|
232
|
+
assert.equal(card.head.oid, null);
|
|
233
|
+
assert.equal(card.repo.rootCommit, null);
|
|
234
|
+
assert.equal(card.dirty.untracked, 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('ahead/behind report against the upstream after local and remote commits', async () => {
|
|
238
|
+
const origin = await makeRepo('ab-origin');
|
|
239
|
+
const dir = path.join(tempRoot, 'ab-clone');
|
|
240
|
+
await run(tempRoot, 'clone', origin, dir);
|
|
241
|
+
|
|
242
|
+
write(dir, 'local.txt', 'local\n');
|
|
243
|
+
await run(dir, 'add', '.');
|
|
244
|
+
await run(dir, 'commit', '-m', 'local work');
|
|
245
|
+
|
|
246
|
+
write(origin, 'remote.txt', 'remote\n');
|
|
247
|
+
await run(origin, 'add', '.');
|
|
248
|
+
await run(origin, 'commit', '-m', 'remote work');
|
|
249
|
+
await run(dir, 'fetch');
|
|
250
|
+
|
|
251
|
+
const card = await buildRepoCard(dir);
|
|
252
|
+
assert.equal(card.head.upstream, 'origin/main');
|
|
253
|
+
assert.equal(card.head.ahead, 1);
|
|
254
|
+
assert.equal(card.head.behind, 1);
|
|
255
|
+
assert.equal(card.repo.remote, null, 'a local-path clone has no transportable remote identity');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a conflicted merge cards as operation=merge with conflicted counts', async () => {
|
|
259
|
+
const dir = await makeRepo('merging');
|
|
260
|
+
write(dir, 'file.txt', 'base\n');
|
|
261
|
+
await run(dir, 'add', '.');
|
|
262
|
+
await run(dir, 'commit', '-m', 'base');
|
|
263
|
+
await run(dir, 'checkout', '-b', 'side');
|
|
264
|
+
write(dir, 'file.txt', 'side\n');
|
|
265
|
+
await run(dir, 'commit', '-am', 'side');
|
|
266
|
+
await run(dir, 'checkout', 'main');
|
|
267
|
+
write(dir, 'file.txt', 'main\n');
|
|
268
|
+
await run(dir, 'commit', '-am', 'main');
|
|
269
|
+
await assert.rejects(run(dir, 'merge', 'side'));
|
|
270
|
+
|
|
271
|
+
const card = await buildRepoCard(dir);
|
|
272
|
+
assert.equal(card.operation, 'merge');
|
|
273
|
+
assert.equal(card.dirty.conflicted, 1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a conflicted cherry-pick cards as operation=cherry-pick', async () => {
|
|
277
|
+
const dir = await makeRepo('picking');
|
|
278
|
+
write(dir, 'file.txt', 'base\n');
|
|
279
|
+
await run(dir, 'add', '.');
|
|
280
|
+
await run(dir, 'commit', '-m', 'base');
|
|
281
|
+
await run(dir, 'checkout', '-b', 'side');
|
|
282
|
+
write(dir, 'file.txt', 'side\n');
|
|
283
|
+
await run(dir, 'commit', '-am', 'side');
|
|
284
|
+
await run(dir, 'checkout', 'main');
|
|
285
|
+
write(dir, 'file.txt', 'main\n');
|
|
286
|
+
await run(dir, 'commit', '-am', 'main');
|
|
287
|
+
await assert.rejects(run(dir, 'cherry-pick', 'side'));
|
|
288
|
+
|
|
289
|
+
const card = await buildRepoCard(dir);
|
|
290
|
+
assert.equal(card.operation, 'cherry-pick');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a submodule cards as kind=submodule when tracked explicitly, and stays out of parent discovery', async () => {
|
|
294
|
+
const inner = await makeRepo('sub-inner');
|
|
295
|
+
const outer = await makeRepo('sub-outer');
|
|
296
|
+
await run(outer, 'submodule', 'add', inner, 'vendored');
|
|
297
|
+
await run(outer, 'commit', '-m', 'add submodule');
|
|
298
|
+
|
|
299
|
+
const submoduleDir = path.join(outer, 'vendored');
|
|
300
|
+
const classified = await classifyDirectory(submoduleDir);
|
|
301
|
+
assert.equal(classified.kind, 'worktree');
|
|
302
|
+
assert.equal(classified.superproject, await fs.promises.realpath(outer));
|
|
303
|
+
|
|
304
|
+
const card = await buildRepoCard(submoduleDir);
|
|
305
|
+
assert.equal(card.kind, 'submodule');
|
|
306
|
+
|
|
307
|
+
const discovered = await discoverWorktrees(outer);
|
|
308
|
+
assert.deepEqual(discovered.worktrees, [await fs.promises.realpath(outer)]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('a folder of projects discovers its repos, skips the rest, ignores hidden entries', async () => {
|
|
312
|
+
const parent = path.join(tempRoot, 'projects');
|
|
313
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
314
|
+
const repoA = await makeRepo('projects/alpha');
|
|
315
|
+
const repoB = await makeRepo('projects/beta');
|
|
316
|
+
fs.mkdirSync(path.join(parent, 'notes'));
|
|
317
|
+
fs.writeFileSync(path.join(parent, 'stray.txt'), 'not a folder\n');
|
|
318
|
+
await makeRepo('projects/.hidden-repo');
|
|
319
|
+
await run(parent, 'init', '--bare', 'archive.git');
|
|
320
|
+
|
|
321
|
+
const { worktrees, skipped } = await discoverWorktrees(parent);
|
|
322
|
+
assert.deepEqual(worktrees, [await fs.promises.realpath(repoA), await fs.promises.realpath(repoB)].sort());
|
|
323
|
+
assert.deepEqual(
|
|
324
|
+
skipped.map((entry) => [path.basename(entry.path), entry.reason]).sort((a, b) => a[0].localeCompare(b[0])),
|
|
325
|
+
[['archive.git', 'bare'], ['notes', 'not-a-repo']],
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('a deleted-but-registered worktree is skipped as prunable, not crashed on', async () => {
|
|
330
|
+
const dir = await makeRepo('stale');
|
|
331
|
+
const worktreePath = path.join(tempRoot, 'stale-wt');
|
|
332
|
+
await run(dir, 'worktree', 'add', worktreePath, '-b', 'stale-branch');
|
|
333
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
334
|
+
|
|
335
|
+
const { worktrees, skipped } = await discoverWorktrees(dir);
|
|
336
|
+
assert.deepEqual(worktrees, [await fs.promises.realpath(dir)]);
|
|
337
|
+
const recordedPath = path.join(await fs.promises.realpath(tempRoot), 'stale-wt');
|
|
338
|
+
assert.deepEqual(skipped, [{ path: recordedPath, reason: 'prunable' }]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('two doors into one repo expand it once — no duplicate cards or skips', async () => {
|
|
342
|
+
const parent = path.join(tempRoot, 'twin-doors');
|
|
343
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
344
|
+
const repo = await makeRepo('twin-doors/repo');
|
|
345
|
+
const worktreePath = path.join(parent, 'repo-wt');
|
|
346
|
+
await run(repo, 'worktree', 'add', worktreePath, '-b', 'door-two');
|
|
347
|
+
const stalePath = path.join(parent, 'repo-stale');
|
|
348
|
+
await run(repo, 'worktree', 'add', stalePath, '-b', 'door-gone');
|
|
349
|
+
fs.rmSync(stalePath, { recursive: true, force: true });
|
|
350
|
+
|
|
351
|
+
const { worktrees, skipped } = await discoverWorktrees(parent);
|
|
352
|
+
assert.deepEqual(
|
|
353
|
+
worktrees,
|
|
354
|
+
[await fs.promises.realpath(repo), await fs.promises.realpath(worktreePath)].sort(),
|
|
355
|
+
);
|
|
356
|
+
assert.equal(skipped.filter((entry) => entry.reason === 'prunable').length, 1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a worktree orphaned by a repo move is skipped as broken, not crashed on', async () => {
|
|
360
|
+
const dir = await makeRepo('mover');
|
|
361
|
+
const worktreePath = path.join(tempRoot, 'mover-wt');
|
|
362
|
+
await run(dir, 'worktree', 'add', worktreePath, '-b', 'orphan-branch');
|
|
363
|
+
const movedDir = path.join(tempRoot, 'mover-moved');
|
|
364
|
+
fs.renameSync(dir, movedDir);
|
|
365
|
+
|
|
366
|
+
const { worktrees, skipped } = await discoverWorktrees(movedDir);
|
|
367
|
+
assert.deepEqual(worktrees, [await fs.promises.realpath(movedDir)]);
|
|
368
|
+
assert.deepEqual(skipped, [{ path: await fs.promises.realpath(worktreePath), reason: 'broken-worktree' }]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('a symlinked project resolves to the real repo and dedupes', async () => {
|
|
372
|
+
const parent = path.join(tempRoot, 'linked-projects');
|
|
373
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
374
|
+
const repo = await makeRepo('linked-projects/real');
|
|
375
|
+
fs.symlinkSync(repo, path.join(parent, 'alias'));
|
|
376
|
+
|
|
377
|
+
const { worktrees } = await discoverWorktrees(parent);
|
|
378
|
+
assert.deepEqual(worktrees, [await fs.promises.realpath(repo)]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('building a card never writes to the repo', async () => {
|
|
382
|
+
const dir = await makeRepo('untouched');
|
|
383
|
+
write(dir, 'work.txt', 'w\n');
|
|
384
|
+
const indexPath = path.join(dir, '.git', 'index');
|
|
385
|
+
const before = fs.statSync(indexPath).mtimeMs;
|
|
386
|
+
|
|
387
|
+
await buildRepoCard(dir);
|
|
388
|
+
assert.equal(fs.statSync(indexPath).mtimeMs, before);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('the fingerprint holds across observations and moves on any real change', async () => {
|
|
392
|
+
const dir = await makeRepo('printed');
|
|
393
|
+
|
|
394
|
+
const first = await buildRepoCard(dir);
|
|
395
|
+
const second = await buildRepoCard(dir);
|
|
396
|
+
assert.equal(cardFingerprint(first), cardFingerprint(second));
|
|
397
|
+
|
|
398
|
+
write(dir, 'new.txt', 'n\n');
|
|
399
|
+
const third = await buildRepoCard(dir);
|
|
400
|
+
assert.notEqual(cardFingerprint(second), cardFingerprint(third));
|
|
401
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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-wall-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 = 'machine-a';
|
|
17
|
+
process.env.AMALGM_DEVICE_ID = 'device-machine-a';
|
|
18
|
+
|
|
19
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
22
|
+
JSON.stringify({ computer_id: 'machine-a', user_id: '11111111-1111-4111-8111-111111111111' }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const { closeLocalDb } = require('../state/db');
|
|
26
|
+
const { registerReplica } = require('../state/replicas');
|
|
27
|
+
const { openDoc, getDocText, writeDocText } = require('../state/docs');
|
|
28
|
+
const { upsertWorkspace } = require('../workspace/store');
|
|
29
|
+
const { refreshRepoCards } = require('../workspace/cards');
|
|
30
|
+
const { publishWall, readWall, wallResourceId, wallStats } = require('../workspace/wall');
|
|
31
|
+
|
|
32
|
+
test.after(() => {
|
|
33
|
+
closeLocalDb();
|
|
34
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function run(cwd, ...args) {
|
|
38
|
+
await execFileAsync(
|
|
39
|
+
'git',
|
|
40
|
+
['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
|
|
41
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const wallFile = path.join(process.env.AMALGM_DIR, 'state', 'machine-cards.jsonl');
|
|
46
|
+
|
|
47
|
+
test('the wall resource id is derived from the account: stable per user, distinct across users, valid on the rail', () => {
|
|
48
|
+
const userA = '11111111-1111-4111-8111-111111111111';
|
|
49
|
+
const userB = '22222222-2222-4222-8222-222222222222';
|
|
50
|
+
assert.equal(wallResourceId(userA), wallResourceId(userA));
|
|
51
|
+
assert.notEqual(wallResourceId(userA), wallResourceId(userB));
|
|
52
|
+
assert.match(wallResourceId(userA), /^shr_[A-Za-z0-9_-]{16,128}$/);
|
|
53
|
+
assert.equal(wallResourceId(null), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('publishing without a bound wall is a visible skip, never a write', () => {
|
|
57
|
+
const result = publishWall();
|
|
58
|
+
assert.equal(result.published, false);
|
|
59
|
+
assert.equal(result.reason, 'wall-not-bound');
|
|
60
|
+
assert.equal(fs.existsSync(wallFile), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a bound wall carries exactly one line per machine, replaced in place', async () => {
|
|
64
|
+
const repo = path.join(tempRoot, 'projects/app');
|
|
65
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
66
|
+
await run(repo, 'init', '-b', 'main');
|
|
67
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# app\n');
|
|
68
|
+
await run(repo, 'add', '.');
|
|
69
|
+
await run(repo, 'commit', '-m', 'initial');
|
|
70
|
+
upsertWorkspace({ path: path.join(tempRoot, 'projects'), name: 'Projects', source: 'computer' });
|
|
71
|
+
await refreshRepoCards();
|
|
72
|
+
|
|
73
|
+
// Bind the wall the way a completed attachment would: an empty file with
|
|
74
|
+
// a registered replica for the account's derived resource id.
|
|
75
|
+
fs.mkdirSync(path.dirname(wallFile), { recursive: true });
|
|
76
|
+
fs.writeFileSync(wallFile, '');
|
|
77
|
+
const opened = openDoc(wallFile);
|
|
78
|
+
registerReplica({
|
|
79
|
+
resourceId: wallResourceId(),
|
|
80
|
+
localResource: opened.resource,
|
|
81
|
+
localPath: wallFile,
|
|
82
|
+
contract: 'text-yjs@1',
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
authorityEpoch: 1,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const first = publishWall();
|
|
88
|
+
assert.equal(first.published, true);
|
|
89
|
+
const entries = readWall();
|
|
90
|
+
assert.equal(entries.length, 1);
|
|
91
|
+
assert.equal(entries[0].machine, 'machine-a');
|
|
92
|
+
assert.equal(entries[0].cards.length, 1);
|
|
93
|
+
assert.equal(entries[0].cards[0].head.branch, 'main');
|
|
94
|
+
|
|
95
|
+
// Same cards again: no new version, no doc write.
|
|
96
|
+
const again = publishWall();
|
|
97
|
+
assert.equal(again.published, false);
|
|
98
|
+
assert.equal(again.reason, 'unchanged');
|
|
99
|
+
|
|
100
|
+
// A real change replaces the line — never appends a second one.
|
|
101
|
+
fs.writeFileSync(path.join(repo, 'new.txt'), 'x\n');
|
|
102
|
+
await refreshRepoCards();
|
|
103
|
+
const republished = publishWall();
|
|
104
|
+
assert.equal(republished.published, true);
|
|
105
|
+
const after = readWall();
|
|
106
|
+
assert.equal(after.length, 1);
|
|
107
|
+
assert.equal(after[0].cards[0].dirty.untracked, 1);
|
|
108
|
+
assert.equal(getDocText(wallFile).trim().split('\n').length, 1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a peer machine\'s line is preserved byte-for-byte through this machine\'s publishes', async () => {
|
|
112
|
+
const peerLine = JSON.stringify({
|
|
113
|
+
wall: 'machine-cards@1',
|
|
114
|
+
machine: 'machine-b',
|
|
115
|
+
publishedAt: '2026-07-19T00:00:00.000Z',
|
|
116
|
+
cards: [{ card: 'repo-card@2', machine: 'machine-b', replica: 'rpl_peer', path: '/work/app', head: { branch: 'preview' } }],
|
|
117
|
+
});
|
|
118
|
+
// Land the peer's line the way fanout would: a doc edit from elsewhere.
|
|
119
|
+
writeDocText(wallFile, `${getDocText(wallFile)}${peerLine}\n`);
|
|
120
|
+
|
|
121
|
+
const repo = path.join(tempRoot, 'projects/app');
|
|
122
|
+
fs.writeFileSync(path.join(repo, 'another.txt'), 'y\n');
|
|
123
|
+
await refreshRepoCards();
|
|
124
|
+
const result = publishWall();
|
|
125
|
+
assert.equal(result.published, true);
|
|
126
|
+
|
|
127
|
+
const lines = getDocText(wallFile).trim().split('\n');
|
|
128
|
+
assert.equal(lines.length, 2);
|
|
129
|
+
assert.ok(lines.includes(peerLine), 'the peer line must survive untouched');
|
|
130
|
+
const entries = readWall();
|
|
131
|
+
assert.deepEqual(entries.map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a torn line is ignored by readers and healed by its owner, never fatal', () => {
|
|
135
|
+
writeDocText(wallFile, `${getDocText(wallFile)}{"wall":"machine-cards@1","machine":"machine-c","car\n`);
|
|
136
|
+
const entries = readWall();
|
|
137
|
+
assert.deepEqual(entries.map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('wall stats name the resource, the binding, and the publish counters', () => {
|
|
141
|
+
const stats = wallStats();
|
|
142
|
+
assert.equal(stats.resourceId, wallResourceId());
|
|
143
|
+
assert.equal(stats.contract, 'machine-cards@1');
|
|
144
|
+
assert.equal(stats.replica.appliedVersion >= 0, true);
|
|
145
|
+
assert.equal(stats.machines, 2);
|
|
146
|
+
assert.ok(stats.publishes >= 3);
|
|
147
|
+
assert.ok(stats.publishSkips >= 1);
|
|
148
|
+
});
|