gabriel / muse public
test_cmd_gc_hardening.py python
696 lines 27.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 28 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 stash.json
13 - ``_collect_reachable_objects`` size cap on stash.json
14 - ``_collect_reachable_objects`` malformed stash.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 stash.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 stash does NOT delete stashed objects
42 - GC with corrupt stash.json succeeds (skips stash 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 sha = hashlib.sha256(content).hexdigest()
97 obj_dir = root / ".muse" / "objects" / sha[:2]
98 obj_dir.mkdir(parents=True, exist_ok=True)
99 (obj_dir / sha[2:]).write_bytes(content)
100 return sha
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 elapsed_seconds: 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 elapsed_seconds=float(raw["elapsed_seconds"]),
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/<prefix> → external dir
243 link = root / ".muse" / "objects" / sha[:2]
244 link.symlink_to(real_dir)
245
246 pairs = _list_stored_objects(root, grace_period_seconds=0)
247 found_ids = {oid for oid, _ in pairs}
248 assert sha not in found_ids, "Symlinked prefix dir must not be entered"
249
250 def test_symlink_object_file_is_skipped(self, tmp_path: pathlib.Path) -> None:
251 """A symlinked object file must not be listed or ever unlinked."""
252 from muse.core.gc import _list_stored_objects
253 root = _make_repo(tmp_path)
254 # Write a real file outside the repo.
255 external = tmp_path / "external_secret"
256 external.write_bytes(b"secret content")
257
258 sha = "b" * 64
259 prefix_dir = root / ".muse" / "objects" / sha[:2]
260 prefix_dir.mkdir(parents=True, exist_ok=True)
261 link = prefix_dir / sha[2:]
262 link.symlink_to(external)
263
264 pairs = _list_stored_objects(root, grace_period_seconds=0)
265 found_ids = {oid for oid, _ in pairs}
266 assert sha not in found_ids, "Symlinked object file must not be listed"
267 # The external file must be untouched.
268 assert external.exists()
269
270 def test_grace_period_filters_recent_files(self, tmp_path: pathlib.Path) -> None:
271 """Objects written within the grace window are excluded."""
272 from muse.core.gc import _list_stored_objects
273 root = _make_repo(tmp_path)
274 _write_object(root, b"new orphan")
275 # Grace period of 60 s — the object was written <1 s ago.
276 pairs = _list_stored_objects(root, grace_period_seconds=60)
277 assert len(pairs) == 0
278
279 def test_grace_period_zero_includes_all_files(self, tmp_path: pathlib.Path) -> None:
280 """grace_period_seconds=0 bypasses the mtime check."""
281 from muse.core.gc import _list_stored_objects
282 root = _make_repo(tmp_path)
283 _write_object(root, b"orphan")
284 pairs = _list_stored_objects(root, grace_period_seconds=0)
285 assert len(pairs) == 1
286
287 def test_non_hex_prefix_dir_skipped(self, tmp_path: pathlib.Path) -> None:
288 from muse.core.gc import _list_stored_objects
289 root = _make_repo(tmp_path)
290 (root / ".muse" / "objects" / "zz").mkdir()
291 pairs = _list_stored_objects(root, grace_period_seconds=0)
292 assert len(pairs) == 0
293
294 def test_non_hex_object_file_skipped(self, tmp_path: pathlib.Path) -> None:
295 from muse.core.gc import _list_stored_objects
296 root = _make_repo(tmp_path)
297 prefix = root / ".muse" / "objects" / "ab"
298 prefix.mkdir()
299 (prefix / "not-valid-hex!").write_bytes(b"x")
300 pairs = _list_stored_objects(root, grace_period_seconds=0)
301 assert len(pairs) == 0
302
303 def test_valid_object_included(self, tmp_path: pathlib.Path) -> None:
304 from muse.core.gc import _list_stored_objects
305 root = _make_repo(tmp_path)
306 oid = _write_object(root, b"valid object")
307 pairs = _list_stored_objects(root, grace_period_seconds=0)
308 found_ids = {o for o, _ in pairs}
309 assert oid in found_ids
310
311
312 # ---------------------------------------------------------------------------
313 # Unit — _collect_reachable_objects
314 # ---------------------------------------------------------------------------
315
316
317 class TestCollectReachableObjects:
318 def test_stash_symlink_skipped(self, tmp_path: pathlib.Path) -> None:
319 """A symlinked stash.json is ignored during the reachability walk."""
320 from muse.core.gc import _collect_reachable_objects
321 root = _make_repo(tmp_path)
322 # Write a real object and make it look stashed via a symlink.
323 obj_id = _write_object(root, b"stashed content")
324 external = tmp_path / "real_stash.json"
325 external.write_text(json.dumps([{
326 "snapshot_id": "s" * 64,
327 "branch": "main",
328 "stashed_at": "2026-01-01T00:00:00+00:00",
329 "manifest": {"a.py": obj_id},
330 }]))
331 link = root / ".muse" / "stash.json"
332 link.symlink_to(external)
333
334 reachable = _collect_reachable_objects(root)
335 # The object should NOT be marked reachable (symlink was skipped).
336 assert obj_id not in reachable
337
338 def test_stash_oversized_file_skipped(self, tmp_path: pathlib.Path) -> None:
339 """A stash.json exceeding the size cap is skipped, not OOM-killed."""
340 from muse.core.gc import _collect_reachable_objects, _MAX_STASH_BYTES
341 root = _make_repo(tmp_path)
342 obj_id = _write_object(root, b"stashed content")
343 stash_path = root / ".muse" / "stash.json"
344 # Write a file that claims to be larger than the cap.
345 large_payload = "x" * 1024 # placeholder
346 stash_path.write_text(large_payload)
347 # Truncate won't help — fake a large size by patching stat.
348 import unittest.mock as mock
349 fake_stat = os.stat_result((
350 stat.S_IFREG | 0o644, 0, 0, 1, 0, 0,
351 _MAX_STASH_BYTES + 1, 0, 0, 0,
352 ))
353 with mock.patch.object(pathlib.Path, "stat", return_value=fake_stat):
354 reachable = _collect_reachable_objects(root)
355 # With the size cap triggered, the stash walk is skipped.
356 assert obj_id not in reachable
357
358 def test_malformed_stash_json_skipped(self, tmp_path: pathlib.Path) -> None:
359 from muse.core.gc import _collect_reachable_objects
360 root = _make_repo(tmp_path)
361 (root / ".muse" / "stash.json").write_text("not valid json{{{}}", encoding="utf-8")
362 # Should not raise.
363 reachable = _collect_reachable_objects(root)
364 assert isinstance(reachable, set)
365
366 def test_valid_stash_objects_marked_reachable(self, tmp_path: pathlib.Path) -> None:
367 from muse.core.gc import _collect_reachable_objects
368 root = _make_repo(tmp_path)
369 obj_id = _write_object(root, b"stashed content")
370 (root / ".muse" / "stash.json").write_text(json.dumps([{
371 "snapshot_id": "s" * 64,
372 "branch": "main",
373 "stashed_at": "2026-01-01T00:00:00+00:00",
374 "manifest": {"a.py": obj_id},
375 }]), encoding="utf-8")
376 reachable = _collect_reachable_objects(root)
377 assert obj_id in reachable
378
379
380 # ---------------------------------------------------------------------------
381 # Unit — run_gc result fields
382 # ---------------------------------------------------------------------------
383
384
385 class TestRunGcResult:
386 def test_grace_period_stored_in_result(self, tmp_path: pathlib.Path) -> None:
387 from muse.core.gc import run_gc
388 root = _make_repo(tmp_path)
389 result = run_gc(root, grace_period_seconds=42)
390 assert result.grace_period_seconds == 42
391
392 def test_dry_run_flag_stored_in_result(self, tmp_path: pathlib.Path) -> None:
393 from muse.core.gc import run_gc
394 root = _make_repo(tmp_path)
395 result = run_gc(root, dry_run=True, grace_period_seconds=0)
396 assert result.dry_run is True
397
398 def test_elapsed_seconds_is_non_negative(self, tmp_path: pathlib.Path) -> None:
399 from muse.core.gc import run_gc
400 root = _make_repo(tmp_path)
401 result = run_gc(root, grace_period_seconds=0)
402 assert result.elapsed_seconds >= 0.0
403
404
405 # ---------------------------------------------------------------------------
406 # Security — CLI
407 # ---------------------------------------------------------------------------
408
409
410 class TestSecurity:
411 def test_symlink_in_objects_not_deleted(self, tmp_path: pathlib.Path) -> None:
412 """GC must never delete a file outside the repo via a symlink."""
413 root = _make_repo(tmp_path)
414 _make_commit(root)
415 external = tmp_path / "precious_file"
416 external.write_bytes(b"important data")
417 sha = "c" * 64
418 prefix_dir = root / ".muse" / "objects" / sha[:2]
419 prefix_dir.mkdir(parents=True, exist_ok=True)
420 link = prefix_dir / sha[2:]
421 link.symlink_to(external)
422
423 _invoke_gc(root)
424
425 assert external.exists(), "External file must not be deleted via symlink"
426
427 def test_ansi_in_object_id_sanitized(self, tmp_path: pathlib.Path) -> None:
428 """sanitize_display must strip ANSI sequences from object IDs in verbose output."""
429 root = _make_repo(tmp_path)
430 _make_commit(root)
431 # Write a real orphan (we can't control its SHA, but we test the path is taken).
432 _write_object(root, b"orphan for sanitize test")
433 result = _invoke_gc(root, "--verbose")
434 assert result.exit_code == 0
435 # The output must not contain raw ESC bytes.
436 assert "\x1b" not in result.output
437
438 def test_invalid_format_exits_nonzero_and_writes_stderr(
439 self, tmp_path: pathlib.Path
440 ) -> None:
441 root = _make_repo(tmp_path)
442 # argparse now uses choices= so invalid format triggers argparse error.
443 result = runner.invoke(cli, ["gc", "--format", "csv"], env=_env(root))
444 assert result.exit_code != 0
445
446 def test_negative_grace_period_rejected(self, tmp_path: pathlib.Path) -> None:
447 root = _make_repo(tmp_path)
448 result = runner.invoke(cli, ["gc", "--grace-period", "-1"], env=_env(root))
449 assert result.exit_code != 0
450
451
452 # ---------------------------------------------------------------------------
453 # Integration — JSON output schema
454 # ---------------------------------------------------------------------------
455
456
457 class TestJsonSchema:
458 def test_json_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None:
459 root = _make_repo(tmp_path)
460 _make_commit(root)
461 _write_object(root, b"orphan for json test")
462 result = _invoke_gc(root, "--json")
463 assert result.exit_code == 0
464 payload = _parse_gc_json(result.output)
465 assert payload["collected_count"] == 1
466 assert payload["collected_bytes"] > 0
467 assert payload["reachable_count"] == 0
468 assert payload["elapsed_seconds"] >= 0.0
469 assert payload["grace_period_seconds"] == 0
470 assert payload["dry_run"] is False
471 assert len(payload["collected_ids"]) == 1
472
473 def test_json_dry_run_does_not_delete(self, tmp_path: pathlib.Path) -> None:
474 root = _make_repo(tmp_path)
475 _make_commit(root)
476 orphan_id = _write_object(root, b"dry run orphan")
477 result = _invoke_gc(root, "--dry-run", "--json")
478 assert result.exit_code == 0
479 payload = _parse_gc_json(result.output)
480 assert payload["dry_run"] is True
481 assert payload["collected_count"] == 1
482 # File must still exist.
483 obj_path = root / ".muse" / "objects" / orphan_id[:2] / orphan_id[2:]
484 assert obj_path.exists()
485
486 def test_json_grace_period_field_reflects_flag(self, tmp_path: pathlib.Path) -> None:
487 root = _make_repo(tmp_path)
488 result = runner.invoke(
489 cli, ["gc", "--grace-period", "99", "--json"],
490 env=_env(root), catch_exceptions=False,
491 )
492 assert result.exit_code == 0
493 payload = _parse_gc_json(result.output)
494 assert payload["grace_period_seconds"] == 99
495
496 def test_json_collected_ids_sorted(self, tmp_path: pathlib.Path) -> None:
497 root = _make_repo(tmp_path)
498 for i in range(5):
499 _write_object(root, f"sort test {i}".encode())
500 result = _invoke_gc(root, "--json")
501 assert result.exit_code == 0
502 payload = _parse_gc_json(result.output)
503 assert payload["collected_ids"] == sorted(payload["collected_ids"])
504
505 def test_json_clean_repo_shows_zero_counts(self, tmp_path: pathlib.Path) -> None:
506 root = _make_repo(tmp_path)
507 _make_commit(root)
508 result = _invoke_gc(root, "--json")
509 assert result.exit_code == 0
510 payload = _parse_gc_json(result.output)
511 assert payload["collected_count"] == 0
512 assert payload["collected_bytes"] == 0
513 assert payload["collected_ids"] == []
514
515 def test_shorthand_json_flag(self, tmp_path: pathlib.Path) -> None:
516 root = _make_repo(tmp_path)
517 result = _invoke_gc(root, "--json")
518 assert result.exit_code == 0
519 _parse_gc_json(result.output) # must not raise
520
521
522 # ---------------------------------------------------------------------------
523 # E2E — full lifecycle
524 # ---------------------------------------------------------------------------
525
526
527 class TestE2E:
528 def test_orphan_from_abandoned_branch_reclaimed(self, tmp_path: pathlib.Path) -> None:
529 """Objects written for a branch that was never committed are reclaimed."""
530 root = _make_repo(tmp_path)
531 # Write objects that were staged but never committed.
532 orphan_a = _write_object(root, b"branch work A")
533 orphan_b = _write_object(root, b"branch work B")
534 # Now run GC.
535 result = _invoke_gc(root, "--json")
536 assert result.exit_code == 0
537 payload = _parse_gc_json(result.output)
538 assert orphan_a in payload["collected_ids"]
539 assert orphan_b in payload["collected_ids"]
540
541 def test_gc_after_stash_preserves_stash_objects(self, tmp_path: pathlib.Path) -> None:
542 root = _make_repo(tmp_path)
543 stash_obj = _write_object(root, b"stashed file content")
544 (root / ".muse" / "stash.json").write_text(json.dumps([{
545 "snapshot_id": "s" * 64,
546 "branch": "main",
547 "stashed_at": "2026-01-01T00:00:00+00:00",
548 "manifest": {"file.py": stash_obj},
549 }]), encoding="utf-8")
550
551 result = _invoke_gc(root, "--json")
552 assert result.exit_code == 0
553 payload = _parse_gc_json(result.output)
554 assert stash_obj not in payload["collected_ids"]
555 # Blob must still be on disk.
556 assert (root / ".muse" / "objects" / stash_obj[:2] / stash_obj[2:]).exists()
557
558 def test_gc_with_corrupt_stash_json_succeeds(self, tmp_path: pathlib.Path) -> None:
559 root = _make_repo(tmp_path)
560 orphan = _write_object(root, b"orphan despite corrupt stash")
561 (root / ".muse" / "stash.json").write_text("{not json", encoding="utf-8")
562 result = _invoke_gc(root, "--json")
563 assert result.exit_code == 0
564 payload = _parse_gc_json(result.output)
565 # Orphan is still collected even though stash was corrupt.
566 assert orphan in payload["collected_ids"]
567
568 def test_grace_period_zero_collects_fresh_orphan(self, tmp_path: pathlib.Path) -> None:
569 root = _make_repo(tmp_path)
570 orphan = _write_object(root, b"fresh orphan")
571 result = _invoke_gc(root, "--grace-period", "0", "--json")
572 assert result.exit_code == 0
573 payload = _parse_gc_json(result.output)
574 assert orphan in payload["collected_ids"]
575
576 def test_grace_period_large_protects_fresh_orphan(self, tmp_path: pathlib.Path) -> None:
577 root = _make_repo(tmp_path)
578 _write_object(root, b"fresh orphan protected")
579 result = runner.invoke(
580 cli, ["gc", "--grace-period", "9999", "--json"],
581 env=_env(root), catch_exceptions=False,
582 )
583 assert result.exit_code == 0
584 payload = _parse_gc_json(result.output)
585 assert payload["collected_count"] == 0
586
587 def test_repeated_gc_is_idempotent(self, tmp_path: pathlib.Path) -> None:
588 root = _make_repo(tmp_path)
589 _write_object(root, b"first orphan")
590 _invoke_gc(root)
591 result2 = _invoke_gc(root, "--json")
592 assert result2.exit_code == 0
593 payload = _parse_gc_json(result2.output)
594 assert payload["collected_count"] == 0
595
596 def test_gc_removes_empty_prefix_dirs(self, tmp_path: pathlib.Path) -> None:
597 """After GC, empty prefix dirs under .muse/objects/ are cleaned up."""
598 root = _make_repo(tmp_path)
599 sha = _write_object(root, b"sole object in prefix")
600 prefix_dir = root / ".muse" / "objects" / sha[:2]
601 assert prefix_dir.exists()
602 _invoke_gc(root)
603 # Directory should be removed since it's empty now.
604 assert not prefix_dir.exists()
605
606 def test_verbose_lists_full_sha256_ids(self, tmp_path: pathlib.Path) -> None:
607 root = _make_repo(tmp_path)
608 orphan = _write_object(root, b"verbose test object")
609 result = _invoke_gc(root, "--verbose")
610 assert result.exit_code == 0
611 assert orphan in result.output
612
613 def test_dry_run_verbose_lists_without_deleting(self, tmp_path: pathlib.Path) -> None:
614 root = _make_repo(tmp_path)
615 orphan = _write_object(root, b"dry verbose test")
616 result = _invoke_gc(root, "--dry-run", "--verbose")
617 assert result.exit_code == 0
618 assert orphan in result.output
619 assert (root / ".muse" / "objects" / orphan[:2] / orphan[2:]).exists()
620
621 def test_dry_run_prefix_present_in_text_output(self, tmp_path: pathlib.Path) -> None:
622 root = _make_repo(tmp_path)
623 result = _invoke_gc(root, "--dry-run")
624 assert result.exit_code == 0
625 assert "[dry-run]" in result.output
626
627 def test_reachable_count_reflects_committed_objects(self, tmp_path: pathlib.Path) -> None:
628 root = _make_repo(tmp_path)
629 obj = _write_object(root, b"committed content")
630 _make_commit(root, manifest={"file.txt": obj})
631 result = _invoke_gc(root, "--json")
632 payload = _parse_gc_json(result.output)
633 assert payload["reachable_count"] == 1
634 assert payload["collected_count"] == 0
635
636
637 # ---------------------------------------------------------------------------
638 # Stress
639 # ---------------------------------------------------------------------------
640
641
642 class TestStress:
643 def test_500_orphans_all_collected(self, tmp_path: pathlib.Path) -> None:
644 root = _make_repo(tmp_path)
645 _make_commit(root)
646 orphan_ids = [_write_object(root, f"stress-{i:04d}".encode()) for i in range(500)]
647 result = _invoke_gc(root, "--json")
648 assert result.exit_code == 0
649 payload = _parse_gc_json(result.output)
650 assert payload["collected_count"] == 500
651 assert set(payload["collected_ids"]) == set(orphan_ids)
652 # Objects directory should be empty after GC.
653 obj_dir = root / ".muse" / "objects"
654 remaining_files = [p for p in obj_dir.rglob("*") if p.is_file()]
655 assert remaining_files == []
656
657 def test_concurrent_dry_run_does_not_crash(self, tmp_path: pathlib.Path) -> None:
658 """Multiple concurrent dry-run GCs on the same repo must not crash."""
659 root = _make_repo(tmp_path)
660 _make_commit(root)
661 for i in range(20):
662 _write_object(root, f"concurrent-orphan-{i}".encode())
663
664 errors: list[str] = []
665
666 def _run_dry() -> None:
667 try:
668 from muse.core.gc import run_gc
669 run_gc(root, dry_run=True, grace_period_seconds=0)
670 except Exception as exc:
671 errors.append(str(exc))
672
673 threads = [threading.Thread(target=_run_dry) for _ in range(8)]
674 for t in threads:
675 t.start()
676 for t in threads:
677 t.join()
678
679 assert not errors, f"Concurrent dry-run GC failures: {errors}"
680
681 def test_gc_across_many_prefix_dirs(self, tmp_path: pathlib.Path) -> None:
682 """Objects spread across many prefix dirs are all found and collected."""
683 root = _make_repo(tmp_path)
684 # Force objects into many distinct prefix dirs by varying content.
685 ids: list[str] = []
686 for i in range(100):
687 ids.append(_write_object(root, f"spread-{i:08d}".encode()))
688 # Verify we have multiple prefix dirs.
689 obj_dir = root / ".muse" / "objects"
690 prefix_dirs = [d for d in obj_dir.iterdir() if d.is_dir()]
691 assert len(prefix_dirs) > 1, "Test needs objects in multiple prefix dirs"
692
693 result = _invoke_gc(root, "--json")
694 payload = _parse_gc_json(result.output)
695 assert payload["collected_count"] == 100
696 assert set(payload["collected_ids"]) == set(ids)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 28 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 28 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 31 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 50 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 103 days ago