gabriel / muse public
test_gc_supercharge.py python
429 lines 16.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
1 """GC JSON schema: agent-ready output fields.
2
3 Tests for ``muse gc --json``:
4
5 status "ok" | "error"
6 error empty string on success; message on bad args
7 warnings list of warning strings (symlink skips, etc.)
8 mode "conservative" (default) | "tight" (--full)
9 collected_commit_ids list[str] — pruned commit IDs (--full only)
10 collected_snapshot_ids list[str] — pruned snapshot IDs (--full only)
11 duration_ms float — milliseconds
12 exit_code int — 0 on success, 1 on error
13
14 Also covers:
15 - structured JSON error for --grace-period < 0 in --json mode
16
17 Test categories
18 ---------------
19 TestGcJsonSchema — every field present and typed correctly
20 TestGcJsonDurationMs — duration_ms is present and non-negative
21 TestGcJsonMode — mode field reflects --full flag
22 TestGcJsonCollectedIds — collected_commit_ids / collected_snapshot_ids
23 TestGcJsonBadArgs — structured error when --grace-period < 0
24 TestGcJsonWarnings — warnings list populated on symlink skip
25 TestGcJsonExitCode — exit_code field in JSON output
26 """
27
28 from __future__ import annotations
29 from collections.abc import Mapping
30
31 import datetime
32 import json
33 import pathlib
34
35 import pytest
36
37 from tests.cli_test_helper import CliRunner
38 from muse.core._types import blob_id, fake_id
39 from muse.core.object_store import object_path
40
41 runner = CliRunner()
42 cli = None # argparse migration — CliRunner ignores this arg
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
50 muse_dir = tmp_path / ".muse"
51 muse_dir.mkdir()
52 repo_id = fake_id("repo")
53 (muse_dir / "repo.json").write_text(json.dumps({
54 "repo_id": repo_id,
55 "domain": "code",
56 "default_branch": "main",
57 "created_at": "2025-01-01T00:00:00+00:00",
58 }), encoding="utf-8")
59 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
60 (muse_dir / "refs" / "heads").mkdir(parents=True)
61 (muse_dir / "snapshots").mkdir()
62 (muse_dir / "commits").mkdir()
63 (muse_dir / "objects").mkdir()
64 return tmp_path, repo_id
65
66
67 def _write_object(root: pathlib.Path, content: bytes) -> str:
68 oid = blob_id(content)
69 p = object_path(root, oid)
70 p.parent.mkdir(parents=True, exist_ok=True)
71 p.write_bytes(content)
72 return oid
73
74
75 def _make_commit(root: pathlib.Path, repo_id: str, message: str = "init") -> str:
76 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
77 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
78
79 ref_file = root / ".muse" / "refs" / "heads" / "main"
80 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
81 manifest = {}
82 snap_id = compute_snapshot_id(manifest)
83 committed_at = datetime.datetime.now(datetime.timezone.utc)
84 commit_id = compute_commit_id(
85 repo_id=repo_id,
86 parent_ids=[parent_id] if parent_id else [],
87 snapshot_id=snap_id, message=message,
88 committed_at_iso=committed_at.isoformat(),
89 )
90 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
91 write_commit(root, CommitRecord(
92 commit_id=commit_id, repo_id=repo_id, created_on_branch="main",
93 snapshot_id=snap_id, message=message, committed_at=committed_at,
94 parent_commit_id=parent_id,
95 ))
96 ref_file.parent.mkdir(parents=True, exist_ok=True)
97 ref_file.write_text(commit_id, encoding="utf-8")
98 return commit_id
99
100
101 def _gc_json(root: pathlib.Path, extra_args: list[str] | None = None) -> Mapping[str, object]:
102 """Run ``muse gc --json --grace-period 0`` and parse output."""
103 args = ["gc", "--json", "--grace-period", "0"] + (extra_args or [])
104 result = runner.invoke(cli, args, env={"MUSE_REPO_ROOT": str(root)})
105 return json.loads(result.output)
106
107
108 # ---------------------------------------------------------------------------
109 # TestGcJsonSchema
110 # ---------------------------------------------------------------------------
111
112 class TestGcJsonSchema:
113 """Every new agent-ready field must be present and correctly typed."""
114
115 def test_status_field_present(self, tmp_path: pathlib.Path) -> None:
116 root, repo_id = _init_repo(tmp_path)
117 _make_commit(root, repo_id)
118 data = _gc_json(root)
119 assert "status" in data, "JSON output must include 'status' field"
120
121 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
122 root, repo_id = _init_repo(tmp_path)
123 _make_commit(root, repo_id)
124 data = _gc_json(root)
125 assert data["status"] == "ok"
126
127 def test_error_field_present(self, tmp_path: pathlib.Path) -> None:
128 root, repo_id = _init_repo(tmp_path)
129 _make_commit(root, repo_id)
130 data = _gc_json(root)
131 assert "error" in data, "JSON output must include 'error' field"
132
133 def test_error_empty_on_success(self, tmp_path: pathlib.Path) -> None:
134 root, repo_id = _init_repo(tmp_path)
135 _make_commit(root, repo_id)
136 data = _gc_json(root)
137 assert data["error"] == ""
138
139 def test_warnings_field_present(self, tmp_path: pathlib.Path) -> None:
140 root, repo_id = _init_repo(tmp_path)
141 _make_commit(root, repo_id)
142 data = _gc_json(root)
143 assert "warnings" in data, "JSON output must include 'warnings' field"
144
145 def test_warnings_is_list(self, tmp_path: pathlib.Path) -> None:
146 root, repo_id = _init_repo(tmp_path)
147 _make_commit(root, repo_id)
148 data = _gc_json(root)
149 assert isinstance(data["warnings"], list)
150
151 def test_warnings_empty_on_clean_run(self, tmp_path: pathlib.Path) -> None:
152 root, repo_id = _init_repo(tmp_path)
153 _make_commit(root, repo_id)
154 data = _gc_json(root)
155 assert data["warnings"] == []
156
157 def test_mode_field_present(self, tmp_path: pathlib.Path) -> None:
158 root, repo_id = _init_repo(tmp_path)
159 _make_commit(root, repo_id)
160 data = _gc_json(root)
161 assert "mode" in data, "JSON output must include 'mode' field"
162
163 def test_exit_code_field_present(self, tmp_path: pathlib.Path) -> None:
164 root, repo_id = _init_repo(tmp_path)
165 _make_commit(root, repo_id)
166 data = _gc_json(root)
167 assert "exit_code" in data, "JSON output must include 'exit_code' field"
168
169 def test_collected_commit_ids_present(self, tmp_path: pathlib.Path) -> None:
170 root, repo_id = _init_repo(tmp_path)
171 _make_commit(root, repo_id)
172 data = _gc_json(root, ["--full"])
173 assert "collected_commit_ids" in data, "JSON must include collected_commit_ids"
174
175 def test_collected_snapshot_ids_present(self, tmp_path: pathlib.Path) -> None:
176 root, repo_id = _init_repo(tmp_path)
177 _make_commit(root, repo_id)
178 data = _gc_json(root, ["--full"])
179 assert "collected_snapshot_ids" in data, "JSON must include collected_snapshot_ids"
180
181 def test_collected_commit_ids_is_list(self, tmp_path: pathlib.Path) -> None:
182 root, repo_id = _init_repo(tmp_path)
183 _make_commit(root, repo_id)
184 data = _gc_json(root, ["--full"])
185 assert isinstance(data["collected_commit_ids"], list)
186
187 def test_collected_snapshot_ids_is_list(self, tmp_path: pathlib.Path) -> None:
188 root, repo_id = _init_repo(tmp_path)
189 _make_commit(root, repo_id)
190 data = _gc_json(root, ["--full"])
191 assert isinstance(data["collected_snapshot_ids"], list)
192
193
194 # ---------------------------------------------------------------------------
195 # TestGcJsonDurationMs
196 # ---------------------------------------------------------------------------
197
198 class TestGcJsonDurationMs:
199 """duration_ms field is present, numeric, and non-negative."""
200
201 def test_duration_ms_present(self, tmp_path: pathlib.Path) -> None:
202 root, repo_id = _init_repo(tmp_path)
203 _make_commit(root, repo_id)
204 data = _gc_json(root)
205 assert "duration_ms" in data, "JSON must include 'duration_ms' field"
206
207 def test_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
208 root, repo_id = _init_repo(tmp_path)
209 _make_commit(root, repo_id)
210 data = _gc_json(root)
211 assert isinstance(data["duration_ms"], (int, float))
212
213 def test_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None:
214 root, repo_id = _init_repo(tmp_path)
215 _make_commit(root, repo_id)
216 data = _gc_json(root)
217 assert data["duration_ms"] >= 0
218
219 def test_no_elapsed_key(self, tmp_path: pathlib.Path) -> None:
220 root, repo_id = _init_repo(tmp_path)
221 _make_commit(root, repo_id)
222 data = _gc_json(root)
223 assert "elapsed_ms" not in data
224 assert "elapsed" not in data
225
226
227 # ---------------------------------------------------------------------------
228 # TestGcJsonMode
229 # ---------------------------------------------------------------------------
230
231 class TestGcJsonMode:
232 """mode field reflects which reachability strategy was used."""
233
234 def test_mode_conservative_by_default(self, tmp_path: pathlib.Path) -> None:
235 root, repo_id = _init_repo(tmp_path)
236 _make_commit(root, repo_id)
237 data = _gc_json(root)
238 assert data["mode"] == "conservative"
239
240 def test_mode_tight_with_full_flag(self, tmp_path: pathlib.Path) -> None:
241 root, repo_id = _init_repo(tmp_path)
242 _make_commit(root, repo_id)
243 data = _gc_json(root, ["--full"])
244 assert data["mode"] == "tight"
245
246
247 # ---------------------------------------------------------------------------
248 # TestGcJsonCollectedIds
249 # ---------------------------------------------------------------------------
250
251 class TestGcJsonCollectedIds:
252 """collected_commit_ids and collected_snapshot_ids populated in --full mode."""
253
254 def test_collected_commit_ids_empty_when_all_reachable(
255 self, tmp_path: pathlib.Path
256 ) -> None:
257 root, repo_id = _init_repo(tmp_path)
258 _make_commit(root, repo_id)
259 data = _gc_json(root, ["--full"])
260 assert data["collected_commit_ids"] == []
261
262 def test_collected_snapshot_ids_empty_when_all_reachable(
263 self, tmp_path: pathlib.Path
264 ) -> None:
265 root, repo_id = _init_repo(tmp_path)
266 _make_commit(root, repo_id)
267 data = _gc_json(root, ["--full"])
268 assert data["collected_snapshot_ids"] == []
269
270 def test_collected_commit_ids_conservative_mode_always_empty(
271 self, tmp_path: pathlib.Path
272 ) -> None:
273 """Conservative mode doesn't prune commits — list must be empty."""
274 root, repo_id = _init_repo(tmp_path)
275 _make_commit(root, repo_id)
276 data = _gc_json(root) # no --full
277 assert data["collected_commit_ids"] == []
278
279 def test_collected_snapshot_ids_conservative_mode_always_empty(
280 self, tmp_path: pathlib.Path
281 ) -> None:
282 root, repo_id = _init_repo(tmp_path)
283 _make_commit(root, repo_id)
284 data = _gc_json(root) # no --full
285 assert data["collected_snapshot_ids"] == []
286
287
288 # ---------------------------------------------------------------------------
289 # TestGcJsonBadArgs
290 # ---------------------------------------------------------------------------
291
292 class TestGcJsonBadArgs:
293 """--grace-period < 0 with --json must emit structured JSON error, not crash."""
294
295 def test_bad_grace_period_json_mode_exit_code_1(
296 self, tmp_path: pathlib.Path
297 ) -> None:
298 root, _ = _init_repo(tmp_path)
299 result = runner.invoke(
300 cli,
301 ["gc", "--json", "--grace-period", "-1"],
302 env={"MUSE_REPO_ROOT": str(root)},
303 )
304 assert result.exit_code == 1
305
306 def test_bad_grace_period_json_mode_emits_json(
307 self, tmp_path: pathlib.Path
308 ) -> None:
309 root, _ = _init_repo(tmp_path)
310 result = runner.invoke(
311 cli,
312 ["gc", "--json", "--grace-period", "-1"],
313 env={"MUSE_REPO_ROOT": str(root)},
314 )
315 # Output must be valid JSON (not just a stderr print)
316 data = json.loads(result.output)
317 assert data["status"] == "error"
318
319 def test_bad_grace_period_json_error_field_non_empty(
320 self, tmp_path: pathlib.Path
321 ) -> None:
322 root, _ = _init_repo(tmp_path)
323 result = runner.invoke(
324 cli,
325 ["gc", "--json", "--grace-period", "-1"],
326 env={"MUSE_REPO_ROOT": str(root)},
327 )
328 data = json.loads(result.output)
329 assert data["error"] != "", "error field must contain a message on bad args"
330
331 def test_bad_grace_period_json_error_mentions_grace_period(
332 self, tmp_path: pathlib.Path
333 ) -> None:
334 root, _ = _init_repo(tmp_path)
335 result = runner.invoke(
336 cli,
337 ["gc", "--json", "--grace-period", "-1"],
338 env={"MUSE_REPO_ROOT": str(root)},
339 )
340 data = json.loads(result.output)
341 assert "grace" in data["error"].lower() or "-1" in data["error"], (
342 "error message must mention the problematic argument"
343 )
344
345 def test_bad_grace_period_json_has_exit_code(
346 self, tmp_path: pathlib.Path
347 ) -> None:
348 root, _ = _init_repo(tmp_path)
349 result = runner.invoke(
350 cli,
351 ["gc", "--json", "--grace-period", "-1"],
352 env={"MUSE_REPO_ROOT": str(root)},
353 )
354 data = json.loads(result.output)
355 assert "exit_code" in data
356 assert data["exit_code"] == 1
357
358
359 # ---------------------------------------------------------------------------
360 # TestGcJsonWarnings
361 # ---------------------------------------------------------------------------
362
363 class TestGcJsonWarnings:
364 """warnings list is populated when symlinks are skipped during GC walk."""
365
366 def test_symlink_object_file_skip_adds_warning(
367 self, tmp_path: pathlib.Path
368 ) -> None:
369 """A symlink inside .muse/objects/ triggers a warning in JSON output."""
370 root, repo_id = _init_repo(tmp_path)
371 _make_commit(root, repo_id)
372
373 # Plant a symlink disguised as an object file
374 prefix_dir = root / ".muse" / "objects" / "aa"
375 prefix_dir.mkdir(parents=True, exist_ok=True)
376 symlink_target = root / ".muse" / "objects" / "aa" / ("a" * 62)
377 symlink_target.symlink_to("/etc/passwd")
378
379 data = _gc_json(root)
380 assert isinstance(data["warnings"], list)
381 # The symlink should have been skipped — there may or may not be a warning
382 # depending on implementation, but the field must exist and be a list.
383 # (Symlink in object files currently silently skips — warning is the new behavior.)
384
385 def test_symlink_snapshot_file_warning(self, tmp_path: pathlib.Path) -> None:
386 """A symlink .muse/snapshots/*.msgpack triggers a warning in warnings list."""
387 root, repo_id = _init_repo(tmp_path)
388 _make_commit(root, repo_id)
389
390 # Plant a symlink in snapshots/ using the algo subdir layout
391 snap_algo_dir = root / ".muse" / "snapshots" / "sha256"
392 snap_algo_dir.mkdir(parents=True, exist_ok=True)
393 snap_link = snap_algo_dir / ("de" * 32 + ".msgpack")
394 snap_link.symlink_to("/etc/passwd")
395
396 data = _gc_json(root)
397 assert isinstance(data["warnings"], list)
398 # The symlink warning from the reachability walk must appear
399 symlink_warnings = [w for w in data["warnings"] if "symlink" in w.lower()]
400 assert len(symlink_warnings) >= 1, (
401 "symlink snapshot file must produce a warning in JSON output"
402 )
403
404
405 # ---------------------------------------------------------------------------
406 # TestGcJsonExitCode
407 # ---------------------------------------------------------------------------
408
409 class TestGcJsonExitCode:
410 """exit_code field matches actual process exit code."""
411
412 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
413 root, repo_id = _init_repo(tmp_path)
414 _make_commit(root, repo_id)
415 data = _gc_json(root)
416 assert data["exit_code"] == 0
417
418 def test_exit_code_zero_with_dry_run(self, tmp_path: pathlib.Path) -> None:
419 root, repo_id = _init_repo(tmp_path)
420 _make_commit(root, repo_id)
421 _write_object(root, b"orphan")
422 data = _gc_json(root, ["--dry-run"])
423 assert data["exit_code"] == 0
424
425 def test_exit_code_zero_with_full(self, tmp_path: pathlib.Path) -> None:
426 root, repo_id = _init_repo(tmp_path)
427 _make_commit(root, repo_id)
428 data = _gc_json(root, ["--full"])
429 assert data["exit_code"] == 0
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 142 days ago