cgcardona / muse public
test_log_flags.py python
822 lines 25.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for the extended ``muse log`` flag set.
2
3 Covers all new flags added to ``_log_async``:
4 - ``--oneline`` — one line per commit
5 - ``--stat`` — file-change statistics per commit
6 - ``--patch`` — path-level diff per commit
7 - ``--since`` / ``--until`` — date range filtering
8 - ``--author`` — author substring filter
9 - ``--emotion`` / ``--section`` / ``--track`` — music-native tag filters
10
11 All tests call ``_log_async`` directly with an in-memory SQLite session and
12 a ``tmp_path`` repo root. Tag-based tests insert ``MuseCliTag`` rows directly
13 to avoid depending on ``muse commit --emotion`` (a separate issue).
14 """
15 from __future__ import annotations
16
17 import json
18 import pathlib
19 import uuid
20 from datetime import datetime, timedelta, timezone
21
22 import pytest
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from maestro.muse_cli.commands.commit import _commit_async
26 from maestro.muse_cli.commands.log import (
27 CommitDiff,
28 _compute_diff,
29 _filter_by_tags,
30 _load_commits,
31 _log_async,
32 _render_oneline,
33 _render_stat,
34 _render_patch,
35 parse_date_filter,
36 )
37 from maestro.muse_cli.errors import ExitCode
38 from maestro.muse_cli.models import MuseCliCommit, MuseCliTag
39
40
41 # ---------------------------------------------------------------------------
42 # Shared helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
47 rid = repo_id or str(uuid.uuid4())
48 muse = root / ".muse"
49 (muse / "refs" / "heads").mkdir(parents=True)
50 (muse / "repo.json").write_text(
51 json.dumps({"repo_id": rid, "schema_version": "1"})
52 )
53 (muse / "HEAD").write_text("refs/heads/main")
54 (muse / "refs" / "heads" / "main").write_text("")
55 return rid
56
57
58 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
59 workdir = root / "muse-work"
60 workdir.mkdir(exist_ok=True)
61 for name, content in files.items():
62 (workdir / name).write_bytes(content)
63
64
65 async def _make_commits(
66 root: pathlib.Path,
67 session: AsyncSession,
68 messages: list[str],
69 file_seed: int = 0,
70 author: str = "",
71 ) -> list[str]:
72 """Create N commits returning their commit IDs in order (oldest first)."""
73 commit_ids: list[str] = []
74 for i, msg in enumerate(messages):
75 _write_workdir(root, {f"track_{file_seed + i}.mid": f"MIDI-{file_seed + i}".encode()})
76 cid = await _commit_async(message=msg, root=root, session=session)
77 # Patch author directly on the DB row if needed
78 if author:
79 commit = await session.get(MuseCliCommit, cid)
80 if commit is not None:
81 commit.author = author
82 session.add(commit)
83 await session.flush()
84 commit_ids.append(cid)
85 return commit_ids
86
87
88 async def _tag_commit(
89 session: AsyncSession,
90 repo_id: str,
91 commit_id: str,
92 tag: str,
93 ) -> None:
94 """Insert a MuseCliTag row for testing tag-based filters."""
95 session.add(
96 MuseCliTag(
97 repo_id=repo_id,
98 commit_id=commit_id,
99 tag=tag,
100 )
101 )
102 await session.flush()
103
104
105 # ---------------------------------------------------------------------------
106 # parse_date_filter unit tests
107 # ---------------------------------------------------------------------------
108
109
110 def test_parse_date_filter_iso_date() -> None:
111 """ISO date string produces a UTC-aware datetime at midnight."""
112 dt = parse_date_filter("2026-01-15")
113 assert dt.year == 2026
114 assert dt.month == 1
115 assert dt.day == 15
116 assert dt.tzinfo is not None
117
118
119 def test_parse_date_filter_iso_datetime() -> None:
120 """ISO datetime string produces correct UTC-aware datetime."""
121 dt = parse_date_filter("2026-06-01T14:30:00")
122 assert dt.hour == 14
123 assert dt.minute == 30
124
125
126 def test_parse_date_filter_relative_days() -> None:
127 """'N days ago' produces a datetime N days before now."""
128 before = datetime.now(timezone.utc) - timedelta(days=7)
129 dt = parse_date_filter("7 days ago")
130 assert abs((dt - before).total_seconds()) < 60
131
132
133 def test_parse_date_filter_relative_weeks() -> None:
134 """'N weeks ago' produces a datetime N*7 days before now."""
135 before = datetime.now(timezone.utc) - timedelta(weeks=2)
136 dt = parse_date_filter("2 weeks ago")
137 assert abs((dt - before).total_seconds()) < 60
138
139
140 def test_parse_date_filter_relative_months() -> None:
141 """'N months ago' approximates N*30 days before now."""
142 before = datetime.now(timezone.utc) - timedelta(days=30)
143 dt = parse_date_filter("1 month ago")
144 assert abs((dt - before).total_seconds()) < 60
145
146
147 def test_parse_date_filter_today() -> None:
148 """'today' returns today at midnight UTC."""
149 dt = parse_date_filter("today")
150 now = datetime.now(timezone.utc)
151 assert dt.year == now.year
152 assert dt.month == now.month
153 assert dt.day == now.day
154 assert dt.hour == 0
155
156
157 def test_parse_date_filter_yesterday() -> None:
158 """'yesterday' returns yesterday at midnight UTC."""
159 dt = parse_date_filter("yesterday")
160 yesterday = datetime.now(timezone.utc) - timedelta(days=1)
161 assert dt.day == yesterday.day
162
163
164 def test_parse_date_filter_invalid_raises() -> None:
165 """An unrecognised string raises ValueError."""
166 with pytest.raises(ValueError, match="Cannot parse date"):
167 parse_date_filter("not a date")
168
169
170 # ---------------------------------------------------------------------------
171 # test_log_oneline_format
172 # ---------------------------------------------------------------------------
173
174
175 @pytest.mark.anyio
176 async def test_log_oneline_format(
177 tmp_path: pathlib.Path,
178 muse_cli_db_session: AsyncSession,
179 capsys: pytest.CaptureFixture[str],
180 ) -> None:
181 """``--oneline`` shows exactly one line per commit with short id and message."""
182 _init_muse_repo(tmp_path)
183 cids = await _make_commits(tmp_path, muse_cli_db_session, ["take 1", "take 2", "take 3"])
184
185 capsys.readouterr()
186 await _log_async(
187 root=tmp_path,
188 session=muse_cli_db_session,
189 limit=1000,
190 graph=False,
191 oneline=True,
192 )
193 out = capsys.readouterr().out
194 lines = [ln for ln in out.splitlines() if ln.strip()]
195
196 # Three commits → three lines
197 assert len(lines) == 3
198 # Each line starts with the short commit id
199 for cid, msg in zip(reversed(cids), ["take 3", "take 2", "take 1"]):
200 matching = [l for l in lines if l.startswith(cid[:8])]
201 assert matching, f"No oneline entry for commit {cid[:8]}"
202 assert msg in matching[0]
203
204
205 @pytest.mark.anyio
206 async def test_log_oneline_head_marker(
207 tmp_path: pathlib.Path,
208 muse_cli_db_session: AsyncSession,
209 capsys: pytest.CaptureFixture[str],
210 ) -> None:
211 """``--oneline`` HEAD commit line includes the branch marker."""
212 _init_muse_repo(tmp_path)
213 cids = await _make_commits(tmp_path, muse_cli_db_session, ["first", "second"])
214
215 capsys.readouterr()
216 await _log_async(
217 root=tmp_path,
218 session=muse_cli_db_session,
219 limit=1000,
220 graph=False,
221 oneline=True,
222 )
223 out = capsys.readouterr().out
224 head_line = [l for l in out.splitlines() if cids[1][:8] in l][0]
225 assert "(HEAD -> main)" in head_line
226 # Older commit must NOT have the HEAD marker
227 old_lines = [l for l in out.splitlines() if cids[0][:8] in l]
228 assert old_lines
229 assert "(HEAD -> main)" not in old_lines[0]
230
231
232 # ---------------------------------------------------------------------------
233 # test_log_since_until_filter
234 # ---------------------------------------------------------------------------
235
236
237 @pytest.mark.anyio
238 async def test_log_since_filter(
239 tmp_path: pathlib.Path,
240 muse_cli_db_session: AsyncSession,
241 capsys: pytest.CaptureFixture[str],
242 ) -> None:
243 """``--since`` excludes commits older than the cutoff date."""
244 _init_muse_repo(tmp_path)
245 cids = await _make_commits(tmp_path, muse_cli_db_session, ["old", "recent"])
246
247 # Back-date the first commit to 10 days ago
248 old_commit = await muse_cli_db_session.get(MuseCliCommit, cids[0])
249 assert old_commit is not None
250 old_commit.committed_at = datetime.now(timezone.utc) - timedelta(days=10)
251 muse_cli_db_session.add(old_commit)
252 await muse_cli_db_session.flush()
253
254 since_dt = datetime.now(timezone.utc) - timedelta(days=5)
255
256 capsys.readouterr()
257 await _log_async(
258 root=tmp_path,
259 session=muse_cli_db_session,
260 limit=1000,
261 graph=False,
262 since=since_dt,
263 )
264 out = capsys.readouterr().out
265
266 assert cids[1] in out # recent commit present
267 assert cids[0] not in out # old commit excluded
268
269
270 @pytest.mark.anyio
271 async def test_log_until_filter(
272 tmp_path: pathlib.Path,
273 muse_cli_db_session: AsyncSession,
274 capsys: pytest.CaptureFixture[str],
275 ) -> None:
276 """``--until`` excludes commits after the cutoff date."""
277 _init_muse_repo(tmp_path)
278 cids = await _make_commits(tmp_path, muse_cli_db_session, ["past", "future"])
279
280 # Forward-date the second commit to 10 days from now
281 future_commit = await muse_cli_db_session.get(MuseCliCommit, cids[1])
282 assert future_commit is not None
283 future_commit.committed_at = datetime.now(timezone.utc) + timedelta(days=10)
284 muse_cli_db_session.add(future_commit)
285 await muse_cli_db_session.flush()
286
287 until_dt = datetime.now(timezone.utc) + timedelta(days=1)
288
289 capsys.readouterr()
290 await _log_async(
291 root=tmp_path,
292 session=muse_cli_db_session,
293 limit=1000,
294 graph=False,
295 until=until_dt,
296 )
297 out = capsys.readouterr().out
298
299 assert cids[0] in out # past commit present
300 assert cids[1] not in out # future commit excluded
301
302
303 @pytest.mark.anyio
304 async def test_log_since_until_combined(
305 tmp_path: pathlib.Path,
306 muse_cli_db_session: AsyncSession,
307 capsys: pytest.CaptureFixture[str],
308 ) -> None:
309 """``--since`` and ``--until`` combined narrow to an exact window."""
310 _init_muse_repo(tmp_path)
311 now = datetime.now(timezone.utc)
312 cids = await _make_commits(
313 tmp_path, muse_cli_db_session, ["very old", "in window", "very new"]
314 )
315
316 # Arrange timestamps: very old = 20 days ago, in window = 5 days ago, very new = now
317 commits = [await muse_cli_db_session.get(MuseCliCommit, cid) for cid in cids]
318 assert all(c is not None for c in commits)
319 commits[0].committed_at = now - timedelta(days=20) # type: ignore[union-attr]
320 commits[1].committed_at = now - timedelta(days=5) # type: ignore[union-attr]
321 commits[2].committed_at = now # type: ignore[union-attr]
322 for c in commits:
323 muse_cli_db_session.add(c)
324 await muse_cli_db_session.flush()
325
326 since_dt = now - timedelta(days=10)
327 until_dt = now - timedelta(days=1)
328
329 capsys.readouterr()
330 await _log_async(
331 root=tmp_path,
332 session=muse_cli_db_session,
333 limit=1000,
334 graph=False,
335 since=since_dt,
336 until=until_dt,
337 )
338 out = capsys.readouterr().out
339
340 assert cids[1] in out # in window
341 assert cids[0] not in out # too old
342 assert cids[2] not in out # too new
343
344
345 # ---------------------------------------------------------------------------
346 # test_log_author_filter
347 # ---------------------------------------------------------------------------
348
349
350 @pytest.mark.anyio
351 async def test_log_author_filter(
352 tmp_path: pathlib.Path,
353 muse_cli_db_session: AsyncSession,
354 capsys: pytest.CaptureFixture[str],
355 ) -> None:
356 """``--author`` returns only commits by the matching author."""
357 _init_muse_repo(tmp_path)
358 cids_a = await _make_commits(
359 tmp_path, muse_cli_db_session, ["alice track"], file_seed=0, author="alice"
360 )
361 cids_b = await _make_commits(
362 tmp_path, muse_cli_db_session, ["bob track"], file_seed=10, author="bob"
363 )
364
365 capsys.readouterr()
366 await _log_async(
367 root=tmp_path,
368 session=muse_cli_db_session,
369 limit=1000,
370 graph=False,
371 author="alice",
372 )
373 out = capsys.readouterr().out
374
375 assert cids_a[0] in out
376 assert cids_b[0] not in out
377
378
379 @pytest.mark.anyio
380 async def test_log_author_filter_case_insensitive(
381 tmp_path: pathlib.Path,
382 muse_cli_db_session: AsyncSession,
383 capsys: pytest.CaptureFixture[str],
384 ) -> None:
385 """Author filter is case-insensitive."""
386 _init_muse_repo(tmp_path)
387 cids = await _make_commits(
388 tmp_path, muse_cli_db_session, ["track"], author="Alice"
389 )
390
391 capsys.readouterr()
392 await _log_async(
393 root=tmp_path,
394 session=muse_cli_db_session,
395 limit=1000,
396 graph=False,
397 author="alice", # lowercase query, uppercase author
398 )
399 out = capsys.readouterr().out
400 assert cids[0] in out
401
402
403 # ---------------------------------------------------------------------------
404 # test_log_emotion_section_track_filter
405 # ---------------------------------------------------------------------------
406
407
408 @pytest.mark.anyio
409 async def test_log_emotion_filter(
410 tmp_path: pathlib.Path,
411 muse_cli_db_session: AsyncSession,
412 capsys: pytest.CaptureFixture[str],
413 ) -> None:
414 """``--emotion`` retains only commits tagged with emotion:<value>."""
415 repo_id = _init_muse_repo(tmp_path)
416 cids = await _make_commits(
417 tmp_path, muse_cli_db_session, ["sad verse", "happy chorus"]
418 )
419 await _tag_commit(muse_cli_db_session, repo_id, cids[0], "emotion:melancholic")
420 await _tag_commit(muse_cli_db_session, repo_id, cids[1], "emotion:euphoric")
421
422 capsys.readouterr()
423 await _log_async(
424 root=tmp_path,
425 session=muse_cli_db_session,
426 limit=1000,
427 graph=False,
428 emotion="melancholic",
429 )
430 out = capsys.readouterr().out
431
432 assert cids[0] in out
433 assert cids[1] not in out
434
435
436 @pytest.mark.anyio
437 async def test_log_section_filter(
438 tmp_path: pathlib.Path,
439 muse_cli_db_session: AsyncSession,
440 capsys: pytest.CaptureFixture[str],
441 ) -> None:
442 """``--section`` retains only commits tagged with section:<value>."""
443 repo_id = _init_muse_repo(tmp_path)
444 cids = await _make_commits(
445 tmp_path, muse_cli_db_session, ["chorus take", "verse take"]
446 )
447 await _tag_commit(muse_cli_db_session, repo_id, cids[0], "section:chorus")
448 await _tag_commit(muse_cli_db_session, repo_id, cids[1], "section:verse")
449
450 capsys.readouterr()
451 await _log_async(
452 root=tmp_path,
453 session=muse_cli_db_session,
454 limit=1000,
455 graph=False,
456 section="chorus",
457 )
458 out = capsys.readouterr().out
459
460 assert cids[0] in out
461 assert cids[1] not in out
462
463
464 @pytest.mark.anyio
465 async def test_log_track_filter(
466 tmp_path: pathlib.Path,
467 muse_cli_db_session: AsyncSession,
468 capsys: pytest.CaptureFixture[str],
469 ) -> None:
470 """``--track`` retains only commits tagged with track:<value>."""
471 repo_id = _init_muse_repo(tmp_path)
472 cids = await _make_commits(
473 tmp_path, muse_cli_db_session, ["drums pattern", "bass line"]
474 )
475 await _tag_commit(muse_cli_db_session, repo_id, cids[0], "track:drums")
476 await _tag_commit(muse_cli_db_session, repo_id, cids[1], "track:bass")
477
478 capsys.readouterr()
479 await _log_async(
480 root=tmp_path,
481 session=muse_cli_db_session,
482 limit=1000,
483 graph=False,
484 track="drums",
485 )
486 out = capsys.readouterr().out
487
488 assert cids[0] in out
489 assert cids[1] not in out
490
491
492 @pytest.mark.anyio
493 async def test_log_emotion_section_combined(
494 tmp_path: pathlib.Path,
495 muse_cli_db_session: AsyncSession,
496 capsys: pytest.CaptureFixture[str],
497 ) -> None:
498 """``--emotion`` AND ``--section`` together apply as AND filter."""
499 repo_id = _init_muse_repo(tmp_path)
500 cids = await _make_commits(
501 tmp_path,
502 muse_cli_db_session,
503 ["match both", "only emotion", "only section", "neither"],
504 )
505 # cids[0]: both tags
506 await _tag_commit(muse_cli_db_session, repo_id, cids[0], "emotion:melancholic")
507 await _tag_commit(muse_cli_db_session, repo_id, cids[0], "section:chorus")
508 # cids[1]: emotion only
509 await _tag_commit(muse_cli_db_session, repo_id, cids[1], "emotion:melancholic")
510 # cids[2]: section only
511 await _tag_commit(muse_cli_db_session, repo_id, cids[2], "section:chorus")
512 # cids[3]: no tags
513
514 capsys.readouterr()
515 await _log_async(
516 root=tmp_path,
517 session=muse_cli_db_session,
518 limit=1000,
519 graph=False,
520 emotion="melancholic",
521 section="chorus",
522 )
523 out = capsys.readouterr().out
524
525 assert cids[0] in out # has both
526 assert cids[1] not in out # only emotion
527 assert cids[2] not in out # only section
528 assert cids[3] not in out # neither
529
530
531 # ---------------------------------------------------------------------------
532 # test_log_stat_format
533 # ---------------------------------------------------------------------------
534
535
536 @pytest.mark.anyio
537 async def test_log_stat_format(
538 tmp_path: pathlib.Path,
539 muse_cli_db_session: AsyncSession,
540 capsys: pytest.CaptureFixture[str],
541 ) -> None:
542 """``--stat`` shows file-change summary with 'N files changed' line."""
543 _init_muse_repo(tmp_path)
544 cids = await _make_commits(tmp_path, muse_cli_db_session, ["take 1", "take 2"])
545
546 capsys.readouterr()
547 await _log_async(
548 root=tmp_path,
549 session=muse_cli_db_session,
550 limit=1000,
551 graph=False,
552 stat=True,
553 )
554 out = capsys.readouterr().out
555
556 # Both commit IDs in output
557 assert cids[0] in out
558 assert cids[1] in out
559 # File change summary present
560 assert "file" in out and "changed" in out
561
562
563 @pytest.mark.anyio
564 async def test_log_stat_shows_added_files(
565 tmp_path: pathlib.Path,
566 muse_cli_db_session: AsyncSession,
567 capsys: pytest.CaptureFixture[str],
568 ) -> None:
569 """``--stat`` lists individual added files per commit."""
570 _init_muse_repo(tmp_path)
571 _write_workdir(tmp_path, {"song.mid": b"MIDI"})
572 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
573
574 capsys.readouterr()
575 await _log_async(
576 root=tmp_path,
577 session=muse_cli_db_session,
578 limit=1,
579 graph=False,
580 stat=True,
581 )
582 out = capsys.readouterr().out
583 assert "song.mid" in out
584 assert "added" in out
585
586
587 # ---------------------------------------------------------------------------
588 # test_log_patch_format
589 # ---------------------------------------------------------------------------
590
591
592 @pytest.mark.anyio
593 async def test_log_patch_format(
594 tmp_path: pathlib.Path,
595 muse_cli_db_session: AsyncSession,
596 capsys: pytest.CaptureFixture[str],
597 ) -> None:
598 """``--patch`` shows ``--- /dev/null`` / ``+++`` diff lines for added files."""
599 _init_muse_repo(tmp_path)
600 _write_workdir(tmp_path, {"drums.mid": b"MIDI"})
601 cids = [await _commit_async(message="add drums", root=tmp_path, session=muse_cli_db_session)]
602
603 capsys.readouterr()
604 await _log_async(
605 root=tmp_path,
606 session=muse_cli_db_session,
607 limit=1,
608 graph=False,
609 patch=True,
610 )
611 out = capsys.readouterr().out
612
613 assert cids[0] in out
614 assert "--- /dev/null" in out
615 assert "+++ drums.mid" in out
616
617
618 @pytest.mark.anyio
619 async def test_log_patch_removed_files(
620 tmp_path: pathlib.Path,
621 muse_cli_db_session: AsyncSession,
622 capsys: pytest.CaptureFixture[str],
623 ) -> None:
624 """``--patch`` shows ``+++ /dev/null`` for removed files."""
625 _init_muse_repo(tmp_path)
626 workdir = tmp_path / "muse-work"
627 workdir.mkdir(exist_ok=True)
628
629 # First commit: two files
630 (workdir / "a.mid").write_bytes(b"A")
631 (workdir / "b.mid").write_bytes(b"B")
632 await _commit_async(message="two files", root=tmp_path, session=muse_cli_db_session)
633
634 # Second commit: remove b.mid
635 (workdir / "b.mid").unlink()
636 cids2 = [await _commit_async(message="remove b", root=tmp_path, session=muse_cli_db_session)]
637
638 capsys.readouterr()
639 await _log_async(
640 root=tmp_path,
641 session=muse_cli_db_session,
642 limit=1,
643 graph=False,
644 patch=True,
645 )
646 out = capsys.readouterr().out
647
648 assert "b.mid" in out
649 assert "+++ /dev/null" in out
650
651
652 # ---------------------------------------------------------------------------
653 # test_compute_diff unit tests
654 # ---------------------------------------------------------------------------
655
656
657 @pytest.mark.anyio
658 async def test_compute_diff_root_commit(
659 tmp_path: pathlib.Path,
660 muse_cli_db_session: AsyncSession,
661 ) -> None:
662 """Root commit (no parent) treats all files as added."""
663 _init_muse_repo(tmp_path)
664 _write_workdir(tmp_path, {"a.mid": b"A", "b.mid": b"B"})
665 cid = await _commit_async(message="root", root=tmp_path, session=muse_cli_db_session)
666
667 commit = await muse_cli_db_session.get(MuseCliCommit, cid)
668 assert commit is not None
669 diff = await _compute_diff(muse_cli_db_session, commit)
670
671 assert set(diff.added) == {"a.mid", "b.mid"}
672 assert diff.removed == []
673 assert diff.changed == []
674
675
676 @pytest.mark.anyio
677 async def test_compute_diff_added_and_removed(
678 tmp_path: pathlib.Path,
679 muse_cli_db_session: AsyncSession,
680 ) -> None:
681 """Diff between two commits correctly classifies added and removed files."""
682 _init_muse_repo(tmp_path)
683 workdir = tmp_path / "muse-work"
684 workdir.mkdir(exist_ok=True)
685
686 (workdir / "keep.mid").write_bytes(b"KEEP")
687 (workdir / "gone.mid").write_bytes(b"GONE")
688 await _commit_async(message="v1", root=tmp_path, session=muse_cli_db_session)
689
690 (workdir / "gone.mid").unlink()
691 (workdir / "new.mid").write_bytes(b"NEW")
692 cid2 = await _commit_async(message="v2", root=tmp_path, session=muse_cli_db_session)
693
694 commit2 = await muse_cli_db_session.get(MuseCliCommit, cid2)
695 assert commit2 is not None
696 diff = await _compute_diff(muse_cli_db_session, commit2)
697
698 assert "new.mid" in diff.added
699 assert "gone.mid" in diff.removed
700 assert diff.changed == []
701
702
703 # ---------------------------------------------------------------------------
704 # Flag combination tests
705 # ---------------------------------------------------------------------------
706
707
708 @pytest.mark.anyio
709 async def test_log_oneline_with_author_filter(
710 tmp_path: pathlib.Path,
711 muse_cli_db_session: AsyncSession,
712 capsys: pytest.CaptureFixture[str],
713 ) -> None:
714 """``--oneline`` and ``--author`` can be combined: one line per matching commit."""
715 _init_muse_repo(tmp_path)
716 cids_a = await _make_commits(
717 tmp_path, muse_cli_db_session, ["alice work"], file_seed=0, author="alice"
718 )
719 cids_b = await _make_commits(
720 tmp_path, muse_cli_db_session, ["bob work"], file_seed=5, author="bob"
721 )
722
723 capsys.readouterr()
724 await _log_async(
725 root=tmp_path,
726 session=muse_cli_db_session,
727 limit=1000,
728 graph=False,
729 oneline=True,
730 author="alice",
731 )
732 out = capsys.readouterr().out
733 lines = [ln for ln in out.splitlines() if ln.strip()]
734
735 assert len(lines) == 1
736 assert cids_a[0][:8] in lines[0]
737 assert cids_b[0][:8] not in out
738
739
740 @pytest.mark.anyio
741 async def test_log_since_with_oneline(
742 tmp_path: pathlib.Path,
743 muse_cli_db_session: AsyncSession,
744 capsys: pytest.CaptureFixture[str],
745 ) -> None:
746 """``--since`` combined with ``--oneline`` filters correctly in oneline format."""
747 _init_muse_repo(tmp_path)
748 now = datetime.now(timezone.utc)
749 cids = await _make_commits(tmp_path, muse_cli_db_session, ["old", "new"])
750
751 # Back-date first commit
752 old_commit = await muse_cli_db_session.get(MuseCliCommit, cids[0])
753 assert old_commit is not None
754 old_commit.committed_at = now - timedelta(days=10)
755 muse_cli_db_session.add(old_commit)
756 await muse_cli_db_session.flush()
757
758 since_dt = now - timedelta(days=3)
759 capsys.readouterr()
760 await _log_async(
761 root=tmp_path,
762 session=muse_cli_db_session,
763 limit=1000,
764 graph=False,
765 oneline=True,
766 since=since_dt,
767 )
768 out = capsys.readouterr().out
769 lines = [ln for ln in out.splitlines() if ln.strip()]
770
771 assert len(lines) == 1
772 assert cids[1][:8] in lines[0]
773
774
775 @pytest.mark.anyio
776 async def test_log_no_results_after_filter_exits_zero(
777 tmp_path: pathlib.Path,
778 muse_cli_db_session: AsyncSession,
779 capsys: pytest.CaptureFixture[str],
780 ) -> None:
781 """When filters eliminate all commits, exits 0 with friendly message."""
782 import typer
783
784 _init_muse_repo(tmp_path)
785 await _make_commits(tmp_path, muse_cli_db_session, ["take 1"])
786
787 # Set since to future so all commits are excluded
788 future = datetime.now(timezone.utc) + timedelta(days=100)
789
790 with pytest.raises(typer.Exit) as exc_info:
791 await _log_async(
792 root=tmp_path,
793 session=muse_cli_db_session,
794 limit=1000,
795 graph=False,
796 since=future,
797 )
798
799 assert exc_info.value.exit_code == ExitCode.SUCCESS
800 out = capsys.readouterr().out
801 assert "No commits yet" in out
802
803
804 # ---------------------------------------------------------------------------
805 # CommitDiff.total_files property test
806 # ---------------------------------------------------------------------------
807
808
809 def test_commit_diff_total_files() -> None:
810 """CommitDiff.total_files sums added + removed + changed."""
811 diff = CommitDiff(
812 added=["a.mid", "b.mid"],
813 removed=["c.mid"],
814 changed=["d.mid", "e.mid"],
815 )
816 assert diff.total_files == 5
817
818
819 def test_commit_diff_empty() -> None:
820 """CommitDiff with no changes has total_files == 0."""
821 diff = CommitDiff(added=[], removed=[], changed=[])
822 assert diff.total_files == 0