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