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