gabriel / muse public
test_plumbing_integration.py python
441 lines 16.0 KB
dec4604a feat(mwp): replace JSON+base64 wire protocol with MWP binary msgpack Gabriel Cardona <gabriel@tellurstori.com> 13h ago
1 """Cross-command integration tests for the Muse plumbing layer.
2
3 These tests chain multiple plumbing commands together the way real agent
4 pipelines and scripts would, verifying that the output of one command is
5 correctly consumed by the next and that the whole chain is self-consistent.
6
7 Pipelines tested:
8 - hash-object → cat-object → verify-object (object write/read/integrity)
9 - commit-tree → update-ref → rev-parse (commit creation end-to-end)
10 - pack-objects → unpack-objects round-trip (transport)
11 - snapshot-diff → ls-files cross-check (diff vs. manifest consistency)
12 - show-ref → for-each-ref consistency (ref listing cross-check)
13 - symbolic-ref → rev-parse → read-commit (HEAD dereference chain)
14 - merge-base → snapshot-diff (divergence analysis)
15 - commit-graph → name-rev (graph walk + naming)
16 """
17
18 from __future__ import annotations
19
20 import datetime
21 import hashlib
22 import json
23 import pathlib
24
25 from tests.cli_test_helper import CliRunner
26
27 cli = None # argparse migration — CliRunner ignores this arg
28 from muse.core.object_store import write_object
29 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Shared helpers
36 # ---------------------------------------------------------------------------
37
38
39 def _sha(tag: str) -> str:
40 return hashlib.sha256(tag.encode()).hexdigest()
41
42
43 def _sha_bytes(data: bytes) -> str:
44 return hashlib.sha256(data).hexdigest()
45
46
47 def _init_repo(path: pathlib.Path) -> pathlib.Path:
48 muse = path / ".muse"
49 (muse / "commits").mkdir(parents=True)
50 (muse / "snapshots").mkdir(parents=True)
51 (muse / "objects").mkdir(parents=True)
52 (muse / "refs" / "heads").mkdir(parents=True)
53 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
54 (muse / "repo.json").write_text(
55 json.dumps({"repo_id": "test-repo", "domain": "midi"}), encoding="utf-8"
56 )
57 return path
58
59
60 def _env(repo: pathlib.Path) -> dict[str, str]:
61 return {"MUSE_REPO_ROOT": str(repo)}
62
63
64 def _snap(repo: pathlib.Path, manifest: dict[str, str] | None = None, tag: str = "s") -> str:
65 m = manifest or {}
66 sid = _sha(f"snap-{tag}-{sorted(m.items())}")
67 write_snapshot(
68 repo,
69 SnapshotRecord(
70 snapshot_id=sid,
71 manifest=m,
72 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
73 ),
74 )
75 return sid
76
77
78 def _commit(
79 repo: pathlib.Path, tag: str, sid: str, branch: str = "main", parent: str | None = None
80 ) -> str:
81 cid = _sha(tag)
82 write_commit(
83 repo,
84 CommitRecord(
85 commit_id=cid,
86 repo_id="test-repo",
87 branch=branch,
88 snapshot_id=sid,
89 message=tag,
90 committed_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
91 author="tester",
92 parent_commit_id=parent,
93 ),
94 )
95 ref = repo / ".muse" / "refs" / "heads" / branch
96 ref.parent.mkdir(parents=True, exist_ok=True)
97 ref.write_text(cid, encoding="utf-8")
98 return cid
99
100
101 def _obj(repo: pathlib.Path, content: bytes) -> str:
102 oid = _sha_bytes(content)
103 write_object(repo, oid, content)
104 return oid
105
106
107 def _invoke(args: list[str], repo: pathlib.Path, stdin: str | None = None) -> dict[str, str | bool | int | None | list[str] | list[dict[str, str | bool | int | None]]]:
108 result = runner.invoke(cli, args, env=_env(repo), input=stdin)
109 assert result.exit_code == 0, f"Command {args!r} failed: {result.output}"
110 parsed = json.loads(result.stdout)
111 assert isinstance(parsed, dict)
112 return parsed
113
114
115 def _invoke_text(args: list[str], repo: pathlib.Path) -> str:
116 result = runner.invoke(cli, args, env=_env(repo))
117 assert result.exit_code == 0, f"Command {args!r} failed: {result.output}"
118 return result.stdout.strip()
119
120
121 # ---------------------------------------------------------------------------
122 # Pipeline 1: hash-object → cat-object → verify-object
123 # ---------------------------------------------------------------------------
124
125
126 class TestHashCatVerifyPipeline:
127 def test_write_then_cat_returns_same_bytes(self, tmp_path: pathlib.Path) -> None:
128 content = b"pipeline test content"
129 f = tmp_path / "src.mid"
130 f.write_bytes(content)
131 repo = _init_repo(tmp_path / "repo")
132
133 # Step 1: hash-object --write
134 ho = _invoke(["plumbing", "hash-object", "--write", str(f)], repo)
135 oid = ho["object_id"]
136 assert ho["stored"] is True
137
138 # Step 2: cat-object --format info → size matches
139 info = _invoke(["plumbing", "cat-object", "--format", "info", oid], repo)
140 assert info["size_bytes"] == len(content)
141 assert info["present"] is True
142
143 # Step 3: verify-object → all_ok
144 vfy = _invoke(["plumbing", "verify-object", oid], repo)
145 assert vfy["all_ok"] is True
146 assert vfy["failed"] == 0
147
148 def test_hash_without_write_not_in_store(self, tmp_path: pathlib.Path) -> None:
149 content = b"no-write"
150 f = tmp_path / "nw.mid"
151 f.write_bytes(content)
152 repo = _init_repo(tmp_path / "repo")
153
154 ho = _invoke(["plumbing", "hash-object", str(f)], repo)
155 oid = ho["object_id"]
156
157 # cat-object with --format info should report present=False
158 result = runner.invoke(
159 cli,
160 ["plumbing", "cat-object", "--format", "info", oid],
161 env=_env(repo),
162 )
163 assert result.exit_code != 0
164 assert json.loads(result.stdout)["present"] is False
165
166
167 # ---------------------------------------------------------------------------
168 # Pipeline 2: commit-tree → update-ref → rev-parse
169 # ---------------------------------------------------------------------------
170
171
172 class TestCommitTreeUpdateRefRevParse:
173 def test_full_commit_creation_pipeline(self, tmp_path: pathlib.Path) -> None:
174 repo = _init_repo(tmp_path)
175 sid = _snap(repo)
176
177 # Step 1: commit-tree
178 ct = _invoke(
179 ["plumbing", "commit-tree", "--snapshot", sid, "--message", "pipeline"],
180 repo,
181 )
182 cid = ct["commit_id"]
183
184 # Step 2: update-ref
185 ur = _invoke(["plumbing", "update-ref", "main", cid], repo)
186 assert ur["commit_id"] == cid
187
188 # Step 3: rev-parse HEAD → should resolve to the same commit
189 rp = _invoke(["plumbing", "rev-parse", "HEAD"], repo)
190 assert rp["commit_id"] == cid
191
192 def test_two_commit_chain_rev_parse_follows_ref(self, tmp_path: pathlib.Path) -> None:
193 repo = _init_repo(tmp_path)
194 sid1 = _snap(repo, tag="s1")
195 sid2 = _snap(repo, tag="s2")
196
197 ct1 = _invoke(["plumbing", "commit-tree", "--snapshot", sid1, "--message", "c1"], repo)
198 cid1 = ct1["commit_id"]
199 _invoke(["plumbing", "update-ref", "main", cid1], repo)
200
201 ct2 = _invoke(
202 ["plumbing", "commit-tree", "--snapshot", sid2, "--message", "c2", "--parent", cid1],
203 repo,
204 )
205 cid2 = ct2["commit_id"]
206 _invoke(["plumbing", "update-ref", "main", cid2], repo)
207
208 rp = _invoke(["plumbing", "rev-parse", "main"], repo)
209 assert rp["commit_id"] == cid2
210
211
212 # ---------------------------------------------------------------------------
213 # Pipeline 3: pack-objects → unpack-objects round-trip
214 # ---------------------------------------------------------------------------
215
216
217 class TestPackUnpackPipeline:
218 def test_all_objects_survive_transport(self, tmp_path: pathlib.Path) -> None:
219 from muse.core.object_store import has_object
220 from muse.core.store import read_commit, read_snapshot
221
222 src = _init_repo(tmp_path / "src")
223 dst = _init_repo(tmp_path / "dst")
224
225 content = b"MIDI blob for transport"
226 oid = _obj(src, content)
227 sid = _snap(src, {"track.mid": oid})
228 cid = _commit(src, "transport-test", sid)
229
230 pack_result = runner.invoke(cli, ["plumbing", "pack-objects", cid], env=_env(src))
231 assert pack_result.exit_code == 0
232 bundle_bytes = pack_result.stdout_bytes
233
234 unpack_result = runner.invoke(
235 cli, ["plumbing", "unpack-objects"], input=bundle_bytes, env=_env(dst)
236 )
237 assert unpack_result.exit_code == 0
238
239 assert read_commit(dst, cid) is not None
240 assert read_snapshot(dst, sid) is not None
241 assert has_object(dst, oid)
242
243 def test_pack_then_verify_object_in_dst(self, tmp_path: pathlib.Path) -> None:
244 src = _init_repo(tmp_path / "src")
245 dst = _init_repo(tmp_path / "dst")
246 oid = _obj(src, b"verify after unpack")
247 sid = _snap(src, {"v.mid": oid})
248 cid = _commit(src, "verify-after", sid)
249
250 bundle_bytes = runner.invoke(
251 cli, ["plumbing", "pack-objects", cid], env=_env(src)
252 ).stdout_bytes
253 runner.invoke(cli, ["plumbing", "unpack-objects"], input=bundle_bytes, env=_env(dst))
254
255 vfy = _invoke(["plumbing", "verify-object", oid], dst)
256 assert vfy["all_ok"] is True
257
258
259 # ---------------------------------------------------------------------------
260 # Pipeline 4: snapshot-diff vs. ls-files cross-check
261 # ---------------------------------------------------------------------------
262
263
264 class TestSnapshotDiffLsFilesCrossCheck:
265 def test_added_files_in_diff_appear_in_new_ls_files(self, tmp_path: pathlib.Path) -> None:
266 repo = _init_repo(tmp_path)
267 oid_a = _sha("obj-a")
268 oid_b = _sha("obj-b")
269
270 sid1 = _snap(repo, {"a.mid": oid_a}, "s1")
271 sid2 = _snap(repo, {"a.mid": oid_a, "b.mid": oid_b}, "s2")
272 cid1 = _commit(repo, "c1", sid1)
273 cid2 = _commit(repo, "c2", sid2, parent=cid1)
274
275 diff = _invoke(["plumbing", "snapshot-diff", sid1, sid2], repo)
276 added_paths = {e["path"] for e in diff["added"]}
277
278 ls = _invoke(["plumbing", "ls-files", "--commit", cid2], repo)
279 ls_paths = {f["path"] for f in ls["files"]}
280
281 assert added_paths.issubset(ls_paths)
282
283 def test_deleted_files_absent_from_new_ls_files(self, tmp_path: pathlib.Path) -> None:
284 repo = _init_repo(tmp_path)
285 oid = _sha("obj")
286 sid1 = _snap(repo, {"gone.mid": oid}, "s1")
287 sid2 = _snap(repo, {}, "s2")
288 cid1 = _commit(repo, "d1", sid1)
289 cid2 = _commit(repo, "d2", sid2, parent=cid1)
290
291 diff = _invoke(["plumbing", "snapshot-diff", sid1, sid2], repo)
292 deleted_paths = {e["path"] for e in diff["deleted"]}
293
294 ls = _invoke(["plumbing", "ls-files", "--commit", cid2], repo)
295 ls_paths = {f["path"] for f in ls["files"]}
296
297 assert deleted_paths.isdisjoint(ls_paths)
298
299
300 # ---------------------------------------------------------------------------
301 # Pipeline 5: show-ref ↔ for-each-ref consistency
302 # ---------------------------------------------------------------------------
303
304
305 class TestShowRefForEachRefConsistency:
306 def test_both_commands_report_same_commit_ids(self, tmp_path: pathlib.Path) -> None:
307 repo = _init_repo(tmp_path)
308 sid = _snap(repo)
309 cid_main = _commit(repo, "main-tip", sid, branch="main")
310 cid_dev = _commit(repo, "dev-tip", sid, branch="dev")
311
312 show = _invoke(["plumbing", "show-ref"], repo)
313 show_ids = {r["commit_id"] for r in show["refs"]}
314
315 each = _invoke(["plumbing", "for-each-ref"], repo)
316 each_ids = {r["commit_id"] for r in each["refs"]}
317
318 assert show_ids == each_ids
319
320 def test_both_commands_report_same_branch_count(self, tmp_path: pathlib.Path) -> None:
321 repo = _init_repo(tmp_path)
322 sid = _snap(repo)
323 for branch in ("main", "dev", "feat"):
324 _commit(repo, f"{branch}-tip", sid, branch=branch)
325
326 show = _invoke(["plumbing", "show-ref"], repo)
327 each = _invoke(["plumbing", "for-each-ref"], repo)
328 assert show["count"] == len(each["refs"])
329
330
331 # ---------------------------------------------------------------------------
332 # Pipeline 6: symbolic-ref → rev-parse → read-commit
333 # ---------------------------------------------------------------------------
334
335
336 class TestSymbolicRefRevParseReadCommit:
337 def test_symbolic_ref_branch_matches_rev_parse_commit(self, tmp_path: pathlib.Path) -> None:
338 repo = _init_repo(tmp_path)
339 sid = _snap(repo)
340 cid = _commit(repo, "head-chain", sid)
341
342 sym = _invoke(["plumbing", "symbolic-ref"], repo)
343 branch = sym["branch"]
344
345 rp = _invoke(["plumbing", "rev-parse", branch], repo)
346 assert rp["commit_id"] == cid
347
348 rc = _invoke(["plumbing", "read-commit", cid], repo)
349 assert rc["branch"] == branch
350
351 def test_set_and_read_symbolic_ref_consistent(self, tmp_path: pathlib.Path) -> None:
352 repo = _init_repo(tmp_path)
353 sid = _snap(repo)
354 _commit(repo, "main-c", sid, branch="main")
355 _commit(repo, "dev-c", sid, branch="dev")
356
357 # Switch HEAD to dev
358 result = runner.invoke(
359 cli, ["plumbing", "symbolic-ref", "--set", "dev"], env=_env(repo)
360 )
361 assert result.exit_code == 0
362
363 sym = _invoke(["plumbing", "symbolic-ref"], repo)
364 assert sym["branch"] == "dev"
365
366 rp = _invoke(["plumbing", "rev-parse", "HEAD"], repo)
367 assert rp["commit_id"] == _sha("dev-c")
368
369
370 # ---------------------------------------------------------------------------
371 # Pipeline 7: merge-base → snapshot-diff (divergence analysis)
372 # ---------------------------------------------------------------------------
373
374
375 class TestMergeBaseSnapshotDiff:
376 def test_diff_between_branches_using_merge_base(self, tmp_path: pathlib.Path) -> None:
377 repo = _init_repo(tmp_path)
378 oid_common = _sha("common")
379 oid_main = _sha("main-only")
380 oid_feat = _sha("feat-only")
381
382 sid_base = _snap(repo, {"common.mid": oid_common}, "base")
383 sid_main = _snap(repo, {"common.mid": oid_common, "main.mid": oid_main}, "main")
384 sid_feat = _snap(repo, {"common.mid": oid_common, "feat.mid": oid_feat}, "feat")
385
386 c_base = _commit(repo, "base-commit", sid_base)
387 c_main = _commit(repo, "main-commit", sid_main, branch="main", parent=c_base)
388 c_feat = _commit(repo, "feat-commit", sid_feat, branch="feat", parent=c_base)
389
390 mb = _invoke(["plumbing", "merge-base", "main", "feat"], repo)
391 base_cid = mb["merge_base"]
392 assert base_cid == c_base
393
394 # Snapshot of the merge base
395 rc_base = _invoke(["plumbing", "read-commit", base_cid], repo)
396 sid_at_base = rc_base["snapshot_id"]
397
398 # Diff main's snapshot vs. base — should show main.mid as added
399 diff_main = _invoke(["plumbing", "snapshot-diff", str(sid_at_base), str(sid_main)], repo)
400 added = {e["path"] for e in diff_main["added"]}
401 assert "main.mid" in added
402
403
404 # ---------------------------------------------------------------------------
405 # Pipeline 8: commit-graph → name-rev
406 # ---------------------------------------------------------------------------
407
408
409 class TestCommitGraphNameRev:
410 def test_graph_tip_named_branch_tilde_zero(self, tmp_path: pathlib.Path) -> None:
411 repo = _init_repo(tmp_path)
412 sid = _snap(repo)
413 c0 = _commit(repo, "c0", sid)
414 c1 = _commit(repo, "c1", sid, parent=c0)
415 c2 = _commit(repo, "c2", sid, parent=c1)
416
417 graph = _invoke(["plumbing", "commit-graph"], repo)
418 tip = graph["tip"]
419
420 nr = _invoke(["plumbing", "name-rev", tip], repo)
421 named = nr["results"][0]
422 assert named["commit_id"] == tip
423 # Tip commit: distance=0, name is just the branch name (no ~0 suffix).
424 assert named["name"] == "main"
425
426 def test_all_graph_commits_nameable(self, tmp_path: pathlib.Path) -> None:
427 repo = _init_repo(tmp_path)
428 sid = _snap(repo)
429 parent: str | None = None
430 cids: list[str] = []
431 for i in range(5):
432 cid = _commit(repo, f"chain-{i}", sid, parent=parent)
433 cids.append(cid)
434 parent = cid
435
436 graph = _invoke(["plumbing", "commit-graph"], repo)
437 graph_ids = [c["commit_id"] for c in graph["commits"]]
438
439 nr = _invoke(["plumbing", "name-rev", *graph_ids], repo)
440 for entry in nr["results"]:
441 assert not entry["undefined"], f"Commit {entry['commit_id']} is undefined"