gabriel / muse public
test_plumbing_integration.py python
441 lines 16.0 KB
99746394 feat(tests+docs): supercharge plumbing test suite and update reference doc Gabriel Cardona <gabriel@tellurstori.com> 2d 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 typer.testing import CliRunner
26
27 from muse.cli.app import cli
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_json = pack_result.stdout
233
234 unpack_result = runner.invoke(
235 cli, ["plumbing", "unpack-objects"], input=bundle_json, 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_json = runner.invoke(
251 cli, ["plumbing", "pack-objects", cid], env=_env(src)
252 ).stdout
253 runner.invoke(cli, ["plumbing", "unpack-objects"], input=bundle_json, 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"