gabriel / muse public
test_prune_supercharge.py python
687 lines 28.3 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 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 argparse
45 import json
46 import os
47 import pathlib
48 import time
49
50 import pytest
51
52 from tests.cli_test_helper import CliRunner, InvokeResult
53 from muse.core.object_store import write_object, has_object
54 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
55 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
56 from muse.core.types import Manifest, blob_id
57 from muse.core.paths import commits_dir, merge_state_path, muse_dir, objects_dir, ref_path, snapshots_dir
58
59 runner = CliRunner()
60
61 _REPO_ID = "prune-supercharge-test"
62 _counter = 0
63
64
65 # ---------------------------------------------------------------------------
66 # Helpers
67 # ---------------------------------------------------------------------------
68
69
70 def _oid(content: bytes) -> str:
71 """sha256:-prefixed object ID — correct format for all Muse APIs."""
72 return blob_id(content)
73
74
75 def _bare(content: bytes) -> str:
76 """sha256:-prefixed object ID — for assertions against _collect_all_reachable_ids."""
77 return blob_id(content)
78
79
80 def _init_repo(path: pathlib.Path) -> pathlib.Path:
81 muse = muse_dir(path)
82 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
83 (muse / d).mkdir(parents=True, exist_ok=True)
84 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
85 (muse / "repo.json").write_text(
86 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
87 )
88 return path
89
90
91 def _env(repo: pathlib.Path) -> Mapping[str, str]:
92 return {"MUSE_REPO_ROOT": str(repo)}
93
94
95 def _commit_files(
96 root: pathlib.Path,
97 files: Mapping[str, bytes],
98 branch: str = "main",
99 ) -> str:
100 global _counter
101 _counter += 1
102 manifest: Manifest = {}
103 for rel_path, content in files.items():
104 obj_id = _oid(content)
105 write_object(root, obj_id, content)
106 manifest[rel_path] = obj_id
107 abs_path = root / rel_path
108 abs_path.parent.mkdir(parents=True, exist_ok=True)
109 abs_path.write_bytes(content)
110 snap_id = compute_snapshot_id(manifest)
111 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
112 committed_at = datetime.datetime.now(datetime.timezone.utc)
113 branch_ref = ref_path(root, branch)
114 parent_id = branch_ref.read_text(encoding="utf-8").strip() if branch_ref.exists() else None
115 parents = [parent_id] if parent_id else []
116 commit_id = compute_commit_id(
117 parents, snap_id, f"commit {_counter}", committed_at.isoformat(),
118 )
119 write_commit(
120 root,
121 CommitRecord(
122 repo_id=_REPO_ID,
123 commit_id=commit_id,
124 branch=branch,
125 snapshot_id=snap_id,
126 message=f"commit {_counter}",
127 committed_at=committed_at,
128 parent_commit_id=parent_id,
129 ),
130 )
131 branch_ref.write_text(commit_id, encoding="utf-8")
132 return commit_id
133
134
135 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
136 from muse.cli.app import main as cli
137 return runner.invoke(cli, ["prune", *args], env=_env(repo))
138
139
140 def _object_count(root: pathlib.Path) -> int:
141 from muse.core.object_store import iter_stored_objects
142 return sum(1 for _ in iter_stored_objects(root))
143
144
145 # ---------------------------------------------------------------------------
146 # Unit — _collect_all_reachable_ids
147 # ---------------------------------------------------------------------------
148
149
150 class TestCollectReachable:
151 def test_empty_repo_returns_empty_set(self, tmp_path: pathlib.Path) -> None:
152 from muse.cli.commands.prune import _collect_all_reachable_ids
153 root = _init_repo(tmp_path)
154 ids = _collect_all_reachable_ids(root)
155 assert isinstance(ids, set)
156 assert len(ids) == 0
157
158 def test_returns_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
159 """_collect_all_reachable_ids must return sha256:-prefixed object IDs."""
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 oid.startswith("sha256:"), (
166 f"Expected sha256:-prefixed ID 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((objects_dir(root)).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((commits_dir(root)).glob("*.msgpack"))
536 snaps_before = list((snapshots_dir(root)).glob("*.msgpack"))
537 _invoke(root)
538 commits_after = list((commits_dir(root)).glob("*.msgpack"))
539 snaps_after = list((snapshots_dir(root)).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 (objects_dir(root)).rglob("*") if f.is_file()
551 )
552 _invoke(root, "--dry-run")
553 after_files = set(
554 str(f) for f in (objects_dir(root)).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 ms_path = merge_state_path(root)
585 ms_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
654
655
656 # ---------------------------------------------------------------------------
657 # TestRegisterFlags — argparse-level verification
658 # ---------------------------------------------------------------------------
659
660
661 class TestRegisterFlags:
662 """Verify that register() wires --json / -j correctly."""
663
664 def _make_parser(self) -> "argparse.ArgumentParser":
665 import argparse
666 from muse.cli.commands.prune import register
667 ap = argparse.ArgumentParser()
668 subs = ap.add_subparsers()
669 register(subs)
670 return ap
671
672 def test_json_flag_long(self) -> None:
673 ns = self._make_parser().parse_args(["prune", "--json"])
674 assert ns.json_out is True
675
676 def test_j_alias(self) -> None:
677 ns = self._make_parser().parse_args(["prune", "-j"])
678 assert ns.json_out is True
679
680 def test_default_is_text(self) -> None:
681 ns = self._make_parser().parse_args(["prune"])
682 assert ns.json_out is False
683
684 def test_dest_is_json_out(self) -> None:
685 ns = self._make_parser().parse_args(["prune", "-j"])
686 assert hasattr(ns, "json_out")
687 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago