gabriel / muse public
test_cmd_gc_reflog.py python
316 lines 11.0 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 """Phase 4 — RL_18–RL_20: GC integration with reflog expiry.
2
3 TDD: tests written before implementation.
4
5 Coverage tiers
6 --------------
7 RL_18 muse gc --json prunes old reflog entries; output has reflog_expired field
8 RL_19 muse gc --dry-run --json reports reflog_expired count; writes nothing
9 RL_20 Integration: entries older than 90 days gone after muse gc; verified on disk
10 """
11 from __future__ import annotations
12
13 import json
14 import pathlib
15 import time
16
17 import pytest
18
19 from muse.core.paths import muse_dir, logs_dir
20 from muse.core.types import fake_id
21 from tests.cli_test_helper import CliRunner
22
23 runner = CliRunner()
24 cli = None
25
26 _DAY = 86_400
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32 def _env(root: pathlib.Path) -> dict[str, str]:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
37 """Minimal .muse/ repo with no commits and an empty config."""
38 from muse._version import __version__
39
40 dot = muse_dir(tmp_path)
41 for sub in (
42 "refs/heads", "objects/sha256", "commits", "snapshots",
43 "logs/refs/heads", "logs",
44 ):
45 (dot / sub).mkdir(parents=True, exist_ok=True)
46 (dot / "repo.json").write_text(
47 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
48 )
49 (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
50 (dot / "config.toml").write_text("")
51 return tmp_path
52
53
54 def _head_log(root: pathlib.Path) -> pathlib.Path:
55 return logs_dir(root) / "HEAD"
56
57
58 def _branch_log(root: pathlib.Path, branch: str) -> pathlib.Path:
59 return logs_dir(root) / "refs" / "heads" / branch
60
61
62 def _write_entries(log_path: pathlib.Path, entries: list[tuple[int, str]]) -> None:
63 """Write raw reflog entries at specified unix timestamps."""
64 log_path.parent.mkdir(parents=True, exist_ok=True)
65 old = fake_id("old")
66 new = fake_id("new")
67 lines = [f"{old} {new} user {ts} +0000\t{op}\n" for ts, op in entries]
68 log_path.write_text("".join(lines), encoding="utf-8")
69
70
71 def _now() -> int:
72 return int(time.time())
73
74
75 def _old_ts(days: int) -> int:
76 return _now() - days * _DAY
77
78
79 def _recent_ts(days: int = 1) -> int:
80 return _now() - days * _DAY
81
82
83 # ---------------------------------------------------------------------------
84 # RL_18 — muse gc --json has reflog_expired field and prunes old entries
85 # ---------------------------------------------------------------------------
86
87 class TestGcReflogExpired:
88 """RL_18: gc --json gains reflog_expired and actually prunes old entries."""
89
90 def test_json_output_has_reflog_expired_field(self, tmp_path: pathlib.Path) -> None:
91 root = _make_repo(tmp_path)
92 # Write one recent entry so the log exists but nothing expires.
93 _write_entries(_head_log(root), [(_recent_ts(5), "recent")])
94
95 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
96
97 assert r.exit_code == 0, r.output
98 data = json.loads(r.output)
99 assert "reflog_expired" in data, (
100 f"'reflog_expired' missing from gc JSON output; keys: {list(data)}"
101 )
102
103 def test_old_entries_counted_in_reflog_expired(self, tmp_path: pathlib.Path) -> None:
104 root = _make_repo(tmp_path)
105 _write_entries(_head_log(root), [
106 (_old_ts(100), "old-a"),
107 (_old_ts(95), "old-b"),
108 (_recent_ts(5), "recent"),
109 ])
110
111 r = runner.invoke(
112 cli,
113 ["gc", "--json", "--grace-period", "0"],
114 env=_env(root),
115 )
116
117 assert r.exit_code == 0, r.output
118 data = json.loads(r.output)
119 assert data["reflog_expired"] == 2
120
121 def test_recent_entries_not_expired(self, tmp_path: pathlib.Path) -> None:
122 root = _make_repo(tmp_path)
123 _write_entries(_head_log(root), [
124 (_recent_ts(5), "a"),
125 (_recent_ts(10), "b"),
126 ])
127
128 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
129
130 data = json.loads(r.output)
131 assert data["reflog_expired"] == 0
132
133 def test_gc_expires_branch_logs_too(self, tmp_path: pathlib.Path) -> None:
134 root = _make_repo(tmp_path)
135 # HEAD log: 1 old
136 _write_entries(_head_log(root), [(_old_ts(100), "head-old")])
137 # branch log: 1 old
138 _write_entries(_branch_log(root, "main"), [(_old_ts(100), "main-old")])
139
140 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
141
142 data = json.loads(r.output)
143 # 2 total expired (1 from HEAD + 1 from main)
144 assert data["reflog_expired"] == 2
145
146 def test_reflog_expired_zero_when_no_log_files(self, tmp_path: pathlib.Path) -> None:
147 """GC on a repo with no reflog files must not crash and reports 0."""
148 root = _make_repo(tmp_path)
149
150 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
151
152 assert r.exit_code == 0, r.output
153 data = json.loads(r.output)
154 assert data["reflog_expired"] == 0
155
156 def test_reflog_expire_days_config_respected(self, tmp_path: pathlib.Path) -> None:
157 """When reflog.expire-days is 15, entries older than 15 days expire."""
158 root = _make_repo(tmp_path)
159 _write_entries(_head_log(root), [
160 (_old_ts(20), "twenty-days-old"), # expires at TTL=15
161 (_recent_ts(5), "five-days-old"), # kept at TTL=15
162 ])
163
164 # Set TTL to 15 days.
165 runner.invoke(cli, ["config", "set", "reflog.expire-days", "15"], env=_env(root))
166
167 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
168
169 data = json.loads(r.output)
170 assert data["reflog_expired"] == 1
171
172
173 # ---------------------------------------------------------------------------
174 # RL_19 — muse gc --dry-run --json reports count but writes nothing
175 # ---------------------------------------------------------------------------
176
177 class TestGcReflogDryRun:
178 """RL_19: dry-run reports reflog_expired count but leaves log files untouched."""
179
180 def test_dry_run_reports_reflog_expired(self, tmp_path: pathlib.Path) -> None:
181 root = _make_repo(tmp_path)
182 _write_entries(_head_log(root), [
183 (_old_ts(100), "old"),
184 (_recent_ts(5), "recent"),
185 ])
186
187 r = runner.invoke(
188 cli,
189 ["gc", "--dry-run", "--json", "--grace-period", "0"],
190 env=_env(root),
191 )
192
193 assert r.exit_code == 0, r.output
194 data = json.loads(r.output)
195 assert data["reflog_expired"] == 1
196 assert data["dry_run"] is True
197
198 def test_dry_run_does_not_delete_log_entries(self, tmp_path: pathlib.Path) -> None:
199 root = _make_repo(tmp_path)
200 log = _head_log(root)
201 _write_entries(log, [(_old_ts(100), "old")])
202 original_content = log.read_text()
203
204 runner.invoke(
205 cli,
206 ["gc", "--dry-run", "--json", "--grace-period", "0"],
207 env=_env(root),
208 )
209
210 assert log.exists(), "dry-run must not delete the log file"
211 assert log.read_text() == original_content, "dry-run must not modify log content"
212
213 def test_dry_run_does_not_remove_log_file(self, tmp_path: pathlib.Path) -> None:
214 root = _make_repo(tmp_path)
215 log = _head_log(root)
216 _write_entries(log, [(_old_ts(200), "very-old")])
217
218 runner.invoke(
219 cli,
220 ["gc", "--dry-run", "--json", "--grace-period", "0"],
221 env=_env(root),
222 )
223
224 assert log.exists(), "dry-run must not remove the log file"
225
226 def test_dry_run_branch_log_untouched(self, tmp_path: pathlib.Path) -> None:
227 root = _make_repo(tmp_path)
228 blog = _branch_log(root, "dev")
229 _write_entries(blog, [(_old_ts(100), "dev-old")])
230 original = blog.read_text()
231
232 runner.invoke(
233 cli,
234 ["gc", "--dry-run", "--grace-period", "0"],
235 env=_env(root),
236 )
237
238 assert blog.read_text() == original
239
240
241 # ---------------------------------------------------------------------------
242 # RL_20 — Integration: entries older than 90 days gone after muse gc
243 # ---------------------------------------------------------------------------
244
245 class TestGcReflogIntegration:
246 """RL_20: old entries are gone from disk after gc; recent ones survive."""
247
248 def test_old_head_entries_removed_after_gc(self, tmp_path: pathlib.Path) -> None:
249 from muse.core.reflog import read_reflog
250
251 root = _make_repo(tmp_path)
252 _write_entries(_head_log(root), [
253 (_old_ts(100), "old-entry"),
254 (_recent_ts(10), "recent-entry"),
255 ])
256
257 r = runner.invoke(cli, ["gc", "--grace-period", "0"], env=_env(root))
258
259 assert r.exit_code == 0, r.output
260 remaining = read_reflog(root, branch=None, limit=100)
261 ops = [e.operation for e in remaining]
262 assert "old-entry" not in ops
263 assert "recent-entry" in ops
264
265 def test_all_old_entries_removed_file_deleted(self, tmp_path: pathlib.Path) -> None:
266 root = _make_repo(tmp_path)
267 log = _head_log(root)
268 _write_entries(log, [(_old_ts(200), "very-old")])
269
270 runner.invoke(cli, ["gc", "--grace-period", "0"], env=_env(root))
271
272 assert not log.exists(), "log file must be deleted when all entries expire"
273
274 def test_branch_old_entries_removed_after_gc(self, tmp_path: pathlib.Path) -> None:
275 from muse.core.reflog import read_reflog
276
277 root = _make_repo(tmp_path)
278 _write_entries(_branch_log(root, "main"), [
279 (_old_ts(120), "main-old"),
280 (_recent_ts(5), "main-recent"),
281 ])
282
283 runner.invoke(cli, ["gc", "--grace-period", "0"], env=_env(root))
284
285 remaining = read_reflog(root, branch="main", limit=100)
286 ops = [e.operation for e in remaining]
287 assert "main-old" not in ops
288 assert "main-recent" in ops
289
290 def test_exact_counts_from_100_entry_log(self, tmp_path: pathlib.Path) -> None:
291 from muse.core.reflog import read_reflog
292
293 root = _make_repo(tmp_path)
294 old_entries = [(_old_ts(100), f"old-{i}") for i in range(60)]
295 new_entries = [(_recent_ts(10), f"recent-{i}") for i in range(40)]
296 _write_entries(_head_log(root), old_entries + new_entries)
297
298 r = runner.invoke(cli, ["gc", "--json", "--grace-period", "0"], env=_env(root))
299
300 data = json.loads(r.output)
301 assert data["reflog_expired"] == 60
302
303 remaining = read_reflog(root, branch=None, limit=200)
304 assert len(remaining) == 40
305
306 def test_gc_output_without_json_flag_mentions_reflog(self, tmp_path: pathlib.Path) -> None:
307 """Human-readable gc output should mention reflog entries when any expired."""
308 root = _make_repo(tmp_path)
309 _write_entries(_head_log(root), [(_old_ts(100), "old")])
310
311 r = runner.invoke(cli, ["gc", "--grace-period", "0"], env=_env(root))
312
313 assert r.exit_code == 0, r.output
314 assert "reflog" in r.output.lower(), (
315 f"Expected 'reflog' in human output when entries expired; got: {r.output!r}"
316 )
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