"""Tests for ``muse count-objects`` — object store diagnostics. Coverage tiers: - Unit: _count_loose_objects, _collect_reachable_ids helpers - Integration: empty store, single object, multi-shard, verbose breakdown, --unreachable counts GC candidates, JSON schema, text format, objects match expected after N commits - End-to-end: full CLI via CliRunner - Security: read-only — no mutations; no content reads (stat only) - Stress: store with many objects; --unreachable on multi-commit repo """ from __future__ import annotations import datetime import hashlib import json import os import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.object_store import write_object from muse.core.snapshot import compute_commit_id, compute_snapshot_id from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot from muse.core._types import Manifest runner = CliRunner() _REPO_ID = "count-objects-test" _counter = 0 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _init_repo(path: pathlib.Path) -> pathlib.Path: muse = path / ".muse" for d in ("commits", "snapshots", "objects", "refs/heads", "code"): (muse / d).mkdir(parents=True, exist_ok=True) (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (muse / "repo.json").write_text( json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8" ) return path def _env(repo: pathlib.Path) -> dict[str, str]: return {"MUSE_REPO_ROOT": str(repo)} def _commit_files( root: pathlib.Path, files: dict[str, bytes], branch: str = "main", ) -> str: global _counter _counter += 1 manifest: Manifest = {} for rel_path, content in files.items(): obj_id = _sha(content) write_object(root, obj_id, content) manifest[rel_path] = obj_id abs_path = root / rel_path abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_bytes(content) snap_id = compute_snapshot_id(manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) committed_at = datetime.datetime.now(datetime.timezone.utc) # Read the current tip to use as parent (proper chain for reachability BFS). ref_path = root / ".muse" / "refs" / "heads" / branch parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None parents = [parent_id] if parent_id else [] commit_id = compute_commit_id( parents, snap_id, f"commit {_counter}", committed_at.isoformat() ) write_commit( root, CommitRecord( commit_id=commit_id, repo_id=_REPO_ID, branch=branch, snapshot_id=snap_id, message=f"commit {_counter}", committed_at=committed_at, parent_commit_id=parent_id, ), ) ref_path.write_text(commit_id, encoding="utf-8") return commit_id def _invoke(repo: pathlib.Path, *args: str): from muse.cli.app import main as cli return runner.invoke(cli, ["count-objects", *args], env=_env(repo)) # --------------------------------------------------------------------------- # Unit — _count_loose_objects # --------------------------------------------------------------------------- def test_count_loose_objects_empty_store(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _count_loose_objects root = _init_repo(tmp_path) count, size = _count_loose_objects(root) assert count == 0 assert size == 0 def test_count_loose_objects_single_object(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _count_loose_objects root = _init_repo(tmp_path) content = b"hello world" obj_id = _sha(content) write_object(root, obj_id, content) count, size = _count_loose_objects(root) assert count == 1 assert size > 0 def test_count_loose_objects_multiple_shards(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _count_loose_objects root = _init_repo(tmp_path) # Write 5 distinct objects (may land in different shards) for i in range(5): content = f"object {i}".encode() write_object(root, _sha(content), content) count, _ = _count_loose_objects(root) assert count == 5 # --------------------------------------------------------------------------- # Unit — _collect_reachable_ids # --------------------------------------------------------------------------- def test_collect_reachable_ids_empty_repo(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _collect_reachable_ids root = _init_repo(tmp_path) ids = _collect_reachable_ids(root) assert isinstance(ids, set) assert len(ids) == 0 def test_collect_reachable_ids_after_commit(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _collect_reachable_ids root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n"}) ids = _collect_reachable_ids(root) # At minimum: the blob object for a.py assert len(ids) >= 1 def test_collect_reachable_ids_includes_all_blobs(tmp_path: pathlib.Path) -> None: from muse.cli.commands.count_objects import _collect_reachable_ids root = _init_repo(tmp_path) files = {"a.py": b"# a\n", "b.py": b"# b\n", "c.py": b"# c\n"} _commit_files(root, files) ids = _collect_reachable_ids(root) # All three blob IDs must be reachable for content in files.values(): assert _sha(content) in ids # --------------------------------------------------------------------------- # Integration — JSON output schema # --------------------------------------------------------------------------- def test_count_objects_json_schema_keys(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n"}) result = _invoke(root, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) for key in ("loose_objects", "loose_size_kb", "total_objects", "total_size_kb", "object_store_path"): assert key in data, f"Missing key: {key}" def test_count_objects_json_count_matches_written(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) # Write 3 unique blobs directly (no commit overhead) for i in range(3): content = f"direct blob {i}".encode() write_object(root, _sha(content), content) result = _invoke(root, "--json") data = json.loads(result.stdout) assert data["loose_objects"] >= 3 def test_count_objects_json_empty_store(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) result = _invoke(root, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["loose_objects"] == 0 assert data["total_objects"] == 0 def test_count_objects_json_size_nonzero_after_write(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) write_object(root, _sha(b"x" * 1000), b"x" * 1000) result = _invoke(root, "--json") data = json.loads(result.stdout) assert data["loose_size_kb"] > 0 or data["total_size_kb"] > 0 def test_count_objects_json_object_store_path_present(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) result = _invoke(root, "--json") data = json.loads(result.stdout) assert "objects" in data["object_store_path"] # --------------------------------------------------------------------------- # Integration — text output format # --------------------------------------------------------------------------- def test_count_objects_text_output_nonempty(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n"}) result = _invoke(root) assert result.exit_code == 0 assert result.stdout.strip() def test_count_objects_text_mentions_count(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) for i in range(5): write_object(root, _sha(f"obj{i}".encode()), f"obj{i}".encode()) result = _invoke(root) # The count should appear somewhere in the output assert any(char.isdigit() for char in result.stdout) # --------------------------------------------------------------------------- # Integration — --verbose shard breakdown # --------------------------------------------------------------------------- def test_count_objects_verbose_json_has_shards(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) for i in range(4): content = f"shard content {i}".encode() write_object(root, _sha(content), content) result = _invoke(root, "--verbose", "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert "shards" in data assert isinstance(data["shards"], list) def test_count_objects_verbose_shards_sum_to_total(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) for i in range(6): content = f"v content {i}".encode() write_object(root, _sha(content), content) result = _invoke(root, "--verbose", "--json") data = json.loads(result.stdout) shard_total = sum(s["count"] for s in data["shards"]) assert shard_total == data["loose_objects"] # --------------------------------------------------------------------------- # Integration — --unreachable # --------------------------------------------------------------------------- def test_count_objects_unreachable_zero_after_clean_commit(tmp_path: pathlib.Path) -> None: """After a commit where all blobs are referenced, unreachable should be 0.""" root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n", "b.py": b"# b\n"}) result = _invoke(root, "--unreachable", "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert "unreachable_objects" in data assert data["unreachable_objects"] == 0 def test_count_objects_unreachable_detects_orphan_blobs(tmp_path: pathlib.Path) -> None: """Blobs written but not referenced by any commit are unreachable.""" root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n"}) # Write an extra blob that is NOT referenced by any commit orphan = b"i am an orphan blob" write_object(root, _sha(orphan), orphan) result = _invoke(root, "--unreachable", "--json") data = json.loads(result.stdout) assert data["unreachable_objects"] >= 1 def test_count_objects_unreachable_empty_repo(tmp_path: pathlib.Path) -> None: root = _init_repo(tmp_path) result = _invoke(root, "--unreachable", "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["unreachable_objects"] == 0 # --------------------------------------------------------------------------- # Security — read-only, no mutations # --------------------------------------------------------------------------- def test_count_objects_does_not_modify_store(tmp_path: pathlib.Path) -> None: """count-objects must not write, delete, or move any object files.""" root = _init_repo(tmp_path) _commit_files(root, {"a.py": b"# a\n"}) objects_dir = root / ".muse" / "objects" # Collect (path, mtime) before before = { str(p): p.stat().st_mtime for p in objects_dir.rglob("*") if p.is_file() } _invoke(root, "--json") _invoke(root, "--unreachable", "--json") # Collect after after = { str(p): p.stat().st_mtime for p in objects_dir.rglob("*") if p.is_file() } assert before == after, "count-objects modified the object store" # --------------------------------------------------------------------------- # Stress # --------------------------------------------------------------------------- def test_count_objects_large_store(tmp_path: pathlib.Path) -> None: """Store with 200 objects — count should be accurate.""" root = _init_repo(tmp_path) for i in range(200): content = f"stress object {i:04d}".encode() write_object(root, _sha(content), content) result = _invoke(root, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["loose_objects"] == 200 def test_count_objects_unreachable_large_repo(tmp_path: pathlib.Path) -> None: """10 commits with 10 files each — all referenced, unreachable = 0.""" root = _init_repo(tmp_path) for i in range(10): files = {f"pkg/file_{i}_{j}.py": f"# {i},{j}\n".encode() for j in range(10)} _commit_files(root, files) result = _invoke(root, "--unreachable", "--json") assert result.exit_code == 0 data = json.loads(result.stdout) assert data["unreachable_objects"] == 0