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