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