gabriel / muse public
test_cmd_count_objects.py python
577 lines 21.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Tests for ``muse count-objects`` — object store diagnostics.
2
3 Coverage tiers:
4 - Unit: _count_loose_objects, _collect_reachable_ids helpers
5 - Integration: empty store, single object, multi-shard, verbose breakdown,
6 --unreachable counts GC candidates, JSON schema, text format,
7 objects match expected after N commits
8 - End-to-end: full CLI via CliRunner
9 - Security: read-only — no mutations; no content reads (stat only)
10 - Stress: store with many objects; --unreachable on multi-commit repo
11 """
12
13 from __future__ import annotations
14
15 import datetime
16 import hashlib
17 import json
18 import os
19 import pathlib
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24 from muse.core.object_store import write_object
25 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
26 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
27 from muse.core._types import Manifest, long_id
28
29 runner = CliRunner()
30
31 _REPO_ID = "count-objects-test"
32 _counter = 0
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _sha(data: bytes) -> str:
41 return long_id(hashlib.sha256(data).hexdigest())
42
43
44 def _init_repo(path: pathlib.Path) -> pathlib.Path:
45 muse = path / ".muse"
46 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
47 (muse / d).mkdir(parents=True, exist_ok=True)
48 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
49 (muse / "repo.json").write_text(
50 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
51 )
52 return path
53
54
55 def _env(repo: pathlib.Path) -> dict[str, str]:
56 return {"MUSE_REPO_ROOT": str(repo)}
57
58
59 def _commit_files(
60 root: pathlib.Path,
61 files: dict[str, bytes],
62 branch: str = "main",
63 ) -> str:
64 global _counter
65 _counter += 1
66 manifest: Manifest = {}
67 for rel_path, content in files.items():
68 obj_id = _sha(content)
69 write_object(root, obj_id, content)
70 manifest[rel_path] = obj_id
71 abs_path = root / rel_path
72 abs_path.parent.mkdir(parents=True, exist_ok=True)
73 abs_path.write_bytes(content)
74 snap_id = compute_snapshot_id(manifest)
75 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
76 committed_at = datetime.datetime.now(datetime.timezone.utc)
77 # Read the current tip to use as parent (proper chain for reachability BFS).
78 ref_path = root / ".muse" / "refs" / "heads" / branch
79 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
80 parents = [parent_id] if parent_id else []
81 commit_id = compute_commit_id(
82 parents, snap_id, f"commit {_counter}", committed_at.isoformat()
83 )
84 write_commit(
85 root,
86 CommitRecord(
87 commit_id=commit_id,
88 repo_id=_REPO_ID,
89 branch=branch,
90 snapshot_id=snap_id,
91 message=f"commit {_counter}",
92 committed_at=committed_at,
93 parent_commit_id=parent_id,
94 ),
95 )
96 ref_path.write_text(commit_id, encoding="utf-8")
97 return commit_id
98
99
100 def _invoke(repo: pathlib.Path, *args: str):
101 from muse.cli.app import main as cli
102 return runner.invoke(cli, ["count-objects", *args], env=_env(repo))
103
104
105 # ---------------------------------------------------------------------------
106 # Unit — _count_loose_objects
107 # ---------------------------------------------------------------------------
108
109
110 def test_count_loose_objects_empty_store(tmp_path: pathlib.Path) -> None:
111 from muse.cli.commands.count_objects import _count_loose_objects
112 root = _init_repo(tmp_path)
113 count, size = _count_loose_objects(root)
114 assert count == 0
115 assert size == 0
116
117
118 def test_count_loose_objects_single_object(tmp_path: pathlib.Path) -> None:
119 from muse.cli.commands.count_objects import _count_loose_objects
120 root = _init_repo(tmp_path)
121 content = b"hello world"
122 obj_id = _sha(content)
123 write_object(root, obj_id, content)
124 count, size = _count_loose_objects(root)
125 assert count == 1
126 assert size > 0
127
128
129 def test_count_loose_objects_multiple_shards(tmp_path: pathlib.Path) -> None:
130 from muse.cli.commands.count_objects import _count_loose_objects
131 root = _init_repo(tmp_path)
132 # Write 5 distinct objects (may land in different shards)
133 for i in range(5):
134 content = f"object {i}".encode()
135 write_object(root, _sha(content), content)
136 count, _ = _count_loose_objects(root)
137 assert count == 5
138
139
140 # ---------------------------------------------------------------------------
141 # Unit — _collect_reachable_ids
142 # ---------------------------------------------------------------------------
143
144
145 def test_collect_reachable_ids_empty_repo(tmp_path: pathlib.Path) -> None:
146 from muse.cli.commands.count_objects import _collect_reachable_ids
147 root = _init_repo(tmp_path)
148 ids = _collect_reachable_ids(root)
149 assert isinstance(ids, set)
150 assert len(ids) == 0
151
152
153 def test_collect_reachable_ids_after_commit(tmp_path: pathlib.Path) -> None:
154 from muse.cli.commands.count_objects import _collect_reachable_ids
155 root = _init_repo(tmp_path)
156 _commit_files(root, {"a.py": b"# a\n"})
157 ids = _collect_reachable_ids(root)
158 # At minimum: the blob object for a.py
159 assert len(ids) >= 1
160
161
162 def test_collect_reachable_ids_includes_all_blobs(tmp_path: pathlib.Path) -> None:
163 from muse.cli.commands.count_objects import _collect_reachable_ids
164 root = _init_repo(tmp_path)
165 files = {"a.py": b"# a\n", "b.py": b"# b\n", "c.py": b"# c\n"}
166 _commit_files(root, files)
167 ids = _collect_reachable_ids(root)
168 # All three blob IDs must be reachable
169 for content in files.values():
170 assert _sha(content) in ids
171
172
173 # ---------------------------------------------------------------------------
174 # Integration — JSON output schema
175 # ---------------------------------------------------------------------------
176
177
178 def test_count_objects_json_schema_keys(tmp_path: pathlib.Path) -> None:
179 root = _init_repo(tmp_path)
180 _commit_files(root, {"a.py": b"# a\n"})
181 result = _invoke(root, "--json")
182 assert result.exit_code == 0
183 data = json.loads(result.stdout)
184 for key in ("loose_objects", "loose_size_kb", "total_objects", "total_size_kb",
185 "object_store_path", "duration_ms", "exit_code"):
186 assert key in data, f"Missing key: {key}"
187
188
189 def test_count_objects_json_count_matches_written(tmp_path: pathlib.Path) -> None:
190 root = _init_repo(tmp_path)
191 # Write 3 unique blobs directly (no commit overhead)
192 for i in range(3):
193 content = f"direct blob {i}".encode()
194 write_object(root, _sha(content), content)
195 result = _invoke(root, "--json")
196 data = json.loads(result.stdout)
197 assert data["loose_objects"] >= 3
198
199
200 def test_count_objects_json_empty_store(tmp_path: pathlib.Path) -> None:
201 root = _init_repo(tmp_path)
202 result = _invoke(root, "--json")
203 assert result.exit_code == 0
204 data = json.loads(result.stdout)
205 assert data["loose_objects"] == 0
206 assert data["total_objects"] == 0
207
208
209 def test_count_objects_json_size_nonzero_after_write(tmp_path: pathlib.Path) -> None:
210 root = _init_repo(tmp_path)
211 write_object(root, _sha(b"x" * 1000), b"x" * 1000)
212 result = _invoke(root, "--json")
213 data = json.loads(result.stdout)
214 assert data["loose_size_kb"] > 0 or data["total_size_kb"] > 0
215
216
217 def test_count_objects_json_object_store_path_present(tmp_path: pathlib.Path) -> None:
218 root = _init_repo(tmp_path)
219 result = _invoke(root, "--json")
220 data = json.loads(result.stdout)
221 assert "objects" in data["object_store_path"]
222
223
224 # ---------------------------------------------------------------------------
225 # Integration — text output format
226 # ---------------------------------------------------------------------------
227
228
229 def test_count_objects_text_output_nonempty(tmp_path: pathlib.Path) -> None:
230 root = _init_repo(tmp_path)
231 _commit_files(root, {"a.py": b"# a\n"})
232 result = _invoke(root)
233 assert result.exit_code == 0
234 assert result.stdout.strip()
235
236
237 def test_count_objects_text_mentions_count(tmp_path: pathlib.Path) -> None:
238 root = _init_repo(tmp_path)
239 for i in range(5):
240 write_object(root, _sha(f"obj{i}".encode()), f"obj{i}".encode())
241 result = _invoke(root)
242 # The count should appear somewhere in the output
243 assert any(char.isdigit() for char in result.stdout)
244
245
246 # ---------------------------------------------------------------------------
247 # Integration — --verbose shard breakdown
248 # ---------------------------------------------------------------------------
249
250
251 def test_count_objects_verbose_json_has_shards(tmp_path: pathlib.Path) -> None:
252 root = _init_repo(tmp_path)
253 for i in range(4):
254 content = f"shard content {i}".encode()
255 write_object(root, _sha(content), content)
256 result = _invoke(root, "--verbose", "--json")
257 assert result.exit_code == 0
258 data = json.loads(result.stdout)
259 assert "shards" in data
260 assert isinstance(data["shards"], list)
261
262
263 def test_count_objects_verbose_shards_sum_to_total(tmp_path: pathlib.Path) -> None:
264 root = _init_repo(tmp_path)
265 for i in range(6):
266 content = f"v content {i}".encode()
267 write_object(root, _sha(content), content)
268 result = _invoke(root, "--verbose", "--json")
269 data = json.loads(result.stdout)
270 shard_total = sum(s["count"] for s in data["shards"])
271 assert shard_total == data["loose_objects"]
272
273
274 # ---------------------------------------------------------------------------
275 # Integration — --unreachable
276 # ---------------------------------------------------------------------------
277
278
279 def test_count_objects_unreachable_zero_after_clean_commit(tmp_path: pathlib.Path) -> None:
280 """After a commit where all blobs are referenced, unreachable should be 0."""
281 root = _init_repo(tmp_path)
282 _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"})
283 result = _invoke(root, "--unreachable", "--json")
284 assert result.exit_code == 0
285 data = json.loads(result.stdout)
286 assert "unreachable_objects" in data
287 assert data["unreachable_objects"] == 0
288
289
290 def test_count_objects_unreachable_detects_orphan_blobs(tmp_path: pathlib.Path) -> None:
291 """Blobs written but not referenced by any commit are unreachable."""
292 root = _init_repo(tmp_path)
293 _commit_files(root, {"a.py": b"# a\n"})
294 # Write an extra blob that is NOT referenced by any commit
295 orphan = b"i am an orphan blob"
296 write_object(root, _sha(orphan), orphan)
297 result = _invoke(root, "--unreachable", "--json")
298 data = json.loads(result.stdout)
299 assert data["unreachable_objects"] >= 1
300
301
302 def test_count_objects_unreachable_empty_repo(tmp_path: pathlib.Path) -> None:
303 root = _init_repo(tmp_path)
304 result = _invoke(root, "--unreachable", "--json")
305 assert result.exit_code == 0
306 data = json.loads(result.stdout)
307 assert data["unreachable_objects"] == 0
308
309
310 # ---------------------------------------------------------------------------
311 # Security — read-only, no mutations
312 # ---------------------------------------------------------------------------
313
314
315 def test_count_objects_does_not_modify_store(tmp_path: pathlib.Path) -> None:
316 """count-objects must not write, delete, or move any object files."""
317 root = _init_repo(tmp_path)
318 _commit_files(root, {"a.py": b"# a\n"})
319 objects_dir = root / ".muse" / "objects"
320 # Collect (path, mtime) before
321 before = {
322 str(p): p.stat().st_mtime
323 for p in objects_dir.rglob("*")
324 if p.is_file()
325 }
326 _invoke(root, "--json")
327 _invoke(root, "--unreachable", "--json")
328 # Collect after
329 after = {
330 str(p): p.stat().st_mtime
331 for p in objects_dir.rglob("*")
332 if p.is_file()
333 }
334 assert before == after, "count-objects modified the object store"
335
336
337 # ---------------------------------------------------------------------------
338 # Stress
339 # ---------------------------------------------------------------------------
340
341
342 def test_count_objects_large_store(tmp_path: pathlib.Path) -> None:
343 """Store with 200 objects — count should be accurate."""
344 root = _init_repo(tmp_path)
345 for i in range(200):
346 content = f"stress object {i:04d}".encode()
347 write_object(root, _sha(content), content)
348 result = _invoke(root, "--json")
349 assert result.exit_code == 0
350 data = json.loads(result.stdout)
351 assert data["loose_objects"] == 200
352
353
354 def test_count_objects_unreachable_large_repo(tmp_path: pathlib.Path) -> None:
355 """10 commits with 10 files each — all referenced, unreachable = 0."""
356 root = _init_repo(tmp_path)
357 for i in range(10):
358 files = {f"pkg/file_{i}_{j}.py": f"# {i},{j}\n".encode() for j in range(10)}
359 _commit_files(root, files)
360 result = _invoke(root, "--unreachable", "--json")
361 assert result.exit_code == 0
362 data = json.loads(result.stdout)
363 assert data["unreachable_objects"] == 0
364
365
366 # ---------------------------------------------------------------------------
367 # TestJsonSchemaComplete
368 # ---------------------------------------------------------------------------
369
370
371 _REQUIRED_KEYS = frozenset({
372 "loose_objects",
373 "loose_size_kb",
374 "total_objects",
375 "total_size_kb",
376 "object_store_path",
377 "duration_ms",
378 "exit_code",
379 })
380
381 _REQUIRED_KEYS_UNREACHABLE = _REQUIRED_KEYS | {"unreachable_objects"}
382 _REQUIRED_KEYS_VERBOSE = _REQUIRED_KEYS | {"shards"}
383
384
385 class TestJsonSchemaComplete:
386 """Every required key must appear in every JSON output path."""
387
388 def test_base_keys_present(self, tmp_path: pathlib.Path) -> None:
389 root = _init_repo(tmp_path)
390 result = _invoke(root, "--json")
391 assert result.exit_code == 0
392 data = json.loads(result.stdout)
393 missing = _REQUIRED_KEYS - data.keys()
394 assert not missing, f"Missing keys: {missing}"
395
396 def test_unreachable_keys_present(self, tmp_path: pathlib.Path) -> None:
397 root = _init_repo(tmp_path)
398 result = _invoke(root, "--unreachable", "--json")
399 assert result.exit_code == 0
400 data = json.loads(result.stdout)
401 missing = _REQUIRED_KEYS_UNREACHABLE - data.keys()
402 assert not missing, f"Missing keys: {missing}"
403
404 def test_verbose_keys_present(self, tmp_path: pathlib.Path) -> None:
405 root = _init_repo(tmp_path)
406 result = _invoke(root, "--verbose", "--json")
407 assert result.exit_code == 0
408 data = json.loads(result.stdout)
409 missing = _REQUIRED_KEYS_VERBOSE - data.keys()
410 assert not missing, f"Missing keys: {missing}"
411
412 def test_all_flags_keys_present(self, tmp_path: pathlib.Path) -> None:
413 root = _init_repo(tmp_path)
414 result = _invoke(root, "--unreachable", "--verbose", "--json")
415 assert result.exit_code == 0
416 data = json.loads(result.stdout)
417 missing = (_REQUIRED_KEYS_UNREACHABLE | _REQUIRED_KEYS_VERBOSE) - data.keys()
418 assert not missing, f"Missing keys: {missing}"
419
420 def test_exit_code_field_is_zero_on_success(self, tmp_path: pathlib.Path) -> None:
421 root = _init_repo(tmp_path)
422 result = _invoke(root, "--json")
423 assert result.exit_code == 0
424 assert json.loads(result.stdout)["exit_code"] == 0
425
426 def test_exit_code_is_integer(self, tmp_path: pathlib.Path) -> None:
427 root = _init_repo(tmp_path)
428 result = _invoke(root, "--json")
429 assert isinstance(json.loads(result.stdout)["exit_code"], int)
430
431 def test_json_is_compact(self, tmp_path: pathlib.Path) -> None:
432 root = _init_repo(tmp_path)
433 result = _invoke(root, "--json")
434 lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
435 assert len(lines) == 1, "JSON output must be a single line"
436
437 def test_exit_code_in_json_matches_process_exit(self, tmp_path: pathlib.Path) -> None:
438 root = _init_repo(tmp_path)
439 result = _invoke(root, "--json")
440 assert json.loads(result.stdout)["exit_code"] == result.exit_code
441
442
443 # ---------------------------------------------------------------------------
444 # TestElapsedSeconds
445 # ---------------------------------------------------------------------------
446
447
448 class TestElapsedSeconds:
449 """``duration_ms`` must be a non-negative float in every JSON path."""
450
451 def _assert_elapsed(self, data: dict) -> None: # type: ignore[type-arg]
452 assert "duration_ms" in data
453 assert isinstance(data["duration_ms"], float)
454 assert data["duration_ms"] >= 0.0
455
456 def test_elapsed_base(self, tmp_path: pathlib.Path) -> None:
457 root = _init_repo(tmp_path)
458 result = _invoke(root, "--json")
459 self._assert_elapsed(json.loads(result.stdout))
460
461 def test_elapsed_with_unreachable(self, tmp_path: pathlib.Path) -> None:
462 root = _init_repo(tmp_path)
463 _commit_files(root, {"a.py": b"# a\n"})
464 result = _invoke(root, "--unreachable", "--json")
465 self._assert_elapsed(json.loads(result.stdout))
466
467 def test_elapsed_with_verbose(self, tmp_path: pathlib.Path) -> None:
468 root = _init_repo(tmp_path)
469 _commit_files(root, {"a.py": b"# a\n"})
470 result = _invoke(root, "--verbose", "--json")
471 self._assert_elapsed(json.loads(result.stdout))
472
473 def test_elapsed_all_flags(self, tmp_path: pathlib.Path) -> None:
474 root = _init_repo(tmp_path)
475 _commit_files(root, {"a.py": b"# a\n"})
476 result = _invoke(root, "--unreachable", "--verbose", "--json")
477 self._assert_elapsed(json.loads(result.stdout))
478
479 def test_elapsed_is_float_not_int(self, tmp_path: pathlib.Path) -> None:
480 root = _init_repo(tmp_path)
481 result = _invoke(root, "--json")
482 data = json.loads(result.stdout)
483 assert isinstance(data["duration_ms"], float)
484
485 def test_elapsed_reasonable_upper_bound(self, tmp_path: pathlib.Path) -> None:
486 root = _init_repo(tmp_path)
487 result = _invoke(root, "--json")
488 assert json.loads(result.stdout)["duration_ms"] < 5.0
489
490 def test_elapsed_six_decimal_places(self, tmp_path: pathlib.Path) -> None:
491 root = _init_repo(tmp_path)
492 result = _invoke(root, "--json")
493 elapsed = json.loads(result.stdout)["duration_ms"]
494 assert round(elapsed, 6) == elapsed
495
496
497 # ---------------------------------------------------------------------------
498 # TestExitCode
499 # ---------------------------------------------------------------------------
500
501
502 class TestExitCode:
503 """``exit_code`` in JSON must mirror the process exit code."""
504
505 def test_exit_code_zero_empty_store(self, tmp_path: pathlib.Path) -> None:
506 root = _init_repo(tmp_path)
507 result = _invoke(root, "--json")
508 assert result.exit_code == 0
509 assert json.loads(result.stdout)["exit_code"] == 0
510
511 def test_exit_code_zero_with_objects(self, tmp_path: pathlib.Path) -> None:
512 root = _init_repo(tmp_path)
513 _commit_files(root, {"a.py": b"# a\n"})
514 result = _invoke(root, "--json")
515 assert result.exit_code == 0
516 assert json.loads(result.stdout)["exit_code"] == 0
517
518 def test_exit_code_zero_unreachable_flag(self, tmp_path: pathlib.Path) -> None:
519 root = _init_repo(tmp_path)
520 _commit_files(root, {"a.py": b"# a\n"})
521 result = _invoke(root, "--unreachable", "--json")
522 assert result.exit_code == 0
523 assert json.loads(result.stdout)["exit_code"] == 0
524
525 def test_exit_code_zero_verbose_flag(self, tmp_path: pathlib.Path) -> None:
526 root = _init_repo(tmp_path)
527 result = _invoke(root, "--verbose", "--json")
528 assert result.exit_code == 0
529 assert json.loads(result.stdout)["exit_code"] == 0
530
531 def test_exit_code_matches_process_exit(self, tmp_path: pathlib.Path) -> None:
532 root = _init_repo(tmp_path)
533 result = _invoke(root, "--json")
534 assert json.loads(result.stdout)["exit_code"] == result.exit_code
535
536
537 # ---------------------------------------------------------------------------
538 # Data integrity — unreachable detection correctness with sha256: prefix
539 # ---------------------------------------------------------------------------
540
541
542 class TestUnreachableDetection:
543 """Verify unreachable detection correctly handles sha256:-prefixed IDs."""
544
545 def test_all_committed_blobs_reachable(self, tmp_path: pathlib.Path) -> None:
546 root = _init_repo(tmp_path)
547 _commit_files(root, {"x.py": b"x = 1\n", "y.py": b"y = 2\n"})
548 result = _invoke(root, "--unreachable", "--json")
549 assert result.exit_code == 0
550 assert json.loads(result.stdout)["unreachable_objects"] == 0
551
552 def test_orphan_blob_detected(self, tmp_path: pathlib.Path) -> None:
553 root = _init_repo(tmp_path)
554 _commit_files(root, {"a.py": b"# committed\n"})
555 write_object(root, _sha(b"orphan"), b"orphan")
556 result = _invoke(root, "--unreachable", "--json")
557 assert json.loads(result.stdout)["unreachable_objects"] >= 1
558
559 def test_multiple_orphans_all_counted(self, tmp_path: pathlib.Path) -> None:
560 root = _init_repo(tmp_path)
561 _commit_files(root, {"a.py": b"# committed\n"})
562 for i in range(5):
563 content = f"orphan {i}".encode()
564 write_object(root, _sha(content), content)
565 result = _invoke(root, "--unreachable", "--json")
566 assert json.loads(result.stdout)["unreachable_objects"] >= 5
567
568 def test_reachable_set_uses_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
569 """_collect_reachable_ids must return sha256:-prefixed IDs."""
570 from muse.cli.commands.count_objects import _collect_reachable_ids
571 root = _init_repo(tmp_path)
572 content = b"# test\n"
573 _commit_files(root, {"a.py": content})
574 ids = _collect_reachable_ids(root)
575 assert len(ids) > 0
576 for obj_id in ids:
577 assert obj_id.startswith("sha256:"), f"ID missing sha256: prefix: {obj_id!r}"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago