gabriel / muse public
log.py python
196 lines 6.8 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.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
37 from __future__ import annotations
38
39 import json
40 import logging
41 import pathlib
42 import re
43 from datetime import datetime, timedelta, timezone
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_commit_snapshot_manifest, get_commits_for_branch, read_current_branch, read_snapshot
50 from muse.core.validation import sanitize_display
51
52 logger = logging.getLogger(__name__)
53
54 app = typer.Typer()
55
56 _DEFAULT_LIMIT = 1000
57
58
59 def _read_branch(root: pathlib.Path) -> str:
60 return read_current_branch(root)
61
62
63 def _read_repo_id(root: pathlib.Path) -> str:
64 return str(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: str | None = 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: str | None = typer.Option(None, "--since", help="Show commits after date."),
115 until: str | None = typer.Option(None, "--until", help="Show commits before date."),
116 author: str | None = typer.Option(None, "--author", help="Filter by author."),
117 section: str | None = typer.Option(None, "--section", help="Filter by section metadata."),
118 track: str | None = typer.Option(None, "--track", help="Filter by track metadata."),
119 emotion: str | None = typer.Option(None, "--emotion", help="Filter by emotion metadata."),
120 ) -> None:
121 """Display commit history."""
122 if limit < 1:
123 typer.echo("❌ --max-count must be at least 1.", err=True)
124 raise typer.Exit(code=ExitCode.USER_ERROR)
125 root = require_repo()
126 repo_id = _read_repo_id(root)
127 branch = ref or _read_branch(root)
128
129 since_dt = _parse_date(since) if since else None
130 until_dt = _parse_date(until) if until else None
131
132 commits = get_commits_for_branch(root, repo_id, branch)
133
134 # Apply filters
135 filtered: list[CommitRecord] = []
136 for c in commits:
137 if since_dt and c.committed_at < since_dt:
138 continue
139 if until_dt and c.committed_at > until_dt:
140 continue
141 if author and author.lower() not in c.author.lower():
142 continue
143 if section and c.metadata.get("section") != section:
144 continue
145 if track and c.metadata.get("track") != track:
146 continue
147 if emotion and c.metadata.get("emotion") != emotion:
148 continue
149 filtered.append(c)
150 # Guard against zero or negative limit causing unbounded traversal.
151 if limit > 0 and len(filtered) >= limit:
152 break
153
154 if not filtered:
155 typer.echo("(no commits)")
156 return
157
158 head_commit_id = filtered[0].commit_id if filtered else None
159
160 for c in filtered:
161 is_head = c.commit_id == head_commit_id
162 ref_label = f" (HEAD -> {branch})" if is_head else ""
163
164 msg = sanitize_display(c.message)
165 author = sanitize_display(c.author)
166
167 if oneline:
168 typer.echo(f"{c.commit_id[:8]}{ref_label} {msg}")
169
170 elif graph:
171 typer.echo(f"* {c.commit_id[:8]}{ref_label} {msg}")
172
173 else:
174 typer.echo(f"commit {c.commit_id[:8]}{ref_label}")
175 if author:
176 typer.echo(f"Author: {author}")
177 typer.echo(f"Date: {_format_date(c.committed_at)}")
178 if c.sem_ver_bump and c.sem_ver_bump != "none":
179 typer.echo(f"SemVer: {c.sem_ver_bump.upper()}")
180 if c.breaking_changes:
181 safe_breaks = [sanitize_display(b) for b in c.breaking_changes[:3]]
182 typer.echo(f"Breaking: {', '.join(safe_breaks)}"
183 + (f" +{len(c.breaking_changes) - 3} more" if len(c.breaking_changes) > 3 else ""))
184 if c.metadata:
185 meta_parts = [f"{sanitize_display(k)}: {sanitize_display(v)}" for k, v in sorted(c.metadata.items())]
186 typer.echo(f"Meta: {', '.join(meta_parts)}")
187 typer.echo(f"\n {msg}\n")
188
189 if stat or patch:
190 added, removed = _file_diff(root, c)
191 for p in added:
192 typer.echo(f" + {p}")
193 for p in removed:
194 typer.echo(f" - {p}")
195 if added or removed:
196 typer.echo(f" {len(added)} added, {len(removed)} removed\n")