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