log.py
python
| 1 | """muse log — display commit history. |
| 2 | |
| 3 | Output modes |
| 4 | ------------ |
| 5 | |
| 6 | Default:: |
| 7 | |
| 8 | commit a1b2c3d4 (HEAD -> main) |
| 9 | Author: gabriel |
| 10 | Date: 2026-03-16 12:00:00 UTC |
| 11 | |
| 12 | Add verse melody |
| 13 | |
| 14 | --oneline:: |
| 15 | |
| 16 | a1b2c3d4 (HEAD -> main) Add verse melody |
| 17 | f9e8d7c6 Initial commit |
| 18 | |
| 19 | --graph:: |
| 20 | |
| 21 | * a1b2c3d4 (HEAD -> main) Add verse melody |
| 22 | * f9e8d7c6 Initial commit |
| 23 | |
| 24 | --stat:: |
| 25 | |
| 26 | commit a1b2c3d4 (HEAD -> main) |
| 27 | Date: 2026-03-16 12:00:00 UTC |
| 28 | |
| 29 | Add verse melody |
| 30 | |
| 31 | tracks/drums.mid | added |
| 32 | 1 file changed |
| 33 | |
| 34 | Filters: --since, --until, --author, --section, --track, --emotion |
| 35 | """ |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import json |
| 39 | import logging |
| 40 | import pathlib |
| 41 | import re |
| 42 | from datetime import datetime, timedelta, timezone |
| 43 | from typing import Optional |
| 44 | |
| 45 | import typer |
| 46 | |
| 47 | from muse.core.errors import ExitCode |
| 48 | from muse.core.repo import require_repo |
| 49 | from muse.core.store import CommitRecord, get_commits_for_branch, get_commit_snapshot_manifest, read_snapshot |
| 50 | |
| 51 | logger = logging.getLogger(__name__) |
| 52 | |
| 53 | app = typer.Typer() |
| 54 | |
| 55 | _DEFAULT_LIMIT = 1000 |
| 56 | |
| 57 | |
| 58 | def _read_branch(root: pathlib.Path) -> str: |
| 59 | head_ref = (root / ".muse" / "HEAD").read_text().strip() |
| 60 | return head_ref.removeprefix("refs/heads/").strip() |
| 61 | |
| 62 | |
| 63 | def _read_repo_id(root: pathlib.Path) -> str: |
| 64 | return json.loads((root / ".muse" / "repo.json").read_text())["repo_id"] |
| 65 | |
| 66 | |
| 67 | def _parse_date(text: str) -> datetime: |
| 68 | text = text.strip().lower() |
| 69 | now = datetime.now(timezone.utc) |
| 70 | if text == "today": |
| 71 | return now.replace(hour=0, minute=0, second=0, microsecond=0) |
| 72 | if text == "yesterday": |
| 73 | return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) |
| 74 | m = re.match(r"^(\d+)\s+(day|week|month|year)s?\s+ago$", text) |
| 75 | if m: |
| 76 | n = int(m.group(1)) |
| 77 | unit = m.group(2) |
| 78 | deltas = {"day": timedelta(days=n), "week": timedelta(weeks=n), |
| 79 | "month": timedelta(days=n * 30), "year": timedelta(days=n * 365)} |
| 80 | return now - deltas[unit] |
| 81 | for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): |
| 82 | try: |
| 83 | return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) |
| 84 | except ValueError: |
| 85 | continue |
| 86 | raise ValueError(f"Cannot parse date: {text!r}") |
| 87 | |
| 88 | |
| 89 | def _file_diff(root: pathlib.Path, commit: CommitRecord) -> tuple[list[str], list[str]]: |
| 90 | """Return (added, removed) file lists relative to the commit's parent.""" |
| 91 | current_manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 92 | if commit.parent_commit_id: |
| 93 | parent_manifest = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {} |
| 94 | else: |
| 95 | parent_manifest = {} |
| 96 | added = sorted(set(current_manifest) - set(parent_manifest)) |
| 97 | removed = sorted(set(parent_manifest) - set(current_manifest)) |
| 98 | return added, removed |
| 99 | |
| 100 | |
| 101 | def _format_date(dt: datetime) -> str: |
| 102 | return dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt.tzinfo else str(dt) |
| 103 | |
| 104 | |
| 105 | @app.callback(invoke_without_command=True) |
| 106 | def log( |
| 107 | ctx: typer.Context, |
| 108 | ref: Optional[str] = typer.Argument(None, help="Branch or commit to start from."), |
| 109 | oneline: bool = typer.Option(False, "--oneline", help="One line per commit."), |
| 110 | graph: bool = typer.Option(False, "--graph", help="ASCII graph."), |
| 111 | stat: bool = typer.Option(False, "--stat", help="Show file change summary."), |
| 112 | patch: bool = typer.Option(False, "--patch", "-p", help="Show file diff."), |
| 113 | limit: int = typer.Option(_DEFAULT_LIMIT, "-n", "--max-count", help="Limit number of commits."), |
| 114 | since: Optional[str] = typer.Option(None, "--since", help="Show commits after date."), |
| 115 | until: Optional[str] = typer.Option(None, "--until", help="Show commits before date."), |
| 116 | author: Optional[str] = typer.Option(None, "--author", help="Filter by author."), |
| 117 | section: Optional[str] = typer.Option(None, "--section", help="Filter by section metadata."), |
| 118 | track: Optional[str] = typer.Option(None, "--track", help="Filter by track metadata."), |
| 119 | emotion: Optional[str] = typer.Option(None, "--emotion", help="Filter by emotion metadata."), |
| 120 | ) -> None: |
| 121 | """Display commit history.""" |
| 122 | root = require_repo() |
| 123 | repo_id = _read_repo_id(root) |
| 124 | branch = ref or _read_branch(root) |
| 125 | |
| 126 | since_dt = _parse_date(since) if since else None |
| 127 | until_dt = _parse_date(until) if until else None |
| 128 | |
| 129 | commits = get_commits_for_branch(root, repo_id, branch) |
| 130 | |
| 131 | # Apply filters |
| 132 | filtered: list[CommitRecord] = [] |
| 133 | for c in commits: |
| 134 | if since_dt and c.committed_at < since_dt: |
| 135 | continue |
| 136 | if until_dt and c.committed_at > until_dt: |
| 137 | continue |
| 138 | if author and author.lower() not in c.author.lower(): |
| 139 | continue |
| 140 | if section and c.metadata.get("section") != section: |
| 141 | continue |
| 142 | if track and c.metadata.get("track") != track: |
| 143 | continue |
| 144 | if emotion and c.metadata.get("emotion") != emotion: |
| 145 | continue |
| 146 | filtered.append(c) |
| 147 | if len(filtered) >= limit: |
| 148 | break |
| 149 | |
| 150 | if not filtered: |
| 151 | typer.echo("(no commits)") |
| 152 | return |
| 153 | |
| 154 | head_commit_id = filtered[0].commit_id if filtered else None |
| 155 | |
| 156 | for c in filtered: |
| 157 | is_head = c.commit_id == head_commit_id |
| 158 | ref_label = f" (HEAD -> {branch})" if is_head else "" |
| 159 | |
| 160 | if oneline: |
| 161 | typer.echo(f"{c.commit_id[:8]}{ref_label} {c.message}") |
| 162 | |
| 163 | elif graph: |
| 164 | typer.echo(f"* {c.commit_id[:8]}{ref_label} {c.message}") |
| 165 | |
| 166 | else: |
| 167 | typer.echo(f"commit {c.commit_id[:8]}{ref_label}") |
| 168 | if c.author: |
| 169 | typer.echo(f"Author: {c.author}") |
| 170 | typer.echo(f"Date: {_format_date(c.committed_at)}") |
| 171 | if c.metadata: |
| 172 | meta_parts = [f"{k}: {v}" for k, v in sorted(c.metadata.items())] |
| 173 | typer.echo(f"Meta: {', '.join(meta_parts)}") |
| 174 | typer.echo(f"\n {c.message}\n") |
| 175 | |
| 176 | if stat or patch: |
| 177 | added, removed = _file_diff(root, c) |
| 178 | for p in added: |
| 179 | typer.echo(f" + {p}") |
| 180 | for p in removed: |
| 181 | typer.echo(f" - {p}") |
| 182 | if added or removed: |
| 183 | typer.echo(f" {len(added)} added, {len(removed)} removed\n") |