cgcardona / muse public
log.py python
183 lines 6.1 KB
d87ef453 Introduce Muse v2 architecture: domain-agnostic VCS with plugin interface Gabriel Cardona <gabriel@tellurstori.com> 3d ago
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")