cgcardona / muse public
log.py python
655 lines 21.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse log — commit history display with full flag set for history navigation.
2
3 Walks the commit parent chain from the current branch HEAD and prints
4 each commit newest-first with configurable formatting and filtering.
5
6 Output modes (combinable with filters):
7
8 Default (``git log`` style)::
9
10 commit a1b2c3d4 (HEAD -> main)
11 Parent: f9e8d7c6
12 Date: 2026-02-27 17:30:00
13
14 boom bap demo take 1
15
16 ``--oneline``::
17
18 a1b2c3d4 (HEAD -> main) boom bap demo take 1
19 f9e8d7c6 initial take
20
21 ``--graph``::
22
23 * a1b2c3d4 boom bap demo take 1 (HEAD)
24 * f9e8d7c6 initial take
25
26 ``--stat``::
27
28 commit a1b2c3d4 (HEAD -> main)
29 Date: 2026-02-27 17:30:00
30
31 boom bap demo take 1
32
33 muse-work/drums/jazz.mid | added
34 2 files changed, 1 added, 1 removed
35
36 ``--patch``::
37
38 commit a1b2c3d4 (HEAD -> main)
39 Date: 2026-02-27 17:30:00
40
41 boom bap demo take 1
42
43 --- /dev/null
44 +++ muse-work/drums/jazz.mid
45 --- muse-work/bass/old.mid
46 +++ /dev/null
47
48 Filters (all combinable with each other and with output modes):
49
50 - ``--since DATE`` / ``--until DATE`` — ISO date or relative ("2 weeks ago")
51 - ``--author TEXT`` — case-insensitive substring match on author field
52 - ``--emotion TEXT`` — commits tagged ``emotion:<TEXT>``
53 - ``--section TEXT`` — commits tagged ``section:<TEXT>``
54 - ``--track TEXT`` — commits tagged ``track:<TEXT>``
55
56 ``--graph`` reuses ``maestro.services.muse_log_render.render_ascii_graph``
57 by adapting ``MuseCliCommit`` rows to the ``MuseLogGraph``/``MuseLogNode``
58 dataclasses that the renderer expects.
59
60 Merge commits (two parents) will be supported once ``muse merge`` lands. The current data model stores a single ``parent_commit_id``;
61 ``parent2_commit_id`` is reserved for that iteration.
62 """
63 from __future__ import annotations
64
65 import asyncio
66 import json
67 import logging
68 import pathlib
69 import re
70 from dataclasses import dataclass
71 from datetime import datetime, timedelta, timezone
72 from typing import Optional
73
74 import typer
75 from sqlalchemy.ext.asyncio import AsyncSession
76 from sqlalchemy.future import select
77
78 from maestro.muse_cli._repo import require_repo
79 from maestro.muse_cli.db import open_session
80 from maestro.muse_cli.errors import ExitCode
81 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot, MuseCliTag
82
83 logger = logging.getLogger(__name__)
84
85 app = typer.Typer()
86
87 _DEFAULT_LIMIT = 1000
88
89
90 # ---------------------------------------------------------------------------
91 # Date parsing
92 # ---------------------------------------------------------------------------
93
94
95 def parse_date_filter(text: str) -> datetime:
96 """Parse a human-readable date string into a timezone-aware UTC datetime.
97
98 Accepts ISO dates and relative English expressions so that producers can
99 write ``--since "2 weeks ago"`` without computing exact timestamps.
100
101 Supported formats:
102 - ISO: ``"2026-01-01"``, ``"2026-01-01T12:00:00"``, ``"2026-01-01 12:00:00"``
103 - Relative: ``"N days ago"``, ``"N weeks ago"``, ``"N months ago"``,
104 ``"N years ago"``, ``"yesterday"``, ``"today"``
105
106 Raises:
107 ValueError: When no supported format matches.
108 """
109 text = text.strip().lower()
110 now = datetime.now(timezone.utc)
111
112 if text == "today":
113 return now.replace(hour=0, minute=0, second=0, microsecond=0)
114 if text == "yesterday":
115 return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
116
117 m = re.match(r"^(\d+)\s+(day|week|month|year)s?\s+ago$", text)
118 if m:
119 n = int(m.group(1))
120 unit = m.group(2)
121 delta: timedelta
122 if unit == "day":
123 delta = timedelta(days=n)
124 elif unit == "week":
125 delta = timedelta(weeks=n)
126 elif unit == "month":
127 delta = timedelta(days=n * 30)
128 else: # year
129 delta = timedelta(days=n * 365)
130 return now - delta
131
132 for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
133 try:
134 dt = datetime.strptime(text, fmt)
135 return dt.replace(tzinfo=timezone.utc)
136 except ValueError:
137 continue
138
139 raise ValueError(
140 f"Cannot parse date: {text!r}. "
141 "Use ISO format (YYYY-MM-DD) or relative ('N days/weeks/months ago')."
142 )
143
144
145 # ---------------------------------------------------------------------------
146 # Snapshot diff
147 # ---------------------------------------------------------------------------
148
149
150 @dataclass
151 class CommitDiff:
152 """File-level diff between a commit and its parent.
153
154 Computed by comparing snapshot manifests (path → object_id maps).
155 Used by --stat and --patch renderers to describe what changed in each commit.
156 """
157
158 added: list[str]
159 removed: list[str]
160 changed: list[str]
161
162 @property
163 def total_files(self) -> int:
164 return len(self.added) + len(self.removed) + len(self.changed)
165
166
167 async def _compute_diff(session: AsyncSession, commit: MuseCliCommit) -> CommitDiff:
168 """Compare *commit*'s snapshot with its parent's snapshot.
169
170 Returns a :class:`CommitDiff` with lists of added, removed, and modified paths.
171 For the root commit (no parent) all files are treated as added.
172 """
173 current_snap = await session.get(MuseCliSnapshot, commit.snapshot_id)
174 current_manifest: dict[str, str] = dict(current_snap.manifest) if current_snap else {}
175
176 parent_manifest: dict[str, str] = {}
177 if commit.parent_commit_id:
178 parent_commit = await session.get(MuseCliCommit, commit.parent_commit_id)
179 if parent_commit:
180 parent_snap = await session.get(MuseCliSnapshot, parent_commit.snapshot_id)
181 if parent_snap:
182 parent_manifest = dict(parent_snap.manifest)
183
184 current_paths = set(current_manifest.keys())
185 parent_paths = set(parent_manifest.keys())
186
187 return CommitDiff(
188 added=sorted(current_paths - parent_paths),
189 removed=sorted(parent_paths - current_paths),
190 changed=sorted(
191 p for p in current_paths & parent_paths
192 if current_manifest[p] != parent_manifest[p]
193 ),
194 )
195
196
197 # ---------------------------------------------------------------------------
198 # Commit loading with inline filters
199 # ---------------------------------------------------------------------------
200
201
202 async def _load_commits(
203 session: AsyncSession,
204 head_commit_id: str,
205 limit: int,
206 since: datetime | None = None,
207 until: datetime | None = None,
208 author: str | None = None,
209 ) -> list[MuseCliCommit]:
210 """Walk the parent chain from *head_commit_id*, returning newest-first.
211
212 Applies date and author filters inline while walking so we stop early when
213 walking past the ``--since`` boundary. Tag-based filters (emotion, section,
214 track) are applied afterward by ``_filter_by_tags`` to keep this function
215 focused on chain traversal.
216
217 Date comparison uses ``committed_at`` (UTC-aware). Both ``since`` and
218 ``until`` should be UTC-aware datetimes (produced by :func:`parse_date_filter`).
219 """
220 commits: list[MuseCliCommit] = []
221 current_id: str | None = head_commit_id
222 while current_id and len(commits) < limit:
223 commit = await session.get(MuseCliCommit, current_id)
224 if commit is None:
225 logger.warning("⚠️ Commit %s not found in DB — chain broken", current_id[:8])
226 break
227
228 ts = commit.committed_at
229 # Normalise to UTC-aware for comparison
230 if ts.tzinfo is None:
231 ts = ts.replace(tzinfo=timezone.utc)
232
233 # --until: skip commits after the cutoff but keep walking (older commits may qualify)
234 if until is not None:
235 until_aware = until if until.tzinfo else until.replace(tzinfo=timezone.utc)
236 if ts > until_aware:
237 current_id = commit.parent_commit_id
238 continue
239
240 # --since: stop walking — everything older is also out of range
241 if since is not None:
242 since_aware = since if since.tzinfo else since.replace(tzinfo=timezone.utc)
243 if ts < since_aware:
244 break
245
246 # --author: case-insensitive substring match
247 if author is not None and author.lower() not in commit.author.lower():
248 current_id = commit.parent_commit_id
249 continue
250
251 commits.append(commit)
252 current_id = commit.parent_commit_id
253
254 return commits
255
256
257 async def _filter_by_tags(
258 session: AsyncSession,
259 commits: list[MuseCliCommit],
260 emotion: str | None,
261 section: str | None,
262 track: str | None,
263 ) -> list[MuseCliCommit]:
264 """Retain only commits that have ALL of the requested music-native tags.
265
266 Tags are stored in the ``muse_cli_tags`` table with ``emotion:<value>``,
267 ``section:<value>``, and ``track:<value>`` conventions. A commit must
268 carry every specified tag to pass the filter — filters are AND-combined.
269
270 When no tag filters are specified the input list is returned unchanged.
271 """
272 required_tags: list[str] = []
273 if emotion:
274 required_tags.append(f"emotion:{emotion}")
275 if section:
276 required_tags.append(f"section:{section}")
277 if track:
278 required_tags.append(f"track:{track}")
279
280 if not required_tags:
281 return commits
282
283 matched: list[MuseCliCommit] = []
284 for commit in commits:
285 cid = commit.commit_id
286 has_all = True
287 for tag_val in required_tags:
288 result = await session.execute(
289 select(MuseCliTag).where(
290 MuseCliTag.commit_id == cid,
291 MuseCliTag.tag == tag_val,
292 )
293 )
294 if result.scalar_one_or_none() is None:
295 has_all = False
296 break
297 if has_all:
298 matched.append(commit)
299
300 return matched
301
302
303 # ---------------------------------------------------------------------------
304 # Renderers
305 # ---------------------------------------------------------------------------
306
307
308 def _render_log(
309 commits: list[MuseCliCommit],
310 *,
311 head_commit_id: str,
312 branch: str,
313 ) -> None:
314 """Print commits in ``git log`` style, newest-first."""
315 for commit in commits:
316 head_marker = f" (HEAD -> {branch})" if commit.commit_id == head_commit_id else ""
317 typer.echo(f"commit {commit.commit_id}{head_marker}")
318 if commit.parent_commit_id:
319 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
320 ts = commit.committed_at.strftime("%Y-%m-%d %H:%M:%S")
321 typer.echo(f"Date: {ts}")
322 typer.echo("")
323 typer.echo(f" {commit.message}")
324 typer.echo("")
325
326
327 def _render_oneline(
328 commits: list[MuseCliCommit],
329 *,
330 head_commit_id: str,
331 branch: str,
332 ) -> None:
333 """Print one line per commit: ``<short_id> [HEAD marker] <message>``."""
334 for commit in commits:
335 short = commit.commit_id[:8]
336 head_marker = f" (HEAD -> {branch})" if commit.commit_id == head_commit_id else ""
337 typer.echo(f"{short}{head_marker} {commit.message}")
338
339
340 def _render_graph(commits: list[MuseCliCommit], *, head_commit_id: str) -> None:
341 """Render ASCII DAG via ``render_ascii_graph``.
342
343 Adapts ``MuseCliCommit`` rows to ``MuseLogGraph``/``MuseLogNode`` so
344 the existing renderer can be reused without modification.
345
346 Commits are passed in newest-first (as returned by ``_load_commits``);
347 the renderer expects oldest-first, so the list is reversed before
348 building the graph.
349 """
350 from maestro.services.muse_log_graph import MuseLogGraph, MuseLogNode
351 from maestro.services.muse_log_render import render_ascii_graph
352
353 nodes = tuple(
354 MuseLogNode(
355 variation_id=c.commit_id,
356 parent=c.parent_commit_id,
357 parent2=None, # merge parent — added
358 is_head=(c.commit_id == head_commit_id),
359 timestamp=c.committed_at.timestamp(),
360 intent=c.message,
361 affected_regions=(),
362 )
363 for c in reversed(commits) # oldest → newest for the DAG walker
364 )
365 graph_obj = MuseLogGraph(project_id="muse-cli", head=head_commit_id, nodes=nodes)
366 typer.echo(render_ascii_graph(graph_obj))
367
368
369 def _render_stat(
370 commits: list[MuseCliCommit],
371 diffs: list[CommitDiff],
372 *,
373 head_commit_id: str,
374 branch: str,
375 ) -> None:
376 """Print commits with per-commit file change statistics.
377
378 Each commit block shows the standard header followed by a compact
379 file-change summary: one line per changed path and a totals line.
380 Mirrors ``git log --stat`` output style.
381 """
382 for commit, diff in zip(commits, diffs):
383 head_marker = f" (HEAD -> {branch})" if commit.commit_id == head_commit_id else ""
384 typer.echo(f"commit {commit.commit_id}{head_marker}")
385 if commit.parent_commit_id:
386 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
387 ts = commit.committed_at.strftime("%Y-%m-%d %H:%M:%S")
388 typer.echo(f"Date: {ts}")
389 typer.echo("")
390 typer.echo(f" {commit.message}")
391 typer.echo("")
392
393 # File stats
394 for path in diff.added:
395 typer.echo(f" {path} | added")
396 for path in diff.changed:
397 typer.echo(f" {path} | modified")
398 for path in diff.removed:
399 typer.echo(f" {path} | removed")
400
401 total = diff.total_files
402 if total:
403 parts = []
404 if diff.added or diff.changed:
405 parts.append(f"{len(diff.added) + len(diff.changed)} added")
406 if diff.removed or diff.changed:
407 parts.append(f"{len(diff.removed) + len(diff.changed)} removed")
408 typer.echo(f" {total} file{'s' if total != 1 else ''} changed, {', '.join(parts)}")
409 else:
410 typer.echo(" (no file changes)")
411 typer.echo("")
412
413
414 def _render_patch(
415 commits: list[MuseCliCommit],
416 diffs: list[CommitDiff],
417 *,
418 head_commit_id: str,
419 branch: str,
420 ) -> None:
421 """Print commits with a path-level diff block.
422
423 Shows which files were added, removed, or modified relative to the
424 parent commit. This is a structural diff (path-level, not byte-level)
425 since Muse tracks MIDI/audio blobs that are not line-diffable.
426 Mirrors the visual intent of ``git log --patch``.
427 """
428 for commit, diff in zip(commits, diffs):
429 head_marker = f" (HEAD -> {branch})" if commit.commit_id == head_commit_id else ""
430 typer.echo(f"commit {commit.commit_id}{head_marker}")
431 if commit.parent_commit_id:
432 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
433 ts = commit.committed_at.strftime("%Y-%m-%d %H:%M:%S")
434 typer.echo(f"Date: {ts}")
435 typer.echo("")
436 typer.echo(f" {commit.message}")
437 typer.echo("")
438
439 if diff.total_files == 0:
440 typer.echo("(no file changes)")
441 typer.echo("")
442 continue
443
444 for path in diff.added:
445 typer.echo(f"--- /dev/null")
446 typer.echo(f"+++ {path}")
447 for path in diff.changed:
448 typer.echo(f"--- {path}")
449 typer.echo(f"+++ {path}")
450 for path in diff.removed:
451 typer.echo(f"--- {path}")
452 typer.echo(f"+++ /dev/null")
453 typer.echo("")
454
455
456 # ---------------------------------------------------------------------------
457 # Testable async core
458 # ---------------------------------------------------------------------------
459
460
461 async def _log_async(
462 *,
463 root: pathlib.Path,
464 session: AsyncSession,
465 limit: int,
466 graph: bool,
467 oneline: bool = False,
468 stat: bool = False,
469 patch: bool = False,
470 since: datetime | None = None,
471 until: datetime | None = None,
472 author: str | None = None,
473 emotion: str | None = None,
474 section: str | None = None,
475 track: str | None = None,
476 ) -> None:
477 """Core log logic — fully injectable for tests.
478
479 Reads repo state from ``.muse/``, loads and filters commits from the DB
480 session, then dispatches to the appropriate renderer based on output mode
481 flags. All flags are combinable: filters narrow the commit set, output
482 mode flags control formatting.
483
484 Priority when multiple output modes are specified:
485 ``--graph`` > ``--oneline`` > ``--stat`` > ``--patch`` > default.
486 """
487 muse_dir = root / ".muse"
488 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
489 repo_id = repo_data["repo_id"] # noqa: F841 — kept for future remote filtering
490
491 head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main"
492 branch = head_ref.rsplit("/", 1)[-1] # "main"
493 ref_path = muse_dir / pathlib.Path(head_ref)
494
495 head_commit_id = ""
496 if ref_path.exists():
497 head_commit_id = ref_path.read_text().strip()
498
499 if not head_commit_id:
500 typer.echo(f"No commits yet on branch {branch}")
501 raise typer.Exit(code=ExitCode.SUCCESS)
502
503 commits = await _load_commits(
504 session,
505 head_commit_id=head_commit_id,
506 limit=limit,
507 since=since,
508 until=until,
509 author=author,
510 )
511
512 # Apply tag-based filters (emotion, section, track)
513 commits = await _filter_by_tags(session, commits, emotion=emotion, section=section, track=track)
514
515 if not commits:
516 typer.echo(f"No commits yet on branch {branch}")
517 raise typer.Exit(code=ExitCode.SUCCESS)
518
519 if graph:
520 _render_graph(commits, head_commit_id=head_commit_id)
521 elif oneline:
522 _render_oneline(commits, head_commit_id=head_commit_id, branch=branch)
523 elif stat:
524 diffs = [await _compute_diff(session, c) for c in commits]
525 _render_stat(commits, diffs, head_commit_id=head_commit_id, branch=branch)
526 elif patch:
527 diffs = [await _compute_diff(session, c) for c in commits]
528 _render_patch(commits, diffs, head_commit_id=head_commit_id, branch=branch)
529 else:
530 _render_log(commits, head_commit_id=head_commit_id, branch=branch)
531
532
533 # ---------------------------------------------------------------------------
534 # Typer command
535 # ---------------------------------------------------------------------------
536
537
538 @app.callback(invoke_without_command=True)
539 def log(
540 ctx: typer.Context,
541 limit: int = typer.Option(
542 _DEFAULT_LIMIT,
543 "--limit",
544 "-n",
545 help="Maximum number of commits to show.",
546 min=1,
547 ),
548 graph: bool = typer.Option(
549 False,
550 "--graph",
551 help="Show ASCII DAG (git log --graph style).",
552 ),
553 oneline: bool = typer.Option(
554 False,
555 "--oneline",
556 help="One line per commit: <short_id> [HEAD] <message>.",
557 ),
558 stat: bool = typer.Option(
559 False,
560 "--stat",
561 help="Show file-change statistics per commit (files added/removed/modified).",
562 ),
563 patch: bool = typer.Option(
564 False,
565 "--patch",
566 "-p",
567 help="Show path-level diff per commit (files added/removed/modified).",
568 ),
569 since: Optional[str] = typer.Option(
570 None,
571 "--since",
572 help="Only show commits after DATE (ISO or '2 weeks ago').",
573 metavar="DATE",
574 ),
575 until: Optional[str] = typer.Option(
576 None,
577 "--until",
578 help="Only show commits before DATE (ISO or '2 weeks ago').",
579 metavar="DATE",
580 ),
581 author: Optional[str] = typer.Option(
582 None,
583 "--author",
584 help="Filter commits by author (case-insensitive substring match).",
585 metavar="TEXT",
586 ),
587 emotion: Optional[str] = typer.Option(
588 None,
589 "--emotion",
590 help="Filter commits tagged with emotion:<TEXT> (e.g. 'melancholic').",
591 metavar="TEXT",
592 ),
593 section: Optional[str] = typer.Option(
594 None,
595 "--section",
596 help="Filter commits tagged with section:<TEXT> (e.g. 'chorus').",
597 metavar="TEXT",
598 ),
599 track: Optional[str] = typer.Option(
600 None,
601 "--track",
602 help="Filter commits tagged with track:<TEXT> (e.g. 'drums').",
603 metavar="TEXT",
604 ),
605 ) -> None:
606 """Display the commit history for the current branch.
607
608 Supports filtering by date, author, and music-native metadata tags,
609 and multiple output formats including oneline, graph, stat, and patch.
610 All flags are combinable.
611 """
612 root = require_repo()
613
614 # Parse date filters eagerly so CLI errors surface before any DB work
615 since_dt: datetime | None = None
616 until_dt: datetime | None = None
617 if since:
618 try:
619 since_dt = parse_date_filter(since)
620 except ValueError as exc:
621 typer.echo(f"❌ --since: {exc}")
622 raise typer.Exit(code=ExitCode.USER_ERROR)
623 if until:
624 try:
625 until_dt = parse_date_filter(until)
626 except ValueError as exc:
627 typer.echo(f"❌ --until: {exc}")
628 raise typer.Exit(code=ExitCode.USER_ERROR)
629
630 async def _run() -> None:
631 async with open_session() as session:
632 await _log_async(
633 root=root,
634 session=session,
635 limit=limit,
636 graph=graph,
637 oneline=oneline,
638 stat=stat,
639 patch=patch,
640 since=since_dt,
641 until=until_dt,
642 author=author,
643 emotion=emotion,
644 section=section,
645 track=track,
646 )
647
648 try:
649 asyncio.run(_run())
650 except typer.Exit:
651 raise
652 except Exception as exc:
653 typer.echo(f"❌ muse log failed: {exc}")
654 logger.error("❌ muse log error: %s", exc, exc_info=True)
655 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)