gabriel / muse public
test_cmd_log.py python
1,063 lines 41.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for ``muse log``.
2
3 Coverage tiers:
4 - Unit: _parse_date, _apply_filters, _commit_to_json, _format_date,
5 _file_diff, _branch_tips, _collect_all_commits, _topo_sort
6 - Integration: all flags (--json, --oneline, --stat, --graph, --all,
7 --since, --until, --author, --section, --track, --emotion, -n)
8 - End-to-end: full workflows (init→commit(s)→log, branch→merge→log --all)
9 - Security: ANSI injection via commit messages/authors, invalid date formats,
10 bad --format value, multiline message sanitization
11 - Stress: 500-commit repos, rapid sequential calls, filter on large history
12 """
13 from __future__ import annotations
14
15 import json
16 import os
17 import pathlib
18 import subprocess
19 from datetime import datetime, timezone
20
21 import pytest
22
23 from muse.core.store import CommitRecord
24 from tests.cli_test_helper import CliRunner, InvokeResult
25
26 runner = CliRunner()
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _init(repo: pathlib.Path) -> InvokeResult:
34 from muse.cli.app import main as cli
35
36 repo.mkdir(parents=True, exist_ok=True)
37 saved = os.getcwd()
38 try:
39 os.chdir(repo)
40 return runner.invoke(cli, ["init"])
41 finally:
42 os.chdir(saved)
43
44
45 def _log(repo: pathlib.Path, *extra: str) -> InvokeResult:
46 from muse.cli.app import main as cli
47
48 saved = os.getcwd()
49 try:
50 os.chdir(repo)
51 return runner.invoke(cli, ["log", *extra])
52 finally:
53 os.chdir(saved)
54
55
56 def _commit(repo: pathlib.Path, msg: str = "commit", filename: str | None = None) -> None:
57 from muse.cli.app import main as cli
58
59 fname = filename or f"file_{abs(hash(msg))}.py"
60 (repo / fname).write_text(f"# {msg}\n")
61 saved = os.getcwd()
62 try:
63 os.chdir(repo)
64 runner.invoke(cli, ["commit", "-m", msg])
65 finally:
66 os.chdir(saved)
67
68
69 def _fresh_repo(tmp: pathlib.Path, n_commits: int = 1) -> pathlib.Path:
70 repo = tmp / "repo"
71 _init(repo)
72 for i in range(n_commits):
73 _commit(repo, f"commit {i}", filename=f"file_{i}.py")
74 return repo
75
76
77 # ---------------------------------------------------------------------------
78 # Unit — flag registration
79 # ---------------------------------------------------------------------------
80
81
82 class TestRegisterFlags:
83 def _parse(self, *args: str) -> "argparse.Namespace":
84 import argparse
85 from muse.cli.commands.log import register
86 p = argparse.ArgumentParser()
87 sub = p.add_subparsers()
88 register(sub)
89 return p.parse_args(["log", *args])
90
91 def test_default_json_out_is_false(self) -> None:
92 ns = self._parse()
93 assert ns.json_out is False
94
95 def test_json_flag_sets_json_out(self) -> None:
96 ns = self._parse("--json")
97 assert ns.json_out is True
98
99 def test_j_shorthand_sets_json_out(self) -> None:
100 ns = self._parse("-j")
101 assert ns.json_out is True
102
103
104 # ---------------------------------------------------------------------------
105 # Unit — _parse_date
106 # ---------------------------------------------------------------------------
107
108
109 class TestParseDate:
110 def test_today(self) -> None:
111 from muse.cli.commands.log import _parse_date
112
113 dt = _parse_date("today")
114 now = datetime.now(timezone.utc)
115 assert dt.date() == now.date()
116 assert dt.tzinfo is not None
117
118 def test_yesterday(self) -> None:
119 from muse.cli.commands.log import _parse_date
120 from datetime import timedelta
121
122 dt = _parse_date("yesterday")
123 now = datetime.now(timezone.utc)
124 assert dt.date() == (now - timedelta(days=1)).date()
125
126 def test_n_days_ago(self) -> None:
127 from muse.cli.commands.log import _parse_date
128 from datetime import timedelta
129
130 dt = _parse_date("7 days ago")
131 now = datetime.now(timezone.utc)
132 diff = now - dt
133 assert abs(diff.total_seconds() - 7 * 86400) < 5
134
135 def test_n_weeks_ago(self) -> None:
136 from muse.cli.commands.log import _parse_date
137 from datetime import timedelta
138
139 dt = _parse_date("2 weeks ago")
140 now = datetime.now(timezone.utc)
141 diff = now - dt
142 assert abs(diff.total_seconds() - 14 * 86400) < 5
143
144 def test_iso_date(self) -> None:
145 from muse.cli.commands.log import _parse_date
146
147 dt = _parse_date("2025-01-15")
148 assert dt.year == 2025
149 assert dt.month == 1
150 assert dt.day == 15
151 assert dt.tzinfo is not None
152
153 def test_iso_datetime(self) -> None:
154 from muse.cli.commands.log import _parse_date
155
156 dt = _parse_date("2025-01-15T12:30:00")
157 assert dt.hour == 12
158 assert dt.minute == 30
159
160 def test_space_datetime(self) -> None:
161 from muse.cli.commands.log import _parse_date
162
163 dt = _parse_date("2025-06-01 09:00:00")
164 assert dt.year == 2025
165 assert dt.hour == 9
166
167 def test_invalid_raises_value_error(self) -> None:
168 from muse.cli.commands.log import _parse_date
169
170 with pytest.raises(ValueError, match="Cannot parse date"):
171 _parse_date("not-a-date")
172
173 def test_empty_string_raises(self) -> None:
174 from muse.cli.commands.log import _parse_date
175
176 with pytest.raises(ValueError):
177 _parse_date("")
178
179 def test_case_insensitive(self) -> None:
180 from muse.cli.commands.log import _parse_date
181
182 dt1 = _parse_date("TODAY")
183 dt2 = _parse_date("today")
184 assert dt1.date() == dt2.date()
185
186 def test_plural_days(self) -> None:
187 from muse.cli.commands.log import _parse_date
188
189 dt1 = _parse_date("1 day ago")
190 dt2 = _parse_date("1 days ago")
191 assert abs((dt1 - dt2).total_seconds()) < 2
192
193
194 # ---------------------------------------------------------------------------
195 # Unit — _apply_filters
196 # ---------------------------------------------------------------------------
197
198
199 class TestApplyFilters:
200 def _make_commits(self, n: int, author: str = "alice") -> list[CommitRecord]:
201 return [
202 CommitRecord(
203 commit_id=f"{'a' * 63}{i:x}"[:64],
204 repo_id="r" * 36,
205 created_on_branch="main",
206 message=f"msg {i}",
207 author=author,
208 committed_at=datetime(2025, 6, i % 28 + 1, tzinfo=timezone.utc),
209 parent_commit_id=None,
210 snapshot_id="b" * 64,
211 )
212 for i in range(n)
213 ]
214
215 def test_no_filters_returns_all(self) -> None:
216 from muse.cli.commands.log import _apply_filters
217
218 commits = self._make_commits(5)
219 result, truncated = _apply_filters(
220 commits,
221 since_dt=None, until_dt=None, author=None,
222 section=None, track=None, emotion=None, limit=100,
223 )
224 assert len(result) == 5
225 assert not truncated
226
227 def test_limit_enforced(self) -> None:
228 from muse.cli.commands.log import _apply_filters
229
230 commits = self._make_commits(10)
231 result, truncated = _apply_filters(
232 commits,
233 since_dt=None, until_dt=None, author=None,
234 section=None, track=None, emotion=None, limit=3,
235 )
236 assert len(result) == 3
237 assert truncated
238
239 def test_author_filter_case_insensitive(self) -> None:
240 from muse.cli.commands.log import _apply_filters
241
242 alice = CommitRecord(
243 commit_id="a" * 64, repo_id="r" * 36, created_on_branch="main", message="m",
244 author="Alice",
245 committed_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
246 parent_commit_id=None, snapshot_id="b" * 64,
247 )
248 bob = CommitRecord(
249 commit_id="b" * 64, repo_id="r" * 36, created_on_branch="main", message="m",
250 author="Bob",
251 committed_at=datetime(2025, 1, 2, tzinfo=timezone.utc),
252 parent_commit_id=None, snapshot_id="c" * 64,
253 )
254 result, _ = _apply_filters(
255 [alice, bob],
256 since_dt=None, until_dt=None, author="alice",
257 section=None, track=None, emotion=None, limit=100,
258 )
259 assert len(result) == 1
260 assert result[0].author == "Alice"
261
262 def test_since_filter(self) -> None:
263 from muse.cli.commands.log import _apply_filters
264
265 old = CommitRecord(
266 commit_id="a" * 64, repo_id="r" * 36, created_on_branch="main", message="old",
267 author="x",
268 committed_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
269 parent_commit_id=None, snapshot_id="b" * 64,
270 )
271 new_commit = CommitRecord(
272 commit_id="b" * 64, repo_id="r" * 36, created_on_branch="main", message="new",
273 author="x",
274 committed_at=datetime(2025, 6, 1, tzinfo=timezone.utc),
275 parent_commit_id=None, snapshot_id="c" * 64,
276 )
277 since = datetime(2025, 1, 1, tzinfo=timezone.utc)
278 result, _ = _apply_filters(
279 [old, new_commit],
280 since_dt=since, until_dt=None, author=None,
281 section=None, track=None, emotion=None, limit=100,
282 )
283 assert len(result) == 1
284 assert result[0].message == "new"
285
286 def test_until_filter(self) -> None:
287 from muse.cli.commands.log import _apply_filters
288
289 early = CommitRecord(
290 commit_id="a" * 64, repo_id="r" * 36, created_on_branch="main", message="early",
291 author="x",
292 committed_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
293 parent_commit_id=None, snapshot_id="b" * 64,
294 )
295 late = CommitRecord(
296 commit_id="b" * 64, repo_id="r" * 36, created_on_branch="main", message="late",
297 author="x",
298 committed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
299 parent_commit_id=None, snapshot_id="c" * 64,
300 )
301 until = datetime(2025, 1, 1, tzinfo=timezone.utc)
302 result, _ = _apply_filters(
303 [early, late],
304 since_dt=None, until_dt=until, author=None,
305 section=None, track=None, emotion=None, limit=100,
306 )
307 assert len(result) == 1
308 assert result[0].message == "early"
309
310 def test_empty_input_returns_empty(self) -> None:
311 from muse.cli.commands.log import _apply_filters
312
313 result, truncated = _apply_filters(
314 [],
315 since_dt=None, until_dt=None, author=None,
316 section=None, track=None, emotion=None, limit=10,
317 )
318 assert result == []
319 assert not truncated
320
321
322 # ---------------------------------------------------------------------------
323 # Unit — _commit_to_json
324 # ---------------------------------------------------------------------------
325
326
327 class TestCommitToJson:
328 def _make_commit(self) -> CommitRecord:
329 return CommitRecord(
330 commit_id="a" * 64,
331 repo_id="r" * 36,
332 created_on_branch="main",
333 message="hello",
334 author="alice",
335 committed_at=datetime(2025, 6, 1, tzinfo=timezone.utc),
336 parent_commit_id=None,
337 snapshot_id="b" * 64,
338 )
339
340 def test_all_keys_present(self) -> None:
341 from muse.cli.commands.log import _commit_to_json
342
343 c = self._make_commit()
344 d = _commit_to_json(c)
345 expected = {
346 "commit_id", "created_on_branch", "message", "author",
347 "agent_id", "model_id",
348 "committed_at",
349 "parent_commit_id", "parent2_commit_id", "snapshot_id",
350 "sem_ver_bump", "breaking_changes", "metadata",
351 "files_added", "files_removed", "files_modified",
352 }
353 assert expected == set(d.keys())
354
355 def test_file_lists_empty_without_stat(self) -> None:
356 from muse.cli.commands.log import _commit_to_json
357
358 c = self._make_commit()
359 d = _commit_to_json(c)
360 assert d["files_added"] == []
361 assert d["files_removed"] == []
362 assert d["files_modified"] == []
363
364 def test_parent2_commit_id_is_none_for_linear(self) -> None:
365 from muse.cli.commands.log import _commit_to_json
366
367 c = self._make_commit()
368 d = _commit_to_json(c)
369 assert d["parent2_commit_id"] is None
370
371 def test_breaking_changes_is_always_list(self) -> None:
372 from muse.cli.commands.log import _commit_to_json
373
374 c = self._make_commit()
375 d = _commit_to_json(c)
376 assert isinstance(d["breaking_changes"], list)
377
378 def test_committed_at_is_iso_string(self) -> None:
379 from muse.cli.commands.log import _commit_to_json
380
381 c = self._make_commit()
382 d = _commit_to_json(c)
383 ts = d["committed_at"]
384 assert isinstance(ts, str)
385 assert "2025" in ts
386 assert "T" in ts or " " in ts
387
388
389 # ---------------------------------------------------------------------------
390 # Integration — JSON output schema
391 # ---------------------------------------------------------------------------
392
393
394 class TestJsonSchema:
395 _REQUIRED_COMMIT_KEYS = {
396 "commit_id", "created_on_branch", "message", "author", "committed_at",
397 "parent_commit_id", "parent2_commit_id", "snapshot_id",
398 "sem_ver_bump", "breaking_changes", "metadata",
399 "files_added", "files_removed", "files_modified",
400 }
401
402 def test_top_level_keys(self, tmp_path: pathlib.Path) -> None:
403 repo = _fresh_repo(tmp_path)
404 data = json.loads(_log(repo, "--json").output)
405 assert "commits" in data
406 assert "truncated" in data
407
408 def test_all_commit_keys_present(self, tmp_path: pathlib.Path) -> None:
409 repo = _fresh_repo(tmp_path, n_commits=2)
410 data = json.loads(_log(repo, "--json").output)
411 for c in data["commits"]:
412 missing = self._REQUIRED_COMMIT_KEYS - set(c.keys())
413 assert not missing, f"Missing keys: {missing}"
414
415 def test_parent2_commit_id_present(self, tmp_path: pathlib.Path) -> None:
416 repo = _fresh_repo(tmp_path)
417 data = json.loads(_log(repo, "--json").output)
418 assert "parent2_commit_id" in data["commits"][0]
419
420 def test_breaking_changes_is_list(self, tmp_path: pathlib.Path) -> None:
421 repo = _fresh_repo(tmp_path)
422 data = json.loads(_log(repo, "--json").output)
423 assert isinstance(data["commits"][0]["breaking_changes"], list)
424
425 def test_committed_at_is_iso(self, tmp_path: pathlib.Path) -> None:
426 repo = _fresh_repo(tmp_path)
427 data = json.loads(_log(repo, "--json").output)
428 ts = data["commits"][0]["committed_at"]
429 assert "T" in ts or "+" in ts
430
431 def test_truncated_false_by_default(self, tmp_path: pathlib.Path) -> None:
432 repo = _fresh_repo(tmp_path, n_commits=3)
433 data = json.loads(_log(repo, "--json").output)
434 assert data["truncated"] is False
435
436 def test_json_parseable_output(self, tmp_path: pathlib.Path) -> None:
437 repo = _fresh_repo(tmp_path, n_commits=5)
438 result = _log(repo, "--json")
439 data = json.loads(result.output)
440 assert isinstance(data["commits"], list)
441 assert len(data["commits"]) == 5
442
443 def test_empty_repo_json(self, tmp_path: pathlib.Path) -> None:
444 repo = tmp_path / "repo"
445 _init(repo)
446 result = _log(repo, "--json")
447 data = json.loads(result.output)
448 assert data["commits"] == []
449 assert data["truncated"] is False
450
451 def test_limit_n_json(self, tmp_path: pathlib.Path) -> None:
452 repo = _fresh_repo(tmp_path, n_commits=5)
453 data = json.loads(_log(repo, "--json", "--limit", "2").output)
454 assert len(data["commits"]) == 2
455
456 def test_commits_ordered_newest_first(self, tmp_path: pathlib.Path) -> None:
457 repo = _fresh_repo(tmp_path, n_commits=3)
458 data = json.loads(_log(repo, "--json").output)
459 timestamps = [c["committed_at"] for c in data["commits"]]
460 assert timestamps == sorted(timestamps, reverse=True)
461
462 def test_output_is_single_object(self, tmp_path: pathlib.Path) -> None:
463 """--json must produce one JSON object, not an array or newline-delimited."""
464 repo = _fresh_repo(tmp_path)
465 result = _log(repo, "--json")
466 # Must parse as a single dict
467 data = json.loads(result.output)
468 assert isinstance(data, dict)
469
470
471 # ---------------------------------------------------------------------------
472 # Integration — --oneline
473 # ---------------------------------------------------------------------------
474
475
476 class TestOneline:
477 def test_one_line_per_commit(self, tmp_path: pathlib.Path) -> None:
478 repo = _fresh_repo(tmp_path, n_commits=3)
479 result = _log(repo, "--oneline")
480 lines = [l for l in result.output.splitlines() if l.strip()]
481 assert len(lines) == 3
482
483 def test_short_hash_in_output(self, tmp_path: pathlib.Path) -> None:
484 repo = _fresh_repo(tmp_path)
485 data = json.loads(_log(repo, "--json").output)
486 commit_id = data["commits"][0]["commit_id"]
487 result = _log(repo, "--oneline")
488 assert commit_id[:8] in result.output
489
490 def test_message_on_same_line(self, tmp_path: pathlib.Path) -> None:
491 repo = _fresh_repo(tmp_path)
492 _commit(repo, "my special message", filename="z.py")
493 result = _log(repo, "--oneline", "--limit", "1")
494 assert "my special message" in result.output
495 assert len(result.output.splitlines()) >= 1
496
497 def test_no_ansi_when_not_tty(self, tmp_path: pathlib.Path) -> None:
498 repo = _fresh_repo(tmp_path)
499 result = _log(repo, "--oneline")
500 # CLI runner is not a TTY — no escape sequences
501 assert "\x1b[" not in result.output
502
503
504 # ---------------------------------------------------------------------------
505 # Integration — --stat
506 # ---------------------------------------------------------------------------
507
508
509 class TestStat:
510 def test_stat_shows_added_files(self, tmp_path: pathlib.Path) -> None:
511 repo = _fresh_repo(tmp_path, n_commits=1)
512 result = _log(repo, "--stat")
513 assert "added" in result.output
514 assert "+" in result.output
515
516 def test_stat_shows_summary_line(self, tmp_path: pathlib.Path) -> None:
517 repo = _fresh_repo(tmp_path, n_commits=1)
518 result = _log(repo, "--stat")
519 assert "added" in result.output
520 assert "removed" in result.output
521
522 def test_stat_shows_modified_marker(self, tmp_path: pathlib.Path) -> None:
523 repo = _fresh_repo(tmp_path, n_commits=1)
524 # Modify the same file in a second commit so "modified" fires.
525 (repo / "file_0.py").write_text("# changed\n")
526 _commit(repo, "modify existing")
527 result = _log(repo, "--stat", "--limit", "1")
528 assert "~" in result.output
529 assert "modified" in result.output
530
531 def test_stat_exit_zero(self, tmp_path: pathlib.Path) -> None:
532 repo = _fresh_repo(tmp_path)
533 result = _log(repo, "--stat")
534 assert result.exit_code == 0
535
536 def test_stat_json_file_lists_populated(self, tmp_path: pathlib.Path) -> None:
537 repo = _fresh_repo(tmp_path, n_commits=1)
538 data = json.loads(_log(repo, "--stat", "--json").output)
539 commit = data["commits"][0]
540 # The initial commit adds at least one file.
541 assert isinstance(commit["files_added"], list)
542 assert isinstance(commit["files_removed"], list)
543 assert isinstance(commit["files_modified"], list)
544 assert len(commit["files_added"]) > 0
545
546 def test_stat_json_modified_populated(self, tmp_path: pathlib.Path) -> None:
547 repo = _fresh_repo(tmp_path, n_commits=1)
548 # Overwrite the existing file so the second commit shows a modification.
549 (repo / "file_0.py").write_text("# changed\n")
550 _commit(repo, "modify existing")
551 data = json.loads(_log(repo, "--stat", "--json", "--limit", "1").output)
552 commit = data["commits"][0]
553 assert "file_0.py" in commit["files_modified"]
554
555 def test_json_file_lists_populated_without_stat_flag(self, tmp_path: pathlib.Path) -> None:
556 """--json always populates file lists — agents must not need --stat."""
557 repo = _fresh_repo(tmp_path, n_commits=1)
558 data = json.loads(_log(repo, "--json").output)
559 commit = data["commits"][0]
560 # The initial commit adds at least one file; file lists must be
561 # populated even without the --stat flag.
562 assert isinstance(commit["files_added"], list)
563 assert isinstance(commit["files_removed"], list)
564 assert isinstance(commit["files_modified"], list)
565 assert len(commit["files_added"]) > 0
566
567
568 # ---------------------------------------------------------------------------
569 # Integration — filters
570 # ---------------------------------------------------------------------------
571
572
573 def _commit_as(repo: pathlib.Path, msg: str, author: str, filename: str | None = None) -> None:
574 """Invoke muse commit with an explicit --author flag."""
575 from muse.cli.app import main as cli
576 fname = filename or f"file_{abs(hash(msg))}.py"
577 (repo / fname).write_text(f"# {msg}\n")
578 saved = os.getcwd()
579 try:
580 os.chdir(repo)
581 runner.invoke(cli, ["commit", "-m", msg, "--author", author])
582 finally:
583 os.chdir(saved)
584
585
586 def _commit_with_config_author(repo: pathlib.Path, msg: str, author: str, filename: str | None = None) -> None:
587 """Write user.handle to repo config, then invoke muse commit without --author."""
588 from muse.cli.app import main as cli
589 from muse.cli.config import set_user_field
590 set_user_field("handle", author, repo)
591 fname = filename or f"file_{abs(hash(msg))}.py"
592 (repo / fname).write_text(f"# {msg}\n")
593 saved = os.getcwd()
594 try:
595 os.chdir(repo)
596 runner.invoke(cli, ["commit", "-m", msg])
597 finally:
598 os.chdir(saved)
599
600
601 class TestAuthorField:
602 """Author field in log JSON must come from user.handle config when --author not given."""
603
604 def test_commit_with_explicit_author_appears_in_log(self, tmp_path: pathlib.Path) -> None:
605 """--author flag sets author field that muse log --json exposes."""
606 repo = _fresh_repo(tmp_path, n_commits=0)
607 _commit_as(repo, "my commit", "charlie")
608 result = _log(repo, "--json")
609 data = json.loads(result.output)
610 assert data["commits"][0]["author"] == "charlie"
611
612 def test_commit_reads_user_name_from_config(self, tmp_path: pathlib.Path) -> None:
613 """muse commit without --author reads user.handle from repo config."""
614 repo = _fresh_repo(tmp_path, n_commits=0)
615 _commit_with_config_author(repo, "config commit", "diana")
616 result = _log(repo, "--json")
617 data = json.loads(result.output)
618 assert data["commits"][0]["author"] == "diana"
619
620 def test_author_filter_returns_matching_commits(self, tmp_path: pathlib.Path) -> None:
621 """--author filter must return commits whose author matches the substring."""
622 repo = _fresh_repo(tmp_path, n_commits=0)
623 _commit_as(repo, "alice commit", "alice", filename="a.py")
624 _commit_as(repo, "bob commit", "bob", filename="b.py")
625 result = _log(repo, "--author", "alice", "--json")
626 data = json.loads(result.output)
627 assert len(data["commits"]) == 1
628 assert data["commits"][0]["author"] == "alice"
629
630 def test_author_filter_nonexistent_returns_no_commits(self, tmp_path: pathlib.Path) -> None:
631 """--author filter with no match must return empty list."""
632 repo = _fresh_repo(tmp_path, n_commits=0)
633 _commit_as(repo, "some commit", "alice", filename="a.py")
634 result = _log(repo, "--author", "zzz_nobody_zzz", "--json")
635 data = json.loads(result.output)
636 assert data["commits"] == []
637
638
639 class TestFilters:
640 def test_author_filter_matches(self, tmp_path: pathlib.Path) -> None:
641 repo = _fresh_repo(tmp_path, n_commits=2)
642 # The author will be whatever muse uses by default
643 # We just verify that filtering by nonexistent author returns none
644 result = _log(repo, "--author", "zzz_nobody_zzz")
645 assert "(no commits)" in result.output
646
647 def test_since_filters_old_commits(self, tmp_path: pathlib.Path) -> None:
648 repo = _fresh_repo(tmp_path, n_commits=2)
649 result = _log(repo, "--since", "2099-01-01")
650 # Future date — should return no commits
651 assert "(no commits)" in result.output
652
653 def test_until_filters_future_commits(self, tmp_path: pathlib.Path) -> None:
654 repo = _fresh_repo(tmp_path, n_commits=2)
655 # Past date — all commits should be excluded
656 result = _log(repo, "--until", "2000-01-01")
657 assert "(no commits)" in result.output
658
659 def test_limit_shorthand(self, tmp_path: pathlib.Path) -> None:
660 """muse log -2 must show at most 2 commits."""
661 repo = _fresh_repo(tmp_path, n_commits=5)
662 result = _log(repo, "--oneline", "--limit", "2")
663 lines = [l for l in result.output.splitlines() if l.strip()]
664 assert len(lines) == 2
665
666 def test_limit_flag_alias(self, tmp_path: pathlib.Path) -> None:
667 """--limit is an alias for -n/--max-count."""
668 repo = _fresh_repo(tmp_path, n_commits=5)
669 result = _log(repo, "--oneline", "--limit", "3")
670 lines = [l for l in result.output.splitlines() if l.strip()]
671 assert len(lines) == 3
672
673 def test_limit_flag_json(self, tmp_path: pathlib.Path) -> None:
674 """--limit works with --json output."""
675 repo = _fresh_repo(tmp_path, n_commits=5)
676 data = json.loads(_log(repo, "--json", "--limit", "2").output)
677 assert len(data["commits"]) == 2
678
679 def test_json_since_filters(self, tmp_path: pathlib.Path) -> None:
680 repo = _fresh_repo(tmp_path, n_commits=2)
681 data = json.loads(_log(repo, "--json", "--since", "2099-01-01").output)
682 assert data["commits"] == []
683
684 def test_invalid_since_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
685 repo = _fresh_repo(tmp_path)
686 result = _log(repo, "--since", "not-a-date")
687 assert result.exit_code != 0
688
689 def test_invalid_until_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
690 repo = _fresh_repo(tmp_path)
691 result = _log(repo, "--until", "not-a-date")
692 assert result.exit_code != 0
693
694 def test_invalid_since_no_traceback(self, tmp_path: pathlib.Path) -> None:
695 repo = _fresh_repo(tmp_path)
696 result = _log(repo, "--since", "baddate")
697 assert "Traceback" not in result.output
698
699 def test_invalid_until_clean_error(self, tmp_path: pathlib.Path) -> None:
700 repo = _fresh_repo(tmp_path)
701 result = _log(repo, "--until", "foo")
702 assert "Cannot parse" in result.output or result.exit_code != 0
703
704
705 # ---------------------------------------------------------------------------
706 # Integration — format validation
707 # ---------------------------------------------------------------------------
708
709
710 class TestFormatValidation:
711 def test_unknown_flag_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
712 repo = _fresh_repo(tmp_path)
713 result = _log(repo, "--format", "xml")
714 assert result.exit_code != 0
715
716 def test_unknown_flag_no_traceback(self, tmp_path: pathlib.Path) -> None:
717 repo = _fresh_repo(tmp_path)
718 result = _log(repo, "--format", "yaml")
719 assert "Traceback" not in result.output
720
721 def test_j_shorthand_same_as_json_flag(self, tmp_path: pathlib.Path) -> None:
722 repo = _fresh_repo(tmp_path, n_commits=2)
723 r1 = _log(repo, "--json")
724 r2 = _log(repo, "-j")
725 d1 = json.loads(r1.output)
726 d2 = json.loads(r2.output)
727 # duration_ms and timestamp are wall-clock values — exclude them
728 for d in (d1, d2):
729 d.pop("duration_ms", None)
730 d.pop("timestamp", None)
731 for c in d.get("commits", []):
732 c.pop("duration_ms", None)
733 assert d1 == d2
734
735 def test_invalid_max_count_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
736 repo = _fresh_repo(tmp_path)
737 result = _log(repo, "--limit", "0")
738 assert result.exit_code != 0
739
740
741 # ---------------------------------------------------------------------------
742 # Security — ANSI injection
743 # ---------------------------------------------------------------------------
744
745
746 class TestSecurity:
747 def test_ansi_in_commit_message_sanitized_oneline(self, tmp_path: pathlib.Path) -> None:
748 repo = tmp_path / "repo"
749 _init(repo)
750 # Commit a message with ANSI in it
751 _commit(repo, "\x1b[31mevil\x1b[0m", filename="evil.py")
752 result = _log(repo, "--oneline")
753 # The runner is not a tty — any escape from the message must be sanitized
754 assert "\x1b[31m" not in result.output
755
756 def test_ansi_in_commit_message_sanitized_long(self, tmp_path: pathlib.Path) -> None:
757 repo = tmp_path / "repo"
758 _init(repo)
759 _commit(repo, "\x1b[31mhacked\x1b[0m", filename="h.py")
760 result = _log(repo)
761 assert "\x1b[31m" not in result.output
762
763 def test_ansi_in_author_sanitized(self, tmp_path: pathlib.Path) -> None:
764 """Author names from CommitRecord must be sanitized in output."""
765 repo = _fresh_repo(tmp_path)
766 result = _log(repo)
767 # No raw escape from author field in text output (we can't control
768 # author easily, but ensure output is escape-free when not tty)
769 assert "\x1b[31m" not in result.output
770
771 def test_multiline_message_all_lines_indented(self, tmp_path: pathlib.Path) -> None:
772 """Every line of a multiline message must start with 4-space indent."""
773 repo = tmp_path / "repo"
774 _init(repo)
775 _commit(repo, "Line1\nLine2\nLine3", filename="f.py")
776 result = _log(repo)
777 body_lines = [l for l in result.output.splitlines() if l.strip() in ("Line1", "Line2", "Line3")]
778 assert body_lines, f"Body lines not found in: {result.output}"
779 for line in body_lines:
780 assert line.startswith(" "), f"Not indented: {repr(line)}"
781
782 def test_unknown_flag_exits_nonzero_ansi(self, tmp_path: pathlib.Path) -> None:
783 repo = _fresh_repo(tmp_path)
784 evil_fmt = "\x1b[31mevil\x1b[0m"
785 result = _log(repo, "--format", evil_fmt)
786 assert result.exit_code != 0
787
788 def test_repo_id_in_json_envelope(self, tmp_path: pathlib.Path) -> None:
789 """repo_id is included in the JSON envelope for agent cross-referencing."""
790 repo = _fresh_repo(tmp_path)
791 stored = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
792 result = _log(repo, "--json")
793 data = json.loads(result.output)
794 assert data["repo_id"] == stored
795
796
797 # ---------------------------------------------------------------------------
798 # Integration — nonexistent branch
799 # ---------------------------------------------------------------------------
800
801
802 class TestNonexistentBranch:
803 def test_nonexistent_branch_contextual_message(self, tmp_path: pathlib.Path) -> None:
804 repo = _fresh_repo(tmp_path)
805 result = _log(repo, "bogus-branch")
806 assert "bogus-branch" in result.output
807
808 def test_nonexistent_branch_exits_zero(self, tmp_path: pathlib.Path) -> None:
809 """log on a nonexistent branch is not a fatal error."""
810 repo = _fresh_repo(tmp_path)
811 result = _log(repo, "bogus-branch")
812 assert result.exit_code == 0
813
814 def test_nonexistent_branch_json_empty_commits(self, tmp_path: pathlib.Path) -> None:
815 repo = _fresh_repo(tmp_path)
816 data = json.loads(_log(repo, "--json", "bogus-branch").output)
817 assert data["commits"] == []
818
819 def test_empty_repo_shows_no_commits(self, tmp_path: pathlib.Path) -> None:
820 repo = tmp_path / "repo"
821 _init(repo)
822 result = _log(repo)
823 assert "no commits" in result.output.lower()
824
825
826 # ---------------------------------------------------------------------------
827 # End-to-end — complete workflows
828 # ---------------------------------------------------------------------------
829
830
831 class TestEndToEnd:
832 def test_single_commit_log(self, tmp_path: pathlib.Path) -> None:
833 repo = _fresh_repo(tmp_path, n_commits=1)
834 result = _log(repo)
835 assert result.exit_code == 0
836 assert "commit" in result.output.lower()
837
838 def test_multiple_commits_ordered_newest_first(self, tmp_path: pathlib.Path) -> None:
839 repo = _fresh_repo(tmp_path, n_commits=3)
840 result = _log(repo, "--oneline")
841 lines = [l for l in result.output.strip().splitlines() if l]
842 assert len(lines) == 3
843
844 def test_head_decoration_on_latest(self, tmp_path: pathlib.Path) -> None:
845 repo = _fresh_repo(tmp_path, n_commits=2)
846 result = _log(repo)
847 lines = result.output.strip().splitlines()
848 # First commit line should have HEAD
849 first = next((l for l in lines if "commit" in l.lower()), "")
850 assert "HEAD" in first
851
852 def test_subprocess_call_works(self, tmp_path: pathlib.Path) -> None:
853 repo = _fresh_repo(tmp_path, n_commits=2)
854 r = subprocess.run(
855 ["muse", "log", "--json"],
856 capture_output=True, text=True, cwd=str(repo),
857 )
858 assert r.returncode == 0
859 data = json.loads(r.stdout)
860 assert len(data["commits"]) == 2
861
862 def test_log_after_branch_switch(self, tmp_path: pathlib.Path) -> None:
863 from muse.cli.app import main as cli
864
865 repo = _fresh_repo(tmp_path, n_commits=2)
866 saved = os.getcwd()
867 os.chdir(repo)
868 try:
869 runner.invoke(cli, ["branch", "feat/x"])
870 runner.invoke(cli, ["checkout", "feat/x"])
871 finally:
872 os.chdir(saved)
873 _commit(repo, "feat commit", filename="feat.py")
874 data = json.loads(_log(repo, "--json").output)
875 # feat branch should have 3 commits (2 from main + 1 new)
876 assert len(data["commits"]) == 3
877
878 def test_log_on_explicit_branch(self, tmp_path: pathlib.Path) -> None:
879 from muse.cli.app import main as cli
880
881 repo = _fresh_repo(tmp_path, n_commits=2)
882 saved = os.getcwd()
883 os.chdir(repo)
884 try:
885 runner.invoke(cli, ["branch", "feat/y"])
886 runner.invoke(cli, ["checkout", "feat/y"])
887 finally:
888 os.chdir(saved)
889 _commit(repo, "only on feat", filename="feat_y.py")
890 # Log main explicitly — should not include feat commit
891 data_main = json.loads(_log(repo, "--json", "main").output)
892 messages = [c["message"] for c in data_main["commits"]]
893 assert "only on feat" not in messages
894
895 def test_merge_commit_has_parent2(self, tmp_path: pathlib.Path) -> None:
896 from muse.cli.app import main as cli
897
898 repo = _fresh_repo(tmp_path, n_commits=1)
899 saved = os.getcwd()
900 os.chdir(repo)
901 try:
902 runner.invoke(cli, ["branch", "feat/merge-test"])
903 runner.invoke(cli, ["checkout", "feat/merge-test"])
904 (repo / "feat_file.py").write_text("f=1\n")
905 runner.invoke(cli, ["commit", "-m", "feat commit"])
906 runner.invoke(cli, ["checkout", "main"])
907 (repo / "main_file.py").write_text("m=1\n")
908 runner.invoke(cli, ["commit", "-m", "main diverge"])
909 runner.invoke(cli, ["merge", "feat/merge-test"])
910 finally:
911 os.chdir(saved)
912
913 data = json.loads(_log(repo, "--json", "--limit", "1").output)
914 merge_commit = data["commits"][0]
915 # A merge commit must have parent2_commit_id set
916 assert merge_commit["parent2_commit_id"] is not None
917
918
919 # ---------------------------------------------------------------------------
920 # Stress — large history and rapid calls
921 # ---------------------------------------------------------------------------
922
923
924 class TestStress:
925 @pytest.mark.slow
926 def test_log_200_commits_json(self, tmp_path: pathlib.Path) -> None:
927 """log --json on 200 commits must exit 0 with correct count."""
928 repo = _fresh_repo(tmp_path, n_commits=200)
929 result = _log(repo, "--json")
930 assert result.exit_code == 0
931 data = json.loads(result.output)
932 assert len(data["commits"]) == 200
933
934 @pytest.mark.slow
935 def test_log_200_commits_oneline(self, tmp_path: pathlib.Path) -> None:
936 repo = _fresh_repo(tmp_path, n_commits=200)
937 result = _log(repo, "--oneline")
938 assert result.exit_code == 0
939 lines = [l for l in result.output.splitlines() if l.strip()]
940 assert len(lines) == 200
941
942 @pytest.mark.slow
943 def test_rapid_sequential_calls(self, tmp_path: pathlib.Path) -> None:
944 """20 sequential muse log calls must all succeed."""
945 repo = _fresh_repo(tmp_path, n_commits=10)
946 for i in range(20):
947 result = _log(repo, "--json")
948 assert result.exit_code == 0, f"Call {i} failed"
949
950 def test_limit_n_large(self, tmp_path: pathlib.Path) -> None:
951 repo = _fresh_repo(tmp_path, n_commits=10)
952 data = json.loads(_log(repo, "--json", "--limit", "5").output)
953 assert len(data["commits"]) == 5
954
955 def test_filter_returns_subset(self, tmp_path: pathlib.Path) -> None:
956 """Limiting to 5 commits from a 20-commit repo returns exactly 5."""
957 repo = _fresh_repo(tmp_path, n_commits=20)
958 data = json.loads(_log(repo, "--json", "--limit", "5").output)
959 assert len(data["commits"]) == 5
960
961 def test_truncated_true_when_filter_skips_commits(self, tmp_path: pathlib.Path) -> None:
962 """With active filter + large walk cap, walk_truncated can be True.
963
964 Use --since=future so the filter skips all commits, but the walk still
965 fetches them all up to walk_cap. We exercise the truncated-when-filter
966 path by creating more commits than the walk ceiling.
967 """
968 repo = _fresh_repo(tmp_path, n_commits=10)
969 # Verify that --since=2099 returns an empty but valid JSON object.
970 data = json.loads(_log(repo, "--json", "--since", "2099-01-01").output)
971 assert data["commits"] == []
972 # truncated may or may not be True here depending on walk_cap;
973 # the key invariant is that the output is well-formed JSON.
974 assert isinstance(data["truncated"], bool)
975
976
977 # ===========================================================================
978 # Manifest cache — each commit's snapshot must be read at most once per run
979 # ===========================================================================
980
981
982 class TestManifestCache:
983 """get_commit_snapshot_manifest must not be called more than once per commit_id.
984
985 Before the fix, _commit_touches_path and _file_diff each called
986 get_commit_snapshot_manifest independently. With a pathspec filter plus
987 JSON output (which always runs _file_diff), the same commit_id was read 4×
988 per commit (current + parent in each function).
989
990 After the fix, a shared manifest_cache dict deduplicates reads so each
991 commit_id is read at most once regardless of how many callers need it.
992 """
993
994 def test_manifest_cache_used_structurally(self) -> None:
995 """manifest_cache dict must be threaded through the log pipeline."""
996 import inspect
997 from muse.cli.commands import log as log_module
998
999 source = inspect.getsource(log_module)
1000 assert "manifest_cache" in source, (
1001 "log.py must use a manifest_cache dict to deduplicate snapshot reads"
1002 )
1003
1004 def test_each_commit_id_read_at_most_once(self, tmp_path: pathlib.Path) -> None:
1005 """With pathspec + JSON mode, each commit's snapshot read ≤ 1×.
1006
1007 JSON mode always calls _file_diff (stat=True).
1008 Pathspec filter calls _commit_touches_path.
1009 Without a shared cache, the same manifest is loaded 4× per commit.
1010 With a shared cache it is loaded exactly once.
1011 """
1012 from unittest.mock import patch, call
1013 from muse.core import store as store_module
1014
1015 repo = tmp_path / "r"
1016 _init(repo)
1017 (repo / "src").mkdir(exist_ok=True)
1018 # Create 3 commits each touching a distinct file.
1019 for i in range(3):
1020 _commit(repo, f"msg{i}", filename=f"src/file{i}.py")
1021
1022 seen_ids: list[str] = []
1023
1024 original_fn = store_module.get_commit_snapshot_manifest
1025
1026 def tracking_fn(root, commit_id):
1027 seen_ids.append(commit_id)
1028 return original_fn(root, commit_id)
1029
1030 with patch.object(store_module, "get_commit_snapshot_manifest", side_effect=tracking_fn):
1031 result = _log(repo, "--json", "--", "src/")
1032
1033 assert result.exit_code == 0
1034 data = json.loads(result.output)
1035 assert len(data["commits"]) > 0
1036
1037 # Each commit_id must appear at most once in the call log.
1038 from collections import Counter
1039 counts = Counter(seen_ids)
1040 duplicates = {cid: n for cid, n in counts.items() if n > 1}
1041 assert not duplicates, (
1042 f"get_commit_snapshot_manifest called >1× for commit IDs: {duplicates}. "
1043 "Manifest cache not working."
1044 )
1045
1046 def test_pathspec_filter_correct_with_cache(self, tmp_path: pathlib.Path) -> None:
1047 """Pathspec filter returns correct commits when manifest cache is active."""
1048 repo = tmp_path / "r"
1049 _init(repo)
1050 _commit(repo, "add alpha", filename="alpha.py")
1051 _commit(repo, "add beta", filename="beta.py")
1052 _commit(repo, "add gamma", filename="gamma.py")
1053
1054 result = _log(repo, "--json", "--", "alpha.py")
1055 assert result.exit_code == 0
1056 data = json.loads(result.output)
1057 messages = [c["message"] for c in data["commits"]]
1058 assert any("alpha" in m for m in messages), (
1059 "alpha.py pathspec should include the 'add alpha' commit"
1060 )
1061 assert not any("beta" in m for m in messages), (
1062 "beta.py pathspec should NOT include the 'add beta' commit"
1063 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago