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