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