gabriel / muse public
test_cmd_count_objects.py python
363 lines 12.8 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 146 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
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 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"):
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
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago