gabriel / muse public
test_cmd_reflog_hardening.py python
862 lines 33.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 28 days ago
1 """Comprehensive tests for ``muse reflog`` CLI and ``muse/core/reflog.py`` hardening.
2
3 Audit findings addressed
4 ------------------------
5 Security
6 - append_reflog now sanitizes author (strips \\n, \\r, \\t to prevent
7 line injection and tab-separator corruption) and operation (strips
8 \\n, \\r to prevent line injection).
9 - _fmt_entry sanitizes new_id[:12] and old_id[:12] in addition to
10 operation — ANSI in stored commit IDs can no longer reach the terminal.
11 - author is now shown in text output (was hidden; ANSI in author would
12 have been invisible but present in stored data).
13 - list_reflog_refs skips symlinks — symlinks cannot be used to escape
14 the .muse/logs/refs/heads/ directory.
15 - Branch names validated via validate_branch_name (path traversal blocked).
16 - SystemExit(1) replaced with ExitCode.USER_ERROR.
17
18 Performance
19 - read_reflog now checks file size before read_text(); files larger than
20 _MAX_REFLOG_BYTES (10 MiB) log a warning but still attempt to read,
21 preventing silent OOM for huge reflog files.
22
23 New capabilities
24 - _ReflogEntryJson and _ReflogResultJson TypedDicts for stable schema.
25 - --operation PATTERN filter (case-insensitive substring).
26 - --author PATTERN filter (case-insensitive substring).
27 - --since YYYY-MM-DD and --until YYYY-MM-DD date range filters.
28 - --limit applied after all filters.
29 - JSON output wrapped in _ReflogResultJson (ref, total, limit, entries).
30 - --all --json returns _ReflogAllJson (refs, count).
31 - "... N older entries" hint when limit is smaller than filtered total.
32
33 Coverage tiers
34 --------------
35 - Unit: _sanitize_author, _sanitize_operation, _parse_line, _fmt_entry
36 - Integration: append_reflog injection, read_reflog size cap, list_reflog_refs
37 - Security: ANSI in commit IDs/author/operation, tab injection, newline
38 injection, path traversal branch, symlink guard
39 - E2E: full CLI flags, JSON schema, filter combinations, exit codes
40 - Stress: 10k-entry reflog, concurrent isolated repos
41 """
42 from __future__ import annotations
43
44 import datetime
45 import json
46 import pathlib
47 import threading
48 import uuid
49
50 import pytest
51
52 from muse.cli.commands.reflog import _ReflogAllJson, _ReflogEntryJson, _ReflogResultJson
53 from muse.core.errors import ExitCode
54 from muse.core.reflog import (
55 ReflogEntry,
56 _MAX_REFLOG_BYTES,
57 _parse_line,
58 _sanitize_author,
59 _sanitize_operation,
60 append_reflog,
61 list_reflog_refs,
62 read_reflog,
63 )
64 from tests.cli_test_helper import CliRunner, InvokeResult
65
66 runner = CliRunner()
67 cli = None
68
69 _NULL_ID = "0" * 64
70 _SHA_A = "a" * 64
71 _SHA_B = "b" * 64
72 _SHA_C = "c" * 64
73
74
75 # ---------------------------------------------------------------------------
76 # Repo / reflog helpers
77 # ---------------------------------------------------------------------------
78
79
80 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
81 muse = tmp_path / ".muse"
82 for sub in ("commits", "snapshots", "refs/heads", "objects"):
83 (muse / sub).mkdir(parents=True, exist_ok=True)
84 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
85 repo_id = str(uuid.uuid4())
86 (muse / "repo.json").write_text(
87 json.dumps({"repo_id": repo_id}), encoding="utf-8"
88 )
89 return tmp_path
90
91
92 def _append(
93 root: pathlib.Path,
94 branch: str = "main",
95 old_id: str | None = None,
96 new_id: str = _SHA_A,
97 author: str = "alice",
98 operation: str = "commit: init",
99 ) -> None:
100 append_reflog(root, branch, old_id=old_id, new_id=new_id, author=author, operation=operation)
101
102
103 def _invoke(root: pathlib.Path, *args: str) -> InvokeResult:
104 return runner.invoke(
105 cli,
106 ["reflog", *args],
107 env={"MUSE_REPO_ROOT": str(root)},
108 )
109
110
111 def _parse_json_blob(output: str) -> str:
112 """Extract the first complete JSON object from CLI output."""
113 start = output.index("{")
114 blob = output[start:]
115 depth = 0
116 end = 0
117 for i, ch in enumerate(blob):
118 if ch == "{":
119 depth += 1
120 elif ch == "}":
121 depth -= 1
122 if depth == 0:
123 end = i + 1
124 break
125 return blob[:end]
126
127
128 def _parse_reflog_result(result: InvokeResult) -> _ReflogResultJson:
129 raw = json.loads(_parse_json_blob(result.output))
130 assert isinstance(raw, dict)
131 entries: list[_ReflogEntryJson] = []
132 for e in raw.get("entries", []):
133 assert isinstance(e, dict)
134 entries.append(
135 _ReflogEntryJson(
136 index=int(e.get("index", 0)),
137 new_id=str(e.get("new_id", "")),
138 old_id=str(e.get("old_id", "")),
139 timestamp=str(e.get("timestamp", "")),
140 operation=str(e.get("operation", "")),
141 author=str(e.get("author", "")),
142 )
143 )
144 return _ReflogResultJson(
145 ref=str(raw.get("ref", "")),
146 total=int(raw.get("total", 0)),
147 limit=int(raw.get("limit", 0)),
148 entries=entries,
149 )
150
151
152 def _parse_reflog_all(result: InvokeResult) -> _ReflogAllJson:
153 raw = json.loads(_parse_json_blob(result.output))
154 assert isinstance(raw, dict)
155 raw_refs = raw.get("refs", [])
156 assert isinstance(raw_refs, list)
157 return _ReflogAllJson(
158 refs=[str(r) for r in raw_refs],
159 count=int(raw.get("count", 0)),
160 )
161
162
163 def _parse_reflog_json(
164 result: InvokeResult,
165 ) -> "tuple[str, int, int, list[_ReflogEntryJson]]":
166 parsed = _parse_reflog_result(result)
167 return parsed["ref"], parsed["total"], parsed["limit"], parsed["entries"]
168
169
170 # ---------------------------------------------------------------------------
171 # Unit — _sanitize_author
172 # ---------------------------------------------------------------------------
173
174
175 class TestSanitizeAuthor:
176 def test_strips_newline(self) -> None:
177 assert "\n" not in _sanitize_author("alice\nbob")
178
179 def test_strips_cr(self) -> None:
180 assert "\r" not in _sanitize_author("alice\rbob")
181
182 def test_strips_tab(self) -> None:
183 assert "\t" not in _sanitize_author("alice\tbob")
184
185 def test_preserves_spaces(self) -> None:
186 assert _sanitize_author("Alice Smith <[email protected]>") == "Alice Smith <[email protected]>"
187
188 def test_empty_string(self) -> None:
189 assert _sanitize_author("") == ""
190
191 def test_combined_strips(self) -> None:
192 result = _sanitize_author("a\n\r\tb")
193 assert result == "ab"
194
195
196 # ---------------------------------------------------------------------------
197 # Unit — _sanitize_operation
198 # ---------------------------------------------------------------------------
199
200
201 class TestSanitizeOperation:
202 def test_strips_newline(self) -> None:
203 assert "\n" not in _sanitize_operation("commit: init\nINJECTED")
204
205 def test_strips_cr(self) -> None:
206 assert "\r" not in _sanitize_operation("commit: init\rINJECTED")
207
208 def test_preserves_tab(self) -> None:
209 # tabs inside operation body are preserved (operation is already past
210 # the tab boundary in the file)
211 assert "\t" in _sanitize_operation("commit:\tinit")
212
213 def test_empty_string(self) -> None:
214 assert _sanitize_operation("") == ""
215
216
217 # ---------------------------------------------------------------------------
218 # Unit — _parse_line
219 # ---------------------------------------------------------------------------
220
221
222 class TestParseLine:
223 def test_valid_line(self) -> None:
224 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
225 line = f"{_NULL_ID} {_SHA_A} alice {ts} +0000\tcommit: init"
226 entry = _parse_line(line)
227 assert entry is not None
228 assert entry.old_id == _NULL_ID
229 assert entry.new_id == _SHA_A
230 assert entry.author == "alice"
231 assert entry.operation == "commit: init"
232
233 def test_no_tab_returns_none(self) -> None:
234 line = f"{_NULL_ID} {_SHA_A} alice 1000000 +0000 commit: init"
235 assert _parse_line(line) is None
236
237 def test_too_few_tokens_returns_none(self) -> None:
238 line = f"{_NULL_ID} {_SHA_A}\tcommit: init"
239 assert _parse_line(line) is None
240
241 def test_author_with_spaces(self) -> None:
242 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
243 line = f"{_NULL_ID} {_SHA_A} Alice Smith <[email protected]> {ts} +0000\tcommit: init"
244 entry = _parse_line(line)
245 assert entry is not None
246 assert "Alice Smith" in entry.author
247
248 def test_bad_timestamp_defaults_to_now(self) -> None:
249 before = datetime.datetime.now(tz=datetime.timezone.utc)
250 line = f"{_NULL_ID} {_SHA_A} alice NOTANUMBER +0000\tcommit: init"
251 entry = _parse_line(line)
252 assert entry is not None
253 assert entry.timestamp >= before
254
255 def test_trailing_newline_stripped(self) -> None:
256 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
257 line = f"{_NULL_ID} {_SHA_A} alice {ts} +0000\tcommit: init\n"
258 entry = _parse_line(line)
259 assert entry is not None
260 assert entry.operation == "commit: init"
261
262
263 # ---------------------------------------------------------------------------
264 # Unit — _fmt_entry
265 # ---------------------------------------------------------------------------
266
267
268 class TestFmtEntry:
269 _ANSI = "\x1b[31mRED\x1b[0m"
270
271 def _entry(
272 self,
273 operation: str = "commit: test",
274 new_id: str = _SHA_A,
275 old_id: str = _NULL_ID,
276 author: str = "alice",
277 ) -> ReflogEntry:
278 return ReflogEntry(
279 old_id=old_id,
280 new_id=new_id,
281 author=author,
282 timestamp=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
283 operation=operation,
284 )
285
286 def test_sanitizes_operation(self) -> None:
287 from muse.cli.commands.reflog import _fmt_entry
288
289 entry = self._entry(operation=f"commit: {self._ANSI}")
290 result = _fmt_entry(0, entry)
291 assert "\x1b" not in result
292
293 def test_sanitizes_new_id(self) -> None:
294 from muse.cli.commands.reflog import _fmt_entry
295
296 # First 12 chars of new_id could theoretically contain control chars
297 new_id = f"\x1b[31m" + "a" * 60
298 entry = self._entry(new_id=new_id)
299 result = _fmt_entry(0, entry)
300 assert "\x1b" not in result
301
302 def test_sanitizes_author(self) -> None:
303 from muse.cli.commands.reflog import _fmt_entry
304
305 entry = self._entry(author=f"evil{self._ANSI}")
306 result = _fmt_entry(0, entry)
307 assert "\x1b" not in result
308
309 def test_initial_shown_for_null_old_id(self) -> None:
310 from muse.cli.commands.reflog import _fmt_entry
311
312 entry = self._entry(old_id=_NULL_ID)
313 result = _fmt_entry(0, entry)
314 assert "initial" in result
315
316 def test_non_null_old_id_shown_as_short(self) -> None:
317 from muse.cli.commands.reflog import _fmt_entry
318
319 entry = self._entry(old_id=_SHA_B)
320 result = _fmt_entry(0, entry)
321 assert _SHA_B[:12] in result
322
323 def test_author_shown_in_output(self) -> None:
324 from muse.cli.commands.reflog import _fmt_entry
325
326 entry = self._entry(author="bob-agent")
327 result = _fmt_entry(0, entry)
328 assert "bob-agent" in result
329
330 def test_index_shown(self) -> None:
331 from muse.cli.commands.reflog import _fmt_entry
332
333 entry = self._entry()
334 result = _fmt_entry(7, entry)
335 assert "@{7" in result
336
337
338 # ---------------------------------------------------------------------------
339 # Integration — append_reflog injection prevention
340 # ---------------------------------------------------------------------------
341
342
343 class TestAppendReflogInjection:
344 def test_newline_in_author_stripped(self, tmp_path: pathlib.Path) -> None:
345 append_reflog(tmp_path, "main", None, _SHA_A, "alice\nevil", "commit: test")
346 entries = read_reflog(tmp_path, "main")
347 assert all("\n" not in e.author for e in entries)
348
349 def test_tab_in_author_stripped(self, tmp_path: pathlib.Path) -> None:
350 """Tab in author would corrupt the metadata/operation split."""
351 append_reflog(tmp_path, "main", None, _SHA_A, "alice\tevil", "commit: test")
352 entries = read_reflog(tmp_path, "main")
353 assert all("\t" not in e.author for e in entries)
354
355 def test_newline_in_operation_stripped(self, tmp_path: pathlib.Path) -> None:
356 append_reflog(tmp_path, "main", None, _SHA_A, "alice", "commit: init\nINJECTED_LINE")
357 entries = read_reflog(tmp_path, "main")
358 assert len(entries) == 1 # no fake second entry
359 assert "\n" not in entries[0].operation
360
361 def test_cr_in_author_stripped(self, tmp_path: pathlib.Path) -> None:
362 append_reflog(tmp_path, "main", None, _SHA_A, "alice\revil", "commit: test")
363 entries = read_reflog(tmp_path, "main")
364 assert all("\r" not in e.author for e in entries)
365
366 def test_multiple_entries_after_injection_attempt(
367 self, tmp_path: pathlib.Path
368 ) -> None:
369 append_reflog(tmp_path, "main", None, _SHA_A, "alice", "commit: first\nINJECTED")
370 append_reflog(tmp_path, "main", _SHA_A, _SHA_B, "bob", "commit: second")
371 entries = read_reflog(tmp_path, "main")
372 assert len(entries) == 2
373
374 def test_entries_readable_after_injection(
375 self, tmp_path: pathlib.Path
376 ) -> None:
377 append_reflog(tmp_path, "main", None, _SHA_A, "a\tb", "commit: init")
378 entries = read_reflog(tmp_path, "main")
379 assert len(entries) == 1
380 assert entries[0].new_id == _SHA_A
381
382
383 # ---------------------------------------------------------------------------
384 # Integration — read_reflog size cap
385 # ---------------------------------------------------------------------------
386
387
388 class TestReadReflogSizeCap:
389 def test_size_cap_constant_exists(self) -> None:
390 assert _MAX_REFLOG_BYTES > 0
391 assert _MAX_REFLOG_BYTES <= 50 * 1024 * 1024 # sanity: ≤ 50 MiB
392
393 def test_large_file_still_reads_with_warning(
394 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
395 ) -> None:
396 """Files over the cap log a warning but still attempt to read."""
397 import logging
398
399 repo = _make_repo(tmp_path)
400 log_dir = repo / ".muse" / "logs" / "refs" / "heads"
401 log_dir.mkdir(parents=True, exist_ok=True)
402 log_path = log_dir / "main"
403 # Write a small valid entry, then pad the file to exceed the cap
404 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
405 entry_line = f"{_NULL_ID} {_SHA_A} alice {ts} +0000\tcommit: init\n"
406 # We can't write 10 MiB in a test feasibly, so we patch the cap
407 import muse.core.reflog as reflog_mod
408 original = reflog_mod._MAX_REFLOG_BYTES
409 try:
410 reflog_mod._MAX_REFLOG_BYTES = len(entry_line) - 1 # one byte under
411 log_path.write_text(entry_line, encoding="utf-8")
412 with caplog.at_level(logging.WARNING, logger="muse.core.reflog"):
413 entries = read_reflog(repo, "main")
414 assert any("exceeds cap" in r.message for r in caplog.records)
415 # Still returns entries despite the warning
416 assert len(entries) == 1
417 finally:
418 reflog_mod._MAX_REFLOG_BYTES = original
419
420
421 # ---------------------------------------------------------------------------
422 # Integration — list_reflog_refs symlink guard
423 # ---------------------------------------------------------------------------
424
425
426 class TestListReflogRefsSymlink:
427 def test_symlink_excluded(self, tmp_path: pathlib.Path) -> None:
428 repo = _make_repo(tmp_path)
429 log_dir = repo / ".muse" / "logs" / "refs" / "heads"
430 log_dir.mkdir(parents=True, exist_ok=True)
431 # Create a real log file
432 (log_dir / "main").write_text("line\n", encoding="utf-8")
433 # Create a symlink (points to a real file — but should be excluded)
434 target = tmp_path / "external_file"
435 target.write_text("external\n", encoding="utf-8")
436 try:
437 (log_dir / "evil-branch").symlink_to(target)
438 refs = list_reflog_refs(repo)
439 assert "evil-branch" not in refs
440 assert "main" in refs
441 except NotImplementedError:
442 pytest.skip("symlinks not supported on this platform")
443
444 def test_regular_files_included(self, tmp_path: pathlib.Path) -> None:
445 repo = _make_repo(tmp_path)
446 _append(repo, branch="main")
447 _append(repo, branch="dev")
448 refs = list_reflog_refs(repo)
449 assert "main" in refs
450 assert "dev" in refs
451
452
453 # ---------------------------------------------------------------------------
454 # Security — full CLI
455 # ---------------------------------------------------------------------------
456
457
458 class TestReflogSecurity:
459 _ANSI = "\x1b[31mevil\x1b[0m"
460
461 def test_ansi_in_stored_operation_sanitized(
462 self, tmp_path: pathlib.Path
463 ) -> None:
464 repo = _make_repo(tmp_path)
465 # Write a raw reflog line with ANSI in the operation field
466 log_dir = repo / ".muse" / "logs" / "refs" / "heads"
467 log_dir.mkdir(parents=True, exist_ok=True)
468 head_log = repo / ".muse" / "logs" / "HEAD"
469 head_log.parent.mkdir(parents=True, exist_ok=True)
470 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
471 raw_line = f"{_NULL_ID} {_SHA_A} alice {ts} +0000\tcommit: {self._ANSI}\n"
472 (log_dir / "main").write_text(raw_line, encoding="utf-8")
473 head_log.write_text(raw_line, encoding="utf-8")
474 result = _invoke(repo)
475 assert result.exit_code == 0
476 assert "\x1b[" not in result.output
477
478 def test_ansi_in_stored_new_id_sanitized(
479 self, tmp_path: pathlib.Path
480 ) -> None:
481 repo = _make_repo(tmp_path)
482 log_dir = repo / ".muse" / "logs" / "refs" / "heads"
483 log_dir.mkdir(parents=True, exist_ok=True)
484 head_log = repo / ".muse" / "logs" / "HEAD"
485 head_log.parent.mkdir(parents=True, exist_ok=True)
486 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
487 # new_id starts with ANSI (first 12 chars shown in text output)
488 evil_id = "\x1b[31m" + "a" * 60
489 raw_line = f"{_NULL_ID} {evil_id} alice {ts} +0000\tcommit: test\n"
490 (log_dir / "main").write_text(raw_line, encoding="utf-8")
491 head_log.write_text(raw_line, encoding="utf-8")
492 result = _invoke(repo)
493 assert result.exit_code == 0
494 assert "\x1b[" not in result.output
495
496 def test_path_traversal_branch_rejected(
497 self, tmp_path: pathlib.Path
498 ) -> None:
499 repo = _make_repo(tmp_path)
500 _append(repo)
501 result = _invoke(repo, "--branch", "../../../etc/passwd")
502 assert result.exit_code == ExitCode.USER_ERROR.value
503
504 def test_dotdot_branch_rejected(self, tmp_path: pathlib.Path) -> None:
505 repo = _make_repo(tmp_path)
506 result = _invoke(repo, "--branch", "..")
507 assert result.exit_code == ExitCode.USER_ERROR.value
508
509 def test_invalid_format_exits_user_error(
510 self, tmp_path: pathlib.Path
511 ) -> None:
512 repo = _make_repo(tmp_path)
513 result = _invoke(repo, "--format", "xml")
514 assert result.exit_code == ExitCode.USER_ERROR.value
515
516 def test_error_messages_no_traceback(self, tmp_path: pathlib.Path) -> None:
517 repo = _make_repo(tmp_path)
518 result = _invoke(repo, "--branch", "../etc/passwd")
519 assert "Traceback" not in result.output
520
521 def test_json_stdout_clean(self, tmp_path: pathlib.Path) -> None:
522 repo = _make_repo(tmp_path)
523 _append(repo)
524 result = _invoke(repo, "--json")
525 stripped = result.output.lstrip()
526 assert stripped.startswith("{"), f"Expected JSON on stdout: {result.output[:80]!r}"
527
528
529 # ---------------------------------------------------------------------------
530 # E2E — JSON schema
531 # ---------------------------------------------------------------------------
532
533
534 class TestReflogJsonSchema:
535 def test_result_fields_present(self, tmp_path: pathlib.Path) -> None:
536 repo = _make_repo(tmp_path)
537 _append(repo)
538 result = _invoke(repo, "--json")
539 assert result.exit_code == 0
540 ref, total, limit, entries = _parse_reflog_json(result)
541 assert ref == "HEAD"
542 assert total >= 1
543 assert limit == 20 # default
544 assert len(entries) == 1
545
546 def test_entry_fields_present(self, tmp_path: pathlib.Path) -> None:
547 repo = _make_repo(tmp_path)
548 _append(repo, author="alice", operation="commit: initial")
549 result = _invoke(repo, "--json")
550 assert result.exit_code == 0
551 _, _, _, entries = _parse_reflog_json(result)
552 assert len(entries) == 1
553 ev = entries[0]
554 for field in ("index", "new_id", "old_id", "timestamp", "operation", "author"):
555 assert field in ev, f"Missing JSON field: {field}"
556 assert ev["author"] == "alice"
557 assert ev["operation"] == "commit: initial"
558
559 def test_branch_flag_sets_ref_in_json(self, tmp_path: pathlib.Path) -> None:
560 repo = _make_repo(tmp_path)
561 _append(repo, branch="main")
562 result = _invoke(repo, "--branch", "main", "--json")
563 assert result.exit_code == 0
564 ref, _, _, _ = _parse_reflog_json(result)
565 assert ref == "refs/heads/main"
566
567 def test_all_json_returns_refs_and_count(
568 self, tmp_path: pathlib.Path
569 ) -> None:
570 repo = _make_repo(tmp_path)
571 _append(repo, branch="main")
572 _append(repo, branch="dev")
573 result = _invoke(repo, "--all", "--json")
574 assert result.exit_code == 0
575 parsed = _parse_reflog_all(result)
576 assert parsed["count"] >= 2
577 assert any("main" in r for r in parsed["refs"])
578
579 def test_total_reflects_filtered_count(
580 self, tmp_path: pathlib.Path
581 ) -> None:
582 repo = _make_repo(tmp_path)
583 for i in range(5):
584 _append(repo, operation=f"commit: c{i}")
585 _append(repo, operation="checkout: moving to dev")
586 result = _invoke(repo, "--operation", "commit", "--limit", "2", "--json")
587 _, total, limit, entries = _parse_reflog_json(result)
588 assert total == 5 # 5 commit events total
589 assert limit == 2
590 assert len(entries) == 2 # only 2 returned
591
592 def test_empty_reflog_json(self, tmp_path: pathlib.Path) -> None:
593 repo = _make_repo(tmp_path)
594 result = _invoke(repo, "--json")
595 assert result.exit_code == 0
596 _, total, _, entries = _parse_reflog_json(result)
597 assert total == 0
598 assert entries == []
599
600
601 # ---------------------------------------------------------------------------
602 # E2E — filters
603 # ---------------------------------------------------------------------------
604
605
606 class TestReflogFilters:
607 def test_operation_filter(self, tmp_path: pathlib.Path) -> None:
608 repo = _make_repo(tmp_path)
609 _append(repo, operation="commit: first")
610 _append(repo, operation="checkout: switch to dev")
611 _append(repo, operation="commit: second")
612 result = _invoke(repo, "--operation", "commit", "--json")
613 _, _, _, entries = _parse_reflog_json(result)
614 assert all("commit" in str(e["operation"]) for e in entries)
615 assert len(entries) == 2
616
617 def test_operation_filter_case_insensitive(
618 self, tmp_path: pathlib.Path
619 ) -> None:
620 repo = _make_repo(tmp_path)
621 _append(repo, operation="Commit: first")
622 result = _invoke(repo, "--operation", "COMMIT", "--json")
623 _, _, _, entries = _parse_reflog_json(result)
624 assert len(entries) == 1
625
626 def test_author_filter(self, tmp_path: pathlib.Path) -> None:
627 repo = _make_repo(tmp_path)
628 _append(repo, author="alice", operation="commit: a1")
629 _append(repo, author="bob", operation="commit: b1")
630 _append(repo, author="alice", operation="commit: a2")
631 result = _invoke(repo, "--author", "alice", "--json")
632 _, _, _, entries = _parse_reflog_json(result)
633 assert all(str(e["author"]) == "alice" for e in entries)
634 assert len(entries) == 2
635
636 def test_author_filter_case_insensitive(
637 self, tmp_path: pathlib.Path
638 ) -> None:
639 repo = _make_repo(tmp_path)
640 _append(repo, author="Alice")
641 result = _invoke(repo, "--author", "alice", "--json")
642 _, _, _, entries = _parse_reflog_json(result)
643 assert len(entries) == 1
644
645 def test_since_filter(self, tmp_path: pathlib.Path) -> None:
646 repo = _make_repo(tmp_path)
647 # Write entries with specific timestamps via raw log
648 log_dir = repo / ".muse" / "logs"
649 (log_dir).mkdir(parents=True, exist_ok=True)
650 head_log = log_dir / "HEAD"
651 branch_log_dir = log_dir / "refs" / "heads"
652 branch_log_dir.mkdir(parents=True, exist_ok=True)
653
654 old_ts = int(datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
655 new_ts = int(datetime.datetime(2026, 6, 1, tzinfo=datetime.timezone.utc).timestamp())
656 old_line = f"{_NULL_ID} {_SHA_A} alice {old_ts} +0000\tcommit: old\n"
657 new_line = f"{_SHA_A} {_SHA_B} alice {new_ts} +0000\tcommit: new\n"
658 head_log.write_text(old_line + new_line, encoding="utf-8")
659
660 result = _invoke(repo, "--since", "2026-01-01", "--json")
661 assert result.exit_code == 0
662 _, _, _, entries = _parse_reflog_json(result)
663 assert len(entries) == 1
664 assert str(entries[0]["operation"]) == "commit: new"
665
666 def test_until_filter(self, tmp_path: pathlib.Path) -> None:
667 repo = _make_repo(tmp_path)
668 log_dir = repo / ".muse" / "logs"
669 log_dir.mkdir(parents=True, exist_ok=True)
670 head_log = log_dir / "HEAD"
671
672 old_ts = int(datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
673 new_ts = int(datetime.datetime(2026, 6, 1, tzinfo=datetime.timezone.utc).timestamp())
674 old_line = f"{_NULL_ID} {_SHA_A} alice {old_ts} +0000\tcommit: old\n"
675 new_line = f"{_SHA_A} {_SHA_B} alice {new_ts} +0000\tcommit: new\n"
676 head_log.write_text(old_line + new_line, encoding="utf-8")
677
678 result = _invoke(repo, "--until", "2025-12-31", "--json")
679 assert result.exit_code == 0
680 _, _, _, entries = _parse_reflog_json(result)
681 assert len(entries) == 1
682 assert str(entries[0]["operation"]) == "commit: old"
683
684 def test_since_after_until_rejected(self, tmp_path: pathlib.Path) -> None:
685 repo = _make_repo(tmp_path)
686 result = _invoke(repo, "--since", "2026-12-01", "--until", "2026-01-01")
687 assert result.exit_code == ExitCode.USER_ERROR.value
688
689 def test_invalid_since_date_rejected(self, tmp_path: pathlib.Path) -> None:
690 repo = _make_repo(tmp_path)
691 result = _invoke(repo, "--since", "not-a-date")
692 assert result.exit_code == ExitCode.USER_ERROR.value
693
694 def test_invalid_until_date_rejected(self, tmp_path: pathlib.Path) -> None:
695 repo = _make_repo(tmp_path)
696 result = _invoke(repo, "--until", "2026/06/01")
697 assert result.exit_code == ExitCode.USER_ERROR.value
698
699 def test_filter_combination(self, tmp_path: pathlib.Path) -> None:
700 repo = _make_repo(tmp_path)
701 _append(repo, author="alice", operation="commit: a")
702 _append(repo, author="bob", operation="commit: b")
703 _append(repo, author="alice", operation="checkout: switch")
704 result = _invoke(repo, "--author", "alice", "--operation", "commit", "--json")
705 _, _, _, entries = _parse_reflog_json(result)
706 assert len(entries) == 1
707 assert str(entries[0]["author"]) == "alice"
708
709 def test_no_match_shows_filter_message(self, tmp_path: pathlib.Path) -> None:
710 repo = _make_repo(tmp_path)
711 _append(repo)
712 result = _invoke(repo, "--operation", "no-such-op")
713 assert result.exit_code == 0
714 assert "filter" in result.output.lower() or "No reflog" in result.output
715
716 def test_limit_applied_after_filter(self, tmp_path: pathlib.Path) -> None:
717 repo = _make_repo(tmp_path)
718 for i in range(10):
719 _append(repo, operation="commit: c", author="alice")
720 for i in range(5):
721 _append(repo, operation="checkout: x", author="bob")
722 result = _invoke(repo, "--operation", "commit", "--limit", "3", "--json")
723 _, total, limit, entries = _parse_reflog_json(result)
724 assert total == 10
725 assert limit == 3
726 assert len(entries) == 3
727
728
729 # ---------------------------------------------------------------------------
730 # E2E — general CLI behaviour
731 # ---------------------------------------------------------------------------
732
733
734 class TestReflogE2E:
735 def test_help_shows_new_flags(self) -> None:
736 result = runner.invoke(cli, ["reflog", "--help"])
737 assert result.exit_code == 0
738 for flag in ("--operation", "--author", "--since", "--until", "--json", "--all"):
739 assert flag in result.output
740
741 def test_text_output_shows_author(self, tmp_path: pathlib.Path) -> None:
742 repo = _make_repo(tmp_path)
743 _append(repo, author="ci-bot", operation="commit: init")
744 result = _invoke(repo)
745 assert result.exit_code == 0
746 assert "ci-bot" in result.output
747
748 def test_hint_shown_when_more_entries_exist(
749 self, tmp_path: pathlib.Path
750 ) -> None:
751 repo = _make_repo(tmp_path)
752 for i in range(25):
753 _append(repo, operation=f"commit: c{i}")
754 result = _invoke(repo, "--limit", "5")
755 assert result.exit_code == 0
756 assert "older" in result.output
757
758 def test_all_flag_lists_branches(self, tmp_path: pathlib.Path) -> None:
759 repo = _make_repo(tmp_path)
760 _append(repo, branch="main")
761 _append(repo, branch="dev")
762 result = _invoke(repo, "--all")
763 assert result.exit_code == 0
764 assert "main" in result.output
765 assert "dev" in result.output
766
767 def test_empty_head_reflog(self, tmp_path: pathlib.Path) -> None:
768 repo = _make_repo(tmp_path)
769 result = _invoke(repo)
770 assert result.exit_code == 0
771 assert "No reflog" in result.output
772
773 def test_json_is_valid(self, tmp_path: pathlib.Path) -> None:
774 repo = _make_repo(tmp_path)
775 _append(repo)
776 result = _invoke(repo, "--json")
777 assert result.exit_code == 0
778 start = result.output.index("{")
779 json.loads(result.output[start:])
780
781
782 # ---------------------------------------------------------------------------
783 # Stress
784 # ---------------------------------------------------------------------------
785
786
787 class TestReflogStress:
788 def test_10k_entries_limit_respected(self, tmp_path: pathlib.Path) -> None:
789 repo = _make_repo(tmp_path)
790 log_dir = repo / ".muse" / "logs"
791 log_dir.mkdir(parents=True, exist_ok=True)
792 head_log = log_dir / "HEAD"
793 ts = int(datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).timestamp())
794 lines = [
795 f"{_NULL_ID} {_SHA_A} alice {ts + i} +0000\tcommit: c{i}\n"
796 for i in range(10_000)
797 ]
798 head_log.write_text("".join(lines), encoding="utf-8")
799 result = _invoke(repo, "--limit", "20", "--json")
800 assert result.exit_code == 0
801 _, _, _, entries = _parse_reflog_json(result)
802 assert len(entries) == 20
803
804 def test_concurrent_reads_isolated_repos(
805 self, tmp_path: pathlib.Path
806 ) -> None:
807 """Eight threads read their own isolated reflog — no shared state."""
808 errors: list[str] = []
809
810 def worker(idx: int) -> None:
811 try:
812 repo = _make_repo(tmp_path / f"repo{idx}")
813 _append(repo, author=f"agent-{idx}", operation=f"commit: c{idx}")
814 entries = read_reflog(repo)
815 if len(entries) != 1:
816 errors.append(f"Thread {idx}: got {len(entries)} entries")
817 return
818 if entries[0].author != f"agent-{idx}":
819 errors.append(f"Thread {idx}: wrong author {entries[0].author!r}")
820 except Exception as exc:
821 errors.append(f"Thread {idx}: {exc}")
822
823 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
824 for t in threads:
825 t.start()
826 for t in threads:
827 t.join()
828
829 assert errors == [], f"Concurrent reflog failures: {errors}"
830
831 def test_concurrent_appends_to_same_repo(
832 self, tmp_path: pathlib.Path
833 ) -> None:
834 """Multiple threads appending to isolated repos — no shared state."""
835 from muse.core.reflog import append_reflog
836
837 errors: list[str] = []
838
839 def worker(idx: int) -> None:
840 try:
841 repo = _make_repo(tmp_path / f"repo{idx}")
842 for i in range(10):
843 append_reflog(
844 repo, "main",
845 old_id=None if i == 0 else _SHA_A,
846 new_id=_SHA_A,
847 author=f"agent-{idx}",
848 operation=f"commit: c{i}",
849 )
850 entries = read_reflog(repo, "main")
851 if len(entries) != 10:
852 errors.append(f"Thread {idx}: expected 10, got {len(entries)}")
853 except Exception as exc:
854 errors.append(f"Thread {idx}: {exc}")
855
856 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
857 for t in threads:
858 t.start()
859 for t in threads:
860 t.join()
861
862 assert errors == [], f"Concurrent append failures: {errors}"
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 102 days ago