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.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- 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
|
+
});
|