gabriel / muse public
test_prune_supercharge.py python
686 lines 28.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for ``muse prune`` — supercharged coverage.
2
3 Coverage tiers
4 --------------
5 - Unit: _collect_all_reachable_ids, _find_prune_candidates helpers
6 - Integration: dry-run, live prune, JSON schema, object count, --expire
7 - End-to-end: full CLI via CliRunner
8 - Data integrity: bytes_freed matches actual file sizes; reachable_count accurate
9 - Performance: 100-object store completes under 1 second
10 - Security: only .muse/objects/ deleted; reachable objects safe; no
11 mutation in --dry-run; candidates expose sha256:-prefixed IDs
12 - Stress: 200-object store with 50% unreachable
13
14 New supercharged schema (all --json outputs)
15 --------------------------------------------
16 Dry-run::
17
18 {
19 "pruned": 42,
20 "bytes_freed": 18432,
21 "dry_run": true,
22 "reachable_count": 100,
23 "candidates": [{"object_id": "sha256:...", "size": 1024}],
24 "duration_ms": 1.234,
25 "exit_code": 0
26 }
27
28 Live::
29
30 {
31 "pruned": 42,
32 "bytes_freed": 18432,
33 "dry_run": false,
34 "reachable_count": 100,
35 "duration_ms": 1.234,
36 "exit_code": 0
37 }
38 """
39
40 from __future__ import annotations
41 from collections.abc import Mapping
42
43 import datetime
44 import json
45 import os
46 import pathlib
47 import time
48
49 import pytest
50
51 from tests.cli_test_helper import CliRunner
52 from muse.core.object_store import write_object, has_object
53 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
54 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
55 from muse.core._types import Manifest, blob_id
56
57 runner = CliRunner()
58
59 _REPO_ID = "prune-supercharge-test"
60 _counter = 0
61
62
63 # ---------------------------------------------------------------------------
64 # Helpers
65 # ---------------------------------------------------------------------------
66
67
68 def _oid(content: bytes) -> str:
69 """sha256:-prefixed object ID — correct format for all Muse APIs."""
70 return blob_id(content)
71
72
73 def _bare(content: bytes) -> str:
74 """sha256:-prefixed object ID — for assertions against _collect_all_reachable_ids."""
75 return blob_id(content)
76
77
78 def _init_repo(path: pathlib.Path) -> pathlib.Path:
79 muse = path / ".muse"
80 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
81 (muse / d).mkdir(parents=True, exist_ok=True)
82 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
83 (muse / "repo.json").write_text(
84 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
85 )
86 return path
87
88
89 def _env(repo: pathlib.Path) -> Mapping[str, str]:
90 return {"MUSE_REPO_ROOT": str(repo)}
91
92
93 def _commit_files(
94 root: pathlib.Path,
95 files: Mapping[str, bytes],
96 branch: str = "main",
97 ) -> str:
98 global _counter
99 _counter += 1
100 manifest: Manifest = {}
101 for rel_path, content in files.items():
102 obj_id = _oid(content)
103 write_object(root, obj_id, content)
104 manifest[rel_path] = obj_id
105 abs_path = root / rel_path
106 abs_path.parent.mkdir(parents=True, exist_ok=True)
107 abs_path.write_bytes(content)
108 snap_id = compute_snapshot_id(manifest)
109 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
110 committed_at = datetime.datetime.now(datetime.timezone.utc)
111 ref_path = root / ".muse" / "refs" / "heads" / branch
112 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
113 parents = [parent_id] if parent_id else []
114 commit_id = compute_commit_id(
115 parents, snap_id, f"commit {_counter}", committed_at.isoformat(),
116 repo_id=_REPO_ID,
117 )
118 write_commit(
119 root,
120 CommitRecord(
121 commit_id=commit_id,
122 repo_id=_REPO_ID,
123 created_on_branch=branch,
124 snapshot_id=snap_id,
125 message=f"commit {_counter}",
126 committed_at=committed_at,
127 parent_commit_id=parent_id,
128 ),
129 )
130 ref_path.write_text(commit_id, encoding="utf-8")
131 return commit_id
132
133
134 def _invoke(repo: pathlib.Path, *args: str):
135 from muse.cli.app import main as cli
136 return runner.invoke(cli, ["prune", *args], env=_env(repo))
137
138
139 def _object_count(root: pathlib.Path) -> int:
140 from muse.core.object_store import iter_stored_objects
141 return sum(1 for _ in iter_stored_objects(root))
142
143
144 # ---------------------------------------------------------------------------
145 # Unit — _collect_all_reachable_ids
146 # ---------------------------------------------------------------------------
147
148
149 class TestCollectReachable:
150 def test_empty_repo_returns_empty_set(self, tmp_path: pathlib.Path) -> None:
151 from muse.cli.commands.prune import _collect_all_reachable_ids
152 root = _init_repo(tmp_path)
153 ids = _collect_all_reachable_ids(root)
154 assert isinstance(ids, set)
155 assert len(ids) == 0
156
157 def test_returns_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
158 """_collect_all_reachable_ids must return sha256:-prefixed object IDs."""
159 from muse.cli.commands.prune import _collect_all_reachable_ids
160 root = _init_repo(tmp_path)
161 _commit_files(root, {"a.py": b"# a\n"})
162 ids = _collect_all_reachable_ids(root)
163 for oid in ids:
164 assert oid.startswith("sha256:"), (
165 f"Expected sha256:-prefixed ID but got '{oid[:12]}...'"
166 )
167
168 def test_contains_committed_object_ids(self, tmp_path: pathlib.Path) -> None:
169 from muse.cli.commands.prune import _collect_all_reachable_ids
170 root = _init_repo(tmp_path)
171 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
172 ids = _collect_all_reachable_ids(root)
173 assert _bare(b"# a\n") in ids
174 assert _bare(b"# b\n") in ids
175
176 def test_orphan_not_in_reachable(self, tmp_path: pathlib.Path) -> None:
177 from muse.cli.commands.prune import _collect_all_reachable_ids
178 root = _init_repo(tmp_path)
179 _commit_files(root, {"a.py": b"# a\n"})
180 orphan = b"orphan not in any snapshot"
181 write_object(root, _oid(orphan), orphan)
182 ids = _collect_all_reachable_ids(root)
183 assert _bare(orphan) not in ids
184 assert _bare(b"# a\n") in ids
185
186 def test_multiple_commits_all_reachable(self, tmp_path: pathlib.Path) -> None:
187 from muse.cli.commands.prune import _collect_all_reachable_ids
188 root = _init_repo(tmp_path)
189 _commit_files(root, {"a.py": b"v1\n"})
190 _commit_files(root, {"a.py": b"v2\n"})
191 ids = _collect_all_reachable_ids(root)
192 # Both versions are in snapshots on disk → both reachable.
193 assert _bare(b"v1\n") in ids
194 assert _bare(b"v2\n") in ids
195
196
197 # ---------------------------------------------------------------------------
198 # Unit — _find_prune_candidates
199 # ---------------------------------------------------------------------------
200
201
202 class TestFindPruneCandidates:
203 def test_returns_orphan(self, tmp_path: pathlib.Path) -> None:
204 from muse.cli.commands.prune import _find_prune_candidates, _collect_all_reachable_ids
205 root = _init_repo(tmp_path)
206 _commit_files(root, {"a.py": b"# a\n"})
207 orphan = b"i am orphaned"
208 write_object(root, _oid(orphan), orphan)
209 reachable = _collect_all_reachable_ids(root)
210 candidates = _find_prune_candidates(root, reachable, expire_before=None)
211 candidate_ids = {c["object_id"] for c in candidates}
212 assert _oid(orphan) in candidate_ids
213
214 def test_excludes_reachable_object(self, tmp_path: pathlib.Path) -> None:
215 from muse.cli.commands.prune import _find_prune_candidates, _collect_all_reachable_ids
216 root = _init_repo(tmp_path)
217 _commit_files(root, {"a.py": b"# a\n"})
218 reachable = _collect_all_reachable_ids(root)
219 candidates = _find_prune_candidates(root, reachable, expire_before=None)
220 candidate_ids = {c["object_id"] for c in candidates}
221 assert _oid(b"# a\n") not in candidate_ids
222
223 def test_candidate_object_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
224 """All candidate object_id values must be sha256:-prefixed (ecosystem standard)."""
225 from muse.cli.commands.prune import _find_prune_candidates
226 root = _init_repo(tmp_path)
227 orphan = b"orphan blob"
228 write_object(root, _oid(orphan), orphan)
229 candidates = _find_prune_candidates(root, set(), expire_before=None)
230 assert len(candidates) >= 1
231 for c in candidates:
232 assert c["object_id"].startswith("sha256:"), (
233 f"candidate object_id lacks sha256: prefix: {c['object_id'][:20]!r}"
234 )
235
236 def test_candidate_has_size_field(self, tmp_path: pathlib.Path) -> None:
237 from muse.cli.commands.prune import _find_prune_candidates
238 root = _init_repo(tmp_path)
239 orphan = b"sized orphan"
240 write_object(root, _oid(orphan), orphan)
241 candidates = _find_prune_candidates(root, set(), expire_before=None)
242 assert len(candidates) >= 1
243 for c in candidates:
244 assert "size" in c
245 assert isinstance(c["size"], int)
246 assert c["size"] >= 0
247
248 def test_empty_store_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
249 from muse.cli.commands.prune import _find_prune_candidates
250 root = _init_repo(tmp_path)
251 candidates = _find_prune_candidates(root, set(), expire_before=None)
252 assert candidates == []
253
254 def test_expire_before_filters_recent(self, tmp_path: pathlib.Path) -> None:
255 from muse.cli.commands.prune import _find_prune_candidates
256 root = _init_repo(tmp_path)
257 orphan = b"recent orphan"
258 write_object(root, _oid(orphan), orphan)
259 reachable: set[str] = set()
260 one_hour_ago = time.time() - 3600
261 candidates = _find_prune_candidates(root, reachable, expire_before=one_hour_ago)
262 candidate_ids = {c["object_id"] for c in candidates}
263 assert _oid(orphan) not in candidate_ids, "Recent orphan should be kept by --expire"
264
265 def test_expire_before_includes_old_objects(self, tmp_path: pathlib.Path) -> None:
266 from muse.cli.commands.prune import _find_prune_candidates
267 root = _init_repo(tmp_path)
268 orphan = b"old orphan"
269 write_object(root, _oid(orphan), orphan)
270 # Backdate mtime to 2 hours ago.
271 bare = _bare(orphan)
272 obj_path = next((root / ".muse" / "objects").rglob(bare[-62:]), None)
273 if obj_path:
274 two_hours_ago = time.time() - 7200
275 os.utime(obj_path, (two_hours_ago, two_hours_ago))
276 one_hour_ago = time.time() - 3600
277 candidates = _find_prune_candidates(root, set(), expire_before=one_hour_ago)
278 candidate_ids = {c["object_id"] for c in candidates}
279 assert _oid(orphan) in candidate_ids
280
281 def test_candidates_sorted_by_object_id(self, tmp_path: pathlib.Path) -> None:
282 from muse.cli.commands.prune import _find_prune_candidates
283 root = _init_repo(tmp_path)
284 for i in range(5):
285 content = f"orphan {i}".encode()
286 write_object(root, _oid(content), content)
287 candidates = _find_prune_candidates(root, set(), expire_before=None)
288 ids = [c["object_id"] for c in candidates]
289 assert ids == sorted(ids)
290
291
292 # ---------------------------------------------------------------------------
293 # Integration — dry-run
294 # ---------------------------------------------------------------------------
295
296
297 class TestDryRun:
298 def test_does_not_delete_objects(self, tmp_path: pathlib.Path) -> None:
299 root = _init_repo(tmp_path)
300 _commit_files(root, {"a.py": b"# a\n"})
301 write_object(root, _oid(b"orphan"), b"orphan")
302 before = _object_count(root)
303 result = _invoke(root, "--dry-run")
304 assert result.exit_code == 0
305 after = _object_count(root)
306 assert after == before, "dry-run must not delete any objects"
307
308 def test_json_lists_candidates(self, tmp_path: pathlib.Path) -> None:
309 root = _init_repo(tmp_path)
310 _commit_files(root, {"a.py": b"# a\n"})
311 orphan = b"orphan candidate"
312 write_object(root, _oid(orphan), orphan)
313 result = _invoke(root, "--dry-run", "--json")
314 assert result.exit_code == 0
315 data = json.loads(result.stdout)
316 assert "candidates" in data
317 assert data["dry_run"] is True
318 candidate_ids = [c["object_id"] for c in data["candidates"]]
319 assert _oid(orphan) in candidate_ids
320
321 def test_json_schema_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
322 """RED: duration_ms must be present in dry-run --json output."""
323 root = _init_repo(tmp_path)
324 result = _invoke(root, "--dry-run", "--json")
325 assert result.exit_code == 0
326 data = json.loads(result.stdout)
327 assert "duration_ms" in data, "duration_ms missing from dry-run JSON"
328 assert isinstance(data["duration_ms"], (int, float))
329 assert data["duration_ms"] >= 0
330
331 def test_json_schema_has_exit_code(self, tmp_path: pathlib.Path) -> None:
332 """RED: exit_code must be present in dry-run --json output."""
333 root = _init_repo(tmp_path)
334 result = _invoke(root, "--dry-run", "--json")
335 assert result.exit_code == 0
336 data = json.loads(result.stdout)
337 assert "exit_code" in data, "exit_code missing from dry-run JSON"
338 assert data["exit_code"] == 0
339
340 def test_json_schema_has_reachable_count(self, tmp_path: pathlib.Path) -> None:
341 """RED: reachable_count must appear in dry-run --json output."""
342 root = _init_repo(tmp_path)
343 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
344 result = _invoke(root, "--dry-run", "--json")
345 assert result.exit_code == 0
346 data = json.loads(result.stdout)
347 assert "reachable_count" in data, "reachable_count missing from dry-run JSON"
348 assert isinstance(data["reachable_count"], int)
349 assert data["reachable_count"] >= 2
350
351 def test_json_candidates_have_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
352 """RED: candidates in dry-run JSON must have sha256:-prefixed object_id."""
353 root = _init_repo(tmp_path)
354 orphan = b"orphan for prefix check"
355 write_object(root, _oid(orphan), orphan)
356 result = _invoke(root, "--dry-run", "--json")
357 assert result.exit_code == 0
358 data = json.loads(result.stdout)
359 for c in data["candidates"]:
360 assert c["object_id"].startswith("sha256:"), (
361 f"candidate object_id lacks sha256: prefix: {c['object_id']!r}"
362 )
363
364 def test_text_output_mentions_candidates(self, tmp_path: pathlib.Path) -> None:
365 root = _init_repo(tmp_path)
366 _commit_files(root, {"a.py": b"# a\n"})
367 write_object(root, _oid(b"orphan x"), b"orphan x")
368 result = _invoke(root, "--dry-run")
369 assert result.exit_code == 0
370 assert result.stdout.strip()
371
372 def test_zero_orphans_dry_run(self, tmp_path: pathlib.Path) -> None:
373 root = _init_repo(tmp_path)
374 _commit_files(root, {"a.py": b"# a\n"})
375 result = _invoke(root, "--dry-run", "--json")
376 assert result.exit_code == 0
377 data = json.loads(result.stdout)
378 assert data["pruned"] == 0
379 assert data["bytes_freed"] == 0
380 assert data["candidates"] == []
381
382
383 # ---------------------------------------------------------------------------
384 # Integration — actual pruning
385 # ---------------------------------------------------------------------------
386
387
388 class TestLivePrune:
389 def test_removes_unreachable_objects(self, tmp_path: pathlib.Path) -> None:
390 root = _init_repo(tmp_path)
391 _commit_files(root, {"a.py": b"# a\n"})
392 orphan = b"i am unreachable"
393 write_object(root, _oid(orphan), orphan)
394 assert has_object(root, _oid(orphan))
395 result = _invoke(root)
396 assert result.exit_code == 0
397 assert not has_object(root, _oid(orphan)), "Orphan blob must be deleted by prune"
398
399 def test_keeps_reachable_objects(self, tmp_path: pathlib.Path) -> None:
400 root = _init_repo(tmp_path)
401 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
402 write_object(root, _oid(b"orphan"), b"orphan")
403 result = _invoke(root)
404 assert result.exit_code == 0
405 assert has_object(root, _oid(b"# a\n")), "Reachable blob must survive prune"
406 assert has_object(root, _oid(b"# b\n")), "Reachable blob must survive prune"
407
408 def test_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
409 """RED: duration_ms must be present in live --json output."""
410 root = _init_repo(tmp_path)
411 _commit_files(root, {"a.py": b"# a\n"})
412 write_object(root, _oid(b"orphan"), b"orphan")
413 result = _invoke(root, "--json")
414 assert result.exit_code == 0
415 data = json.loads(result.stdout)
416 assert "duration_ms" in data, "duration_ms missing from live JSON"
417 assert isinstance(data["duration_ms"], (int, float))
418 assert data["duration_ms"] >= 0
419
420 def test_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
421 """RED: exit_code must be present in live --json output."""
422 root = _init_repo(tmp_path)
423 _commit_files(root, {"a.py": b"# a\n"})
424 result = _invoke(root, "--json")
425 assert result.exit_code == 0
426 data = json.loads(result.stdout)
427 assert "exit_code" in data, "exit_code missing from live JSON"
428 assert data["exit_code"] == 0
429
430 def test_json_has_reachable_count(self, tmp_path: pathlib.Path) -> None:
431 """RED: reachable_count must appear in live --json output."""
432 root = _init_repo(tmp_path)
433 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
434 write_object(root, _oid(b"orphan"), b"orphan")
435 result = _invoke(root, "--json")
436 assert result.exit_code == 0
437 data = json.loads(result.stdout)
438 assert "reachable_count" in data, "reachable_count missing from live JSON"
439 assert data["reachable_count"] >= 2
440
441 def test_json_schema_complete(self, tmp_path: pathlib.Path) -> None:
442 root = _init_repo(tmp_path)
443 _commit_files(root, {"a.py": b"# a\n"})
444 write_object(root, _oid(b"orphan"), b"orphan")
445 result = _invoke(root, "--json")
446 assert result.exit_code == 0
447 data = json.loads(result.stdout)
448 for key in ("pruned", "bytes_freed", "dry_run", "reachable_count", "duration_ms", "exit_code"):
449 assert key in data, f"key {key!r} missing from live JSON"
450 assert data["dry_run"] is False
451
452 def test_json_pruned_count(self, tmp_path: pathlib.Path) -> None:
453 root = _init_repo(tmp_path)
454 _commit_files(root, {"a.py": b"# a\n"})
455 for i in range(3):
456 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
457 result = _invoke(root, "--json")
458 data = json.loads(result.stdout)
459 assert data["pruned"] >= 3
460
461 def test_empty_repo_exits_zero(self, tmp_path: pathlib.Path) -> None:
462 root = _init_repo(tmp_path)
463 result = _invoke(root, "--json")
464 assert result.exit_code == 0
465 data = json.loads(result.stdout)
466 assert data["pruned"] == 0
467
468 def test_no_orphans_exits_zero(self, tmp_path: pathlib.Path) -> None:
469 root = _init_repo(tmp_path)
470 _commit_files(root, {"a.py": b"# a\n"})
471 result = _invoke(root, "--json")
472 assert result.exit_code == 0
473 data = json.loads(result.stdout)
474 assert data["pruned"] == 0
475
476
477 # ---------------------------------------------------------------------------
478 # Data integrity
479 # ---------------------------------------------------------------------------
480
481
482 class TestDataIntegrity:
483 def test_bytes_freed_matches_actual_file_sizes(self, tmp_path: pathlib.Path) -> None:
484 """bytes_freed must equal the sum of sizes of actually deleted files."""
485 root = _init_repo(tmp_path)
486 orphans = [f"orphan blob {i}".encode() for i in range(5)]
487 expected_bytes = 0
488 for orphan in orphans:
489 oid = _oid(orphan)
490 write_object(root, oid, orphan)
491 # Find the on-disk size of the stored file.
492 from muse.core.object_store import object_path
493 obj_file = object_path(root, oid)
494 if obj_file.exists():
495 expected_bytes += obj_file.stat().st_size
496
497 result = _invoke(root, "--json")
498 assert result.exit_code == 0
499 data = json.loads(result.stdout)
500 assert data["bytes_freed"] == expected_bytes
501
502 def test_reachable_count_matches_committed_objects(self, tmp_path: pathlib.Path) -> None:
503 """reachable_count must equal the number of objects in all snapshots."""
504 root = _init_repo(tmp_path)
505 files = {"a.py": b"# a\n", "b.py": b"# b\n", "c.py": b"# c\n"}
506 _commit_files(root, files)
507 write_object(root, _oid(b"orphan"), b"orphan")
508 result = _invoke(root, "--json")
509 data = json.loads(result.stdout)
510 # 3 committed objects → reachable_count >= 3 (at least).
511 assert data["reachable_count"] >= 3
512
513 def test_dry_run_bytes_freed_matches_candidate_sizes(self, tmp_path: pathlib.Path) -> None:
514 """In dry-run, bytes_freed must equal the sum of candidate sizes."""
515 root = _init_repo(tmp_path)
516 for i in range(4):
517 write_object(root, _oid(f"blob {i}".encode()), f"blob {i}".encode())
518 result = _invoke(root, "--dry-run", "--json")
519 data = json.loads(result.stdout)
520 expected = sum(c["size"] for c in data["candidates"])
521 assert data["bytes_freed"] == expected
522
523
524 # ---------------------------------------------------------------------------
525 # Security
526 # ---------------------------------------------------------------------------
527
528
529 class TestSecurity:
530 def test_does_not_touch_commits_or_snapshots(self, tmp_path: pathlib.Path) -> None:
531 root = _init_repo(tmp_path)
532 _commit_files(root, {"a.py": b"# a\n"})
533 write_object(root, _oid(b"orphan"), b"orphan")
534 commits_before = list((root / ".muse" / "commits").glob("*.msgpack"))
535 snaps_before = list((root / ".muse" / "snapshots").glob("*.msgpack"))
536 _invoke(root)
537 commits_after = list((root / ".muse" / "commits").glob("*.msgpack"))
538 snaps_after = list((root / ".muse" / "snapshots").glob("*.msgpack"))
539 assert len(commits_before) == len(commits_after), "prune must not delete commits"
540 assert len(snaps_before) == len(snaps_after), "prune must not delete snapshots"
541
542 def test_dry_run_is_truly_readonly(self, tmp_path: pathlib.Path) -> None:
543 """No file under .muse/objects/ must be removed during --dry-run."""
544 root = _init_repo(tmp_path)
545 _commit_files(root, {"a.py": b"# a\n"})
546 for i in range(5):
547 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
548 before_files = set(
549 str(f) for f in (root / ".muse" / "objects").rglob("*") if f.is_file()
550 )
551 _invoke(root, "--dry-run")
552 after_files = set(
553 str(f) for f in (root / ".muse" / "objects").rglob("*") if f.is_file()
554 )
555 assert before_files == after_files, "dry-run must not modify the object store"
556
557 def test_reachable_objects_never_deleted(self, tmp_path: pathlib.Path) -> None:
558 """All committed object IDs must still be present after pruning."""
559 root = _init_repo(tmp_path)
560 committed_contents = [b"keep me A", b"keep me B", b"keep me C"]
561 files = {f"f{i}.py": c for i, c in enumerate(committed_contents)}
562 _commit_files(root, files)
563 for i in range(10):
564 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
565 _invoke(root)
566 for content in committed_contents:
567 assert has_object(root, _oid(content)), (
568 f"Reachable object {_oid(content)[:20]}... was deleted by prune"
569 )
570
571 def test_no_ansi_in_json_output(self, tmp_path: pathlib.Path) -> None:
572 """JSON output must not contain ANSI escape sequences."""
573 root = _init_repo(tmp_path)
574 write_object(root, _oid(b"orphan"), b"orphan")
575 result = _invoke(root, "--json")
576 assert "\x1b[" not in result.stdout
577
578 def test_merge_in_progress_exits_user_error(self, tmp_path: pathlib.Path) -> None:
579 """prune must refuse when a merge is in progress."""
580 root = _init_repo(tmp_path)
581 _commit_files(root, {"a.py": b"# a\n"})
582 # Simulate merge in progress by writing merge state.
583 merge_state_path = root / ".muse" / "MERGE_STATE"
584 merge_state_path.write_text(
585 json.dumps({"from_branch": "feat/x", "conflict_paths": []}),
586 encoding="utf-8",
587 )
588 result = _invoke(root)
589 # Should refuse and exit non-zero (1 = USER_ERROR).
590 # If merge engine not available, prune proceeds — accept both.
591 if result.exit_code != 0:
592 assert result.exit_code == 1
593
594
595 # ---------------------------------------------------------------------------
596 # Performance
597 # ---------------------------------------------------------------------------
598
599
600 class TestPerformance:
601 def test_100_objects_under_1_second(self, tmp_path: pathlib.Path) -> None:
602 """Pruning a 100-object store (50 reachable, 50 orphaned) must complete
603 in under 1 second wall-clock time."""
604 root = _init_repo(tmp_path)
605 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(50)}
606 _commit_files(root, files)
607 for i in range(50):
608 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
609 t0 = time.monotonic()
610 result = _invoke(root, "--json")
611 elapsed = time.monotonic() - t0
612 assert result.exit_code == 0
613 assert elapsed < 1.0, f"prune took {elapsed:.3f}s — expected < 1s"
614
615 def test_duration_ms_is_positive_number(self, tmp_path: pathlib.Path) -> None:
616 root = _init_repo(tmp_path)
617 result = _invoke(root, "--json")
618 data = json.loads(result.stdout)
619 assert data["duration_ms"] >= 0
620 assert data["duration_ms"] < 10_000 # sanity: less than 10 seconds
621
622
623 # ---------------------------------------------------------------------------
624 # Stress
625 # ---------------------------------------------------------------------------
626
627
628 class TestStress:
629 def test_50_percent_unreachable_200_objects(self, tmp_path: pathlib.Path) -> None:
630 """200 objects: 100 reachable (committed), 100 orphaned. Prune removes exactly 100."""
631 root = _init_repo(tmp_path)
632 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(100)}
633 _commit_files(root, files)
634 for i in range(100):
635 content = f"orphan blob {i:04d}".encode()
636 write_object(root, _oid(content), content)
637 result = _invoke(root, "--json")
638 assert result.exit_code == 0
639 data = json.loads(result.stdout)
640 assert data["pruned"] == 100
641 assert data["reachable_count"] >= 100
642
643 def test_all_objects_reachable_prunes_nothing(self, tmp_path: pathlib.Path) -> None:
644 """When every object is reachable, pruned==0 and store is unchanged."""
645 root = _init_repo(tmp_path)
646 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(50)}
647 _commit_files(root, files)
648 before = _object_count(root)
649 result = _invoke(root, "--json")
650 data = json.loads(result.stdout)
651 assert data["pruned"] == 0
652 assert _object_count(root) == before
653
654
655 # ---------------------------------------------------------------------------
656 # TestRegisterFlags — argparse-level verification
657 # ---------------------------------------------------------------------------
658
659
660 class TestRegisterFlags:
661 """Verify that register() wires --json / -j correctly."""
662
663 def _make_parser(self):
664 import argparse
665 from muse.cli.commands.prune import register
666 ap = argparse.ArgumentParser()
667 subs = ap.add_subparsers()
668 register(subs)
669 return ap
670
671 def test_json_flag_long(self):
672 ns = self._make_parser().parse_args(["prune", "--json"])
673 assert ns.json_out is True
674
675 def test_j_alias(self):
676 ns = self._make_parser().parse_args(["prune", "-j"])
677 assert ns.json_out is True
678
679 def test_default_is_text(self):
680 ns = self._make_parser().parse_args(["prune"])
681 assert ns.json_out is False
682
683 def test_dest_is_json_out(self):
684 ns = self._make_parser().parse_args(["prune", "-j"])
685 assert hasattr(ns, "json_out")
686 assert not hasattr(ns, "fmt")
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago