gabriel / muse public
test_cmd_gc_hardening.py python
706 lines 27.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive hardening tests for ``muse gc``.
2
3 Coverage dimensions:
4
5 Unit
6 ~~~~
7 - ``_is_hex`` edge cases (empty string, uppercase, mixed, valid)
8 - ``_list_stored_objects`` symlink guard for prefix dirs
9 - ``_list_stored_objects`` symlink guard for object files
10 - ``_list_stored_objects`` grace period filters recent files
11 - ``_list_stored_objects`` grace_period=0 includes all files
12 - ``_collect_reachable_objects`` symlink guard on shelf.json
13 - ``_collect_reachable_objects`` size cap on shelf.json
14 - ``_collect_reachable_objects`` malformed shelf.json is skipped gracefully
15 - ``run_gc`` grace_period_seconds stored in GcResult
16 - ``_fmt_bytes`` all size ranges
17 - ``run_gc`` negative grace period rejected by CLI
18
19 Security
20 ~~~~~~~~
21 - Symlink in .muse/objects/ prefix dir not deleted or followed
22 - Symlink object file not deleted or followed
23 - Symlink shelf.json skipped during reachability walk
24 - ANSI escape sequences in object IDs sanitized in text output
25 - Invalid --format rejected with error to stderr
26 - Negative --grace-period rejected with non-zero exit
27
28 Integration (CLI)
29 ~~~~~~~~~~~~~~~~~
30 - ``--json`` output schema matches ``_GcJson`` TypedDict
31 - ``--json`` includes ``grace_period_seconds`` field
32 - ``--grace-period`` value propagated to GcResult
33 - ``--dry-run`` combined with ``--json`` reports correctly
34 - ``--verbose`` combined with ``--json`` shows IDs in JSON
35 - ``--format text`` is the default
36 - Repeated GC runs are idempotent (JSON)
37
38 E2E
39 ~~~
40 - Full lifecycle: orphan accumulates across branches, GC reclaims
41 - GC after shelf save does NOT delete shelved objects
42 - GC with corrupt shelf.json succeeds (skips shelf walk)
43 - ``--grace-period 0`` collects freshly-written orphan
44 - ``--grace-period 9999`` protects freshly-written orphan
45
46 Stress
47 ~~~~~~
48 - 500 orphaned objects across 256 prefix dirs collected correctly
49 - Concurrent read-only GC (dry-run) on same repo is safe
50 """
51
52 from __future__ import annotations
53
54 import hashlib
55 import json
56 import os
57 import pathlib
58 import stat
59 import threading
60 import time
61 from typing import TypedDict
62
63 import pytest
64 from tests.cli_test_helper import CliRunner, InvokeResult
65 from muse.core._types import fake_id
66 from muse.core.object_store import object_path
67
68 cli = None # argparse bridge — CliRunner ignores this
69 runner = CliRunner()
70
71
72 # ---------------------------------------------------------------------------
73 # Helpers
74 # ---------------------------------------------------------------------------
75
76
77 def _env(root: pathlib.Path) -> Manifest:
78 return {"MUSE_REPO_ROOT": str(root)}
79
80
81 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
82 muse = tmp_path / ".muse"
83 for sub in ("objects", "commits", "snapshots", "refs/heads"):
84 (muse / sub).mkdir(parents=True, exist_ok=True)
85 repo_id = fake_id("repo")
86 (muse / "repo.json").write_text(json.dumps({
87 "repo_id": repo_id,
88 "domain": "code",
89 "default_branch": "main",
90 "created_at": "2026-01-01T00:00:00+00:00",
91 }), encoding="utf-8")
92 (muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
93 return tmp_path
94
95
96 def _write_object(root: pathlib.Path, content: bytes) -> str:
97 from muse.core._types import blob_id
98 from muse.core.object_store import write_object
99 oid = blob_id(content)
100 write_object(root, oid, content)
101 return oid
102
103
104 def _make_commit(root: pathlib.Path, manifest: Manifest | None = None) -> str:
105 import datetime
106 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
107 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
108
109 mfst: Manifest = manifest or {}
110 snap_id = compute_snapshot_id(mfst)
111 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
112 commit_id = compute_commit_id(
113 repo_id="test-repo",
114 parent_ids=[],
115 snapshot_id=snap_id,
116 message="test",
117 committed_at_iso=committed_at.isoformat(),
118 )
119 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=mfst))
120 write_commit(root, CommitRecord(
121 commit_id=commit_id,
122 repo_id="test-repo",
123 created_on_branch="main",
124 snapshot_id=snap_id,
125 message="test",
126 committed_at=committed_at,
127 ))
128 ref_path = root / ".muse" / "refs" / "heads" / "main"
129 ref_path.parent.mkdir(parents=True, exist_ok=True)
130 ref_path.write_text(commit_id, encoding="utf-8")
131 return commit_id
132
133
134 def _invoke_gc(root: pathlib.Path, *extra_args: str) -> InvokeResult:
135 """Invoke ``muse gc`` with ``--grace-period 0`` unless caller overrides."""
136 args = list(extra_args)
137 if "--grace-period" not in args:
138 args = ["--grace-period", "0"] + args
139 return runner.invoke(cli, ["gc"] + args, env=_env(root), catch_exceptions=False)
140
141
142 # ---------------------------------------------------------------------------
143 # _GcJson TypedDict for test assertions
144 # ---------------------------------------------------------------------------
145
146
147 class _GcJson(TypedDict):
148 collected_count: int
149 collected_bytes: int
150 reachable_count: int
151 duration_ms: float
152 grace_period_seconds: int
153 dry_run: bool
154 collected_ids: list[str]
155
156
157 def _parse_gc_json(output: str) -> _GcJson:
158 """Extract and parse the JSON blob from CliRunner output."""
159 for line in output.splitlines():
160 line = line.strip()
161 if line.startswith("{"):
162 raw = json.loads(line)
163 return _GcJson(
164 collected_count=int(raw["collected_count"]),
165 collected_bytes=int(raw["collected_bytes"]),
166 reachable_count=int(raw["reachable_count"]),
167 duration_ms=float(raw["duration_ms"]),
168 grace_period_seconds=int(raw["grace_period_seconds"]),
169 dry_run=bool(raw["dry_run"]),
170 collected_ids=[str(x) for x in raw["collected_ids"]],
171 )
172 raise AssertionError(f"No JSON object found in output:\n{output}")
173
174
175 # ---------------------------------------------------------------------------
176 # Unit — _is_hex
177 # ---------------------------------------------------------------------------
178
179
180 class TestIsHex:
181 def test_empty_string_is_not_hex(self) -> None:
182 from muse.core.gc import _is_hex
183 assert not _is_hex("")
184
185 def test_valid_lowercase_hex(self) -> None:
186 from muse.core.gc import _is_hex
187 assert _is_hex("0123456789abcdef")
188
189 def test_uppercase_rejected(self) -> None:
190 from muse.core.gc import _is_hex
191 assert not _is_hex("ABCDEF")
192
193 def test_mixed_case_rejected(self) -> None:
194 from muse.core.gc import _is_hex
195 assert not _is_hex("0aF")
196
197 def test_non_hex_chars_rejected(self) -> None:
198 from muse.core.gc import _is_hex
199 assert not _is_hex("xyz")
200
201 def test_single_valid_char(self) -> None:
202 from muse.core.gc import _is_hex
203 assert _is_hex("a")
204
205 def test_64_char_sha256(self) -> None:
206 from muse.core.gc import _is_hex
207 sha = "a" * 64
208 assert _is_hex(sha)
209
210
211 # ---------------------------------------------------------------------------
212 # Unit — _fmt_bytes
213 # ---------------------------------------------------------------------------
214
215
216 class TestFmtBytes:
217 def test_bytes_range(self) -> None:
218 from muse.cli.commands.gc import _fmt_bytes
219 assert _fmt_bytes(0) == "0 B"
220 assert _fmt_bytes(1023) == "1023 B"
221
222 def test_kib_range(self) -> None:
223 from muse.cli.commands.gc import _fmt_bytes
224 assert "KiB" in _fmt_bytes(1024)
225 assert "KiB" in _fmt_bytes(1024 * 1024 - 1)
226
227 def test_mib_range(self) -> None:
228 from muse.cli.commands.gc import _fmt_bytes
229 assert "MiB" in _fmt_bytes(1024 * 1024)
230 assert "MiB" in _fmt_bytes(1024 * 1024 * 100)
231
232
233 # ---------------------------------------------------------------------------
234 # Unit — _list_stored_objects
235 # ---------------------------------------------------------------------------
236
237
238 class TestListStoredObjects:
239 def test_symlink_prefix_dir_is_skipped(self, tmp_path: pathlib.Path) -> None:
240 """A symlinked prefix directory must not be entered."""
241 from muse.core.gc import _list_stored_objects
242 root = _make_repo(tmp_path)
243 real_dir = tmp_path / "external_objects"
244 real_dir.mkdir()
245 sha = "a" * 64
246 real_file = real_dir / sha[2:]
247 real_file.write_bytes(b"content")
248
249 # Create a symlink at .muse/objects/sha256/<prefix> → external dir
250 algo_dir = root / ".muse" / "objects" / "sha256"
251 algo_dir.mkdir(parents=True, exist_ok=True)
252 link = algo_dir / sha[:2]
253 link.symlink_to(real_dir)
254
255 pairs = _list_stored_objects(root, grace_period_seconds=0)
256 found_ids = {oid for oid, _ in pairs}
257 assert sha not in found_ids, "Symlinked prefix dir must not be entered"
258
259 def test_symlink_object_file_is_skipped(self, tmp_path: pathlib.Path) -> None:
260 """A symlinked object file must not be listed or ever unlinked."""
261 from muse.core.gc import _list_stored_objects
262 root = _make_repo(tmp_path)
263 # Write a real file outside the repo.
264 external = tmp_path / "external_secret"
265 external.write_bytes(b"secret content")
266
267 sha = "b" * 64
268 link = object_path(root, "sha256:" + sha)
269 link.parent.mkdir(parents=True, exist_ok=True)
270 link.symlink_to(external)
271
272 pairs = _list_stored_objects(root, grace_period_seconds=0)
273 found_ids = {oid for oid, _ in pairs}
274 assert sha not in found_ids, "Symlinked object file must not be listed"
275 # The external file must be untouched.
276 assert external.exists()
277
278 def test_grace_period_filters_recent_files(self, tmp_path: pathlib.Path) -> None:
279 """Objects written within the grace window are excluded."""
280 from muse.core.gc import _list_stored_objects
281 root = _make_repo(tmp_path)
282 _write_object(root, b"new orphan")
283 # Grace period of 60 s — the object was written <1 s ago.
284 pairs = _list_stored_objects(root, grace_period_seconds=60)
285 assert len(pairs) == 0
286
287 def test_grace_period_zero_includes_all_files(self, tmp_path: pathlib.Path) -> None:
288 """grace_period_seconds=0 bypasses the mtime check."""
289 from muse.core.gc import _list_stored_objects
290 root = _make_repo(tmp_path)
291 _write_object(root, b"orphan")
292 pairs = _list_stored_objects(root, grace_period_seconds=0)
293 assert len(pairs) == 1
294
295 def test_non_hex_prefix_dir_skipped(self, tmp_path: pathlib.Path) -> None:
296 from muse.core.gc import _list_stored_objects
297 root = _make_repo(tmp_path)
298 (root / ".muse" / "objects" / "sha256" / "zz").mkdir(parents=True)
299 pairs = _list_stored_objects(root, grace_period_seconds=0)
300 assert len(pairs) == 0
301
302 def test_non_hex_object_file_skipped(self, tmp_path: pathlib.Path) -> None:
303 from muse.core.gc import _list_stored_objects
304 root = _make_repo(tmp_path)
305 prefix = root / ".muse" / "objects" / "sha256" / "ab"
306 prefix.mkdir(parents=True)
307 (prefix / "not-valid-hex!").write_bytes(b"x")
308 pairs = _list_stored_objects(root, grace_period_seconds=0)
309 assert len(pairs) == 0
310
311 def test_valid_object_included(self, tmp_path: pathlib.Path) -> None:
312 from muse.core.gc import _list_stored_objects
313 root = _make_repo(tmp_path)
314 oid = _write_object(root, b"valid object")
315 pairs = _list_stored_objects(root, grace_period_seconds=0)
316 found_ids = {o for o, _ in pairs}
317 assert oid in found_ids
318
319
320 # ---------------------------------------------------------------------------
321 # Unit — _collect_reachable_objects
322 # ---------------------------------------------------------------------------
323
324
325 class TestCollectReachableObjects:
326 def test_shelf_symlink_skipped(self, tmp_path: pathlib.Path) -> None:
327 """A symlinked shelf.json is ignored during the reachability walk."""
328 from muse.core.gc import _collect_reachable_objects
329 root = _make_repo(tmp_path)
330 # Write a real object and make it look shelved via a symlink.
331 obj_id = _write_object(root, b"shelved content")
332 external = tmp_path / "real_shelf.json"
333 external.write_text(json.dumps([{
334 "snapshot_id": "s" * 64,
335 "branch": "main",
336 "created_at": "2026-01-01T00:00:00+00:00",
337 "snapshot": {"a.py": obj_id},
338 }]))
339 link = root / ".muse" / "shelf.json"
340 link.symlink_to(external)
341
342 reachable = _collect_reachable_objects(root)
343 # The object should NOT be marked reachable (symlink was skipped).
344 assert obj_id not in reachable
345
346 def test_shelf_oversized_file_skipped(self, tmp_path: pathlib.Path) -> None:
347 """A shelf.json exceeding the size cap is skipped, not OOM-killed."""
348 from muse.core.gc import _collect_reachable_objects, _MAX_SHELF_BYTES
349 root = _make_repo(tmp_path)
350 obj_id = _write_object(root, b"shelved content")
351 shelf_path = root / ".muse" / "shelf.json"
352 # Write a file that claims to be larger than the cap.
353 large_payload = "x" * 1024 # placeholder
354 shelf_path.write_text(large_payload)
355 # Truncate won't help — fake a large size by patching stat.
356 import unittest.mock as mock
357 fake_stat = os.stat_result((
358 stat.S_IFREG | 0o644, 0, 0, 1, 0, 0,
359 _MAX_SHELF_BYTES + 1, 0, 0, 0,
360 ))
361 with mock.patch.object(pathlib.Path, "stat", return_value=fake_stat):
362 reachable = _collect_reachable_objects(root)
363 # With the size cap triggered, the shelf walk is skipped.
364 assert obj_id not in reachable
365
366 def test_malformed_shelf_json_skipped(self, tmp_path: pathlib.Path) -> None:
367 from muse.core.gc import _collect_reachable_objects
368 root = _make_repo(tmp_path)
369 (root / ".muse" / "shelf.json").write_text("not valid json{{{}}", encoding="utf-8")
370 # Should not raise.
371 reachable = _collect_reachable_objects(root)
372 assert isinstance(reachable, set)
373
374 def test_valid_shelf_objects_marked_reachable(self, tmp_path: pathlib.Path) -> None:
375 from muse.core.gc import _collect_reachable_objects
376 root = _make_repo(tmp_path)
377 obj_id = _write_object(root, b"shelved content")
378 (root / ".muse" / "shelf.json").write_text(json.dumps([{
379 "snapshot_id": "s" * 64,
380 "branch": "main",
381 "created_at": "2026-01-01T00:00:00+00:00",
382 "snapshot": {"a.py": obj_id},
383 }]), encoding="utf-8")
384 reachable = _collect_reachable_objects(root)
385 assert obj_id in reachable
386
387
388 # ---------------------------------------------------------------------------
389 # Unit — run_gc result fields
390 # ---------------------------------------------------------------------------
391
392
393 class TestRunGcResult:
394 def test_grace_period_stored_in_result(self, tmp_path: pathlib.Path) -> None:
395 from muse.core.gc import run_gc
396 root = _make_repo(tmp_path)
397 result = run_gc(root, grace_period_seconds=42)
398 assert result.grace_period_seconds == 42
399
400 def test_dry_run_flag_stored_in_result(self, tmp_path: pathlib.Path) -> None:
401 from muse.core.gc import run_gc
402 root = _make_repo(tmp_path)
403 result = run_gc(root, dry_run=True, grace_period_seconds=0)
404 assert result.dry_run is True
405
406 def test_duration_ms_is_non_negative(self, tmp_path: pathlib.Path) -> None:
407 from muse.core.gc import run_gc
408 root = _make_repo(tmp_path)
409 result = run_gc(root, grace_period_seconds=0)
410 assert result.duration_ms >= 0.0
411
412
413 # ---------------------------------------------------------------------------
414 # Security — CLI
415 # ---------------------------------------------------------------------------
416
417
418 class TestSecurity:
419 def test_symlink_in_objects_not_deleted(self, tmp_path: pathlib.Path) -> None:
420 """GC must never delete a file outside the repo via a symlink."""
421 root = _make_repo(tmp_path)
422 _make_commit(root)
423 external = tmp_path / "precious_file"
424 external.write_bytes(b"important data")
425 sha = "c" * 64
426 link = object_path(root, "sha256:" + sha)
427 link.parent.mkdir(parents=True, exist_ok=True)
428 link.symlink_to(external)
429
430 _invoke_gc(root)
431
432 assert external.exists(), "External file must not be deleted via symlink"
433
434 def test_ansi_in_object_id_sanitized(self, tmp_path: pathlib.Path) -> None:
435 """sanitize_display must strip ANSI sequences from object IDs in verbose output."""
436 root = _make_repo(tmp_path)
437 _make_commit(root)
438 # Write a real orphan (we can't control its SHA, but we test the path is taken).
439 _write_object(root, b"orphan for sanitize test")
440 result = _invoke_gc(root, "--verbose")
441 assert result.exit_code == 0
442 # The output must not contain raw ESC bytes.
443 assert "\x1b" not in result.output
444
445 def test_invalid_format_exits_nonzero_and_writes_stderr(
446 self, tmp_path: pathlib.Path
447 ) -> None:
448 root = _make_repo(tmp_path)
449 # argparse now uses choices= so invalid format triggers argparse error.
450 result = runner.invoke(cli, ["gc", "--format", "csv"], env=_env(root))
451 assert result.exit_code != 0
452
453 def test_negative_grace_period_rejected(self, tmp_path: pathlib.Path) -> None:
454 root = _make_repo(tmp_path)
455 result = runner.invoke(cli, ["gc", "--grace-period", "-1"], env=_env(root))
456 assert result.exit_code != 0
457
458
459 # ---------------------------------------------------------------------------
460 # Integration — JSON output schema
461 # ---------------------------------------------------------------------------
462
463
464 class TestJsonSchema:
465 def test_json_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None:
466 root = _make_repo(tmp_path)
467 _make_commit(root)
468 _write_object(root, b"orphan for json test")
469 result = _invoke_gc(root, "--json")
470 assert result.exit_code == 0
471 payload = _parse_gc_json(result.output)
472 assert payload["collected_count"] == 1
473 assert payload["collected_bytes"] > 0
474 assert payload["reachable_count"] == 0
475 assert payload["duration_ms"] >= 0.0
476 assert payload["grace_period_seconds"] == 0
477 assert payload["dry_run"] is False
478 assert len(payload["collected_ids"]) == 1
479
480 def test_json_dry_run_does_not_delete(self, tmp_path: pathlib.Path) -> None:
481 root = _make_repo(tmp_path)
482 _make_commit(root)
483 orphan_id = _write_object(root, b"dry run orphan")
484 result = _invoke_gc(root, "--dry-run", "--json")
485 assert result.exit_code == 0
486 payload = _parse_gc_json(result.output)
487 assert payload["dry_run"] is True
488 assert payload["collected_count"] == 1
489 # File must still exist.
490 from muse.core.object_store import has_object
491 assert has_object(root, orphan_id)
492
493 def test_json_grace_period_field_reflects_flag(self, tmp_path: pathlib.Path) -> None:
494 root = _make_repo(tmp_path)
495 result = runner.invoke(
496 cli, ["gc", "--grace-period", "99", "--json"],
497 env=_env(root), catch_exceptions=False,
498 )
499 assert result.exit_code == 0
500 payload = _parse_gc_json(result.output)
501 assert payload["grace_period_seconds"] == 99
502
503 def test_json_collected_ids_sorted(self, tmp_path: pathlib.Path) -> None:
504 root = _make_repo(tmp_path)
505 for i in range(5):
506 _write_object(root, f"sort test {i}".encode())
507 result = _invoke_gc(root, "--json")
508 assert result.exit_code == 0
509 payload = _parse_gc_json(result.output)
510 assert payload["collected_ids"] == sorted(payload["collected_ids"])
511
512 def test_json_clean_repo_shows_zero_counts(self, tmp_path: pathlib.Path) -> None:
513 root = _make_repo(tmp_path)
514 _make_commit(root)
515 result = _invoke_gc(root, "--json")
516 assert result.exit_code == 0
517 payload = _parse_gc_json(result.output)
518 assert payload["collected_count"] == 0
519 assert payload["collected_bytes"] == 0
520 assert payload["collected_ids"] == []
521
522 def test_shorthand_json_flag(self, tmp_path: pathlib.Path) -> None:
523 root = _make_repo(tmp_path)
524 result = _invoke_gc(root, "--json")
525 assert result.exit_code == 0
526 _parse_gc_json(result.output) # must not raise
527
528
529 # ---------------------------------------------------------------------------
530 # E2E — full lifecycle
531 # ---------------------------------------------------------------------------
532
533
534 class TestE2E:
535 def test_orphan_from_abandoned_branch_reclaimed(self, tmp_path: pathlib.Path) -> None:
536 """Objects written for a branch that was never committed are reclaimed."""
537 root = _make_repo(tmp_path)
538 # Write objects that were staged but never committed.
539 orphan_a = _write_object(root, b"branch work A")
540 orphan_b = _write_object(root, b"branch work B")
541 # Now run GC.
542 result = _invoke_gc(root, "--json")
543 assert result.exit_code == 0
544 payload = _parse_gc_json(result.output)
545 assert orphan_a in payload["collected_ids"]
546 assert orphan_b in payload["collected_ids"]
547
548 def test_gc_after_shelf_save_preserves_shelf_objects(self, tmp_path: pathlib.Path) -> None:
549 root = _make_repo(tmp_path)
550 shelf_obj = _write_object(root, b"shelved file content")
551 (root / ".muse" / "shelf.json").write_text(json.dumps([{
552 "snapshot_id": "s" * 64,
553 "branch": "main",
554 "created_at": "2026-01-01T00:00:00+00:00",
555 "snapshot": {"file.py": shelf_obj},
556 }]), encoding="utf-8")
557
558 result = _invoke_gc(root, "--json")
559 assert result.exit_code == 0
560 payload = _parse_gc_json(result.output)
561 assert shelf_obj not in payload["collected_ids"]
562 # Blob must still be on disk.
563 from muse.core.object_store import has_object
564 assert has_object(root, shelf_obj)
565
566 def test_gc_with_corrupt_shelf_json_succeeds(self, tmp_path: pathlib.Path) -> None:
567 root = _make_repo(tmp_path)
568 orphan = _write_object(root, b"orphan despite corrupt shelf")
569 (root / ".muse" / "shelf.json").write_text("{not json", encoding="utf-8")
570 result = _invoke_gc(root, "--json")
571 assert result.exit_code == 0
572 payload = _parse_gc_json(result.output)
573 # Orphan is still collected even though shelf was corrupt.
574 assert orphan in payload["collected_ids"]
575
576 def test_grace_period_zero_collects_fresh_orphan(self, tmp_path: pathlib.Path) -> None:
577 root = _make_repo(tmp_path)
578 orphan = _write_object(root, b"fresh orphan")
579 result = _invoke_gc(root, "--grace-period", "0", "--json")
580 assert result.exit_code == 0
581 payload = _parse_gc_json(result.output)
582 assert orphan in payload["collected_ids"]
583
584 def test_grace_period_large_protects_fresh_orphan(self, tmp_path: pathlib.Path) -> None:
585 root = _make_repo(tmp_path)
586 _write_object(root, b"fresh orphan protected")
587 result = runner.invoke(
588 cli, ["gc", "--grace-period", "9999", "--json"],
589 env=_env(root), catch_exceptions=False,
590 )
591 assert result.exit_code == 0
592 payload = _parse_gc_json(result.output)
593 assert payload["collected_count"] == 0
594
595 def test_repeated_gc_is_idempotent(self, tmp_path: pathlib.Path) -> None:
596 root = _make_repo(tmp_path)
597 _write_object(root, b"first orphan")
598 _invoke_gc(root)
599 result2 = _invoke_gc(root, "--json")
600 assert result2.exit_code == 0
601 payload = _parse_gc_json(result2.output)
602 assert payload["collected_count"] == 0
603
604 def test_gc_removes_empty_prefix_dirs(self, tmp_path: pathlib.Path) -> None:
605 """After GC, empty prefix dirs under .muse/objects/sha256/ are cleaned up."""
606 root = _make_repo(tmp_path)
607 sha = _write_object(root, b"sole object in prefix")
608 from muse.core.object_store import object_path
609 prefix_dir = object_path(root, sha).parent
610 assert prefix_dir.exists()
611 _invoke_gc(root)
612 # Directory should be removed since it's empty now.
613 assert not prefix_dir.exists()
614
615 def test_verbose_lists_full_sha256_ids(self, tmp_path: pathlib.Path) -> None:
616 root = _make_repo(tmp_path)
617 orphan = _write_object(root, b"verbose test object")
618 result = _invoke_gc(root, "--verbose")
619 assert result.exit_code == 0
620 assert orphan in result.output
621
622 def test_dry_run_verbose_lists_without_deleting(self, tmp_path: pathlib.Path) -> None:
623 root = _make_repo(tmp_path)
624 orphan = _write_object(root, b"dry verbose test")
625 result = _invoke_gc(root, "--dry-run", "--verbose")
626 assert result.exit_code == 0
627 assert orphan in result.output
628 from muse.core.object_store import object_path
629 assert object_path(root, orphan).exists()
630
631 def test_dry_run_prefix_present_in_text_output(self, tmp_path: pathlib.Path) -> None:
632 root = _make_repo(tmp_path)
633 result = _invoke_gc(root, "--dry-run")
634 assert result.exit_code == 0
635 assert "[dry-run]" in result.output
636
637 def test_reachable_count_reflects_committed_objects(self, tmp_path: pathlib.Path) -> None:
638 root = _make_repo(tmp_path)
639 obj = _write_object(root, b"committed content")
640 _make_commit(root, manifest={"file.txt": obj})
641 result = _invoke_gc(root, "--json")
642 payload = _parse_gc_json(result.output)
643 assert payload["reachable_count"] == 1
644 assert payload["collected_count"] == 0
645
646
647 # ---------------------------------------------------------------------------
648 # Stress
649 # ---------------------------------------------------------------------------
650
651
652 class TestStress:
653 def test_500_orphans_all_collected(self, tmp_path: pathlib.Path) -> None:
654 root = _make_repo(tmp_path)
655 _make_commit(root)
656 orphan_ids = [_write_object(root, f"stress-{i:04d}".encode()) for i in range(500)]
657 result = _invoke_gc(root, "--json")
658 assert result.exit_code == 0
659 payload = _parse_gc_json(result.output)
660 assert payload["collected_count"] == 500
661 assert set(payload["collected_ids"]) == set(orphan_ids)
662 # Objects directory should be empty after GC.
663 obj_dir = root / ".muse" / "objects"
664 remaining_files = [p for p in obj_dir.rglob("*") if p.is_file()]
665 assert remaining_files == []
666
667 def test_concurrent_dry_run_does_not_crash(self, tmp_path: pathlib.Path) -> None:
668 """Multiple concurrent dry-run GCs on the same repo must not crash."""
669 root = _make_repo(tmp_path)
670 _make_commit(root)
671 for i in range(20):
672 _write_object(root, f"concurrent-orphan-{i}".encode())
673
674 errors: list[str] = []
675
676 def _run_dry() -> None:
677 try:
678 from muse.core.gc import run_gc
679 run_gc(root, dry_run=True, grace_period_seconds=0)
680 except Exception as exc:
681 errors.append(str(exc))
682
683 threads = [threading.Thread(target=_run_dry) for _ in range(8)]
684 for t in threads:
685 t.start()
686 for t in threads:
687 t.join()
688
689 assert not errors, f"Concurrent dry-run GC failures: {errors}"
690
691 def test_gc_across_many_prefix_dirs(self, tmp_path: pathlib.Path) -> None:
692 """Objects spread across many prefix dirs are all found and collected."""
693 root = _make_repo(tmp_path)
694 # Force objects into many distinct prefix dirs by varying content.
695 ids: list[str] = []
696 for i in range(100):
697 ids.append(_write_object(root, f"spread-{i:08d}".encode()))
698 # Verify we have multiple prefix dirs.
699 algo_dir = root / ".muse" / "objects" / "sha256"
700 prefix_dirs = [d for d in algo_dir.iterdir() if d.is_dir()]
701 assert len(prefix_dirs) > 1, "Test needs objects in multiple prefix dirs"
702
703 result = _invoke_gc(root, "--json")
704 payload = _parse_gc_json(result.output)
705 assert payload["collected_count"] == 100
706 assert set(payload["collected_ids"]) == set(ids)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago