gabriel / muse public
test_snapshot_diff_supercharge.py python
939 lines 39.8 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
1 """Comprehensive supercharge tests for ``muse snapshot-diff``.
2
3 Covers gaps in test_cmd_snapshot_diff.py:
4
5 * JSON envelope — duration_ms / exit_code / added_count / modified_count /
6 deleted_count on every successful JSON result
7 * JSON schema completeness — all documented fields, correct types
8 * Short prefix ID resolution — bare hex and sha256:<prefix> both accepted
9 * --only filter — restricts output to one category; suppressed lists are empty
10 * --path-prefix filter — scopes diff to a subdirectory; counts are filtered
11 * --only + --path-prefix combined
12 * Batch mode (--stdin) with envelope fields per line
13 * Security — ANSI injection in file paths sanitized in text output
14 * Security — path traversal in path_prefix (no escape outside manifest keys)
15 * Idempotency — diffing a snapshot against itself always yields zero changes
16 * Symmetric diff — (A→B) and (B→A) produce complementary add/delete counts
17 * Large manifest stress — 500-file diff completes and counts correctly
18 * Concurrent batch stress — 10 threads each diffing independently
19 * Empty snapshot edge cases — both empty, one empty
20 * All-modified edge case — every file changed between snapshots
21 * HEAD resolution — snapshot-diff HEAD HEAD produces zero changes
22 * Commit ID resolution — snapshot-diff <commit_id_a> <commit_id_b>
23 * _resolve_to_snapshot_id unit tests — branch / HEAD / snap_id / commit_id / bad
24 * _compute_diff unit tests — only/path_prefix interaction
25 """
26
27 from __future__ import annotations
28 from collections.abc import Mapping
29
30 import datetime
31 import json
32 import pathlib
33 import threading
34
35 import pytest
36
37 from tests.cli_test_helper import CliRunner
38 from muse.core.errors import ExitCode
39 from muse.core.object_store import write_object
40 from muse.core.paths import muse_dir, ref_path
41 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
42 from muse.core.commits import (
43 CommitRecord,
44 write_commit,
45 )
46 from muse.core.snapshots import (
47 SnapshotRecord,
48 write_snapshot,
49 )
50 from muse.core.types import Manifest, blob_id, short_id
51
52 cli = None # argparse migration — CliRunner ignores this arg
53
54 runner = CliRunner()
55
56
57 # ---------------------------------------------------------------------------
58 # Shared helpers (identical to test_cmd_snapshot_diff.py — not imported to
59 # keep each file self-contained)
60 # ---------------------------------------------------------------------------
61
62
63 def _init_repo(path: pathlib.Path) -> pathlib.Path:
64 dot_muse = muse_dir(path)
65 (dot_muse / "commits").mkdir(parents=True)
66 (dot_muse / "snapshots").mkdir(parents=True)
67 (dot_muse / "objects").mkdir(parents=True)
68 (dot_muse / "refs" / "heads").mkdir(parents=True)
69 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
70 (dot_muse / "repo.json").write_text(
71 json.dumps({"repo_id": "supercharge-diff", "domain": "midi"}), encoding="utf-8"
72 )
73 return path
74
75
76 def _env(repo: pathlib.Path) -> Mapping[str, str]:
77 return {"MUSE_REPO_ROOT": str(repo)}
78
79
80 def _obj(repo: pathlib.Path, content: bytes) -> str:
81 oid = blob_id(content)
82 write_object(repo, oid, content)
83 return oid
84
85
86 def _snap(repo: pathlib.Path, manifest: Manifest) -> str:
87 sid = compute_snapshot_id(manifest)
88 write_snapshot(
89 repo,
90 SnapshotRecord(
91 snapshot_id=sid,
92 manifest=manifest,
93 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
94 ),
95 )
96 return sid
97
98
99 def _commit(repo: pathlib.Path, tag: str, sid: str, branch: str = "main") -> str:
100 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
101 cid = compute_commit_id(
102 parent_ids=[],
103 snapshot_id=sid,
104 message=tag,
105 committed_at_iso=committed_at.isoformat(),
106 author="tester",)
107 write_commit(
108 repo,
109 CommitRecord(
110 commit_id=cid,
111 branch=branch,
112 snapshot_id=sid,
113 message=tag,
114 committed_at=committed_at,
115 author="tester",
116 parent_commit_id=None,
117 ),
118 )
119 branch_ref = ref_path(repo, branch)
120 branch_ref.write_text(cid, encoding="utf-8")
121 return cid
122
123
124 # ---------------------------------------------------------------------------
125 # JSON envelope — duration_ms / exit_code / per-category counts
126 # ---------------------------------------------------------------------------
127
128
129 class TestJsonEnvelope:
130 """The JSON result must include duration_ms, exit_code, and per-category counts."""
131
132 def test_duration_ms_present(self, tmp_path: pathlib.Path) -> None:
133 repo = _init_repo(tmp_path)
134 sid = _snap(repo, {"f.mid": _obj(repo, b"x")})
135 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
136 assert result.exit_code == 0
137 data = json.loads(result.stdout)
138 assert "duration_ms" in data
139 assert isinstance(data["duration_ms"], (int, float))
140 assert data["duration_ms"] >= 0
141
142 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
143 repo = _init_repo(tmp_path)
144 sid = _snap(repo, {})
145 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
146 data = json.loads(result.stdout)
147 assert data["exit_code"] == 0
148
149 def test_added_count_matches_list_length(self, tmp_path: pathlib.Path) -> None:
150 repo = _init_repo(tmp_path)
151 sid_a = _snap(repo, {})
152 sid_b = _snap(repo, {
153 "a.mid": _obj(repo, b"a"),
154 "b.mid": _obj(repo, b"b"),
155 "c.mid": _obj(repo, b"c"),
156 })
157 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
158 data = json.loads(result.stdout)
159 assert data["added_count"] == 3
160 assert data["added_count"] == len(data["added"])
161
162 def test_modified_count_matches_list_length(self, tmp_path: pathlib.Path) -> None:
163 repo = _init_repo(tmp_path)
164 v1 = _obj(repo, b"v1")
165 v2 = _obj(repo, b"v2")
166 sid_a = _snap(repo, {"t.mid": v1, "u.mid": v1})
167 sid_b = _snap(repo, {"t.mid": v2, "u.mid": v2})
168 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
169 data = json.loads(result.stdout)
170 assert data["modified_count"] == 2
171 assert data["modified_count"] == len(data["modified"])
172
173 def test_deleted_count_matches_list_length(self, tmp_path: pathlib.Path) -> None:
174 repo = _init_repo(tmp_path)
175 oid = _obj(repo, b"gone")
176 sid_a = _snap(repo, {"x.mid": oid, "y.mid": oid})
177 sid_b = _snap(repo, {})
178 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
179 data = json.loads(result.stdout)
180 assert data["deleted_count"] == 2
181 assert data["deleted_count"] == len(data["deleted"])
182
183 def test_total_changes_equals_sum_of_counts(self, tmp_path: pathlib.Path) -> None:
184 repo = _init_repo(tmp_path)
185 v1 = _obj(repo, b"v1")
186 v2 = _obj(repo, b"v2")
187 sid_a = _snap(repo, {"gone.mid": v1, "same.mid": v1, "changed.mid": v1})
188 sid_b = _snap(repo, {"new.mid": v2, "same.mid": v1, "changed.mid": v2})
189 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
190 data = json.loads(result.stdout)
191 assert data["total_changes"] == (
192 data["added_count"] + data["modified_count"] + data["deleted_count"]
193 )
194 assert data["total_changes"] == 3 # 1 added, 1 modified, 1 deleted
195
196
197 # ---------------------------------------------------------------------------
198 # JSON schema completeness
199 # ---------------------------------------------------------------------------
200
201
202 class TestJsonSchema:
203 """All documented fields must be present with correct types."""
204
205 def test_all_fields_present(self, tmp_path: pathlib.Path) -> None:
206 repo = _init_repo(tmp_path)
207 v1 = _obj(repo, b"v1")
208 v2 = _obj(repo, b"v2")
209 sid_a = _snap(repo, {"a.mid": v1, "b.mid": v1})
210 sid_b = _snap(repo, {"b.mid": v2, "c.mid": v2})
211 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
212 assert result.exit_code == 0
213 data = json.loads(result.stdout)
214 for field in (
215 "snapshot_a", "snapshot_b",
216 "added", "modified", "deleted",
217 "added_count", "modified_count", "deleted_count",
218 "total_changes", "duration_ms", "exit_code",
219 ):
220 assert field in data, f"Missing field: {field}"
221
222 def test_snapshot_ids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
223 repo = _init_repo(tmp_path)
224 sid = _snap(repo, {})
225 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
226 data = json.loads(result.stdout)
227 assert data["snapshot_a"].startswith("sha256:")
228 assert data["snapshot_b"].startswith("sha256:")
229
230 def test_added_entry_schema(self, tmp_path: pathlib.Path) -> None:
231 repo = _init_repo(tmp_path)
232 oid = _obj(repo, b"new")
233 sid_a = _snap(repo, {})
234 sid_b = _snap(repo, {"new.mid": oid})
235 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
236 data = json.loads(result.stdout)
237 entry = data["added"][0]
238 assert isinstance(entry["path"], str)
239 assert isinstance(entry["object_id"], str)
240
241 def test_modified_entry_schema(self, tmp_path: pathlib.Path) -> None:
242 repo = _init_repo(tmp_path)
243 v1 = _obj(repo, b"v1")
244 v2 = _obj(repo, b"v2")
245 sid_a = _snap(repo, {"t.mid": v1})
246 sid_b = _snap(repo, {"t.mid": v2})
247 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
248 data = json.loads(result.stdout)
249 entry = data["modified"][0]
250 assert isinstance(entry["path"], str)
251 assert isinstance(entry["object_id_a"], str)
252 assert isinstance(entry["object_id_b"], str)
253 assert entry["object_id_a"] != entry["object_id_b"]
254
255 def test_deleted_entry_schema(self, tmp_path: pathlib.Path) -> None:
256 repo = _init_repo(tmp_path)
257 oid = _obj(repo, b"gone")
258 sid_a = _snap(repo, {"gone.mid": oid})
259 sid_b = _snap(repo, {})
260 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
261 data = json.loads(result.stdout)
262 entry = data["deleted"][0]
263 assert isinstance(entry["path"], str)
264 assert isinstance(entry["object_id"], str)
265
266 def test_counts_are_integers(self, tmp_path: pathlib.Path) -> None:
267 repo = _init_repo(tmp_path)
268 sid = _snap(repo, {})
269 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
270 data = json.loads(result.stdout)
271 assert isinstance(data["added_count"], int)
272 assert isinstance(data["modified_count"], int)
273 assert isinstance(data["deleted_count"], int)
274 assert isinstance(data["total_changes"], int)
275
276
277 # ---------------------------------------------------------------------------
278 # --only filter
279 # ---------------------------------------------------------------------------
280
281
282 class TestOnlyFilter:
283 """--only restricts output to one category; suppressed lists are empty."""
284
285 def _mixed_diff(self, repo: pathlib.Path) -> tuple[str, str]:
286 v1 = _obj(repo, b"v1")
287 v2 = _obj(repo, b"v2")
288 sid_a = _snap(repo, {"gone.mid": v1, "same.mid": v1, "changed.mid": v1})
289 sid_b = _snap(repo, {"new.mid": v2, "same.mid": v1, "changed.mid": v2})
290 return sid_a, sid_b
291
292 def test_only_added_suppresses_modified_and_deleted(self, tmp_path: pathlib.Path) -> None:
293 repo = _init_repo(tmp_path)
294 sid_a, sid_b = self._mixed_diff(repo)
295 result = runner.invoke(cli, ["snapshot-diff", "--json", "--only", "added", sid_a, sid_b], env=_env(repo))
296 assert result.exit_code == 0
297 data = json.loads(result.stdout)
298 assert data["added_count"] >= 1
299 assert data["modified_count"] == 0
300 assert data["deleted_count"] == 0
301 assert data["modified"] == []
302 assert data["deleted"] == []
303
304 def test_only_modified_suppresses_added_and_deleted(self, tmp_path: pathlib.Path) -> None:
305 repo = _init_repo(tmp_path)
306 sid_a, sid_b = self._mixed_diff(repo)
307 result = runner.invoke(cli, ["snapshot-diff", "--json", "--only", "modified", sid_a, sid_b], env=_env(repo))
308 data = json.loads(result.stdout)
309 assert data["modified_count"] >= 1
310 assert data["added_count"] == 0
311 assert data["deleted_count"] == 0
312
313 def test_only_deleted_suppresses_added_and_modified(self, tmp_path: pathlib.Path) -> None:
314 repo = _init_repo(tmp_path)
315 sid_a, sid_b = self._mixed_diff(repo)
316 result = runner.invoke(cli, ["snapshot-diff", "--json", "--only", "deleted", sid_a, sid_b], env=_env(repo))
317 data = json.loads(result.stdout)
318 assert data["deleted_count"] >= 1
319 assert data["added_count"] == 0
320 assert data["modified_count"] == 0
321
322 def test_only_added_total_changes_reflects_filter(self, tmp_path: pathlib.Path) -> None:
323 repo = _init_repo(tmp_path)
324 sid_a, sid_b = self._mixed_diff(repo)
325 result = runner.invoke(cli, ["snapshot-diff", "--json", "--only", "added", sid_a, sid_b], env=_env(repo))
326 data = json.loads(result.stdout)
327 assert data["total_changes"] == data["added_count"]
328
329 def test_only_text_mode_added(self, tmp_path: pathlib.Path) -> None:
330 repo = _init_repo(tmp_path)
331 sid_a, sid_b = self._mixed_diff(repo)
332 result = runner.invoke(
333 cli, ["snapshot-diff", "--only", "added", sid_a, sid_b],
334 env=_env(repo),
335 )
336 assert result.exit_code == 0
337 assert "A " in result.stdout
338 assert "M " not in result.stdout
339 assert "D " not in result.stdout
340
341 def test_only_text_mode_deleted(self, tmp_path: pathlib.Path) -> None:
342 repo = _init_repo(tmp_path)
343 sid_a, sid_b = self._mixed_diff(repo)
344 result = runner.invoke(
345 cli, ["snapshot-diff", "--only", "deleted", sid_a, sid_b],
346 env=_env(repo),
347 )
348 assert result.exit_code == 0
349 assert "D " in result.stdout
350 assert "A " not in result.stdout
351 assert "M " not in result.stdout
352
353 def test_only_invalid_value_rejected(self, tmp_path: pathlib.Path) -> None:
354 repo = _init_repo(tmp_path)
355 sid = _snap(repo, {})
356 result = runner.invoke(
357 cli, ["snapshot-diff", "--only", "unchanged", sid, sid], env=_env(repo)
358 )
359 assert result.exit_code != 0
360
361 def test_only_short_flag(self, tmp_path: pathlib.Path) -> None:
362 repo = _init_repo(tmp_path)
363 sid_a, sid_b = self._mixed_diff(repo)
364 result = runner.invoke(cli, ["snapshot-diff", "--json", "--only", "added", sid_a, sid_b], env=_env(repo))
365 assert result.exit_code == 0
366 data = json.loads(result.stdout)
367 assert data["modified"] == []
368 assert data["deleted"] == []
369
370
371 # ---------------------------------------------------------------------------
372 # --path-prefix filter
373 # ---------------------------------------------------------------------------
374
375
376 class TestPathPrefixFilter:
377 """--path-prefix scopes the diff to a subdirectory."""
378
379 def _multi_dir_diff(self, repo: pathlib.Path) -> tuple[str, str]:
380 v1 = _obj(repo, b"v1")
381 v2 = _obj(repo, b"v2")
382 sid_a = _snap(repo, {
383 "src/a.mid": v1,
384 "src/b.mid": v1,
385 "docs/guide.md": v1,
386 })
387 sid_b = _snap(repo, {
388 "src/a.mid": v2, # modified
389 "src/c.mid": v2, # added
390 "docs/guide.md": v1, # unchanged
391 })
392 return sid_a, sid_b
393
394 def test_prefix_scopes_to_src(self, tmp_path: pathlib.Path) -> None:
395 repo = _init_repo(tmp_path)
396 sid_a, sid_b = self._multi_dir_diff(repo)
397 result = runner.invoke(
398 cli, ["snapshot-diff", "--json", "--path-prefix", "src/", sid_a, sid_b], env=_env(repo)
399 )
400 assert result.exit_code == 0
401 data = json.loads(result.stdout)
402 all_paths = (
403 [e["path"] for e in data["added"]]
404 + [e["path"] for e in data["modified"]]
405 + [e["path"] for e in data["deleted"]]
406 )
407 assert all(p.startswith("src/") for p in all_paths), all_paths
408
409 def test_prefix_excludes_docs(self, tmp_path: pathlib.Path) -> None:
410 repo = _init_repo(tmp_path)
411 sid_a, sid_b = self._multi_dir_diff(repo)
412 result = runner.invoke(
413 cli, ["snapshot-diff", "--json", "--path-prefix", "src/", sid_a, sid_b], env=_env(repo)
414 )
415 data = json.loads(result.stdout)
416 all_paths = (
417 [e["path"] for e in data["added"]]
418 + [e["path"] for e in data["modified"]]
419 + [e["path"] for e in data["deleted"]]
420 )
421 assert not any(p.startswith("docs/") for p in all_paths)
422
423 def test_prefix_counts_are_filtered(self, tmp_path: pathlib.Path) -> None:
424 repo = _init_repo(tmp_path)
425 sid_a, sid_b = self._multi_dir_diff(repo)
426 # Full diff
427 full = json.loads(runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo)).stdout)
428 # Scoped to src/
429 scoped = json.loads(runner.invoke(
430 cli, ["snapshot-diff", "--json", "--path-prefix", "src/", sid_a, sid_b], env=_env(repo)
431 ).stdout)
432 # src/ diff should have fewer total_changes than the full diff
433 assert scoped["total_changes"] <= full["total_changes"]
434
435 def test_nonmatching_prefix_yields_zero_changes(self, tmp_path: pathlib.Path) -> None:
436 repo = _init_repo(tmp_path)
437 sid_a, sid_b = self._multi_dir_diff(repo)
438 result = runner.invoke(
439 cli, ["snapshot-diff", "--json", "--path-prefix", "nonexistent/", sid_a, sid_b], env=_env(repo)
440 )
441 data = json.loads(result.stdout)
442 assert data["total_changes"] == 0
443
444 def test_prefix_and_only_combined(self, tmp_path: pathlib.Path) -> None:
445 repo = _init_repo(tmp_path)
446 sid_a, sid_b = self._multi_dir_diff(repo)
447 result = runner.invoke(
448 cli,
449 ["snapshot-diff", "--json", "--path-prefix", "src/", "--only", "added", sid_a, sid_b],
450 env=_env(repo),
451 )
452 data = json.loads(result.stdout)
453 assert data["modified"] == []
454 assert data["deleted"] == []
455 assert all(e["path"].startswith("src/") for e in data["added"])
456
457
458 # ---------------------------------------------------------------------------
459 # Batch mode (--stdin) with envelope
460 # ---------------------------------------------------------------------------
461
462
463 class TestStdinEnvelope:
464 """Batch mode results must include duration_ms/exit_code/count fields."""
465
466 def test_batch_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
467 repo = _init_repo(tmp_path)
468 sid = _snap(repo, {})
469 stdin = f"{sid} {sid}\n"
470 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
471 assert result.exit_code == 0
472 data = json.loads(result.stdout.strip())
473 assert "duration_ms" in data
474 assert isinstance(data["duration_ms"], (int, float))
475
476 def test_batch_json_has_count_fields(self, tmp_path: pathlib.Path) -> None:
477 repo = _init_repo(tmp_path)
478 oid = _obj(repo, b"x")
479 sid_a = _snap(repo, {"a.mid": oid})
480 sid_b = _snap(repo, {})
481 stdin = f"{sid_a} {sid_b}\n"
482 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
483 data = json.loads(result.stdout.strip())
484 assert "added_count" in data
485 assert "modified_count" in data
486 assert "deleted_count" in data
487 assert data["deleted_count"] == 1
488
489 def test_batch_with_only_filter(self, tmp_path: pathlib.Path) -> None:
490 repo = _init_repo(tmp_path)
491 v1 = _obj(repo, b"v1")
492 v2 = _obj(repo, b"v2")
493 sid_a = _snap(repo, {"gone.mid": v1, "changed.mid": v1})
494 sid_b = _snap(repo, {"new.mid": v2, "changed.mid": v2})
495 stdin = f"{sid_a} {sid_b}\n"
496 result = runner.invoke(
497 cli, ["snapshot-diff", "--json", "--stdin", "--only", "added"], env=_env(repo), input=stdin
498 )
499 data = json.loads(result.stdout.strip())
500 assert data["modified"] == []
501 assert data["deleted"] == []
502
503 def test_batch_with_path_prefix(self, tmp_path: pathlib.Path) -> None:
504 repo = _init_repo(tmp_path)
505 oid = _obj(repo, b"x")
506 sid_a = _snap(repo, {"src/a.mid": oid, "docs/b.mid": oid})
507 sid_b = _snap(repo, {})
508 stdin = f"{sid_a} {sid_b}\n"
509 result = runner.invoke(
510 cli, ["snapshot-diff", "--json", "--stdin", "--path-prefix", "src/"], env=_env(repo), input=stdin
511 )
512 data = json.loads(result.stdout.strip())
513 assert all(e["path"].startswith("src/") for e in data["deleted"])
514
515
516 # ---------------------------------------------------------------------------
517 # Security
518 # ---------------------------------------------------------------------------
519
520
521 class TestSecurity:
522 def test_ansi_in_path_sanitized_in_text_output(self, tmp_path: pathlib.Path) -> None:
523 """File paths with ANSI escapes must be sanitized in text output."""
524 repo = _init_repo(tmp_path)
525 malicious_path = "\x1b[31msrc/malicious.mid\x1b[0m"
526 oid = _obj(repo, b"malicious")
527 sid_a = _snap(repo, {})
528 sid_b = _snap(repo, {malicious_path: oid})
529 result = runner.invoke(
530 cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo)
531 )
532 assert result.exit_code == 0
533 assert "\x1b" not in result.stdout
534
535 def test_ansi_in_path_not_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
536 """JSON output preserves raw path strings — callers must sanitize for display."""
537 repo = _init_repo(tmp_path)
538 malicious_path = "\x1b[31mmalicious.mid\x1b[0m"
539 oid = _obj(repo, b"content")
540 sid_a = _snap(repo, {})
541 sid_b = _snap(repo, {malicious_path: oid})
542 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
543 assert result.exit_code == 0
544 data = json.loads(result.stdout)
545 # JSON preserves the raw path for programmatic use.
546 assert data["added"][0]["path"] == malicious_path
547
548 def test_path_prefix_cannot_escape_manifest(self, tmp_path: pathlib.Path) -> None:
549 """A crafted --path-prefix with ../ cannot expose paths outside the filter."""
550 repo = _init_repo(tmp_path)
551 oid = _obj(repo, b"safe")
552 sid_a = _snap(repo, {"safe/file.mid": oid})
553 sid_b = _snap(repo, {})
554 # path_prefix is applied as a startswith filter against manifest keys —
555 # "../" will simply not match any key, so zero changes are returned.
556 result = runner.invoke(
557 cli, ["snapshot-diff", "--json", "--path-prefix", "../", sid_a, sid_b], env=_env(repo)
558 )
559 assert result.exit_code == 0
560 data = json.loads(result.stdout)
561 assert data["total_changes"] == 0
562
563
564 # ---------------------------------------------------------------------------
565 # Idempotency and symmetry
566 # ---------------------------------------------------------------------------
567
568
569 class TestDiffProperties:
570 def test_self_diff_always_zero(self, tmp_path: pathlib.Path) -> None:
571 repo = _init_repo(tmp_path)
572 oid = _obj(repo, b"content")
573 sid = _snap(repo, {"a.mid": oid, "b.mid": oid})
574 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
575 data = json.loads(result.stdout)
576 assert data["total_changes"] == 0
577 assert data["added"] == []
578 assert data["modified"] == []
579 assert data["deleted"] == []
580
581 def test_symmetric_add_delete_counts(self, tmp_path: pathlib.Path) -> None:
582 """A→B adds N files; B→A deletes N files."""
583 repo = _init_repo(tmp_path)
584 oid_a = _obj(repo, b"a")
585 oid_b = _obj(repo, b"b")
586 sid_a = _snap(repo, {"x.mid": oid_a})
587 sid_b = _snap(repo, {"y.mid": oid_b})
588 fwd = json.loads(runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo)).stdout)
589 rev = json.loads(runner.invoke(cli, ["snapshot-diff", "--json", sid_b, sid_a], env=_env(repo)).stdout)
590 assert fwd["added_count"] == rev["deleted_count"]
591 assert fwd["deleted_count"] == rev["added_count"]
592
593 def test_all_modified_no_add_delete(self, tmp_path: pathlib.Path) -> None:
594 """Same paths, all different OIDs → modified only, zero added/deleted."""
595 repo = _init_repo(tmp_path)
596 n = 10
597 manifest_a = {f"track_{i}.mid": _obj(repo, f"v1_{i}".encode()) for i in range(n)}
598 manifest_b = {f"track_{i}.mid": _obj(repo, f"v2_{i}".encode()) for i in range(n)}
599 sid_a = _snap(repo, manifest_a)
600 sid_b = _snap(repo, manifest_b)
601 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
602 data = json.loads(result.stdout)
603 assert data["modified_count"] == n
604 assert data["added_count"] == 0
605 assert data["deleted_count"] == 0
606 assert data["total_changes"] == n
607
608 def test_both_empty_zero_changes(self, tmp_path: pathlib.Path) -> None:
609 repo = _init_repo(tmp_path)
610 sid_a = _snap(repo, {})
611 sid_b = _snap(repo, {})
612 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
613 data = json.loads(result.stdout)
614 assert data["total_changes"] == 0
615
616 def test_a_empty_all_added(self, tmp_path: pathlib.Path) -> None:
617 repo = _init_repo(tmp_path)
618 oid = _obj(repo, b"content")
619 sid_a = _snap(repo, {})
620 sid_b = _snap(repo, {"a.mid": oid, "b.mid": oid, "c.mid": oid})
621 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
622 data = json.loads(result.stdout)
623 assert data["added_count"] == 3
624 assert data["modified_count"] == 0
625 assert data["deleted_count"] == 0
626
627 def test_b_empty_all_deleted(self, tmp_path: pathlib.Path) -> None:
628 repo = _init_repo(tmp_path)
629 oid = _obj(repo, b"content")
630 sid_a = _snap(repo, {"a.mid": oid, "b.mid": oid})
631 sid_b = _snap(repo, {})
632 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
633 data = json.loads(result.stdout)
634 assert data["deleted_count"] == 2
635 assert data["added_count"] == 0
636 assert data["modified_count"] == 0
637
638
639 # ---------------------------------------------------------------------------
640 # Resolution — HEAD / commit ID / branch
641 # ---------------------------------------------------------------------------
642
643
644 class TestResolution:
645 def test_head_vs_head_zero_changes(self, tmp_path: pathlib.Path) -> None:
646 repo = _init_repo(tmp_path)
647 sid = _snap(repo, {"f.mid": _obj(repo, b"x")})
648 _commit(repo, "init", sid, branch="main")
649 result = runner.invoke(cli, ["snapshot-diff", "--json", "HEAD", "HEAD"], env=_env(repo))
650 assert result.exit_code == 0
651 data = json.loads(result.stdout)
652 assert data["total_changes"] == 0
653
654 def test_commit_id_resolution(self, tmp_path: pathlib.Path) -> None:
655 repo = _init_repo(tmp_path)
656 oid_a = _obj(repo, b"v1")
657 oid_b = _obj(repo, b"v2")
658 sid_a = _snap(repo, {"f.mid": oid_a})
659 sid_b = _snap(repo, {"f.mid": oid_b})
660 cid_a = _commit(repo, "cmt-a", sid_a, branch="main")
661 cid_b = _commit(repo, "cmt-b", sid_b, branch="dev")
662 result = runner.invoke(cli, ["snapshot-diff", "--json", cid_a, cid_b], env=_env(repo))
663 assert result.exit_code == 0
664 data = json.loads(result.stdout)
665 assert data["modified_count"] == 1
666
667 def test_branch_vs_snapshot_id(self, tmp_path: pathlib.Path) -> None:
668 repo = _init_repo(tmp_path)
669 oid = _obj(repo, b"x")
670 sid_a = _snap(repo, {"f.mid": oid})
671 sid_b = _snap(repo, {})
672 _commit(repo, "cmt", sid_a, branch="main")
673 result = runner.invoke(cli, ["snapshot-diff", "--json", "main", sid_b], env=_env(repo))
674 assert result.exit_code == 0
675 data = json.loads(result.stdout)
676 assert data["deleted_count"] == 1
677
678
679 # ---------------------------------------------------------------------------
680 # _resolve_to_snapshot_id unit
681 # ---------------------------------------------------------------------------
682
683
684 class TestResolveToSnapshotId:
685 def test_resolves_snapshot_id_directly(self, tmp_path: pathlib.Path) -> None:
686 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
687 repo = _init_repo(tmp_path)
688 sid = _snap(repo, {})
689 resolved = _resolve_to_snapshot_id(repo, sid)
690 assert resolved == sid
691
692 def test_resolves_branch_name(self, tmp_path: pathlib.Path) -> None:
693 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
694 repo = _init_repo(tmp_path)
695 sid = _snap(repo, {"f.mid": _obj(repo, b"x")})
696 _commit(repo, "cmt", sid, branch="feature")
697 resolved = _resolve_to_snapshot_id(repo, "feature")
698 assert resolved == sid
699
700 def test_resolves_head(self, tmp_path: pathlib.Path) -> None:
701 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
702 repo = _init_repo(tmp_path)
703 sid = _snap(repo, {})
704 _commit(repo, "cmt", sid, branch="main")
705 resolved = _resolve_to_snapshot_id(repo, "HEAD")
706 assert resolved == sid
707
708 def test_head_case_insensitive(self, tmp_path: pathlib.Path) -> None:
709 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
710 repo = _init_repo(tmp_path)
711 sid = _snap(repo, {})
712 _commit(repo, "cmt", sid, branch="main")
713 assert _resolve_to_snapshot_id(repo, "head") == sid
714 assert _resolve_to_snapshot_id(repo, "Head") == sid
715
716 def test_returns_none_for_unknown_branch(self, tmp_path: pathlib.Path) -> None:
717 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
718 repo = _init_repo(tmp_path)
719 assert _resolve_to_snapshot_id(repo, "no-such-branch") is None
720
721 def test_returns_none_for_bad_ref(self, tmp_path: pathlib.Path) -> None:
722 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
723 repo = _init_repo(tmp_path)
724 assert _resolve_to_snapshot_id(repo, "not-an-id-at-all") is None
725
726 def test_returns_none_for_head_with_no_commits(self, tmp_path: pathlib.Path) -> None:
727 from muse.cli.commands.snapshot_diff import _resolve_to_snapshot_id
728 repo = _init_repo(tmp_path)
729 # No commits written — HEAD cannot be resolved.
730 assert _resolve_to_snapshot_id(repo, "HEAD") is None
731
732
733 # ---------------------------------------------------------------------------
734 # _compute_diff unit
735 # ---------------------------------------------------------------------------
736
737
738 class TestComputeDiff:
739 def test_only_filter_zeroes_suppressed_lists(self, tmp_path: pathlib.Path) -> None:
740 from muse.cli.commands.snapshot_diff import _compute_diff
741 repo = _init_repo(tmp_path)
742 v1 = _obj(repo, b"v1")
743 v2 = _obj(repo, b"v2")
744 sid_a = _snap(repo, {"gone.mid": v1, "changed.mid": v1})
745 sid_b = _snap(repo, {"new.mid": v2, "changed.mid": v2})
746 result = _compute_diff(repo, sid_a, sid_b, only="added")
747 assert result["modified"] == []
748 assert result["deleted"] == []
749 assert len(result["added"]) == 1
750
751 def test_path_prefix_filters_entries(self, tmp_path: pathlib.Path) -> None:
752 from muse.cli.commands.snapshot_diff import _compute_diff
753 repo = _init_repo(tmp_path)
754 oid = _obj(repo, b"x")
755 sid_a = _snap(repo, {"src/a.mid": oid, "docs/b.mid": oid})
756 sid_b = _snap(repo, {})
757 result = _compute_diff(repo, sid_a, sid_b, path_prefix="src/")
758 assert all(e["path"].startswith("src/") for e in result["deleted"])
759 assert result["deleted_count"] == 1
760
761 def test_error_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
762 from muse.cli.commands.snapshot_diff import _compute_diff
763 repo = _init_repo(tmp_path)
764 result = _compute_diff(repo, "bad-ref", "also-bad")
765 assert "error" in result
766
767
768 # ---------------------------------------------------------------------------
769 # Large manifest stress
770 # ---------------------------------------------------------------------------
771
772
773 class TestLargeManifestStress:
774 def test_500_file_diff_counts_correctly(self, tmp_path: pathlib.Path) -> None:
775 """500 adds, 250 modifies, 250 deletes — counts must be exact."""
776 repo = _init_repo(tmp_path)
777 n = 500
778 # Build manifest A: 500 files (first 250 will be deleted, 250 will be modified)
779 manifest_a: Manifest = {}
780 for i in range(n):
781 manifest_a[f"track_{i:04d}.mid"] = _obj(repo, f"v1_{i}".encode())
782 # Build manifest B: keep 250 modified + add 500 new
783 manifest_b: Manifest = {}
784 for i in range(250):
785 manifest_b[f"track_{i:04d}.mid"] = _obj(repo, f"v2_{i}".encode()) # modified
786 for i in range(250, n):
787 manifest_b[f"track_{i:04d}.mid"] = manifest_a[f"track_{i:04d}.mid"] # unchanged (kept same OID)
788 for i in range(n):
789 manifest_b[f"new_{i:04d}.mid"] = _obj(repo, f"new_{i}".encode()) # added
790
791 sid_a = _snap(repo, manifest_a)
792 sid_b = _snap(repo, manifest_b)
793 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
794 assert result.exit_code == 0
795 data = json.loads(result.stdout)
796 assert data["added_count"] == n # 500 new files
797 assert data["modified_count"] == 250 # first 250 modified
798 assert data["deleted_count"] == 0 # none deleted (250 unchanged kept same OID)
799 assert data["total_changes"] == n + 250
800
801
802 # ---------------------------------------------------------------------------
803 # Concurrent stress
804 # ---------------------------------------------------------------------------
805
806
807 class TestConcurrentStress:
808 def test_10_threads_diff_independently(self, tmp_path: pathlib.Path) -> None:
809 """10 threads diffing the same pair concurrently must all succeed."""
810 repo = _init_repo(tmp_path)
811 v1 = _obj(repo, b"v1")
812 v2 = _obj(repo, b"v2")
813 sid_a = _snap(repo, {"f.mid": v1})
814 sid_b = _snap(repo, {"f.mid": v2})
815
816 errors: list[str] = []
817 results: list[dict] = []
818 lock = threading.Lock()
819
820 def _diff() -> None:
821 r = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, sid_b], env=_env(repo))
822 with lock:
823 if r.exit_code != 0:
824 errors.append(r.output)
825 else:
826 results.append(json.loads(r.stdout))
827
828 threads = [threading.Thread(target=_diff) for _ in range(10)]
829 for t in threads:
830 t.start()
831 for t in threads:
832 t.join()
833
834 assert not errors, errors
835 assert len(results) == 10
836 for r in results:
837 assert r["modified_count"] == 1
838 assert r["exit_code"] == 0
839
840
841 # ---------------------------------------------------------------------------
842 # Short prefix ID resolution
843 # ---------------------------------------------------------------------------
844
845
846 class TestPrefixIdResolution:
847 """snapshot-diff must accept short hex prefixes, mirroring snapshot read."""
848
849 def test_bare_hex_prefix_rejected(self, tmp_path: pathlib.Path) -> None:
850 """Bare hex prefix (no sha256: type tag) must be rejected at the CLI boundary."""
851 repo = _init_repo(tmp_path)
852 oid = _obj(repo, b"x")
853 sid_a = _snap(repo, {"f.mid": oid})
854 sid_b = _snap(repo, {})
855 # Strip "sha256:" — bare hex must be rejected, not resolved.
856 prefix_a = sid_a[len("sha256:"):len("sha256:") + 12]
857 prefix_b = sid_b[len("sha256:"):len("sha256:") + 12]
858 result = runner.invoke(cli, ["snapshot-diff", prefix_a, prefix_b], env=_env(repo))
859 assert result.exit_code != 0, "bare hex must be rejected, not resolved"
860
861 def test_sha256_prefixed_short_id_resolves(self, tmp_path: pathlib.Path) -> None:
862 repo = _init_repo(tmp_path)
863 oid = _obj(repo, b"y")
864 sid_a = _snap(repo, {})
865 sid_b = _snap(repo, {"g.mid": oid})
866 # Keep the "sha256:" prefix but truncate the hex portion.
867 short_b = sid_b[:len("sha256:") + 16]
868 result = runner.invoke(cli, ["snapshot-diff", "--json", sid_a, short_b], env=_env(repo))
869 assert result.exit_code == 0, result.output
870 data = json.loads(result.stdout)
871 assert data["added_count"] == 1
872
873 def test_full_id_still_resolves(self, tmp_path: pathlib.Path) -> None:
874 repo = _init_repo(tmp_path)
875 sid = _snap(repo, {})
876 result = runner.invoke(cli, ["snapshot-diff", "--json", sid, sid], env=_env(repo))
877 assert result.exit_code == 0
878 data = json.loads(result.stdout)
879 assert data["total_changes"] == 0
880
881 def test_prefix_resolves_correct_snapshot(self, tmp_path: pathlib.Path) -> None:
882 """sha256:-prefixed short IDs resolve to the correct full snapshot IDs."""
883 repo = _init_repo(tmp_path)
884 oid_a = _obj(repo, b"v_a")
885 oid_b = _obj(repo, b"v_b")
886 sid_a = _snap(repo, {"a.mid": oid_a})
887 sid_b = _snap(repo, {"b.mid": oid_b})
888 # Short prefix must carry the sha256: type tag.
889 prefix_a = short_id(sid_a)
890 prefix_b = short_id(sid_b)
891 result = runner.invoke(cli, ["snapshot-diff", "--json", prefix_a, prefix_b], env=_env(repo))
892 assert result.exit_code == 0
893 data = json.loads(result.stdout)
894 # snapshot_a and snapshot_b in output must be the full resolved IDs.
895 assert data["snapshot_a"] == sid_a
896 assert data["snapshot_b"] == sid_b
897
898 def test_nonexistent_prefix_returns_error(self, tmp_path: pathlib.Path) -> None:
899 repo = _init_repo(tmp_path)
900 sid = _snap(repo, {})
901 result = runner.invoke(cli, ["snapshot-diff", "000000000000", sid], env=_env(repo))
902 assert result.exit_code != 0
903
904 def test_resolve_snapshot_prefix_unit(self, tmp_path: pathlib.Path) -> None:
905 """Bare hex (no sha256:) must return None — rejected at the function level."""
906 from muse.cli.commands.snapshot_diff import _resolve_snapshot_prefix
907 repo = _init_repo(tmp_path)
908 sid = _snap(repo, {"f.mid": _obj(repo, b"x")})
909 bare_prefix = sid[len("sha256:"):len("sha256:") + 10]
910 resolved = _resolve_snapshot_prefix(repo, bare_prefix)
911 assert resolved is None, "bare hex must not resolve — sha256: prefix required"
912
913 def test_resolve_snapshot_prefix_with_sha256_prefix_unit(self, tmp_path: pathlib.Path) -> None:
914 from muse.cli.commands.snapshot_diff import _resolve_snapshot_prefix
915 repo = _init_repo(tmp_path)
916 sid = _snap(repo, {})
917 short = sid[:len("sha256:") + 8]
918 resolved = _resolve_snapshot_prefix(repo, short)
919 assert resolved == sid
920
921 def test_resolve_snapshot_prefix_returns_none_for_no_match(self, tmp_path: pathlib.Path) -> None:
922 from muse.cli.commands.snapshot_diff import _resolve_snapshot_prefix
923 repo = _init_repo(tmp_path)
924 assert _resolve_snapshot_prefix(repo, "000000000000") is None
925
926 def test_prefix_in_batch_stdin_mode(self, tmp_path: pathlib.Path) -> None:
927 """sha256:-prefixed short IDs must resolve correctly in batch stdin mode."""
928 repo = _init_repo(tmp_path)
929 oid = _obj(repo, b"batch")
930 sid_a = _snap(repo, {"f.mid": oid})
931 sid_b = _snap(repo, {})
932 # Short prefixes must carry the sha256: type tag.
933 prefix_a = short_id(sid_a)
934 prefix_b = short_id(sid_b)
935 stdin = f"{prefix_a} {prefix_b}\n"
936 result = runner.invoke(cli, ["snapshot-diff", "--json", "--stdin"], env=_env(repo), input=stdin)
937 assert result.exit_code == 0
938 data = json.loads(result.stdout.strip())
939 assert data["deleted_count"] == 1
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 29 days ago