gabriel / muse public
test_cmd_reflog_expire.py python
465 lines 18.8 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 """Phase 2 — RL_06–RL_12: ``muse reflog expire`` subcommand.
2
3 TDD: tests are written before the implementation. Each class maps to one or
4 more deliverables from issue #47, Phase 2.
5
6 Coverage tiers
7 --------------
8 RL_06 expire --expire-days N removes old entries; JSON reports expired count
9 RL_07 expire --all applies to all branch logs; refs_processed listed
10 RL_08 expire --dry-run reports but writes nothing
11 RL_09 muse config reflog.expire-days N sets default TTL
12 RL_10 expire is atomic (write-tmp + os.replace); original survives mock-kill
13 RL_11 100-entry log, 50 old → exactly 50 removed, 50 kept
14 RL_12 all entries expire → log file removed; muse reflog returns entries:[]
15 """
16 from __future__ import annotations
17
18 import datetime
19 import json
20 import os
21 import pathlib
22 import time
23 from unittest.mock import patch, call
24
25 import pytest
26
27 from muse.core.paths import muse_dir, logs_dir
28 from muse.core.types import fake_id, NULL_COMMIT_ID
29 from tests.cli_test_helper import CliRunner
30
31 runner = CliRunner()
32 cli = None # argparse migration stub
33
34 _DAY = 86400 # seconds
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40 def _env(root: pathlib.Path) -> dict[str, str]:
41 return {"MUSE_REPO_ROOT": str(root)}
42
43
44 def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
45 """Create a minimal .muse/ structure, no commits."""
46 from muse._version import __version__
47
48 dot = muse_dir(tmp_path)
49 for sub in (
50 "refs/heads", "objects", "commits", "snapshots",
51 "logs/refs/heads", "logs",
52 ):
53 (dot / sub).mkdir(parents=True, exist_ok=True)
54 (dot / "repo.json").write_text(
55 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
56 )
57 (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
58 (dot / "config.toml").write_text("")
59 return tmp_path
60
61
62 def _write_log_entries(
63 log_path: pathlib.Path,
64 entries: list[tuple[int, str]], # list of (unix_ts, operation)
65 ) -> None:
66 """Write raw reflog entries at given timestamps (bypasses append_reflog)."""
67 log_path.parent.mkdir(parents=True, exist_ok=True)
68 old = fake_id("old")
69 new = fake_id("new")
70 lines = []
71 for ts, op in entries:
72 lines.append(f"{old} {new} user {ts} +0000\t{op}\n")
73 log_path.write_text("".join(lines), encoding="utf-8")
74
75
76 def _head_log_path(root: pathlib.Path) -> pathlib.Path:
77 return logs_dir(root) / "HEAD"
78
79
80 def _branch_log_path(root: pathlib.Path, branch: str) -> pathlib.Path:
81 return logs_dir(root) / "refs" / "heads" / branch
82
83
84 def _now_ts() -> int:
85 return int(time.time())
86
87
88 def _old_ts(days: int) -> int:
89 """Unix timestamp *days* days ago."""
90 return _now_ts() - days * _DAY
91
92
93 # ---------------------------------------------------------------------------
94 # RL_06 — expire removes old entries; JSON reports expired/kept
95 # ---------------------------------------------------------------------------
96
97 class TestExpireBasic:
98 """RL_06: expire removes entries older than the threshold."""
99
100 def test_removes_old_entries_from_head_log(self, tmp_path: pathlib.Path) -> None:
101 root = _make_repo(tmp_path)
102 log = _head_log_path(root)
103 # 3 old (100 days) + 2 recent (10 days)
104 entries = (
105 [(_old_ts(100), f"old-{i}") for i in range(3)]
106 + [(_old_ts(10), f"recent-{i}") for i in range(2)]
107 )
108 _write_log_entries(log, entries)
109
110 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
111
112 assert r.exit_code == 0, r.output
113 data = json.loads(r.output)
114 assert data["expired"] == 3
115 assert data["kept"] == 2
116 assert data["dry_run"] is False
117
118 def test_remaining_entries_are_the_recent_ones(self, tmp_path: pathlib.Path) -> None:
119 root = _make_repo(tmp_path)
120 log = _head_log_path(root)
121 _write_log_entries(log, [
122 (_old_ts(200), "very-old"),
123 (_old_ts(10), "recent"),
124 ])
125
126 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
127
128 from muse.core.reflog import read_reflog
129 remaining = read_reflog(root, branch=None, limit=100)
130 assert len(remaining) == 1
131 assert remaining[0].operation == "recent"
132
133 def test_json_schema_has_all_required_fields(self, tmp_path: pathlib.Path) -> None:
134 root = _make_repo(tmp_path)
135 _write_log_entries(_head_log_path(root), [(_old_ts(5), "keep")])
136
137 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
138
139 data = json.loads(r.output)
140 for key in ("expired", "kept", "dry_run", "refs_processed", "exit_code", "duration_ms"):
141 assert key in data, f"missing key {key!r}"
142
143 def test_no_entries_expire_when_all_recent(self, tmp_path: pathlib.Path) -> None:
144 root = _make_repo(tmp_path)
145 _write_log_entries(_head_log_path(root), [
146 (_old_ts(1), "a"),
147 (_old_ts(2), "b"),
148 ])
149
150 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
151
152 data = json.loads(r.output)
153 assert data["expired"] == 0
154 assert data["kept"] == 2
155
156 def test_no_log_file_is_graceful(self, tmp_path: pathlib.Path) -> None:
157 """Repo with no reflog entries must not crash."""
158 root = _make_repo(tmp_path)
159
160 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
161
162 assert r.exit_code == 0, r.output
163 data = json.loads(r.output)
164 assert data["expired"] == 0
165 assert data["kept"] == 0
166
167
168 # ---------------------------------------------------------------------------
169 # RL_07 — expire --all covers every branch log
170 # ---------------------------------------------------------------------------
171
172 class TestExpireAll:
173 """RL_07: --all processes HEAD + all branch logs."""
174
175 def test_all_flag_processes_all_branch_logs(self, tmp_path: pathlib.Path) -> None:
176 root = _make_repo(tmp_path)
177
178 # Head log — 1 old entry
179 _write_log_entries(_head_log_path(root), [(_old_ts(100), "head-old")])
180
181 # Two branch logs — each with 1 old entry
182 for branch in ("main", "dev"):
183 _write_log_entries(_branch_log_path(root, branch), [(_old_ts(100), f"{branch}-old")])
184
185 r = runner.invoke(cli, ["reflog", "expire", "--all", "--expire-days", "90", "--json"], env=_env(root))
186
187 assert r.exit_code == 0, r.output
188 data = json.loads(r.output)
189 assert data["expired"] >= 2 # at least the two branch logs
190 # refs_processed must include the branch refs
191 refs = data["refs_processed"]
192 assert any("main" in ref for ref in refs)
193 assert any("dev" in ref for ref in refs)
194
195 def test_refs_processed_lists_every_touched_ref(self, tmp_path: pathlib.Path) -> None:
196 root = _make_repo(tmp_path)
197 for branch in ("main", "feature"):
198 _write_log_entries(_branch_log_path(root, branch), [(_old_ts(5), "keep")])
199
200 r = runner.invoke(cli, ["reflog", "expire", "--all", "--expire-days", "90", "--json"], env=_env(root))
201
202 data = json.loads(r.output)
203 refs = data["refs_processed"]
204 # Both branches must appear even though nothing expired from them
205 assert any("main" in ref for ref in refs)
206 assert any("feature" in ref for ref in refs)
207
208 def test_all_with_specific_branch_flag_is_rejected(self, tmp_path: pathlib.Path) -> None:
209 """--all and --branch together is ambiguous; must fail with USER_ERROR."""
210 root = _make_repo(tmp_path)
211 r = runner.invoke(cli, ["reflog", "expire", "--all", "--branch", "main", "--json"], env=_env(root))
212 assert r.exit_code != 0
213
214
215 # ---------------------------------------------------------------------------
216 # RL_08 — --dry-run reports without writing
217 # ---------------------------------------------------------------------------
218
219 class TestExpireDryRun:
220 """RL_08: dry-run reports what would be removed but writes nothing."""
221
222 def test_dry_run_reports_expired_count(self, tmp_path: pathlib.Path) -> None:
223 root = _make_repo(tmp_path)
224 _write_log_entries(_head_log_path(root), [
225 (_old_ts(100), "old"),
226 (_old_ts(5), "new"),
227 ])
228
229 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--dry-run", "--json"], env=_env(root))
230
231 assert r.exit_code == 0, r.output
232 data = json.loads(r.output)
233 assert data["expired"] == 1
234 assert data["kept"] == 1
235 assert data["dry_run"] is True
236
237 def test_dry_run_does_not_modify_log(self, tmp_path: pathlib.Path) -> None:
238 root = _make_repo(tmp_path)
239 log = _head_log_path(root)
240 _write_log_entries(log, [(_old_ts(100), "old")])
241 original_content = log.read_text(encoding="utf-8")
242
243 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--dry-run"], env=_env(root))
244
245 assert log.read_text(encoding="utf-8") == original_content, (
246 "dry-run must not modify the log file"
247 )
248
249 def test_dry_run_all_does_not_modify_any_log(self, tmp_path: pathlib.Path) -> None:
250 root = _make_repo(tmp_path)
251 for branch in ("main", "dev"):
252 _write_log_entries(_branch_log_path(root, branch), [(_old_ts(100), f"{branch}-old")])
253
254 originals = {
255 b: _branch_log_path(root, b).read_text() for b in ("main", "dev")
256 }
257
258 runner.invoke(cli, ["reflog", "expire", "--all", "--expire-days", "90", "--dry-run"], env=_env(root))
259
260 for b, orig in originals.items():
261 assert _branch_log_path(root, b).read_text() == orig, (
262 f"dry-run modified branch log for {b!r}"
263 )
264
265
266 # ---------------------------------------------------------------------------
267 # RL_09 — muse config reflog.expire-days sets default TTL
268 # ---------------------------------------------------------------------------
269
270 class TestExpireConfigTTL:
271 """RL_09: reflog.expire-days config key is read when --expire-days is omitted."""
272
273 def test_config_expire_days_is_used_as_default(self, tmp_path: pathlib.Path) -> None:
274 root = _make_repo(tmp_path)
275 _write_log_entries(_head_log_path(root), [
276 (_old_ts(20), "old"), # 20 days old — expires at 15-day TTL
277 (_old_ts(5), "recent"), # 5 days old — kept at 15-day TTL
278 ])
279
280 # Set config to 15 days.
281 set_r = runner.invoke(cli, ["config", "set", "reflog.expire-days", "15"], env=_env(root))
282 assert set_r.exit_code == 0, set_r.output
283
284 # Run expire without --expire-days; should use 15 from config.
285 r = runner.invoke(cli, ["reflog", "expire", "--json"], env=_env(root))
286 assert r.exit_code == 0, r.output
287 data = json.loads(r.output)
288 assert data["expired"] == 1
289 assert data["kept"] == 1
290
291 def test_explicit_flag_overrides_config(self, tmp_path: pathlib.Path) -> None:
292 root = _make_repo(tmp_path)
293 _write_log_entries(_head_log_path(root), [
294 (_old_ts(50), "fifty-days-old"),
295 ])
296
297 # Config says 90 days — 50 days old should be kept.
298 set_r = runner.invoke(cli, ["config", "set", "reflog.expire-days", "90"], env=_env(root))
299 assert set_r.exit_code == 0, set_r.output
300
301 # But explicit --expire-days 30 means 50 days is old.
302 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "30", "--json"], env=_env(root))
303 data = json.loads(r.output)
304 assert data["expired"] == 1
305
306 def test_default_ttl_is_90_when_config_not_set(self, tmp_path: pathlib.Path) -> None:
307 root = _make_repo(tmp_path)
308 _write_log_entries(_head_log_path(root), [
309 (_old_ts(100), "very-old"), # 100 > 90 — should expire
310 (_old_ts(80), "recent"), # 80 < 90 — kept
311 ])
312
313 r = runner.invoke(cli, ["reflog", "expire", "--json"], env=_env(root))
314 data = json.loads(r.output)
315 assert data["expired"] == 1
316 assert data["kept"] == 1
317
318 def test_muse_config_get_returns_set_value(self, tmp_path: pathlib.Path) -> None:
319 """muse config get reflog.expire-days returns what was set."""
320 root = _make_repo(tmp_path)
321
322 set_r = runner.invoke(cli, ["config", "set", "reflog.expire-days", "45"], env=_env(root))
323 assert set_r.exit_code == 0, set_r.output
324
325 get_r = runner.invoke(cli, ["config", "get", "reflog.expire-days"], env=_env(root))
326 assert get_r.exit_code == 0, get_r.output
327 assert "45" in get_r.output
328
329
330 # ---------------------------------------------------------------------------
331 # RL_10 — Atomic write (write-tmp + os.replace)
332 # ---------------------------------------------------------------------------
333
334 class TestExpireAtomic:
335 """RL_10: expire uses write-to-tmp + os.replace; original survives mock-kill."""
336
337 def test_original_log_intact_when_replace_fails(self, tmp_path: pathlib.Path) -> None:
338 """If os.replace raises mid-write, the original log is unchanged.
339
340 The atomic-write path (write-tmp + os.replace) is only taken when
341 there are kept entries. Mix old and recent so expire filters to
342 kept_lines > 0 and the replace call is actually exercised.
343 """
344 root = _make_repo(tmp_path)
345 log = _head_log_path(root)
346 # One old entry (will be expired) + one recent (will be kept).
347 # This forces the atomic write path rather than plain unlink.
348 original_content = (
349 f"{fake_id('old')} {fake_id('new')} user {_old_ts(100)} +0000\told\n"
350 f"{fake_id('old')} {fake_id('new')} user {_old_ts(5)} +0000\trecent\n"
351 )
352 log.write_text(original_content, encoding="utf-8")
353
354 with patch("muse.core.reflog.os.replace", side_effect=OSError("disk full")):
355 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
356
357 # Original must be intact — os.replace failed before the swap happened.
358 assert log.read_text(encoding="utf-8") == original_content, (
359 "original log must be unchanged when os.replace raises"
360 )
361
362 def test_no_partial_tmp_file_survives_on_success(self, tmp_path: pathlib.Path) -> None:
363 """After a successful expire the tmp file must be gone (os.replace cleaned it)."""
364 root = _make_repo(tmp_path)
365 _write_log_entries(_head_log_path(root), [(_old_ts(100), "old")])
366
367 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90"], env=_env(root))
368
369 # No .lock / .tmp files should remain anywhere in .muse/logs/
370 log_dir = logs_dir(root)
371 tmp_files = list(log_dir.rglob("*.lock")) + list(log_dir.rglob("*.tmp"))
372 assert not tmp_files, f"Stale tmp files found: {tmp_files}"
373
374
375 # ---------------------------------------------------------------------------
376 # RL_11 — Integration: 100-entry log, exactly 50 removed, 50 kept
377 # ---------------------------------------------------------------------------
378
379 class TestExpireIntegration:
380 """RL_11: large log; expire removes exactly the expected entries."""
381
382 def test_exactly_50_removed_from_100_entry_log(self, tmp_path: pathlib.Path) -> None:
383 root = _make_repo(tmp_path)
384 log = _head_log_path(root)
385
386 # 50 entries at 100 days old, 50 entries at 10 days old.
387 old_entries = [(_old_ts(100), f"old-{i}") for i in range(50)]
388 new_entries = [(_old_ts(10), f"recent-{i}") for i in range(50)]
389 _write_log_entries(log, old_entries + new_entries)
390
391 r = runner.invoke(cli, ["reflog", "expire", "--expire-days", "90", "--json"], env=_env(root))
392
393 assert r.exit_code == 0, r.output
394 data = json.loads(r.output)
395 assert data["expired"] == 50
396 assert data["kept"] == 50
397
398 def test_remaining_entries_are_the_correct_ones(self, tmp_path: pathlib.Path) -> None:
399 """After expire, re-reading the log returns exactly the 50 recent entries."""
400 from muse.core.reflog import read_reflog
401
402 root = _make_repo(tmp_path)
403 log = _head_log_path(root)
404 old_entries = [(_old_ts(100), f"old-{i}") for i in range(50)]
405 new_entries = [(_old_ts(10), f"recent-{i}") for i in range(50)]
406 _write_log_entries(log, old_entries + new_entries)
407
408 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90"], env=_env(root))
409
410 remaining = read_reflog(root, branch=None, limit=200)
411 assert len(remaining) == 50
412 assert all("recent" in e.operation for e in remaining)
413
414
415 # ---------------------------------------------------------------------------
416 # RL_12 — All entries expire → log file removed; muse reflog returns []
417 # ---------------------------------------------------------------------------
418
419 class TestExpireAllEntriesGone:
420 """RL_12: when all entries expire the log file is deleted (not left as zero-byte)."""
421
422 def test_log_file_removed_when_all_entries_expire(self, tmp_path: pathlib.Path) -> None:
423 root = _make_repo(tmp_path)
424 log = _head_log_path(root)
425 _write_log_entries(log, [(_old_ts(200), "very-old")])
426
427 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90"], env=_env(root))
428
429 assert not log.exists(), "log file must be removed when all entries expire"
430
431 def test_muse_reflog_returns_empty_entries_after_full_expire(
432 self, tmp_path: pathlib.Path
433 ) -> None:
434 """muse reflog --json must return entries:[] cleanly when log is gone."""
435 root = _make_repo(tmp_path)
436 _write_log_entries(_head_log_path(root), [(_old_ts(200), "old")])
437
438 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90"], env=_env(root))
439
440 r = runner.invoke(cli, ["reflog", "--json"], env=_env(root))
441 assert r.exit_code == 0, r.output
442 data = json.loads(r.output)
443 assert data["entries"] == []
444 assert data["total"] == 0
445
446 def test_branch_log_removed_when_all_entries_expire(self, tmp_path: pathlib.Path) -> None:
447 root = _make_repo(tmp_path)
448 branch_log = _branch_log_path(root, "main")
449 _write_log_entries(branch_log, [(_old_ts(200), "old")])
450
451 runner.invoke(cli, ["reflog", "expire", "--branch", "main", "--expire-days", "90"], env=_env(root))
452
453 assert not branch_log.exists(), "branch log must be removed when all entries expire"
454
455 def test_zero_byte_file_is_never_left_behind(self, tmp_path: pathlib.Path) -> None:
456 """Ensure expire doesn't write an empty file instead of deleting it."""
457 root = _make_repo(tmp_path)
458 log = _head_log_path(root)
459 _write_log_entries(log, [(_old_ts(200), "old")])
460
461 runner.invoke(cli, ["reflog", "expire", "--expire-days", "90"], env=_env(root))
462
463 # Either the file is gone or it has content — never zero bytes.
464 if log.exists():
465 assert log.stat().st_size > 0, "log file must not be left as zero bytes"
File History 3 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago