gabriel / muse public
test_cmd_symlog_lifecycle.py python
778 lines 28.9 KB
Raw
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901 docs: architectural plan for unified-store snapshot-content… Sonnet 5 9 days ago
1 """Tests for ``muse symlog expire`` / ``muse symlog delete`` (Phase 4 — SL_36–SL_45).
2
3 TDD: this file is written first. All tests are expected to fail until
4 Phase 4 is implemented.
5
6 Test IDs
7 --------
8 SL_36 muse symlog expire SYMBOL --expire-days N --json
9 SL_37 muse symlog expire --file FILE --json
10 SL_38 muse symlog expire --all --json (config default TTL)
11 SL_39 --dry-run reports counts without writing
12 SL_40 muse config set symlog.expire-days N round-trips
13 SL_41 muse symlog delete SYMBOL @{N} --json
14 SL_42 muse symlog delete SYMBOL --all --json
15 SL_43 out-of-bounds @{N} exits USER_ERROR with valid range
16 SL_44 GcResult gains symlog_expired field; muse gc --json includes it
17 SL_45 integration: 100-entry log, 50 old → GC removes 50; dry-run safe
18 """
19
20 from __future__ import annotations
21
22 import argparse
23 import json
24 import pathlib
25 import time
26
27 import pytest
28
29 from muse.core.errors import ExitCode
30 from muse.core.symlog import (
31 NULL_CONTENT_ID,
32 read_symlog,
33 symlog_path,
34 )
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 @pytest.fixture()
43 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
44 """Minimal Muse repository skeleton — just enough for require_repo()."""
45 muse_dir = tmp_path / ".muse"
46 muse_dir.mkdir()
47 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
48 return tmp_path
49
50
51 def _write_entries(
52 repo: pathlib.Path,
53 addr: str,
54 count: int,
55 *,
56 age_seconds: int = 0,
57 ) -> None:
58 """Write *count* symlog entries for *addr*, backdated by *age_seconds*.
59
60 Entries are written oldest-first (as the format requires). Each entry is
61 1 second apart so timestamps are unique.
62 """
63 p = symlog_path(repo, addr)
64 p.parent.mkdir(parents=True, exist_ok=True)
65 now_ts = int(time.time()) - age_seconds
66 fake_commit = "sha256:" + "a" * 64
67 # Build oldest-first: entry i is now_ts - (count - 1 - i) seconds ago
68 lines = []
69 for i in range(count):
70 ts = now_ts - (count - 1 - i)
71 lines.append(
72 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
73 f"claude-code {ts} +0000\tsymbol-modified: entry {i}\n"
74 )
75 # Append-mode so tests can call multiple times on the same addr.
76 with p.open("a", encoding="utf-8") as fh:
77 fh.writelines(lines)
78
79
80 def _invoke_symlog(
81 capsys: pytest.CaptureFixture,
82 monkeypatch: pytest.MonkeyPatch,
83 args: list[str],
84 repo_root: pathlib.Path,
85 ) -> pytest.CaptureResult:
86 """Run ``muse symlog <args>`` inline; does NOT swallow SystemExit."""
87 from muse.cli.commands import symlog as symlog_cmd
88
89 monkeypatch.chdir(repo_root)
90 parser = argparse.ArgumentParser()
91 subparsers = parser.add_subparsers()
92 symlog_cmd.register(subparsers)
93 parsed = parser.parse_args(["symlog"] + args)
94 parsed.func(parsed)
95 return capsys.readouterr()
96
97
98 def _invoke_gc(
99 capsys: pytest.CaptureFixture,
100 monkeypatch: pytest.MonkeyPatch,
101 args: list[str],
102 repo_root: pathlib.Path,
103 ) -> pytest.CaptureResult:
104 """Run ``muse gc <args>`` inline; does NOT swallow SystemExit."""
105 from muse.cli.commands import gc as gc_cmd
106
107 monkeypatch.chdir(repo_root)
108 parser = argparse.ArgumentParser()
109 subparsers = parser.add_subparsers()
110 gc_cmd.register(subparsers)
111 parsed = parser.parse_args(["gc"] + args)
112 gc_cmd.run(parsed)
113 return capsys.readouterr()
114
115
116 # ---------------------------------------------------------------------------
117 # SL_36 — single-symbol expire
118 # ---------------------------------------------------------------------------
119
120
121 class TestSL36SingleSymbolExpire:
122 """SL_36: muse symlog expire SYMBOL --expire-days N --json."""
123
124 def test_expire_removes_old_entries(
125 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
126 ) -> None:
127 """Entries older than threshold are pruned; JSON counts are accurate."""
128 addr = "src/billing.py::compute_total"
129 _write_entries(repo, addr, count=5, age_seconds=100 * 86400)
130
131 out, _ = _invoke_symlog(capsys, monkeypatch, [
132 "expire", addr, "--expire-days", "30", "--json",
133 ], repo)
134 data = json.loads(out)
135 assert data["expired"] == 5
136 assert data["kept"] == 0
137 assert data["dry_run"] is False
138 assert addr in data["symbols_processed"]
139
140 def test_expire_keeps_recent_entries(
141 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
142 ) -> None:
143 """Recent entries survive; only old ones expire."""
144 addr = "src/billing.py::compute_total"
145 p = symlog_path(repo, addr)
146 p.parent.mkdir(parents=True, exist_ok=True)
147 now_ts = int(time.time())
148 fake_commit = "sha256:" + "a" * 64
149 lines: list[str] = []
150 # 3 old entries (150 days)
151 for i in range(3):
152 ts = now_ts - 150 * 86400 - i
153 lines.append(
154 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
155 f"claude-code {ts} +0000\tsymbol-modified: old {i}\n"
156 )
157 # 3 recent entries (1 day)
158 for i in range(3):
159 ts = now_ts - 86400 - i
160 lines.append(
161 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
162 f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n"
163 )
164 p.write_text("".join(lines), encoding="utf-8")
165
166 out, _ = _invoke_symlog(capsys, monkeypatch, [
167 "expire", addr, "--expire-days", "100", "--json",
168 ], repo)
169 data = json.loads(out)
170 assert data["expired"] == 3
171 assert data["kept"] == 3
172
173 def test_expire_nonexistent_symbol_is_noop(
174 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
175 ) -> None:
176 """Expiring a symbol with no log is a silent no-op (expired=0, kept=0)."""
177 out, _ = _invoke_symlog(capsys, monkeypatch, [
178 "expire", "src/billing.py::nonexistent", "--expire-days", "30", "--json",
179 ], repo)
180 data = json.loads(out)
181 assert data["expired"] == 0
182 assert data["kept"] == 0
183
184 def test_expire_deletes_log_file_when_all_expire(
185 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
186 ) -> None:
187 """Log file is deleted (not left as zero-byte stub) when all entries expire."""
188 addr = "src/billing.py::compute_total"
189 _write_entries(repo, addr, count=3, age_seconds=200 * 86400)
190 p = symlog_path(repo, addr)
191 assert p.exists()
192
193 _invoke_symlog(capsys, monkeypatch, [
194 "expire", addr, "--expire-days", "30", "--json",
195 ], repo)
196 assert not p.exists()
197
198 def test_expire_json_schema(
199 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
200 ) -> None:
201 """JSON output includes all required schema fields."""
202 addr = "src/billing.py::compute_total"
203 _write_entries(repo, addr, count=2, age_seconds=100 * 86400)
204
205 out, _ = _invoke_symlog(capsys, monkeypatch, [
206 "expire", addr, "--expire-days", "30", "--json",
207 ], repo)
208 data = json.loads(out)
209 for field in ("exit_code", "duration_ms", "expired", "kept", "dry_run", "symbols_processed"):
210 assert field in data, f"JSON is missing required field: {field!r}"
211
212
213 # ---------------------------------------------------------------------------
214 # SL_37 — --file expire
215 # ---------------------------------------------------------------------------
216
217
218 class TestSL37FileExpire:
219 """SL_37: muse symlog expire --file FILE --json."""
220
221 def test_expire_all_symbols_in_file(
222 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
223 ) -> None:
224 """All symbols under the given file path are processed."""
225 addrs = [
226 "src/billing.py::compute_total",
227 "src/billing.py::validate_invoice",
228 ]
229 for addr in addrs:
230 _write_entries(repo, addr, count=3, age_seconds=200 * 86400)
231
232 out, _ = _invoke_symlog(capsys, monkeypatch, [
233 "expire", "--file", "src/billing.py", "--expire-days", "30", "--json",
234 ], repo)
235 data = json.loads(out)
236 assert data["expired"] == 6
237 assert set(data["symbols_processed"]) == set(addrs)
238
239 def test_expire_file_no_symlogs_is_noop(
240 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
241 ) -> None:
242 """No-op when the file has no symbol logs."""
243 out, _ = _invoke_symlog(capsys, monkeypatch, [
244 "expire", "--file", "src/nonexistent.py", "--expire-days", "30", "--json",
245 ], repo)
246 data = json.loads(out)
247 assert data["expired"] == 0
248 assert data["symbols_processed"] == []
249
250 def test_expire_file_partial_expiry(
251 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
252 ) -> None:
253 """Only old entries in each symbol's log are pruned; recent ones survive."""
254 addr = "src/billing.py::compute_total"
255 p = symlog_path(repo, addr)
256 p.parent.mkdir(parents=True, exist_ok=True)
257 now_ts = int(time.time())
258 fake_commit = "sha256:" + "a" * 64
259 lines = []
260 for i in range(2): # 2 old
261 ts = now_ts - 200 * 86400 - i
262 lines.append(
263 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
264 f"claude-code {ts} +0000\tsymbol-modified: old {i}\n"
265 )
266 for i in range(3): # 3 recent
267 ts = now_ts - 86400 - i
268 lines.append(
269 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
270 f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n"
271 )
272 p.write_text("".join(lines), encoding="utf-8")
273
274 out, _ = _invoke_symlog(capsys, monkeypatch, [
275 "expire", "--file", "src/billing.py", "--expire-days", "100", "--json",
276 ], repo)
277 data = json.loads(out)
278 assert data["expired"] == 2
279 assert data["kept"] == 3
280
281
282 # ---------------------------------------------------------------------------
283 # SL_38 — --all expire
284 # ---------------------------------------------------------------------------
285
286
287 class TestSL38AllExpire:
288 """SL_38: muse symlog expire --all --json (reads config TTL)."""
289
290 def test_expire_all_symbols(
291 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
292 ) -> None:
293 """All symbols across the repo are processed."""
294 addrs = ["src/a.py::fn_a", "src/b.py::fn_b", "src/c.py::fn_c"]
295 for addr in addrs:
296 _write_entries(repo, addr, count=2, age_seconds=200 * 86400)
297
298 out, _ = _invoke_symlog(capsys, monkeypatch, [
299 "expire", "--all", "--expire-days", "30", "--json",
300 ], repo)
301 data = json.loads(out)
302 assert data["expired"] == 6
303 assert set(data["symbols_processed"]) == set(addrs)
304
305 def test_expire_all_default_ttl_keeps_recent(
306 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
307 ) -> None:
308 """Default TTL (90 days) keeps entries that are only 10 days old."""
309 addr = "src/a.py::fn_a"
310 _write_entries(repo, addr, count=1, age_seconds=10 * 86400)
311
312 out, _ = _invoke_symlog(capsys, monkeypatch, [
313 "expire", "--all", "--json", # no --expire-days → defaults to 90
314 ], repo)
315 data = json.loads(out)
316 assert data["kept"] == 1
317 assert data["expired"] == 0
318
319
320 # ---------------------------------------------------------------------------
321 # SL_39 — --dry-run
322 # ---------------------------------------------------------------------------
323
324
325 class TestSL39DryRun:
326 """SL_39: --dry-run reports counts without writing any files."""
327
328 def test_dry_run_does_not_write(
329 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
330 ) -> None:
331 """Files are byte-for-byte unchanged after --dry-run."""
332 addr = "src/billing.py::compute_total"
333 _write_entries(repo, addr, count=5, age_seconds=200 * 86400)
334 p = symlog_path(repo, addr)
335 before = p.read_bytes()
336
337 out, _ = _invoke_symlog(capsys, monkeypatch, [
338 "expire", addr, "--expire-days", "30", "--dry-run", "--json",
339 ], repo)
340 data = json.loads(out)
341 assert data["dry_run"] is True
342 assert data["expired"] == 5
343 assert p.exists()
344 assert p.read_bytes() == before
345
346 def test_dry_run_file_mode(
347 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
348 ) -> None:
349 """--dry-run works in --file mode."""
350 addr = "src/billing.py::compute_total"
351 _write_entries(repo, addr, count=3, age_seconds=200 * 86400)
352 p = symlog_path(repo, addr)
353 before = p.read_bytes()
354
355 out, _ = _invoke_symlog(capsys, monkeypatch, [
356 "expire", "--file", "src/billing.py", "--expire-days", "30", "--dry-run", "--json",
357 ], repo)
358 data = json.loads(out)
359 assert data["dry_run"] is True
360 assert data["expired"] == 3
361 assert p.read_bytes() == before
362
363 def test_dry_run_all_mode(
364 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
365 ) -> None:
366 """--dry-run works in --all mode."""
367 addr = "src/a.py::fn_a"
368 _write_entries(repo, addr, count=2, age_seconds=200 * 86400)
369 p = symlog_path(repo, addr)
370 before = p.read_bytes()
371
372 out, _ = _invoke_symlog(capsys, monkeypatch, [
373 "expire", "--all", "--expire-days", "30", "--dry-run", "--json",
374 ], repo)
375 data = json.loads(out)
376 assert data["dry_run"] is True
377 assert data["expired"] == 2
378 assert p.read_bytes() == before
379
380
381 # ---------------------------------------------------------------------------
382 # SL_40 — symlog.expire-days config key
383 # ---------------------------------------------------------------------------
384
385
386 class TestSL40ConfigExpireDays:
387 """SL_40: muse config set symlog.expire-days N controls the default TTL."""
388
389 def test_set_and_get_round_trip(
390 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
391 ) -> None:
392 """Config key writes and reads back the same integer."""
393 monkeypatch.chdir(repo)
394 from muse.cli.config import get_config_value, set_config_value
395
396 set_config_value("symlog.expire-days", "60", repo)
397 val = get_config_value("symlog.expire-days", repo)
398 assert val == "60"
399
400 def test_invalid_value_raises(
401 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
402 ) -> None:
403 """Non-integer value raises ValueError."""
404 monkeypatch.chdir(repo)
405 from muse.cli.config import set_config_value
406
407 with pytest.raises(ValueError):
408 set_config_value("symlog.expire-days", "not-a-number", repo)
409
410 def test_zero_raises(
411 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
412 ) -> None:
413 """Zero or negative value raises ValueError."""
414 monkeypatch.chdir(repo)
415 from muse.cli.config import set_config_value
416
417 with pytest.raises(ValueError):
418 set_config_value("symlog.expire-days", "0", repo)
419
420 def test_config_ttl_is_used_as_default(
421 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
422 ) -> None:
423 """expire --all uses config TTL when --expire-days not supplied."""
424 monkeypatch.chdir(repo)
425 from muse.cli.config import set_config_value
426
427 # Set TTL to 30 days
428 set_config_value("symlog.expire-days", "30", repo)
429
430 addr = "src/a.py::fn_a"
431 # 60 days old → should expire with 30-day TTL
432 _write_entries(repo, addr, count=1, age_seconds=60 * 86400)
433
434 out, _ = _invoke_symlog(capsys, monkeypatch, [
435 "expire", "--all", "--json", # reads config
436 ], repo)
437 data = json.loads(out)
438 assert data["expired"] == 1
439
440 def test_config_ttl_keeps_recent(
441 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
442 ) -> None:
443 """Config TTL keeps entries that are within the threshold."""
444 monkeypatch.chdir(repo)
445 from muse.cli.config import set_config_value
446
447 set_config_value("symlog.expire-days", "60", repo)
448
449 addr = "src/a.py::fn_a"
450 # 10 days old → should survive 60-day TTL
451 _write_entries(repo, addr, count=1, age_seconds=10 * 86400)
452
453 out, _ = _invoke_symlog(capsys, monkeypatch, [
454 "expire", "--all", "--json",
455 ], repo)
456 data = json.loads(out)
457 assert data["kept"] == 1
458 assert data["expired"] == 0
459
460
461 # ---------------------------------------------------------------------------
462 # SL_41 — delete @{N}
463 # ---------------------------------------------------------------------------
464
465
466 class TestSL41DeleteIndex:
467 """SL_41: muse symlog delete SYMBOL @{N} --json."""
468
469 def test_delete_newest_entry(
470 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
471 ) -> None:
472 """@{0} removes the newest entry and decrements the count by 1."""
473 addr = "src/billing.py::compute_total"
474 _write_entries(repo, addr, count=5)
475
476 out, _ = _invoke_symlog(capsys, monkeypatch, [
477 "delete", addr, "@{0}", "--json",
478 ], repo)
479 data = json.loads(out)
480 assert data["deleted"] == 1
481 assert data["remaining"] == 4
482 assert data["symbol"] == addr
483
484 def test_delete_middle_entry(
485 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
486 ) -> None:
487 """@{2} removes the third-newest entry."""
488 addr = "src/billing.py::compute_total"
489 _write_entries(repo, addr, count=5)
490
491 out, _ = _invoke_symlog(capsys, monkeypatch, [
492 "delete", addr, "@{2}", "--json",
493 ], repo)
494 data = json.loads(out)
495 assert data["deleted"] == 1
496 assert data["remaining"] == 4
497
498 def test_delete_last_entry_removes_file(
499 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
500 ) -> None:
501 """Deleting the only entry removes the log file."""
502 addr = "src/billing.py::compute_total"
503 _write_entries(repo, addr, count=1)
504 p = symlog_path(repo, addr)
505 assert p.exists()
506
507 _invoke_symlog(capsys, monkeypatch, [
508 "delete", addr, "@{0}", "--json",
509 ], repo)
510 assert not p.exists()
511
512 def test_delete_json_schema(
513 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
514 ) -> None:
515 """JSON output has all required schema fields."""
516 addr = "src/billing.py::compute_total"
517 _write_entries(repo, addr, count=3)
518
519 out, _ = _invoke_symlog(capsys, monkeypatch, [
520 "delete", addr, "@{0}", "--json",
521 ], repo)
522 data = json.loads(out)
523 for field in ("exit_code", "duration_ms", "deleted", "remaining", "symbol"):
524 assert field in data, f"JSON is missing required field: {field!r}"
525
526
527 # ---------------------------------------------------------------------------
528 # SL_42 — delete --all
529 # ---------------------------------------------------------------------------
530
531
532 class TestSL42DeleteAll:
533 """SL_42: muse symlog delete SYMBOL --all."""
534
535 def test_delete_all_removes_log_file(
536 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
537 ) -> None:
538 """delete --all removes all entries and deletes the log file."""
539 addr = "src/billing.py::compute_total"
540 _write_entries(repo, addr, count=7)
541 p = symlog_path(repo, addr)
542 assert p.exists()
543
544 out, _ = _invoke_symlog(capsys, monkeypatch, [
545 "delete", addr, "--all", "--json",
546 ], repo)
547 data = json.loads(out)
548 assert data["deleted"] == 7
549 assert data["remaining"] == 0
550 assert data["symbol"] == addr
551 assert not p.exists()
552
553 def test_delete_all_missing_log_graceful(
554 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
555 ) -> None:
556 """delete --all on a non-existent log is a no-op (deleted=0)."""
557 out, _ = _invoke_symlog(capsys, monkeypatch, [
558 "delete", "src/billing.py::nonexistent", "--all", "--json",
559 ], repo)
560 data = json.loads(out)
561 assert data["deleted"] == 0
562 assert data["remaining"] == 0
563
564 def test_delete_all_deleted_equals_prior_count(
565 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
566 ) -> None:
567 """The deleted count equals the number of entries that existed before."""
568 addr = "src/billing.py::compute_total"
569 _write_entries(repo, addr, count=12)
570
571 out, _ = _invoke_symlog(capsys, monkeypatch, [
572 "delete", addr, "--all", "--json",
573 ], repo)
574 data = json.loads(out)
575 assert data["deleted"] == 12
576
577
578 # ---------------------------------------------------------------------------
579 # SL_43 — out-of-bounds @{N}
580 # ---------------------------------------------------------------------------
581
582
583 class TestSL43OutOfBounds:
584 """SL_43: out-of-bounds @{N} exits USER_ERROR with the valid range."""
585
586 def test_index_too_large_exits_user_error(
587 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
588 ) -> None:
589 """@{99} on a 3-entry log exits with USER_ERROR."""
590 addr = "src/billing.py::compute_total"
591 _write_entries(repo, addr, count=3)
592 monkeypatch.chdir(repo)
593
594 with pytest.raises(SystemExit) as exc_info:
595 _invoke_symlog(capsys, monkeypatch, [
596 "delete", addr, "@{99}", "--json",
597 ], repo)
598 assert exc_info.value.code == ExitCode.USER_ERROR
599
600 def test_index_error_message_contains_valid_range(
601 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
602 ) -> None:
603 """Error message mentions both the lower and upper bounds."""
604 addr = "src/billing.py::compute_total"
605 _write_entries(repo, addr, count=3)
606
607 with pytest.raises(SystemExit):
608 _invoke_symlog(capsys, monkeypatch, [
609 "delete", addr, "@{99}",
610 ], repo)
611 _, err = capsys.readouterr()
612 # Message should reference the valid range: @{0} to @{2}
613 assert "@{0}" in err or "0" in err
614 assert "@{2}" in err or "2" in err
615
616 def test_delete_nonexistent_symbol_exits_user_error(
617 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
618 ) -> None:
619 """Deleting @{N} from a missing symbol log exits USER_ERROR."""
620 with pytest.raises(SystemExit) as exc_info:
621 _invoke_symlog(capsys, monkeypatch, [
622 "delete", "src/billing.py::nonexistent", "@{0}",
623 ], repo)
624 assert exc_info.value.code == ExitCode.USER_ERROR
625
626
627 # ---------------------------------------------------------------------------
628 # SL_44 — GC integration: GcResult gains symlog_expired
629 # ---------------------------------------------------------------------------
630
631
632 class TestSL44GcIntegration:
633 """SL_44: GcResult.symlog_expired + muse gc --json includes symlog_expired."""
634
635 def test_gc_result_has_symlog_expired_field(self) -> None:
636 """GcResult.symlog_expired is present and defaults to 0."""
637 from muse.core.gc import GcResult
638
639 r = GcResult()
640 assert hasattr(r, "symlog_expired")
641 assert r.symlog_expired == 0
642
643 def test_gc_json_has_symlog_expired(
644 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
645 ) -> None:
646 """muse gc --json output includes a symlog_expired field."""
647 out, _ = _invoke_gc(capsys, monkeypatch, [
648 "--grace-period", "0", "--json",
649 ], repo)
650 data = json.loads(out)
651 assert "symlog_expired" in data
652
653 def test_gc_expires_old_entries(
654 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
655 ) -> None:
656 """GC removes entries older than the default 90-day TTL."""
657 addr = "src/billing.py::compute_total"
658 _write_entries(repo, addr, count=3, age_seconds=200 * 86400) # 200 days old
659
660 out, _ = _invoke_gc(capsys, monkeypatch, [
661 "--grace-period", "0", "--json",
662 ], repo)
663 data = json.loads(out)
664 assert data["symlog_expired"] == 3
665
666 def test_gc_keeps_recent_entries(
667 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
668 ) -> None:
669 """GC leaves entries that are within the TTL window."""
670 addr = "src/billing.py::compute_total"
671 _write_entries(repo, addr, count=3, age_seconds=10 * 86400) # 10 days old
672
673 out, _ = _invoke_gc(capsys, monkeypatch, [
674 "--grace-period", "0", "--json",
675 ], repo)
676 data = json.loads(out)
677 assert data["symlog_expired"] == 0
678 assert symlog_path(repo, addr).exists()
679
680
681 # ---------------------------------------------------------------------------
682 # SL_45 — integration: 100-entry log, half old → GC removes 50
683 # ---------------------------------------------------------------------------
684
685
686 class TestSL45Integration:
687 """SL_45: 100-entry log, 50 old; GC removes exactly 50; dry-run safe."""
688
689 def _build_mixed_log(self, repo: pathlib.Path, addr: str) -> None:
690 """Write 100 entries: 50 old (200 days), 50 recent (1 day)."""
691 p = symlog_path(repo, addr)
692 p.parent.mkdir(parents=True, exist_ok=True)
693 now_ts = int(time.time())
694 fake_commit = "sha256:" + "a" * 64
695 lines: list[str] = []
696 for i in range(50):
697 ts = now_ts - 200 * 86400 - i
698 lines.append(
699 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
700 f"claude-code {ts} +0000\tsymbol-modified: old {i}\n"
701 )
702 for i in range(50):
703 ts = now_ts - 86400 - i
704 lines.append(
705 f"{NULL_CONTENT_ID} {NULL_CONTENT_ID} {fake_commit} "
706 f"claude-code {ts} +0000\tsymbol-modified: recent {i}\n"
707 )
708 p.write_text("".join(lines), encoding="utf-8")
709
710 def test_gc_removes_50_of_100(
711 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
712 ) -> None:
713 """GC removes exactly 50 old entries and keeps 50 recent ones."""
714 addr = "src/billing.py::compute_total"
715 self._build_mixed_log(repo, addr)
716
717 out, _ = _invoke_gc(capsys, monkeypatch, [
718 "--grace-period", "0", "--json",
719 ], repo)
720 data = json.loads(out)
721 assert data["symlog_expired"] == 50
722
723 # File still exists with 50 remaining entries
724 p = symlog_path(repo, addr)
725 assert p.exists()
726 remaining = read_symlog(repo, addr, limit=200)
727 assert len(remaining) == 50
728
729 def test_gc_dry_run_does_not_write(
730 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
731 ) -> None:
732 """muse gc --dry-run reports symlog_expired but does not write."""
733 addr = "src/billing.py::compute_total"
734 _write_entries(repo, addr, count=5, age_seconds=200 * 86400)
735 p = symlog_path(repo, addr)
736 before = p.read_bytes()
737
738 out, _ = _invoke_gc(capsys, monkeypatch, [
739 "--grace-period", "0", "--dry-run", "--json",
740 ], repo)
741 data = json.loads(out)
742 assert data["symlog_expired"] == 5
743 assert data["dry_run"] is True
744 assert p.read_bytes() == before
745
746 def test_all_expire_deletes_log_file(
747 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
748 ) -> None:
749 """When all entries expire via symlog expire --all, the log file is deleted."""
750 addr = "src/billing.py::compute_total"
751 _write_entries(repo, addr, count=3, age_seconds=200 * 86400)
752 p = symlog_path(repo, addr)
753 assert p.exists()
754
755 _invoke_symlog(capsys, monkeypatch, [
756 "expire", "--all", "--expire-days", "30", "--json",
757 ], repo)
758 assert not p.exists()
759
760 def test_gc_with_custom_symlog_ttl(
761 self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
762 ) -> None:
763 """symlog.expire-days config is respected by muse gc."""
764 monkeypatch.chdir(repo)
765 from muse.cli.config import set_config_value
766
767 # Set 30-day TTL
768 set_config_value("symlog.expire-days", "30", repo)
769
770 addr = "src/billing.py::compute_total"
771 # 60 days old — expires with 30-day TTL
772 _write_entries(repo, addr, count=3, age_seconds=60 * 86400)
773
774 out, _ = _invoke_gc(capsys, monkeypatch, [
775 "--grace-period", "0", "--json",
776 ], repo)
777 data = json.loads(out)
778 assert data["symlog_expired"] == 3
File History 1 commit
sha256:24d9cb81ac1dd071e5d98862c740ad101180588361b4f96a790af79a99aed901 docs: architectural plan for unified-store snapshot-content… Sonnet 5 9 days ago