amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The repocard storm: seeded random sequences over the git alphabet —
5
+ * commits, branches, checkouts, merges (clean and conflicted), rebases,
6
+ * staging games, renames, deletions, binary edits, tags, detached HEAD —
7
+ * with a replica machine following via capture → apply after every round.
8
+ *
9
+ * Judged the same way as the observer storm:
10
+ * 1. apply() proves itself (ids re-derived from the replica must match).
11
+ * 2. An independent referee compares source and replica with git plumbing
12
+ * only — refs, index blob hashes, status, and worktree bytes.
13
+ * 3. Busy states must be reported, never mis-captured.
14
+ *
15
+ * Seeded via AMALGM_STORM_SEED so any failure replays exactly.
16
+ */
17
+
18
+ const test = require('node:test');
19
+ const assert = require('node:assert/strict');
20
+ const { execFileSync } = require('child_process');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ const { capture, apply } = require('../repocard');
26
+
27
+ const SEED = Number(process.env.AMALGM_STORM_SEED || 20260729);
28
+
29
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repocard-storm-'));
30
+ test.after(() => {
31
+ fs.rmSync(tempRoot, { recursive: true, force: true });
32
+ });
33
+
34
+ function mulberry32(seed) {
35
+ let a = seed >>> 0;
36
+ return () => {
37
+ a = (a + 0x6d2b79f5) >>> 0;
38
+ let t = a;
39
+ t = Math.imul(t ^ (t >>> 15), t | 1);
40
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
41
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
42
+ };
43
+ }
44
+
45
+ function git(dir, ...args) {
46
+ return execFileSync('git', args, { cwd: dir, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).toString('utf8').trim();
47
+ }
48
+
49
+ function tryGit(dir, ...args) {
50
+ try { return git(dir, ...args); } catch { return null; }
51
+ }
52
+
53
+ function assertReposIdentical(a, b, label) {
54
+ const sorted = (text) => text.split('\n').filter(Boolean).sort();
55
+ const headA = tryGit(a, 'symbolic-ref', '-q', 'HEAD');
56
+ const headB = tryGit(b, 'symbolic-ref', '-q', 'HEAD');
57
+ assert.equal(headA, headB, `${label}: HEAD ref`);
58
+ assert.equal(tryGit(a, 'rev-parse', '-q', '--verify', 'HEAD'), tryGit(b, 'rev-parse', '-q', '--verify', 'HEAD'), `${label}: HEAD commit`);
59
+ assert.deepEqual(
60
+ sorted(git(a, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags')),
61
+ sorted(git(b, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags')),
62
+ `${label}: refs`,
63
+ );
64
+ assert.deepEqual(sorted(git(a, 'ls-files', '-s')), sorted(git(b, 'ls-files', '-s')), `${label}: index`);
65
+ assert.deepEqual(
66
+ sorted(git(a, 'status', '--porcelain=v2', '--untracked-files=all')),
67
+ sorted(git(b, 'status', '--porcelain=v2', '--untracked-files=all')),
68
+ `${label}: status`,
69
+ );
70
+ const worktree = (dir) => {
71
+ const map = [];
72
+ const list = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: dir })
73
+ .toString('utf8').split('\0').filter(Boolean);
74
+ for (const rel of list) {
75
+ const abs = path.join(dir, rel);
76
+ let stat;
77
+ try {
78
+ stat = fs.lstatSync(abs);
79
+ } catch {
80
+ map.push(`${rel}\tabsent`); // in the index, deleted from the worktree — a real state, compared like any other
81
+ continue;
82
+ }
83
+ map.push(`${rel}\t${stat.isSymbolicLink()
84
+ ? `link:${fs.readlinkSync(abs)}`
85
+ : `${stat.mode & 0o100 ? 'x' : '-'}:${fs.readFileSync(abs).toString('base64')}`}`);
86
+ }
87
+ return map.sort();
88
+ };
89
+ assert.deepEqual(worktree(a), worktree(b), `${label}: worktree bytes`);
90
+ }
91
+
92
+ test('storm: random git histories, a replica follows every round', () => {
93
+ const rng = mulberry32(SEED);
94
+ let stamp = 0;
95
+ const source = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, 'source-')));
96
+ git(source, 'init', '--quiet');
97
+ git(source, 'config', 'user.email', 'storm@amalgm.test');
98
+ git(source, 'config', 'user.name', 'Storm');
99
+ git(source, 'config', 'commit.gpgsign', 'false');
100
+ git(source, 'remote', 'add', 'origin', 'https://example.test/storm.git');
101
+
102
+ // A small set of core files makes merges and rebases collide often — the
103
+ // busy path is part of the alphabet, not an accident.
104
+ const CORE = ['a.md', 'b.md', 'c.md', 'bin.dat'];
105
+ for (const rel of CORE) fs.writeFileSync(path.join(source, rel), `seed ${rel}\n`);
106
+ git(source, 'add', '-A');
107
+ git(source, 'commit', '--quiet', '-m', 'seed');
108
+ const defaultBranch = git(source, 'symbolic-ref', '--short', 'HEAD');
109
+
110
+ const pick = (arr) => arr[Math.floor(rng() * arr.length)];
111
+ const tracked = () => git(source, 'ls-files').split('\n').filter(Boolean);
112
+ const branches = () => git(source, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').split('\n').filter(Boolean);
113
+ const content = () => `edit ${(stamp++).toString(36)} ${'x'.repeat(Math.floor(rng() * 200))}\n`;
114
+
115
+ const ops = {
116
+ editTracked() {
117
+ const rel = pick(tracked());
118
+ if (rng() < 0.5) fs.writeFileSync(path.join(source, rel), content());
119
+ else fs.appendFileSync(path.join(source, rel), content());
120
+ },
121
+ binaryEdit() {
122
+ const bytes = Buffer.alloc(64 + Math.floor(rng() * 512));
123
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(rng() * 256);
124
+ fs.writeFileSync(path.join(source, 'bin.dat'), bytes);
125
+ },
126
+ newUntracked() {
127
+ fs.writeFileSync(path.join(source, `loose${(stamp++).toString(36)}.md`), content());
128
+ },
129
+ stageOne() {
130
+ const dirty = tryGit(source, 'diff', '--name-only');
131
+ const rel = pick((dirty || '').split('\n').filter(Boolean));
132
+ if (rel) git(source, 'add', rel);
133
+ },
134
+ stageAll() {
135
+ git(source, 'add', '-A');
136
+ },
137
+ unstageOne() {
138
+ const staged = tryGit(source, 'diff', '--cached', '--name-only');
139
+ const rel = pick((staged || '').split('\n').filter(Boolean));
140
+ if (rel) tryGit(source, 'restore', '--staged', rel);
141
+ },
142
+ commitAll() {
143
+ git(source, 'add', '-A');
144
+ tryGit(source, 'commit', '--quiet', '-m', `storm ${stamp++}`); // nothing to commit is fine
145
+ },
146
+ branchNew() {
147
+ git(source, 'checkout', '--quiet', '-b', `b${(stamp++).toString(36)}`);
148
+ },
149
+ checkoutOther() {
150
+ tryGit(source, 'checkout', '--quiet', pick(branches())); // may refuse over dirty work: fine
151
+ },
152
+ mergeOther() {
153
+ const target = pick(branches().filter((name) => name !== tryGit(source, 'symbolic-ref', '--short', '-q', 'HEAD')));
154
+ if (!target) return;
155
+ if (tryGit(source, 'merge', '--quiet', '--no-edit', target) === null) {
156
+ // Conflict (or dirty tree). If mid-merge, capture must say busy.
157
+ const snap = capture(source);
158
+ if (snap.busy) {
159
+ assert.equal(snap.busy, 'MERGE_HEAD');
160
+ git(source, 'add', '-A');
161
+ tryGit(source, 'commit', '--quiet', '--no-edit');
162
+ }
163
+ }
164
+ },
165
+ rebaseOther() {
166
+ const target = pick(branches());
167
+ if (tryGit(source, 'rebase', target) === null) {
168
+ const snap = capture(source);
169
+ if (snap.busy) {
170
+ assert.match(snap.busy, /^rebase/);
171
+ git(source, 'rebase', '--abort');
172
+ }
173
+ }
174
+ },
175
+ deleteTracked() {
176
+ const rel = pick(tracked().filter((name) => !CORE.includes(name)));
177
+ if (rel) tryGit(source, 'rm', '--quiet', '-f', rel);
178
+ },
179
+ renameTracked() {
180
+ const rel = pick(tracked().filter((name) => !CORE.includes(name)));
181
+ if (rel) tryGit(source, 'mv', rel, `moved${(stamp++).toString(36)}.md`);
182
+ },
183
+ tagNow() {
184
+ git(source, 'tag', `t${(stamp++).toString(36)}`);
185
+ },
186
+ detachToggle() {
187
+ if (tryGit(source, 'symbolic-ref', '-q', 'HEAD') === null) {
188
+ tryGit(source, 'checkout', '--quiet', defaultBranch);
189
+ } else {
190
+ tryGit(source, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', '--detach', 'HEAD');
191
+ }
192
+ },
193
+ execToggle() {
194
+ const rel = pick(tracked().filter((name) => name.endsWith('.md')));
195
+ if (rel) fs.chmodSync(path.join(source, rel), rng() < 0.5 ? 0o755 : 0o644);
196
+ },
197
+ };
198
+
199
+ const table = [];
200
+ const weigh = (name, weight) => {
201
+ for (let i = 0; i < weight; i += 1) table.push(ops[name]);
202
+ };
203
+ weigh('editTracked', 18);
204
+ weigh('binaryEdit', 6);
205
+ weigh('newUntracked', 10);
206
+ weigh('stageOne', 8);
207
+ weigh('stageAll', 6);
208
+ weigh('unstageOne', 5);
209
+ weigh('commitAll', 12);
210
+ weigh('branchNew', 6);
211
+ weigh('checkoutOther', 8);
212
+ weigh('mergeOther', 6);
213
+ weigh('rebaseOther', 4);
214
+ weigh('deleteTracked', 5);
215
+ weigh('renameTracked', 5);
216
+ weigh('tagNow', 3);
217
+ weigh('detachToggle', 3);
218
+ weigh('execToggle', 4);
219
+
220
+ const replica = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, 'replica-')));
221
+ for (let round = 0; round < 30; round += 1) {
222
+ const opCount = 1 + Math.floor(rng() * 3);
223
+ for (let i = 0; i < opCount; i += 1) pick(table)();
224
+
225
+ let snap = capture(source);
226
+ if (snap.busy) {
227
+ // A round may end mid-operation; the resolution is the next event.
228
+ git(source, 'add', '-A');
229
+ tryGit(source, 'commit', '--quiet', '--no-edit');
230
+ snap = capture(source);
231
+ }
232
+ assert.ok(!snap.busy, `round ${round}: still busy after resolution (seed ${SEED})`);
233
+
234
+ apply(replica, snap); // incremental: same replica follows every round
235
+ assertReposIdentical(source, replica, `round ${round} (seed ${SEED})`);
236
+
237
+ if (round % 6 === 5) {
238
+ // Cold path: a brand-new machine joins from nothing but the state.
239
+ const fresh = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `fresh-${round}-`)));
240
+ apply(fresh, snap);
241
+ assertReposIdentical(source, fresh, `round ${round} fresh (seed ${SEED})`);
242
+ }
243
+ }
244
+ });
245
+
246
+ test('performance: capture and apply costs on a working repo', () => {
247
+ const source = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, 'perf-')));
248
+ git(source, 'init', '--quiet');
249
+ git(source, 'config', 'user.email', 'storm@amalgm.test');
250
+ git(source, 'config', 'user.name', 'Storm');
251
+ git(source, 'config', 'commit.gpgsign', 'false');
252
+ for (let c = 0; c < 30; c += 1) {
253
+ for (let f = 0; f < 10; f += 1) {
254
+ fs.writeFileSync(path.join(source, `f${(c * 10 + f) % 120}.md`), `commit ${c} file ${f}\n`.repeat(30));
255
+ }
256
+ git(source, 'add', '-A');
257
+ git(source, 'commit', '--quiet', '-m', `c${c}`);
258
+ }
259
+ fs.writeFileSync(path.join(source, 'f1.md'), 'dirty edit\n');
260
+ fs.writeFileSync(path.join(source, 'loose.md'), 'untracked\n');
261
+
262
+ const ms = (from) => Number(process.hrtime.bigint() - from) / 1e6;
263
+ let t = process.hrtime.bigint();
264
+ const full = capture(source);
265
+ const captureMs = ms(t);
266
+
267
+ t = process.hrtime.bigint();
268
+ const light = capture(source, { withBundle: false });
269
+ const lightMs = ms(t);
270
+ assert.equal(light.stateId, full.stateId, 'the bundle never enters identity');
271
+
272
+ const replica = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, 'perf-replica-')));
273
+ t = process.hrtime.bigint();
274
+ apply(replica, full);
275
+ const applyMs = ms(t);
276
+
277
+ console.log(
278
+ `# perf (120 files, 30 commits): capture=${captureMs.toFixed(0)}ms ` +
279
+ `captureNoBundle=${lightMs.toFixed(0)}ms coldApply=${applyMs.toFixed(0)}ms`,
280
+ );
281
+ assert.ok(captureMs < 5000, `capture too slow: ${captureMs}ms`);
282
+ assert.ok(lightMs < 2000, `bundle-less capture too slow: ${lightMs}ms`);
283
+ assert.ok(applyMs < 10000, `cold apply too slow: ${applyMs}ms`);
284
+ });
@@ -0,0 +1,371 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execFileSync } = require('child_process');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const { capture, apply, verify } = require('../repocard');
11
+
12
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repocard-test-'));
13
+ test.after(() => {
14
+ fs.rmSync(tempRoot, { recursive: true, force: true });
15
+ });
16
+
17
+ function git(dir, ...args) {
18
+ return execFileSync('git', args, { cwd: dir, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).toString('utf8').trim();
19
+ }
20
+
21
+ function tryRun(fn) {
22
+ try { return { ok: true, value: fn() }; } catch (error) { return { ok: false, error }; }
23
+ }
24
+
25
+ function makeRepo(name) {
26
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
27
+ git(dir, 'init', '--quiet');
28
+ git(dir, 'config', 'user.email', 'storm@amalgm.test');
29
+ git(dir, 'config', 'user.name', 'Storm');
30
+ git(dir, 'config', 'commit.gpgsign', 'false');
31
+ return dir;
32
+ }
33
+
34
+ function blankDir(name) {
35
+ return fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
36
+ }
37
+
38
+ function commit(dir, message) {
39
+ git(dir, 'add', '-A');
40
+ git(dir, 'commit', '--quiet', '-m', message);
41
+ }
42
+
43
+ /**
44
+ * The referee: two repos are identical when git's own plumbing says so —
45
+ * same HEAD, same refs, same index (blob hashes + modes), same status, and
46
+ * byte-identical worktrees over every tracked and untracked file. Uses none
47
+ * of the repocard machinery.
48
+ */
49
+ function assertReposIdentical(a, b, label) {
50
+ const probe = (dir, ...args) => tryRun(() => git(dir, ...args));
51
+ for (const args of [
52
+ ['symbolic-ref', '-q', 'HEAD'],
53
+ ['rev-parse', '-q', '--verify', 'HEAD'],
54
+ ]) {
55
+ const left = probe(a, ...args);
56
+ const right = probe(b, ...args);
57
+ assert.equal(left.ok, right.ok, `${label}: ${args.join(' ')} disagree on success`);
58
+ if (left.ok) assert.equal(left.value, right.value, `${label}: ${args.join(' ')}`);
59
+ }
60
+
61
+ const sorted = (text) => text.split('\n').filter(Boolean).sort();
62
+ assert.deepEqual(
63
+ sorted(git(a, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags')),
64
+ sorted(git(b, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags')),
65
+ `${label}: refs`,
66
+ );
67
+ assert.deepEqual(
68
+ sorted(git(a, 'ls-files', '-s')),
69
+ sorted(git(b, 'ls-files', '-s')),
70
+ `${label}: index`,
71
+ );
72
+ assert.deepEqual(
73
+ sorted(git(a, 'status', '--porcelain=v2', '--untracked-files=all')),
74
+ sorted(git(b, 'status', '--porcelain=v2', '--untracked-files=all')),
75
+ `${label}: status`,
76
+ );
77
+
78
+ const worktree = (dir) => {
79
+ const map = new Map();
80
+ const list = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: dir })
81
+ .toString('utf8').split('\0').filter(Boolean);
82
+ for (const rel of list) {
83
+ const abs = path.join(dir, rel);
84
+ let stat;
85
+ try {
86
+ stat = fs.lstatSync(abs);
87
+ } catch {
88
+ map.set(rel, 'absent'); // in the index, deleted from the worktree — a real state, compared like any other
89
+ continue;
90
+ }
91
+ map.set(rel, stat.isSymbolicLink()
92
+ ? `link:${fs.readlinkSync(abs)}`
93
+ : `file:${stat.mode & 0o100 ? 'x' : '-'}:${fs.readFileSync(abs).toString('base64')}`);
94
+ }
95
+ return map;
96
+ };
97
+ assert.deepEqual([...worktree(a).entries()].sort(), [...worktree(b).entries()].sort(), `${label}: worktree bytes`);
98
+ }
99
+
100
+ function roundTrip(source, name) {
101
+ const state = capture(source);
102
+ assert.ok(!state.busy, `${name}: capture unexpectedly busy: ${state.busy}`);
103
+ const replica = blankDir(`${name}-replica`);
104
+ apply(replica, state); // throws unless the ids reproduce (axiom 4)
105
+ assertReposIdentical(source, replica, name);
106
+ return { state, replica };
107
+ }
108
+
109
+ // --- round trips -------------------------------------------------------------
110
+
111
+ test('a committed repo round-trips exactly: branches, tags, remote, HEAD', () => {
112
+ const source = makeRepo('committed');
113
+ fs.writeFileSync(path.join(source, 'a.md'), 'one');
114
+ commit(source, 'first');
115
+ git(source, 'checkout', '--quiet', '-b', 'feature');
116
+ fs.writeFileSync(path.join(source, 'b.md'), 'two');
117
+ commit(source, 'second');
118
+ git(source, 'tag', 'light');
119
+ git(source, 'tag', '-a', 'heavy', '-m', 'annotated');
120
+ git(source, 'remote', 'add', 'origin', 'https://example.test/repo.git');
121
+
122
+ const { state, replica } = roundTrip(source, 'committed');
123
+ assert.equal(state.card.head.branch, 'feature');
124
+ assert.equal(git(replica, 'remote', 'get-url', 'origin'), 'https://example.test/repo.git');
125
+ });
126
+
127
+ test('staged, unstaged, untracked, binary, and exec-bit work rides the checkpoint', () => {
128
+ const source = makeRepo('dirty');
129
+ fs.writeFileSync(path.join(source, 'a.md'), 'line one\nline two\n');
130
+ fs.writeFileSync(path.join(source, 'bin.dat'), Buffer.from([0, 1, 2, 255, 254, 0, 7]));
131
+ fs.writeFileSync(path.join(source, 'gone.md'), 'to be deleted');
132
+ fs.writeFileSync(path.join(source, 'vanish.md'), 'deleted without git');
133
+ fs.writeFileSync(path.join(source, 'script.sh'), '#!/bin/sh\n');
134
+ commit(source, 'base');
135
+
136
+ fs.writeFileSync(path.join(source, 'a.md'), 'line one\nSTAGED\n');
137
+ git(source, 'add', 'a.md');
138
+ fs.appendFileSync(path.join(source, 'a.md'), 'UNSTAGED on top\n'); // staged AND unstaged
139
+ fs.writeFileSync(path.join(source, 'bin.dat'), Buffer.from([9, 9, 9, 0, 255])); // unstaged binary
140
+ git(source, 'rm', '--quiet', 'gone.md'); // staged deletion
141
+ fs.rmSync(path.join(source, 'vanish.md')); // unstaged deletion: index keeps it, disk loses it
142
+ fs.chmodSync(path.join(source, 'script.sh'), 0o755);
143
+ git(source, 'add', 'script.sh'); // staged mode change
144
+ fs.writeFileSync(path.join(source, 'staged-new.md'), 'brand new, staged');
145
+ git(source, 'add', 'staged-new.md');
146
+ fs.mkdirSync(path.join(source, 'deep', 'nest'), { recursive: true });
147
+ fs.writeFileSync(path.join(source, 'deep', 'nest', 'loose.md'), 'untracked');
148
+ fs.writeFileSync(path.join(source, 'loose.bin'), Buffer.from([255, 0, 128]));
149
+ fs.chmodSync(path.join(source, 'loose.bin'), 0o755);
150
+
151
+ roundTrip(source, 'dirty');
152
+ });
153
+
154
+ test('detached HEAD round-trips', () => {
155
+ const source = makeRepo('detached');
156
+ fs.writeFileSync(path.join(source, 'a.md'), 'v1');
157
+ commit(source, 'first');
158
+ fs.writeFileSync(path.join(source, 'a.md'), 'v2');
159
+ commit(source, 'second');
160
+ git(source, 'checkout', '--quiet', '--detach', 'HEAD~1');
161
+
162
+ const { state, replica } = roundTrip(source, 'detached');
163
+ assert.equal(state.card.head.detached, true);
164
+ assert.equal(tryRun(() => git(replica, 'symbolic-ref', '-q', 'HEAD')).ok, false);
165
+ });
166
+
167
+ test('an unborn repo with staged and untracked work round-trips', () => {
168
+ const source = makeRepo('unborn');
169
+ fs.writeFileSync(path.join(source, 'first.md'), 'staged before any commit');
170
+ git(source, 'add', 'first.md');
171
+ fs.writeFileSync(path.join(source, 'loose.md'), 'untracked');
172
+
173
+ const { state } = roundTrip(source, 'unborn');
174
+ assert.equal(state.card.head.commit, null);
175
+ assert.equal(state.card.bundle, null);
176
+ });
177
+
178
+ // --- identity ------------------------------------------------------------------
179
+
180
+ test('capture is deterministic: same state, same ids, twice', () => {
181
+ const source = makeRepo('deterministic');
182
+ fs.writeFileSync(path.join(source, 'a.md'), 'content');
183
+ commit(source, 'first');
184
+ fs.writeFileSync(path.join(source, 'a.md'), 'dirty');
185
+
186
+ const first = capture(source);
187
+ const second = capture(source);
188
+ assert.equal(first.cardId, second.cardId);
189
+ assert.equal(first.checkpointId, second.checkpointId);
190
+ assert.equal(first.stateId, second.stateId);
191
+ });
192
+
193
+ test('ids separate committed truth from uncommitted work', () => {
194
+ const source = makeRepo('ids');
195
+ fs.writeFileSync(path.join(source, 'a.md'), 'v1');
196
+ commit(source, 'first');
197
+ const clean = capture(source);
198
+
199
+ fs.writeFileSync(path.join(source, 'a.md'), 'edited'); // uncommitted edit
200
+ const dirty = capture(source);
201
+ assert.equal(dirty.cardId, clean.cardId, 'an edit must not move the card');
202
+ assert.notEqual(dirty.checkpointId, clean.checkpointId);
203
+
204
+ commit(source, 'second'); // the commit moves the card and empties the checkpoint
205
+ const committed = capture(source);
206
+ assert.notEqual(committed.cardId, clean.cardId);
207
+ });
208
+
209
+ // --- busy ------------------------------------------------------------------------
210
+
211
+ test('busy repos refuse to lie: merge and rebase conflicts', () => {
212
+ const source = makeRepo('busy');
213
+ fs.writeFileSync(path.join(source, 'a.md'), 'base\n');
214
+ commit(source, 'base');
215
+ git(source, 'checkout', '--quiet', '-b', 'left');
216
+ fs.writeFileSync(path.join(source, 'a.md'), 'left\n');
217
+ commit(source, 'left');
218
+ git(source, 'checkout', '--quiet', '-');
219
+ fs.writeFileSync(path.join(source, 'a.md'), 'right\n');
220
+ commit(source, 'right');
221
+
222
+ assert.equal(tryRun(() => git(source, 'merge', '--quiet', 'left')).ok, false);
223
+ assert.equal(capture(source).busy, 'MERGE_HEAD');
224
+ git(source, 'add', '-A');
225
+ git(source, 'commit', '--quiet', '--no-edit');
226
+ assert.ok(!capture(source).busy, 'a finished merge captures cleanly');
227
+
228
+ git(source, 'checkout', '--quiet', 'left');
229
+ fs.writeFileSync(path.join(source, 'a.md'), 'left again\n');
230
+ commit(source, 'left 2');
231
+ const rebase = tryRun(() => git(source, 'rebase', 'master')) // default branch name varies
232
+ .ok || tryRun(() => git(source, 'rebase', 'main')).ok;
233
+ if (!rebase) {
234
+ assert.match(capture(source).busy, /^rebase/);
235
+ git(source, 'rebase', '--abort');
236
+ }
237
+ assert.ok(!capture(source).busy);
238
+ });
239
+
240
+ test('bisect is a mode, not a busy state: its checkouts are captured', () => {
241
+ const source = makeRepo('bisect');
242
+ const shas = [];
243
+ for (let i = 0; i < 8; i += 1) {
244
+ fs.writeFileSync(path.join(source, 'a.md'), `version ${i}`);
245
+ commit(source, `version ${i}`);
246
+ shas.push(git(source, 'rev-parse', 'HEAD'));
247
+ }
248
+
249
+ const atTip = capture(source, { withBundle: false });
250
+ // 6 candidates between good and bad: two steps always mean two distinct checkouts.
251
+ git(source, 'bisect', 'start', shas[7], shas[0]);
252
+
253
+ // Mid-bisect: capture works and sees the detached checkout bisect made.
254
+ const midBisect = capture(source, { withBundle: false });
255
+ assert.ok(!midBisect.busy, `bisect wrongly reported busy: ${midBisect.busy}`);
256
+ assert.equal(midBisect.card.head.detached, true);
257
+ assert.notEqual(midBisect.cardId, atTip.cardId, 'the bisect checkout is a card change');
258
+
259
+ // Each bisect step is another detectable card change.
260
+ git(source, 'bisect', 'good');
261
+ const nextStep = capture(source, { withBundle: false });
262
+ assert.ok(!nextStep.busy);
263
+ assert.notEqual(nextStep.cardId, midBisect.cardId);
264
+
265
+ git(source, 'bisect', 'reset');
266
+ assert.equal(capture(source, { withBundle: false }).cardId, atTip.cardId);
267
+ });
268
+
269
+ // --- incremental -------------------------------------------------------------------
270
+
271
+ test('checkpoint-only refresh: same card, new checkpoint, live replica follows', () => {
272
+ const source = makeRepo('incremental');
273
+ fs.writeFileSync(path.join(source, 'a.md'), 'v1');
274
+ commit(source, 'first');
275
+ const { state: v1, replica } = roundTrip(source, 'incremental');
276
+
277
+ fs.writeFileSync(path.join(source, 'a.md'), 'edited but uncommitted');
278
+ fs.writeFileSync(path.join(source, 'loose.md'), 'new untracked');
279
+ const v2 = capture(source);
280
+ assert.equal(v2.cardId, v1.cardId);
281
+ assert.notEqual(v2.checkpointId, v1.checkpointId);
282
+
283
+ apply(replica, v2); // same replica dir, no re-clone
284
+ assertReposIdentical(source, replica, 'incremental refresh');
285
+ });
286
+
287
+ test('a checkout on the source is one card change and the replica follows exactly', () => {
288
+ const source = makeRepo('checkout');
289
+ for (let i = 0; i < 20; i += 1) {
290
+ fs.writeFileSync(path.join(source, `f${i}.md`), `main ${i}`);
291
+ }
292
+ commit(source, 'main files');
293
+ git(source, 'checkout', '--quiet', '-b', 'other');
294
+ for (let i = 0; i < 20; i += 1) {
295
+ fs.writeFileSync(path.join(source, `f${i}.md`), `other ${i}`);
296
+ }
297
+ commit(source, 'other files');
298
+ git(source, 'checkout', '--quiet', '-');
299
+ const { state: before, replica } = roundTrip(source, 'checkout');
300
+
301
+ git(source, 'checkout', '--quiet', 'other'); // 20 files change on disk
302
+ const after = capture(source);
303
+ assert.notEqual(after.cardId, before.cardId, 'the checkout is a card change');
304
+ assert.equal(after.checkpoint.stagedPatch.length, 0, 'nothing rides the checkpoint');
305
+ assert.equal(after.checkpoint.unstagedPatch.length, 0);
306
+ apply(replica, after);
307
+ assertReposIdentical(source, replica, 'post-checkout');
308
+ });
309
+
310
+ // --- corruption ------------------------------------------------------------------------
311
+
312
+ test('a corrupted bundle is rejected, never applied', () => {
313
+ const source = makeRepo('corrupt');
314
+ fs.writeFileSync(path.join(source, 'a.md'), 'v1');
315
+ commit(source, 'first');
316
+ const state = capture(source);
317
+ state.card.bundle.bytes = Buffer.concat([state.card.bundle.bytes, Buffer.from('tampered')]);
318
+
319
+ const replica = blankDir('corrupt-replica');
320
+ assert.throws(() => apply(replica, state), /bundle rejected/);
321
+ });
322
+
323
+ // --- nested repositories: territory boundaries -------------------------------
324
+
325
+ test('a nested repository is its own territory: the parent captures around it', () => {
326
+ const dir = makeRepo('nested-parent');
327
+ fs.writeFileSync(path.join(dir, 'a.md'), 'parent\n');
328
+ commit(dir, 'parent');
329
+
330
+ const nested = path.join(dir, 'vendor', 'lib');
331
+ fs.mkdirSync(nested, { recursive: true });
332
+ git(nested, 'init', '--quiet');
333
+ git(nested, 'config', 'user.email', 'storm@amalgm.test');
334
+ git(nested, 'config', 'user.name', 'Storm');
335
+ git(nested, 'config', 'commit.gpgsign', 'false');
336
+ fs.writeFileSync(path.join(nested, 'inner.md'), 'child\n');
337
+ commit(nested, 'child');
338
+
339
+ fs.writeFileSync(path.join(dir, 'b.md'), 'untracked sibling\n');
340
+
341
+ const parent = capture(dir);
342
+ assert.ok(!parent.busy, 'a nested repo must not crash or block the parent capture');
343
+ assert.deepEqual(parent.checkpoint.untracked.map((file) => file.path), ['b.md'],
344
+ 'the nested repository must not ride the parent checkpoint');
345
+
346
+ const child = capture(nested, { withBundle: false });
347
+ assert.ok(!child.busy && child.cardId, 'the nested repository captures as its own territory');
348
+
349
+ // The parent still round-trips (axiom 4) — minus the nested territory,
350
+ // which travels as its own card, never inside the parent.
351
+ const replica = blankDir('nested-replica');
352
+ apply(replica, parent);
353
+ assert.ok(!fs.existsSync(path.join(replica, 'vendor', 'lib')),
354
+ 'the replica must not contain the nested territory');
355
+ });
356
+
357
+ test('a linked worktree (.git file) inside the checkout is excluded the same way', () => {
358
+ const dir = makeRepo('wt-parent');
359
+ fs.writeFileSync(path.join(dir, 'a.md'), 'v1\n');
360
+ commit(dir, 'first');
361
+ git(dir, 'worktree', 'add', '--quiet', path.join(dir, 'worktrees', 'wt1'), '-b', 'side');
362
+
363
+ const parent = capture(dir, { withBundle: false });
364
+ assert.ok(!parent.busy, 'a linked worktree must not crash the parent capture');
365
+ assert.equal(parent.checkpoint.untracked.some((file) => file.path.startsWith('worktrees/')), false,
366
+ 'the linked worktree must not ride the parent checkpoint');
367
+
368
+ const child = capture(path.join(dir, 'worktrees', 'wt1'), { withBundle: false });
369
+ assert.ok(!child.busy, 'the linked worktree captures as its own territory');
370
+ assert.equal(child.card.head.branch, 'side');
371
+ });