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