gabriel / muse public
test_prune_supercharge.py python
653 lines 27.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 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
42 import datetime
43 import hashlib
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, long_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 long_id(hashlib.sha256(content).hexdigest())
71
72
73 def _bare(content: bytes) -> str:
74 """Bare hex digest — for assertions against _collect_all_reachable_ids
75 which normalises to bare hex for filesystem comparison."""
76 return hashlib.sha256(content).hexdigest()
77
78
79 def _init_repo(path: pathlib.Path) -> pathlib.Path:
80 muse = path / ".muse"
81 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
82 (muse / d).mkdir(parents=True, exist_ok=True)
83 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
84 (muse / "repo.json").write_text(
85 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
86 )
87 return path
88
89
90 def _env(repo: pathlib.Path) -> dict[str, str]:
91 return {"MUSE_REPO_ROOT": str(repo)}
92
93
94 def _commit_files(
95 root: pathlib.Path,
96 files: dict[str, bytes],
97 branch: str = "main",
98 ) -> str:
99 global _counter
100 _counter += 1
101 manifest: Manifest = {}
102 for rel_path, content in files.items():
103 obj_id = _oid(content)
104 write_object(root, obj_id, content)
105 manifest[rel_path] = obj_id
106 abs_path = root / rel_path
107 abs_path.parent.mkdir(parents=True, exist_ok=True)
108 abs_path.write_bytes(content)
109 snap_id = compute_snapshot_id(manifest)
110 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
111 committed_at = datetime.datetime.now(datetime.timezone.utc)
112 ref_path = root / ".muse" / "refs" / "heads" / branch
113 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
114 parents = [parent_id] if parent_id else []
115 commit_id = compute_commit_id(
116 parents, snap_id, f"commit {_counter}", committed_at.isoformat()
117 )
118 write_commit(
119 root,
120 CommitRecord(
121 commit_id=commit_id,
122 repo_id=_REPO_ID,
123 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_bare_hex_ids(self, tmp_path: pathlib.Path) -> None:
158 """After the fix, _collect_all_reachable_ids must return bare hex IDs
159 so they match the filesystem shard names in _find_prune_candidates."""
160 from muse.cli.commands.prune import _collect_all_reachable_ids
161 root = _init_repo(tmp_path)
162 _commit_files(root, {"a.py": b"# a\n"})
163 ids = _collect_all_reachable_ids(root)
164 for oid in ids:
165 assert not oid.startswith("sha256:"), (
166 f"Expected bare hex but got '{oid[:12]}...'"
167 )
168
169 def test_contains_committed_object_ids(self, tmp_path: pathlib.Path) -> None:
170 from muse.cli.commands.prune import _collect_all_reachable_ids
171 root = _init_repo(tmp_path)
172 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
173 ids = _collect_all_reachable_ids(root)
174 assert _bare(b"# a\n") in ids
175 assert _bare(b"# b\n") in ids
176
177 def test_orphan_not_in_reachable(self, tmp_path: pathlib.Path) -> None:
178 from muse.cli.commands.prune import _collect_all_reachable_ids
179 root = _init_repo(tmp_path)
180 _commit_files(root, {"a.py": b"# a\n"})
181 orphan = b"orphan not in any snapshot"
182 write_object(root, _oid(orphan), orphan)
183 ids = _collect_all_reachable_ids(root)
184 assert _bare(orphan) not in ids
185 assert _bare(b"# a\n") in ids
186
187 def test_multiple_commits_all_reachable(self, tmp_path: pathlib.Path) -> None:
188 from muse.cli.commands.prune import _collect_all_reachable_ids
189 root = _init_repo(tmp_path)
190 _commit_files(root, {"a.py": b"v1\n"})
191 _commit_files(root, {"a.py": b"v2\n"})
192 ids = _collect_all_reachable_ids(root)
193 # Both versions are in snapshots on disk → both reachable.
194 assert _bare(b"v1\n") in ids
195 assert _bare(b"v2\n") in ids
196
197
198 # ---------------------------------------------------------------------------
199 # Unit — _find_prune_candidates
200 # ---------------------------------------------------------------------------
201
202
203 class TestFindPruneCandidates:
204 def test_returns_orphan(self, tmp_path: pathlib.Path) -> None:
205 from muse.cli.commands.prune import _find_prune_candidates, _collect_all_reachable_ids
206 root = _init_repo(tmp_path)
207 _commit_files(root, {"a.py": b"# a\n"})
208 orphan = b"i am orphaned"
209 write_object(root, _oid(orphan), orphan)
210 reachable = _collect_all_reachable_ids(root)
211 candidates = _find_prune_candidates(root, reachable, expire_before=None)
212 candidate_ids = {c["object_id"] for c in candidates}
213 assert _oid(orphan) in candidate_ids
214
215 def test_excludes_reachable_object(self, tmp_path: pathlib.Path) -> None:
216 from muse.cli.commands.prune import _find_prune_candidates, _collect_all_reachable_ids
217 root = _init_repo(tmp_path)
218 _commit_files(root, {"a.py": b"# a\n"})
219 reachable = _collect_all_reachable_ids(root)
220 candidates = _find_prune_candidates(root, reachable, expire_before=None)
221 candidate_ids = {c["object_id"] for c in candidates}
222 assert _oid(b"# a\n") not in candidate_ids
223
224 def test_candidate_object_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
225 """All candidate object_id values must be sha256:-prefixed (ecosystem standard)."""
226 from muse.cli.commands.prune import _find_prune_candidates
227 root = _init_repo(tmp_path)
228 orphan = b"orphan blob"
229 write_object(root, _oid(orphan), orphan)
230 candidates = _find_prune_candidates(root, set(), expire_before=None)
231 assert len(candidates) >= 1
232 for c in candidates:
233 assert c["object_id"].startswith("sha256:"), (
234 f"candidate object_id lacks sha256: prefix: {c['object_id'][:20]!r}"
235 )
236
237 def test_candidate_has_size_field(self, tmp_path: pathlib.Path) -> None:
238 from muse.cli.commands.prune import _find_prune_candidates
239 root = _init_repo(tmp_path)
240 orphan = b"sized orphan"
241 write_object(root, _oid(orphan), orphan)
242 candidates = _find_prune_candidates(root, set(), expire_before=None)
243 assert len(candidates) >= 1
244 for c in candidates:
245 assert "size" in c
246 assert isinstance(c["size"], int)
247 assert c["size"] >= 0
248
249 def test_empty_store_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
250 from muse.cli.commands.prune import _find_prune_candidates
251 root = _init_repo(tmp_path)
252 candidates = _find_prune_candidates(root, set(), expire_before=None)
253 assert candidates == []
254
255 def test_expire_before_filters_recent(self, tmp_path: pathlib.Path) -> None:
256 from muse.cli.commands.prune import _find_prune_candidates
257 root = _init_repo(tmp_path)
258 orphan = b"recent orphan"
259 write_object(root, _oid(orphan), orphan)
260 reachable: set[str] = set()
261 one_hour_ago = time.time() - 3600
262 candidates = _find_prune_candidates(root, reachable, expire_before=one_hour_ago)
263 candidate_ids = {c["object_id"] for c in candidates}
264 assert _oid(orphan) not in candidate_ids, "Recent orphan should be kept by --expire"
265
266 def test_expire_before_includes_old_objects(self, tmp_path: pathlib.Path) -> None:
267 from muse.cli.commands.prune import _find_prune_candidates
268 root = _init_repo(tmp_path)
269 orphan = b"old orphan"
270 write_object(root, _oid(orphan), orphan)
271 # Backdate mtime to 2 hours ago.
272 bare = _bare(orphan)
273 obj_path = next((root / ".muse" / "objects").rglob(bare[-62:]), None)
274 if obj_path:
275 two_hours_ago = time.time() - 7200
276 os.utime(obj_path, (two_hours_ago, two_hours_ago))
277 one_hour_ago = time.time() - 3600
278 candidates = _find_prune_candidates(root, set(), expire_before=one_hour_ago)
279 candidate_ids = {c["object_id"] for c in candidates}
280 assert _oid(orphan) in candidate_ids
281
282 def test_candidates_sorted_by_object_id(self, tmp_path: pathlib.Path) -> None:
283 from muse.cli.commands.prune import _find_prune_candidates
284 root = _init_repo(tmp_path)
285 for i in range(5):
286 content = f"orphan {i}".encode()
287 write_object(root, _oid(content), content)
288 candidates = _find_prune_candidates(root, set(), expire_before=None)
289 ids = [c["object_id"] for c in candidates]
290 assert ids == sorted(ids)
291
292
293 # ---------------------------------------------------------------------------
294 # Integration — dry-run
295 # ---------------------------------------------------------------------------
296
297
298 class TestDryRun:
299 def test_does_not_delete_objects(self, tmp_path: pathlib.Path) -> None:
300 root = _init_repo(tmp_path)
301 _commit_files(root, {"a.py": b"# a\n"})
302 write_object(root, _oid(b"orphan"), b"orphan")
303 before = _object_count(root)
304 result = _invoke(root, "--dry-run")
305 assert result.exit_code == 0
306 after = _object_count(root)
307 assert after == before, "dry-run must not delete any objects"
308
309 def test_json_lists_candidates(self, tmp_path: pathlib.Path) -> None:
310 root = _init_repo(tmp_path)
311 _commit_files(root, {"a.py": b"# a\n"})
312 orphan = b"orphan candidate"
313 write_object(root, _oid(orphan), orphan)
314 result = _invoke(root, "--dry-run", "--json")
315 assert result.exit_code == 0
316 data = json.loads(result.stdout)
317 assert "candidates" in data
318 assert data["dry_run"] is True
319 candidate_ids = [c["object_id"] for c in data["candidates"]]
320 assert _oid(orphan) in candidate_ids
321
322 def test_json_schema_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
323 """RED: duration_ms must be present in dry-run --json output."""
324 root = _init_repo(tmp_path)
325 result = _invoke(root, "--dry-run", "--json")
326 assert result.exit_code == 0
327 data = json.loads(result.stdout)
328 assert "duration_ms" in data, "duration_ms missing from dry-run JSON"
329 assert isinstance(data["duration_ms"], (int, float))
330 assert data["duration_ms"] >= 0
331
332 def test_json_schema_has_exit_code(self, tmp_path: pathlib.Path) -> None:
333 """RED: exit_code must be present in dry-run --json output."""
334 root = _init_repo(tmp_path)
335 result = _invoke(root, "--dry-run", "--json")
336 assert result.exit_code == 0
337 data = json.loads(result.stdout)
338 assert "exit_code" in data, "exit_code missing from dry-run JSON"
339 assert data["exit_code"] == 0
340
341 def test_json_schema_has_reachable_count(self, tmp_path: pathlib.Path) -> None:
342 """RED: reachable_count must appear in dry-run --json output."""
343 root = _init_repo(tmp_path)
344 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
345 result = _invoke(root, "--dry-run", "--json")
346 assert result.exit_code == 0
347 data = json.loads(result.stdout)
348 assert "reachable_count" in data, "reachable_count missing from dry-run JSON"
349 assert isinstance(data["reachable_count"], int)
350 assert data["reachable_count"] >= 2
351
352 def test_json_candidates_have_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
353 """RED: candidates in dry-run JSON must have sha256:-prefixed object_id."""
354 root = _init_repo(tmp_path)
355 orphan = b"orphan for prefix check"
356 write_object(root, _oid(orphan), orphan)
357 result = _invoke(root, "--dry-run", "--json")
358 assert result.exit_code == 0
359 data = json.loads(result.stdout)
360 for c in data["candidates"]:
361 assert c["object_id"].startswith("sha256:"), (
362 f"candidate object_id lacks sha256: prefix: {c['object_id']!r}"
363 )
364
365 def test_text_output_mentions_candidates(self, tmp_path: pathlib.Path) -> None:
366 root = _init_repo(tmp_path)
367 _commit_files(root, {"a.py": b"# a\n"})
368 write_object(root, _oid(b"orphan x"), b"orphan x")
369 result = _invoke(root, "--dry-run")
370 assert result.exit_code == 0
371 assert result.stdout.strip()
372
373 def test_zero_orphans_dry_run(self, tmp_path: pathlib.Path) -> None:
374 root = _init_repo(tmp_path)
375 _commit_files(root, {"a.py": b"# a\n"})
376 result = _invoke(root, "--dry-run", "--json")
377 assert result.exit_code == 0
378 data = json.loads(result.stdout)
379 assert data["pruned"] == 0
380 assert data["bytes_freed"] == 0
381 assert data["candidates"] == []
382
383
384 # ---------------------------------------------------------------------------
385 # Integration — actual pruning
386 # ---------------------------------------------------------------------------
387
388
389 class TestLivePrune:
390 def test_removes_unreachable_objects(self, tmp_path: pathlib.Path) -> None:
391 root = _init_repo(tmp_path)
392 _commit_files(root, {"a.py": b"# a\n"})
393 orphan = b"i am unreachable"
394 write_object(root, _oid(orphan), orphan)
395 assert has_object(root, _oid(orphan))
396 result = _invoke(root)
397 assert result.exit_code == 0
398 assert not has_object(root, _oid(orphan)), "Orphan blob must be deleted by prune"
399
400 def test_keeps_reachable_objects(self, tmp_path: pathlib.Path) -> None:
401 root = _init_repo(tmp_path)
402 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
403 write_object(root, _oid(b"orphan"), b"orphan")
404 result = _invoke(root)
405 assert result.exit_code == 0
406 assert has_object(root, _oid(b"# a\n")), "Reachable blob must survive prune"
407 assert has_object(root, _oid(b"# b\n")), "Reachable blob must survive prune"
408
409 def test_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
410 """RED: duration_ms must be present in live --json output."""
411 root = _init_repo(tmp_path)
412 _commit_files(root, {"a.py": b"# a\n"})
413 write_object(root, _oid(b"orphan"), b"orphan")
414 result = _invoke(root, "--json")
415 assert result.exit_code == 0
416 data = json.loads(result.stdout)
417 assert "duration_ms" in data, "duration_ms missing from live JSON"
418 assert isinstance(data["duration_ms"], (int, float))
419 assert data["duration_ms"] >= 0
420
421 def test_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
422 """RED: exit_code must be present in live --json output."""
423 root = _init_repo(tmp_path)
424 _commit_files(root, {"a.py": b"# a\n"})
425 result = _invoke(root, "--json")
426 assert result.exit_code == 0
427 data = json.loads(result.stdout)
428 assert "exit_code" in data, "exit_code missing from live JSON"
429 assert data["exit_code"] == 0
430
431 def test_json_has_reachable_count(self, tmp_path: pathlib.Path) -> None:
432 """RED: reachable_count must appear in live --json output."""
433 root = _init_repo(tmp_path)
434 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
435 write_object(root, _oid(b"orphan"), b"orphan")
436 result = _invoke(root, "--json")
437 assert result.exit_code == 0
438 data = json.loads(result.stdout)
439 assert "reachable_count" in data, "reachable_count missing from live JSON"
440 assert data["reachable_count"] >= 2
441
442 def test_json_schema_complete(self, tmp_path: pathlib.Path) -> None:
443 root = _init_repo(tmp_path)
444 _commit_files(root, {"a.py": b"# a\n"})
445 write_object(root, _oid(b"orphan"), b"orphan")
446 result = _invoke(root, "--json")
447 assert result.exit_code == 0
448 data = json.loads(result.stdout)
449 for key in ("pruned", "bytes_freed", "dry_run", "reachable_count", "duration_ms", "exit_code"):
450 assert key in data, f"key {key!r} missing from live JSON"
451 assert data["dry_run"] is False
452
453 def test_json_pruned_count(self, tmp_path: pathlib.Path) -> None:
454 root = _init_repo(tmp_path)
455 _commit_files(root, {"a.py": b"# a\n"})
456 for i in range(3):
457 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
458 result = _invoke(root, "--json")
459 data = json.loads(result.stdout)
460 assert data["pruned"] >= 3
461
462 def test_empty_repo_exits_zero(self, tmp_path: pathlib.Path) -> None:
463 root = _init_repo(tmp_path)
464 result = _invoke(root, "--json")
465 assert result.exit_code == 0
466 data = json.loads(result.stdout)
467 assert data["pruned"] == 0
468
469 def test_no_orphans_exits_zero(self, tmp_path: pathlib.Path) -> None:
470 root = _init_repo(tmp_path)
471 _commit_files(root, {"a.py": b"# a\n"})
472 result = _invoke(root, "--json")
473 assert result.exit_code == 0
474 data = json.loads(result.stdout)
475 assert data["pruned"] == 0
476
477
478 # ---------------------------------------------------------------------------
479 # Data integrity
480 # ---------------------------------------------------------------------------
481
482
483 class TestDataIntegrity:
484 def test_bytes_freed_matches_actual_file_sizes(self, tmp_path: pathlib.Path) -> None:
485 """bytes_freed must equal the sum of sizes of actually deleted files."""
486 root = _init_repo(tmp_path)
487 orphans = [f"orphan blob {i}".encode() for i in range(5)]
488 expected_bytes = 0
489 for orphan in orphans:
490 oid = _oid(orphan)
491 write_object(root, oid, orphan)
492 # Find the on-disk size of the stored file.
493 from muse.core.object_store import object_path
494 obj_file = object_path(root, oid)
495 if obj_file.exists():
496 expected_bytes += obj_file.stat().st_size
497
498 result = _invoke(root, "--json")
499 assert result.exit_code == 0
500 data = json.loads(result.stdout)
501 assert data["bytes_freed"] == expected_bytes
502
503 def test_reachable_count_matches_committed_objects(self, tmp_path: pathlib.Path) -> None:
504 """reachable_count must equal the number of objects in all snapshots."""
505 root = _init_repo(tmp_path)
506 files = {"a.py": b"# a\n", "b.py": b"# b\n", "c.py": b"# c\n"}
507 _commit_files(root, files)
508 write_object(root, _oid(b"orphan"), b"orphan")
509 result = _invoke(root, "--json")
510 data = json.loads(result.stdout)
511 # 3 committed objects → reachable_count >= 3 (at least).
512 assert data["reachable_count"] >= 3
513
514 def test_dry_run_bytes_freed_matches_candidate_sizes(self, tmp_path: pathlib.Path) -> None:
515 """In dry-run, bytes_freed must equal the sum of candidate sizes."""
516 root = _init_repo(tmp_path)
517 for i in range(4):
518 write_object(root, _oid(f"blob {i}".encode()), f"blob {i}".encode())
519 result = _invoke(root, "--dry-run", "--json")
520 data = json.loads(result.stdout)
521 expected = sum(c["size"] for c in data["candidates"])
522 assert data["bytes_freed"] == expected
523
524
525 # ---------------------------------------------------------------------------
526 # Security
527 # ---------------------------------------------------------------------------
528
529
530 class TestSecurity:
531 def test_does_not_touch_commits_or_snapshots(self, tmp_path: pathlib.Path) -> None:
532 root = _init_repo(tmp_path)
533 _commit_files(root, {"a.py": b"# a\n"})
534 write_object(root, _oid(b"orphan"), b"orphan")
535 commits_before = list((root / ".muse" / "commits").glob("*.msgpack"))
536 snaps_before = list((root / ".muse" / "snapshots").glob("*.msgpack"))
537 _invoke(root)
538 commits_after = list((root / ".muse" / "commits").glob("*.msgpack"))
539 snaps_after = list((root / ".muse" / "snapshots").glob("*.msgpack"))
540 assert len(commits_before) == len(commits_after), "prune must not delete commits"
541 assert len(snaps_before) == len(snaps_after), "prune must not delete snapshots"
542
543 def test_dry_run_is_truly_readonly(self, tmp_path: pathlib.Path) -> None:
544 """No file under .muse/objects/ must be removed during --dry-run."""
545 root = _init_repo(tmp_path)
546 _commit_files(root, {"a.py": b"# a\n"})
547 for i in range(5):
548 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
549 before_files = set(
550 str(f) for f in (root / ".muse" / "objects").rglob("*") if f.is_file()
551 )
552 _invoke(root, "--dry-run")
553 after_files = set(
554 str(f) for f in (root / ".muse" / "objects").rglob("*") if f.is_file()
555 )
556 assert before_files == after_files, "dry-run must not modify the object store"
557
558 def test_reachable_objects_never_deleted(self, tmp_path: pathlib.Path) -> None:
559 """All committed object IDs must still be present after pruning."""
560 root = _init_repo(tmp_path)
561 committed_contents = [b"keep me A", b"keep me B", b"keep me C"]
562 files = {f"f{i}.py": c for i, c in enumerate(committed_contents)}
563 _commit_files(root, files)
564 for i in range(10):
565 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
566 _invoke(root)
567 for content in committed_contents:
568 assert has_object(root, _oid(content)), (
569 f"Reachable object {_oid(content)[:20]}... was deleted by prune"
570 )
571
572 def test_no_ansi_in_json_output(self, tmp_path: pathlib.Path) -> None:
573 """JSON output must not contain ANSI escape sequences."""
574 root = _init_repo(tmp_path)
575 write_object(root, _oid(b"orphan"), b"orphan")
576 result = _invoke(root, "--json")
577 assert "\x1b[" not in result.stdout
578
579 def test_merge_in_progress_exits_user_error(self, tmp_path: pathlib.Path) -> None:
580 """prune must refuse when a merge is in progress."""
581 root = _init_repo(tmp_path)
582 _commit_files(root, {"a.py": b"# a\n"})
583 # Simulate merge in progress by writing merge state.
584 merge_state_path = root / ".muse" / "MERGE_STATE"
585 merge_state_path.write_text(
586 json.dumps({"from_branch": "feat/x", "conflict_paths": []}),
587 encoding="utf-8",
588 )
589 result = _invoke(root)
590 # Should refuse and exit non-zero (1 = USER_ERROR).
591 # If merge engine not available, prune proceeds — accept both.
592 if result.exit_code != 0:
593 assert result.exit_code == 1
594
595
596 # ---------------------------------------------------------------------------
597 # Performance
598 # ---------------------------------------------------------------------------
599
600
601 class TestPerformance:
602 def test_100_objects_under_1_second(self, tmp_path: pathlib.Path) -> None:
603 """Pruning a 100-object store (50 reachable, 50 orphaned) must complete
604 in under 1 second wall-clock time."""
605 root = _init_repo(tmp_path)
606 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(50)}
607 _commit_files(root, files)
608 for i in range(50):
609 write_object(root, _oid(f"orphan {i}".encode()), f"orphan {i}".encode())
610 t0 = time.monotonic()
611 result = _invoke(root, "--json")
612 elapsed = time.monotonic() - t0
613 assert result.exit_code == 0
614 assert elapsed < 1.0, f"prune took {elapsed:.3f}s — expected < 1s"
615
616 def test_duration_ms_is_positive_number(self, tmp_path: pathlib.Path) -> None:
617 root = _init_repo(tmp_path)
618 result = _invoke(root, "--json")
619 data = json.loads(result.stdout)
620 assert data["duration_ms"] >= 0
621 assert data["duration_ms"] < 10_000 # sanity: less than 10 seconds
622
623
624 # ---------------------------------------------------------------------------
625 # Stress
626 # ---------------------------------------------------------------------------
627
628
629 class TestStress:
630 def test_50_percent_unreachable_200_objects(self, tmp_path: pathlib.Path) -> None:
631 """200 objects: 100 reachable (committed), 100 orphaned. Prune removes exactly 100."""
632 root = _init_repo(tmp_path)
633 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(100)}
634 _commit_files(root, files)
635 for i in range(100):
636 content = f"orphan blob {i:04d}".encode()
637 write_object(root, _oid(content), content)
638 result = _invoke(root, "--json")
639 assert result.exit_code == 0
640 data = json.loads(result.stdout)
641 assert data["pruned"] == 100
642 assert data["reachable_count"] >= 100
643
644 def test_all_objects_reachable_prunes_nothing(self, tmp_path: pathlib.Path) -> None:
645 """When every object is reachable, pruned==0 and store is unchanged."""
646 root = _init_repo(tmp_path)
647 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(50)}
648 _commit_files(root, files)
649 before = _object_count(root)
650 result = _invoke(root, "--json")
651 data = json.loads(result.stdout)
652 assert data["pruned"] == 0
653 assert _object_count(root) == before
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago