gabriel / muse public
test_cmd_snapshot_diff.py python
481 lines 19.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for ``muse snapshot-diff``.
2
3 Verifies categorisation of added/modified/deleted paths, resolution of
4 snapshot IDs, commit IDs, and branch names, text-format output, and error
5 handling for unresolvable refs.
6 """
7
8 from __future__ import annotations
9
10 import datetime
11 import json
12 import pathlib
13
14 from tests.cli_test_helper import CliRunner
15
16 cli = None # argparse migration — CliRunner ignores this arg
17 from muse.core.errors import ExitCode
18 from muse.core.object_store import write_object
19 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
20 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
21 from muse.core._types import Manifest, blob_id
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30
31 def _init_repo(path: pathlib.Path) -> pathlib.Path:
32 muse = path / ".muse"
33 (muse / "commits").mkdir(parents=True)
34 (muse / "snapshots").mkdir(parents=True)
35 (muse / "objects").mkdir(parents=True)
36 (muse / "refs" / "heads").mkdir(parents=True)
37 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
38 (muse / "repo.json").write_text(
39 json.dumps({"repo_id": "test-repo", "domain": "midi"}), encoding="utf-8"
40 )
41 return path
42
43
44 def _env(repo: pathlib.Path) -> Manifest:
45 return {"MUSE_REPO_ROOT": str(repo)}
46
47
48 def _obj(repo: pathlib.Path, content: bytes) -> str:
49 oid = blob_id(content)
50 write_object(repo, oid, content)
51 return oid
52
53
54 def _snap(repo: pathlib.Path, manifest: Manifest) -> str:
55 sid = compute_snapshot_id(manifest)
56 write_snapshot(
57 repo,
58 SnapshotRecord(
59 snapshot_id=sid,
60 manifest=manifest,
61 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
62 ),
63 )
64 return sid
65
66
67 def _commit(repo: pathlib.Path, tag: str, sid: str, branch: str = "main") -> str:
68 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
69 cid = compute_commit_id(
70 repo_id="test-repo",
71 parent_ids=[],
72 snapshot_id=sid,
73 message=tag,
74 committed_at_iso=committed_at.isoformat(),
75 author="tester",
76 )
77 write_commit(
78 repo,
79 CommitRecord(
80 commit_id=cid,
81 repo_id="test-repo",
82 created_on_branch=branch,
83 snapshot_id=sid,
84 message=tag,
85 committed_at=committed_at,
86 author="tester",
87 parent_commit_id=None,
88 ),
89 )
90 ref = repo / ".muse" / "refs" / "heads" / branch
91 ref.write_text(cid, encoding="utf-8")
92 return cid
93
94
95 # ---------------------------------------------------------------------------
96 # Tests
97 # ---------------------------------------------------------------------------
98
99
100 class TestSnapshotDiff:
101 def test_added_deleted_categorised_correctly(self, tmp_path: pathlib.Path) -> None:
102 repo = _init_repo(tmp_path)
103 shared = _obj(repo, b"shared")
104 new_obj = _obj(repo, b"new")
105 sid_a = _snap(repo, {"shared.mid": shared, "old.mid": shared})
106 sid_b = _snap(repo, {"shared.mid": shared, "new.mid": new_obj})
107 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
108 assert result.exit_code == 0, result.output
109 data = json.loads(result.stdout)
110 assert [e["path"] for e in data["added"]] == ["new.mid"]
111 assert [e["path"] for e in data["deleted"]] == ["old.mid"]
112 assert data["modified"] == []
113 assert data["total_changes"] == 2
114
115 def test_modified_entry_contains_both_object_ids(self, tmp_path: pathlib.Path) -> None:
116 repo = _init_repo(tmp_path)
117 v1 = _obj(repo, b"v1")
118 v2 = _obj(repo, b"v2")
119 sid_a = _snap(repo, {"track.mid": v1})
120 sid_b = _snap(repo, {"track.mid": v2})
121 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
122 assert result.exit_code == 0, result.output
123 data = json.loads(result.stdout)
124 assert len(data["modified"]) == 1
125 mod = data["modified"][0]
126 assert mod["path"] == "track.mid"
127 assert mod["object_id_a"] == v1
128 assert mod["object_id_b"] == v2
129
130 def test_zero_changes_when_snapshots_identical(self, tmp_path: pathlib.Path) -> None:
131 repo = _init_repo(tmp_path)
132 obj = _obj(repo, b"same")
133 sid = _snap(repo, {"f.mid": obj})
134 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
135 assert result.exit_code == 0, result.output
136 data = json.loads(result.stdout)
137 assert data["total_changes"] == 0
138
139 def test_resolves_by_branch_name(self, tmp_path: pathlib.Path) -> None:
140 repo = _init_repo(tmp_path)
141 obj_a = _obj(repo, b"a")
142 obj_b = _obj(repo, b"b")
143 _commit(repo, "cmt-main", _snap(repo, {"a.mid": obj_a}), branch="main")
144 _commit(repo, "cmt-dev", _snap(repo, {"b.mid": obj_b}), branch="dev")
145 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
146 result = runner.invoke(cli, ["snapshot-diff", "--json", "main", "dev"], env=_env(repo))
147 assert result.exit_code == 0, result.output
148 data = json.loads(result.stdout)
149 assert data["total_changes"] == 2
150
151 def test_text_format_shows_status_letters(self, tmp_path: pathlib.Path) -> None:
152 repo = _init_repo(tmp_path)
153 shared = _obj(repo, b"s")
154 new_obj = _obj(repo, b"n")
155 sid_a = _snap(repo, {"gone.mid": shared})
156 sid_b = _snap(repo, {"new.mid": new_obj})
157 result = runner.invoke(
158 cli, ["snapshot-diff", sid_a, sid_b], env=_env(repo)
159 )
160 assert result.exit_code == 0, result.output
161 assert "A new.mid" in result.stdout
162 assert "D gone.mid" in result.stdout
163
164 def test_stat_flag_appends_summary(self, tmp_path: pathlib.Path) -> None:
165 repo = _init_repo(tmp_path)
166 sid_a = _snap(repo, {"gone.mid": _obj(repo, b"g")})
167 sid_b = _snap(repo, {"new.mid": _obj(repo, b"n")})
168 result = runner.invoke(
169 cli,
170 ["snapshot-diff", "--stat", sid_a, sid_b],
171 env=_env(repo),
172 )
173 assert result.exit_code == 0, result.output
174 assert "added" in result.stdout
175 assert "deleted" in result.stdout
176
177 def test_unresolvable_ref_exits_user_error(self, tmp_path: pathlib.Path) -> None:
178 repo = _init_repo(tmp_path)
179 result = runner.invoke(
180 cli, ["snapshot-diff", "no-such-thing", "also-missing"], env=_env(repo)
181 )
182 assert result.exit_code == ExitCode.USER_ERROR
183 assert "error" in json.loads(result.stdout)
184
185 def test_results_sorted_lexicographically(self, tmp_path: pathlib.Path) -> None:
186 repo = _init_repo(tmp_path)
187 sid_a = _snap(repo, {})
188 sid_b = _snap(
189 repo, {"z.mid": _obj(repo, b"z"), "a.mid": _obj(repo, b"a"), "m.mid": _obj(repo, b"m")}
190 )
191 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
192 assert result.exit_code == 0, result.output
193 data = json.loads(result.stdout)
194 added_paths = [e["path"] for e in data["added"]]
195 assert added_paths == sorted(added_paths)
196
197
198 class TestSnapshotDiffStdin:
199 """Tests for ``--stdin`` batch mode."""
200
201 def test_single_pair_via_stdin_json(self, tmp_path: pathlib.Path) -> None:
202 repo = _init_repo(tmp_path)
203 oid_a = _obj(repo, b"a")
204 oid_b = _obj(repo, b"b")
205 sid_a = _snap(repo, {"a.mid": oid_a})
206 sid_b = _snap(repo, {"b.mid": oid_b})
207 stdin = f"{sid_a} {sid_b}\n"
208 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
209 assert result.exit_code == 0, result.output
210 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
211 assert len(lines) == 1
212 data = json.loads(lines[0])
213 assert data["snapshot_a"] == sid_a
214 assert data["snapshot_b"] == sid_b
215 assert len(data["added"]) == 1
216 assert len(data["deleted"]) == 1
217 assert data["total_changes"] == 2
218
219 def test_multiple_pairs_emit_ndjson(self, tmp_path: pathlib.Path) -> None:
220 repo = _init_repo(tmp_path)
221 oid = _obj(repo, b"x")
222 sid1 = _snap(repo, {"x.mid": oid})
223 sid2 = _snap(repo, {})
224 sid3 = _snap(repo, {"x.mid": oid, "y.mid": _obj(repo, b"y")})
225 stdin = f"{sid1} {sid2}\n{sid2} {sid3}\n"
226 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
227 assert result.exit_code == 0, result.output
228 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
229 assert len(lines) == 2
230 first = json.loads(lines[0])
231 second = json.loads(lines[1])
232 assert first["snapshot_a"] == sid1
233 assert first["snapshot_b"] == sid2
234 assert second["snapshot_a"] == sid2
235 assert second["snapshot_b"] == sid3
236
237 def test_invalid_ref_reported_inline_not_exit_error(self, tmp_path: pathlib.Path) -> None:
238 repo = _init_repo(tmp_path)
239 oid = _obj(repo, b"ok")
240 sid_a = _snap(repo, {"f.mid": oid})
241 sid_b = _snap(repo, {})
242 # First line is bad ref, second is valid
243 bad_ref = "a" * 64 # valid OID format but not in store
244 stdin = f"{bad_ref} {bad_ref}\n{sid_a} {sid_b}\n"
245 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
246 assert result.exit_code == 0 # batch mode always exits 0
247 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
248 assert len(lines) == 2
249 first = json.loads(lines[0])
250 assert "error" in first
251 second = json.loads(lines[1])
252 assert "error" not in second
253 assert second["total_changes"] == 1
254
255 def test_empty_lines_and_comments_skipped(self, tmp_path: pathlib.Path) -> None:
256 repo = _init_repo(tmp_path)
257 sid = _snap(repo, {})
258 stdin = f"\n# this is a comment\n\n{sid} {sid}\n\n"
259 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
260 assert result.exit_code == 0, result.output
261 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
262 assert len(lines) == 1
263 data = json.loads(lines[0])
264 assert data["total_changes"] == 0
265
266 def test_malformed_line_single_token_reported_inline(self, tmp_path: pathlib.Path) -> None:
267 repo = _init_repo(tmp_path)
268 sid = _snap(repo, {})
269 stdin = f"only-one-token\n{sid} {sid}\n"
270 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
271 assert result.exit_code == 0
272 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
273 assert len(lines) == 2
274 first = json.loads(lines[0])
275 assert "error" in first
276 second = json.loads(lines[1])
277 assert "error" not in second
278
279 def test_empty_stdin_produces_no_output(self, tmp_path: pathlib.Path) -> None:
280 repo = _init_repo(tmp_path)
281 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input="")
282 assert result.exit_code == 0
283 assert result.stdout.strip() == ""
284
285 def test_stdin_text_format_blank_line_separated(self, tmp_path: pathlib.Path) -> None:
286 repo = _init_repo(tmp_path)
287 oid_a = _obj(repo, b"a")
288 oid_b = _obj(repo, b"b")
289 sid1 = _snap(repo, {"a.mid": oid_a})
290 sid2 = _snap(repo, {"b.mid": oid_b})
291 sid3 = _snap(repo, {})
292 stdin = f"{sid1} {sid2}\n{sid2} {sid3}\n"
293 result = runner.invoke(
294 cli, ["snapshot-diff", "--stdin", ], env=_env(repo), input=stdin
295 )
296 assert result.exit_code == 0, result.output
297 output = result.stdout
298 # Two diffs separated by a blank line
299 assert "A b.mid" in output or "D a.mid" in output
300 # There should be a blank-line separator between the two pairs
301 blocks = [b.strip() for b in output.split("\n\n") if b.strip()]
302 assert len(blocks) == 2
303
304 def test_stdin_all_errors_still_exits_0(self, tmp_path: pathlib.Path) -> None:
305 repo = _init_repo(tmp_path)
306 bad = "b" * 64 # valid format, not in store
307 stdin = f"{bad} {bad}\n{bad} {bad}\n"
308 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
309 assert result.exit_code == 0
310 lines = [ln for ln in result.stdout.strip().splitlines() if ln]
311 assert all("error" in json.loads(ln) for ln in lines)
312
313 def test_stdin_zero_change_pair_included(self, tmp_path: pathlib.Path) -> None:
314 repo = _init_repo(tmp_path)
315 sid = _snap(repo, {"f.mid": _obj(repo, b"f")})
316 stdin = f"{sid} {sid}\n"
317 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
318 assert result.exit_code == 0, result.output
319 data = json.loads(result.stdout.strip())
320 assert data["total_changes"] == 0
321
322
323 class TestSnapshotDiffEdgeCases:
324 """Edge cases not covered by the primary test classes."""
325
326 def test_bad_format_value_exits_user_error(self, tmp_path: pathlib.Path) -> None:
327 repo = _init_repo(tmp_path)
328 sid = _snap(repo, {})
329 result = runner.invoke(
330 cli, ["snapshot-diff", "--only", "xml", sid, sid], env=_env(repo)
331 )
332 assert result.exit_code != 0
333
334 def test_ref_a_provided_ref_b_missing_exits_user_error(self, tmp_path: pathlib.Path) -> None:
335 repo = _init_repo(tmp_path)
336 sid = _snap(repo, {})
337 result = runner.invoke(cli, ["snapshot-diff", "--json", sid], env=_env(repo))
338 assert result.exit_code == ExitCode.USER_ERROR
339
340 def test_raw_with_zero_changes_produces_no_diff_lines(self, tmp_path: pathlib.Path) -> None:
341 repo = _init_repo(tmp_path)
342 sid = _snap(repo, {"f.mid": _obj(repo, b"same")})
343 result = runner.invoke(
344 cli, ["snapshot-diff", "--raw", sid, sid], env=_env(repo)
345 )
346 assert result.exit_code == 0, result.output
347 # No A/M/D lines when there are no changes.
348 for line in result.stdout.splitlines():
349 assert not line.startswith(("A ", "M ", "D "))
350
351 def test_json_shorthand_flag_accepted(self, tmp_path: pathlib.Path) -> None:
352 repo = _init_repo(tmp_path)
353 sid = _snap(repo, {"f.mid": _obj(repo, b"x")})
354 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
355 assert result.exit_code == 0, result.output
356 data = json.loads(result.stdout)
357 assert data["total_changes"] == 0
358
359 def test_no_args_no_stdin_exits_user_error(self, tmp_path: pathlib.Path) -> None:
360 repo = _init_repo(tmp_path)
361 result = runner.invoke(cli, ["snapshot-diff"], env=_env(repo))
362 assert result.exit_code == ExitCode.USER_ERROR
363
364
365 class TestSnapshotDiffRaw:
366 """Tests for ``--raw`` flag (OIDs included in text output)."""
367
368 def test_raw_added_includes_object_id(self, tmp_path: pathlib.Path) -> None:
369 repo = _init_repo(tmp_path)
370 oid = _obj(repo, b"new-content")
371 sid_a = _snap(repo, {})
372 sid_b = _snap(repo, {"new.mid": oid})
373 result = runner.invoke(
374 cli, ["snapshot-diff", "--raw", sid_a, sid_b], env=_env(repo)
375 )
376 assert result.exit_code == 0, result.output
377 assert oid in result.stdout
378 assert "A" in result.stdout
379 assert "new.mid" in result.stdout
380
381 def test_raw_deleted_includes_object_id(self, tmp_path: pathlib.Path) -> None:
382 repo = _init_repo(tmp_path)
383 oid = _obj(repo, b"old-content")
384 sid_a = _snap(repo, {"gone.mid": oid})
385 sid_b = _snap(repo, {})
386 result = runner.invoke(
387 cli, ["snapshot-diff", "--raw", sid_a, sid_b], env=_env(repo)
388 )
389 assert result.exit_code == 0, result.output
390 assert oid in result.stdout
391 assert "D" in result.stdout
392 assert "gone.mid" in result.stdout
393
394 def test_raw_modified_includes_both_object_ids(self, tmp_path: pathlib.Path) -> None:
395 repo = _init_repo(tmp_path)
396 oid_a = _obj(repo, b"version-1")
397 oid_b = _obj(repo, b"version-2")
398 sid_a = _snap(repo, {"track.mid": oid_a})
399 sid_b = _snap(repo, {"track.mid": oid_b})
400 result = runner.invoke(
401 cli, ["snapshot-diff", "--raw", sid_a, sid_b], env=_env(repo)
402 )
403 assert result.exit_code == 0, result.output
404 assert oid_a in result.stdout
405 assert oid_b in result.stdout
406 assert "M" in result.stdout
407 assert "track.mid" in result.stdout
408
409 def test_text_without_raw_omits_object_ids(self, tmp_path: pathlib.Path) -> None:
410 repo = _init_repo(tmp_path)
411 oid = _obj(repo, b"some-content")
412 sid_a = _snap(repo, {})
413 sid_b = _snap(repo, {"file.mid": oid})
414 result = runner.invoke(
415 cli, ["snapshot-diff", sid_a, sid_b], env=_env(repo)
416 )
417 assert result.exit_code == 0, result.output
418 # OID should NOT appear in non-raw text output
419 assert oid not in result.stdout
420 assert "A file.mid" in result.stdout
421
422 def test_raw_has_no_effect_on_json_output(self, tmp_path: pathlib.Path) -> None:
423 repo = _init_repo(tmp_path)
424 oid_a = _obj(repo, b"va")
425 oid_b = _obj(repo, b"vb")
426 sid_a = _snap(repo, {"t.mid": oid_a})
427 sid_b = _snap(repo, {"t.mid": oid_b})
428 # JSON always includes OIDs; --raw flag is documented as no-op for JSON
429 result_plain = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
430 result_raw = runner.invoke(cli, ["snapshot-diff", "--json", "--raw", sid_a, sid_b], env=_env(repo))
431 assert result_plain.exit_code == 0
432 assert result_raw.exit_code == 0
433 data_plain = json.loads(result_plain.stdout)
434 data_raw = json.loads(result_raw.stdout)
435 # duration_ms will differ between two separate invocations — compare everything else.
436 for key in ("snapshot_a", "snapshot_b", "added", "modified", "deleted", "total_changes"):
437 assert data_plain[key] == data_raw[key]
438
439 def test_raw_stdin_batch_text_includes_oids(self, tmp_path: pathlib.Path) -> None:
440 repo = _init_repo(tmp_path)
441 oid = _obj(repo, b"batch-raw")
442 sid_a = _snap(repo, {})
443 sid_b = _snap(repo, {"r.mid": oid})
444 stdin = f"{sid_a} {sid_b}\n"
445 result = runner.invoke(
446 cli,
447 ["snapshot-diff", "--stdin", "--raw"],
448 env=_env(repo),
449 input=stdin,
450 )
451 assert result.exit_code == 0, result.output
452 assert oid in result.stdout
453 assert "A" in result.stdout
454
455
456 # ---------------------------------------------------------------------------
457 # Flag registration tests
458 # ---------------------------------------------------------------------------
459
460
461 class TestRegisterFlags:
462 def _parser(self):
463 import argparse
464 from muse.cli.commands.snapshot_diff import register
465
466 p = argparse.ArgumentParser()
467 subs = p.add_subparsers()
468 register(subs)
469 return p
470
471 def test_default_json_out_is_false(self):
472 args = self._parser().parse_args(["snapshot-diff", "main", "dev"])
473 assert args.json_out is False
474
475 def test_json_flag_sets_json_out(self):
476 args = self._parser().parse_args(["snapshot-diff", "--json", "main", "dev"])
477 assert args.json_out is True
478
479 def test_j_shorthand_sets_json_out(self):
480 args = self._parser().parse_args(["snapshot-diff", "-j", "main", "dev"])
481 assert args.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago