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