"""Tests for ``muse symlog expire`` / ``muse symlog delete`` (Phase 4 — SL_36–SL_45). TDD: this file is written first. All tests are expected to fail until Phase 4 is implemented. Test IDs -------- SL_36 muse symlog expire SYMBOL --expire-days N --json SL_37 muse symlog expire --file FILE --json SL_38 muse symlog expire --all --json (config default TTL) SL_39 --dry-run reports counts without writing SL_40 muse config set symlog.expire-days N round-trips SL_41 muse symlog delete SYMBOL @{N} --json SL_42 muse symlog delete SYMBOL --all --json SL_43 out-of-bounds @{N} exits USER_ERROR with valid range SL_44 GcResult gains symlog_expired field; muse gc --json includes it SL_45 integration: 100-entry log, 50 old → GC removes 50; dry-run safe """ from __future__ import annotations import argparse import json import pathlib import time import pytest from muse.core.errors import ExitCode from muse.core.symlog import ( NULL_CONTENT_ID, read_symlog, symlog_path, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Minimal Muse repository skeleton — just enough for require_repo().""" muse_dir = tmp_path / ".muse" muse_dir.mkdir() (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") return tmp_path def _write_entries( repo: pathlib.Path, addr: str, count: int, *, age_seconds: int = 0, ) -> None: """Write *count* symlog entries for *addr*, backdated by *age_seconds*. Entries are written oldest-first (as the format requires). Each entry is 1 second apart so timestamps are unique. """ p = symlog_path(repo, addr) p.parent.mkdir(parents=True, exist_ok=True) now_ts = int(time.time()) - age_seconds fake_commit = "sha256:" + "a" * 64 # Build oldest-first: entry i is now_ts - (count - 1 - i) seconds ago lines = [] for i in range(count): ts = now_ts - (count - 1 - i) lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: entry {i}\n" ) # Append-mode so tests can call multiple times on the same addr. with p.open("a", encoding="utf-8") as fh: fh.writelines(lines) def _invoke_symlog( capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, args: list[str], repo_root: pathlib.Path, ) -> pytest.CaptureResult: """Run ``muse symlog `` inline; does NOT swallow SystemExit.""" from muse.cli.commands import symlog as symlog_cmd monkeypatch.chdir(repo_root) parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() symlog_cmd.register(subparsers) parsed = parser.parse_args(["symlog"] + args) parsed.func(parsed) return capsys.readouterr() def _invoke_gc( capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, args: list[str], repo_root: pathlib.Path, ) -> pytest.CaptureResult: """Run ``muse gc `` inline; does NOT swallow SystemExit.""" from muse.cli.commands import gc as gc_cmd monkeypatch.chdir(repo_root) parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() gc_cmd.register(subparsers) parsed = parser.parse_args(["gc"] + args) gc_cmd.run(parsed) return capsys.readouterr() # --------------------------------------------------------------------------- # SL_36 — single-symbol expire # --------------------------------------------------------------------------- class TestSL36SingleSymbolExpire: """SL_36: muse symlog expire SYMBOL --expire-days N --json.""" def test_expire_removes_old_entries( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Entries older than threshold are pruned; JSON counts are accurate.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=5, age_seconds=100 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", addr, "--expire-days", "30", "--json", ], repo) data = json.loads(out) assert data["expired"] == 5 assert data["kept"] == 0 assert data["dry_run"] is False assert addr in data["symbols_processed"] def test_expire_keeps_recent_entries( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Recent entries survive; only old ones expire.""" addr = "src/billing.py::compute_total" p = symlog_path(repo, addr) p.parent.mkdir(parents=True, exist_ok=True) now_ts = int(time.time()) fake_commit = "sha256:" + "a" * 64 lines: list[str] = [] # 3 old entries (150 days) for i in range(3): ts = now_ts - 150 * 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: old {i}\n" ) # 3 recent entries (1 day) for i in range(3): ts = now_ts - 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n" ) p.write_text("".join(lines), encoding="utf-8") out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", addr, "--expire-days", "100", "--json", ], repo) data = json.loads(out) assert data["expired"] == 3 assert data["kept"] == 3 def test_expire_nonexistent_symbol_is_noop( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Expiring a symbol with no log is a silent no-op (expired=0, kept=0).""" out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "src/billing.py::nonexistent", "--expire-days", "30", "--json", ], repo) data = json.loads(out) assert data["expired"] == 0 assert data["kept"] == 0 def test_expire_deletes_log_file_when_all_expire( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Log file is deleted (not left as zero-byte stub) when all entries expire.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3, age_seconds=200 * 86400) p = symlog_path(repo, addr) assert p.exists() _invoke_symlog(capsys, monkeypatch, [ "expire", addr, "--expire-days", "30", "--json", ], repo) assert not p.exists() def test_expire_json_schema( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """JSON output includes all required schema fields.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=2, age_seconds=100 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", addr, "--expire-days", "30", "--json", ], repo) data = json.loads(out) for field in ("exit_code", "duration_ms", "expired", "kept", "dry_run", "symbols_processed"): assert field in data, f"JSON is missing required field: {field!r}" # --------------------------------------------------------------------------- # SL_37 — --file expire # --------------------------------------------------------------------------- class TestSL37FileExpire: """SL_37: muse symlog expire --file FILE --json.""" def test_expire_all_symbols_in_file( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """All symbols under the given file path are processed.""" addrs = [ "src/billing.py::compute_total", "src/billing.py::validate_invoice", ] for addr in addrs: _write_entries(repo, addr, count=3, age_seconds=200 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--file", "src/billing.py", "--expire-days", "30", "--json", ], repo) data = json.loads(out) assert data["expired"] == 6 assert set(data["symbols_processed"]) == set(addrs) def test_expire_file_no_symlogs_is_noop( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """No-op when the file has no symbol logs.""" out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--file", "src/nonexistent.py", "--expire-days", "30", "--json", ], repo) data = json.loads(out) assert data["expired"] == 0 assert data["symbols_processed"] == [] def test_expire_file_partial_expiry( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Only old entries in each symbol's log are pruned; recent ones survive.""" addr = "src/billing.py::compute_total" p = symlog_path(repo, addr) p.parent.mkdir(parents=True, exist_ok=True) now_ts = int(time.time()) fake_commit = "sha256:" + "a" * 64 lines = [] for i in range(2): # 2 old ts = now_ts - 200 * 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: old {i}\n" ) for i in range(3): # 3 recent ts = now_ts - 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n" ) p.write_text("".join(lines), encoding="utf-8") out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--file", "src/billing.py", "--expire-days", "100", "--json", ], repo) data = json.loads(out) assert data["expired"] == 2 assert data["kept"] == 3 # --------------------------------------------------------------------------- # SL_38 — --all expire # --------------------------------------------------------------------------- class TestSL38AllExpire: """SL_38: muse symlog expire --all --json (reads config TTL).""" def test_expire_all_symbols( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """All symbols across the repo are processed.""" addrs = ["src/a.py::fn_a", "src/b.py::fn_b", "src/c.py::fn_c"] for addr in addrs: _write_entries(repo, addr, count=2, age_seconds=200 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--expire-days", "30", "--json", ], repo) data = json.loads(out) assert data["expired"] == 6 assert set(data["symbols_processed"]) == set(addrs) def test_expire_all_default_ttl_keeps_recent( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Default TTL (90 days) keeps entries that are only 10 days old.""" addr = "src/a.py::fn_a" _write_entries(repo, addr, count=1, age_seconds=10 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--json", # no --expire-days → defaults to 90 ], repo) data = json.loads(out) assert data["kept"] == 1 assert data["expired"] == 0 # --------------------------------------------------------------------------- # SL_39 — --dry-run # --------------------------------------------------------------------------- class TestSL39DryRun: """SL_39: --dry-run reports counts without writing any files.""" def test_dry_run_does_not_write( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Files are byte-for-byte unchanged after --dry-run.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=5, age_seconds=200 * 86400) p = symlog_path(repo, addr) before = p.read_bytes() out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", addr, "--expire-days", "30", "--dry-run", "--json", ], repo) data = json.loads(out) assert data["dry_run"] is True assert data["expired"] == 5 assert p.exists() assert p.read_bytes() == before def test_dry_run_file_mode( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """--dry-run works in --file mode.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3, age_seconds=200 * 86400) p = symlog_path(repo, addr) before = p.read_bytes() out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--file", "src/billing.py", "--expire-days", "30", "--dry-run", "--json", ], repo) data = json.loads(out) assert data["dry_run"] is True assert data["expired"] == 3 assert p.read_bytes() == before def test_dry_run_all_mode( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """--dry-run works in --all mode.""" addr = "src/a.py::fn_a" _write_entries(repo, addr, count=2, age_seconds=200 * 86400) p = symlog_path(repo, addr) before = p.read_bytes() out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--expire-days", "30", "--dry-run", "--json", ], repo) data = json.loads(out) assert data["dry_run"] is True assert data["expired"] == 2 assert p.read_bytes() == before # --------------------------------------------------------------------------- # SL_40 — symlog.expire-days config key # --------------------------------------------------------------------------- class TestSL40ConfigExpireDays: """SL_40: muse config set symlog.expire-days N controls the default TTL.""" def test_set_and_get_round_trip( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Config key writes and reads back the same integer.""" monkeypatch.chdir(repo) from muse.cli.config import get_config_value, set_config_value set_config_value("symlog.expire-days", "60", repo) val = get_config_value("symlog.expire-days", repo) assert val == "60" def test_invalid_value_raises( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Non-integer value raises ValueError.""" monkeypatch.chdir(repo) from muse.cli.config import set_config_value with pytest.raises(ValueError): set_config_value("symlog.expire-days", "not-a-number", repo) def test_zero_raises( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Zero or negative value raises ValueError.""" monkeypatch.chdir(repo) from muse.cli.config import set_config_value with pytest.raises(ValueError): set_config_value("symlog.expire-days", "0", repo) def test_config_ttl_is_used_as_default( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """expire --all uses config TTL when --expire-days not supplied.""" monkeypatch.chdir(repo) from muse.cli.config import set_config_value # Set TTL to 30 days set_config_value("symlog.expire-days", "30", repo) addr = "src/a.py::fn_a" # 60 days old → should expire with 30-day TTL _write_entries(repo, addr, count=1, age_seconds=60 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--json", # reads config ], repo) data = json.loads(out) assert data["expired"] == 1 def test_config_ttl_keeps_recent( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Config TTL keeps entries that are within the threshold.""" monkeypatch.chdir(repo) from muse.cli.config import set_config_value set_config_value("symlog.expire-days", "60", repo) addr = "src/a.py::fn_a" # 10 days old → should survive 60-day TTL _write_entries(repo, addr, count=1, age_seconds=10 * 86400) out, _ = _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--json", ], repo) data = json.loads(out) assert data["kept"] == 1 assert data["expired"] == 0 # --------------------------------------------------------------------------- # SL_41 — delete @{N} # --------------------------------------------------------------------------- class TestSL41DeleteIndex: """SL_41: muse symlog delete SYMBOL @{N} --json.""" def test_delete_newest_entry( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """@{0} removes the newest entry and decrements the count by 1.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=5) out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{0}", "--json", ], repo) data = json.loads(out) assert data["deleted"] == 1 assert data["remaining"] == 4 assert data["symbol"] == addr def test_delete_middle_entry( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """@{2} removes the third-newest entry.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=5) out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{2}", "--json", ], repo) data = json.loads(out) assert data["deleted"] == 1 assert data["remaining"] == 4 def test_delete_last_entry_removes_file( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Deleting the only entry removes the log file.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=1) p = symlog_path(repo, addr) assert p.exists() _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{0}", "--json", ], repo) assert not p.exists() def test_delete_json_schema( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """JSON output has all required schema fields.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3) out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{0}", "--json", ], repo) data = json.loads(out) for field in ("exit_code", "duration_ms", "deleted", "remaining", "symbol"): assert field in data, f"JSON is missing required field: {field!r}" # --------------------------------------------------------------------------- # SL_42 — delete --all # --------------------------------------------------------------------------- class TestSL42DeleteAll: """SL_42: muse symlog delete SYMBOL --all.""" def test_delete_all_removes_log_file( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """delete --all removes all entries and deletes the log file.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=7) p = symlog_path(repo, addr) assert p.exists() out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "--all", "--json", ], repo) data = json.loads(out) assert data["deleted"] == 7 assert data["remaining"] == 0 assert data["symbol"] == addr assert not p.exists() def test_delete_all_missing_log_graceful( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """delete --all on a non-existent log is a no-op (deleted=0).""" out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", "src/billing.py::nonexistent", "--all", "--json", ], repo) data = json.loads(out) assert data["deleted"] == 0 assert data["remaining"] == 0 def test_delete_all_deleted_equals_prior_count( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """The deleted count equals the number of entries that existed before.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=12) out, _ = _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "--all", "--json", ], repo) data = json.loads(out) assert data["deleted"] == 12 # --------------------------------------------------------------------------- # SL_43 — out-of-bounds @{N} # --------------------------------------------------------------------------- class TestSL43OutOfBounds: """SL_43: out-of-bounds @{N} exits USER_ERROR with the valid range.""" def test_index_too_large_exits_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """@{99} on a 3-entry log exits with USER_ERROR.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3) monkeypatch.chdir(repo) with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{99}", "--json", ], repo) assert exc_info.value.code == ExitCode.USER_ERROR def test_index_error_message_contains_valid_range( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Error message mentions both the lower and upper bounds.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3) with pytest.raises(SystemExit): _invoke_symlog(capsys, monkeypatch, [ "delete", addr, "@{99}", ], repo) _, err = capsys.readouterr() # Message should reference the valid range: @{0} to @{2} assert "@{0}" in err or "0" in err assert "@{2}" in err or "2" in err def test_delete_nonexistent_symbol_exits_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Deleting @{N} from a missing symbol log exits USER_ERROR.""" with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, [ "delete", "src/billing.py::nonexistent", "@{0}", ], repo) assert exc_info.value.code == ExitCode.USER_ERROR # --------------------------------------------------------------------------- # SL_44 — GC integration: GcResult gains symlog_expired # --------------------------------------------------------------------------- class TestSL44GcIntegration: """SL_44: GcResult.symlog_expired + muse gc --json includes symlog_expired.""" def test_gc_result_has_symlog_expired_field(self) -> None: """GcResult.symlog_expired is present and defaults to 0.""" from muse.core.gc import GcResult r = GcResult() assert hasattr(r, "symlog_expired") assert r.symlog_expired == 0 def test_gc_json_has_symlog_expired( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """muse gc --json output includes a symlog_expired field.""" out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--json", ], repo) data = json.loads(out) assert "symlog_expired" in data def test_gc_expires_old_entries( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """GC removes entries older than the default 90-day TTL.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3, age_seconds=200 * 86400) # 200 days old out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--json", ], repo) data = json.loads(out) assert data["symlog_expired"] == 3 def test_gc_keeps_recent_entries( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """GC leaves entries that are within the TTL window.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3, age_seconds=10 * 86400) # 10 days old out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--json", ], repo) data = json.loads(out) assert data["symlog_expired"] == 0 assert symlog_path(repo, addr).exists() # --------------------------------------------------------------------------- # SL_45 — integration: 100-entry log, half old → GC removes 50 # --------------------------------------------------------------------------- class TestSL45Integration: """SL_45: 100-entry log, 50 old; GC removes exactly 50; dry-run safe.""" def _build_mixed_log(self, repo: pathlib.Path, addr: str) -> None: """Write 100 entries: 50 old (200 days), 50 recent (1 day).""" p = symlog_path(repo, addr) p.parent.mkdir(parents=True, exist_ok=True) now_ts = int(time.time()) fake_commit = "sha256:" + "a" * 64 lines: list[str] = [] for i in range(50): ts = now_ts - 200 * 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: old {i}\n" ) for i in range(50): ts = now_ts - 86400 - i lines.append( f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} " f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n" ) p.write_text("".join(lines), encoding="utf-8") def test_gc_removes_50_of_100( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """GC removes exactly 50 old entries and keeps 50 recent ones.""" addr = "src/billing.py::compute_total" self._build_mixed_log(repo, addr) out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--json", ], repo) data = json.loads(out) assert data["symlog_expired"] == 50 # File still exists with 50 remaining entries p = symlog_path(repo, addr) assert p.exists() remaining = read_symlog(repo, addr, limit=200) assert len(remaining) == 50 def test_gc_dry_run_does_not_write( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """muse gc --dry-run reports symlog_expired but does not write.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=5, age_seconds=200 * 86400) p = symlog_path(repo, addr) before = p.read_bytes() out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--dry-run", "--json", ], repo) data = json.loads(out) assert data["symlog_expired"] == 5 assert data["dry_run"] is True assert p.read_bytes() == before def test_all_expire_deletes_log_file( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """When all entries expire via symlog expire --all, the log file is deleted.""" addr = "src/billing.py::compute_total" _write_entries(repo, addr, count=3, age_seconds=200 * 86400) p = symlog_path(repo, addr) assert p.exists() _invoke_symlog(capsys, monkeypatch, [ "expire", "--all", "--expire-days", "30", "--json", ], repo) assert not p.exists() def test_gc_with_custom_symlog_ttl( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """symlog.expire-days config is respected by muse gc.""" monkeypatch.chdir(repo) from muse.cli.config import set_config_value # Set 30-day TTL set_config_value("symlog.expire-days", "30", repo) addr = "src/billing.py::compute_total" # 60 days old — expires with 30-day TTL _write_entries(repo, addr, count=3, age_seconds=60 * 86400) out, _ = _invoke_gc(capsys, monkeypatch, [ "--grace-period", "0", "--json", ], repo) data = json.loads(out) assert data["symlog_expired"] == 3