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