gabriel / muse public
shortlog.py python
498 lines 16.7 KB
Raw
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972 docs: file follow-up issue for symlog filename-too-long (muse#61) Sonnet 4.6 22 days ago
1 """``muse shortlog`` — commit summary grouped by author, agent, model, or branch.
2
3 Groups the commit history by a chosen dimension, counts commits per group, and
4 optionally lists commit messages under each. Useful for changelogs, release
5 notes, and auditing agent contribution.
6
7 Muse's rich commit metadata — ``author``, ``agent_id``, ``model_id`` — makes
8 shortlog especially expressive: you can see exactly which human or which agent
9 class (and which model) contributed each set of commits.
10
11 Usage::
12
13 muse shortlog # current branch, group by author
14 muse shortlog --all # all branches
15 muse shortlog --numbered # sort by commit count (most active first)
16 muse shortlog --summary # counts only, no message list
17 muse shortlog --group-by agent # group by agent_id instead of author
18 muse shortlog --group-by model # group by model_id
19 muse shortlog --group-by branch # group by originating branch
20 muse shortlog --since 2025-01-01 # commits on or after this date
21 muse shortlog --until 2025-06-30 # commits on or before this date
22 muse shortlog --no-merges # exclude merge commits
23 muse shortlog --json # machine-readable output
24
25 JSON output schema::
26
27 {
28 "repo_id": "<sha256:...>",
29 "branch": "<branch name or '__all__'>",
30 "truncated": false,
31 "duration_ms": 4.2,
32 "exit_code": 0,
33 "groups": [
34 {
35 "key": "<author | agent_id | model_id | branch>",
36 "count": <int>,
37 "commits": [
38 {
39 "commit_id": "<sha>",
40 "message": "<message>",
41 "committed_at": "<ISO-8601>",
42 "author": "<author>",
43 "agent_id": "<agent_id | null>",
44 "model_id": "<model_id | null>"
45 }
46 ]
47 }
48 ]
49 }
50
51 ``truncated`` is ``true`` when ``--limit`` capped the number of commits
52 loaded from history — the groups may be incomplete. It is ``false`` when
53 all matching commits were included.
54
55 All JSON responses (including the empty-result ``groups: []`` case) carry
56 ``duration_ms`` (wall-clock ms) and ``exit_code`` so agent pipelines can
57 parse timing and success uniformly. Date-parse errors (``--since`` /
58 ``--until``) emit a structured JSON error to stdout when ``--json`` is set.
59
60 Exit codes::
61
62 0 — output produced (even if empty)
63 1 — bad date format or branch not found
64 2 — not a Muse repository
65
66 Security model::
67
68 Branch names discovered via filesystem enumeration are checked for symlinks
69 before being added to the list; symlinks inside ``.muse/refs/heads/`` are
70 silently skipped. Author names, agent IDs, and commit messages are passed
71 through ``sanitize_display`` in text mode; JSON output is left raw so
72 callers receive the original values.
73 """
74
75 import argparse
76 import datetime
77 import json
78 import logging
79 import pathlib
80 import sys
81 from collections import defaultdict
82 from collections.abc import Callable
83 from typing import TypedDict
84
85 from muse.core.envelope import EnvelopeJson, make_envelope
86 from muse.core.paths import heads_dir as _heads_dir
87 from muse.core.errors import ExitCode
88 from muse.core.repo import read_repo_id, require_repo
89 from muse.core.refs import read_current_branch
90 from muse.core.commits import (
91 CommitRecord,
92 get_commits_for_branch,
93 )
94 from muse.core.validation import clamp_int, sanitize_display
95 from muse.core.timing import start_timer
96
97 type _GroupMap = dict[str, list["CommitRecord"]]
98 logger = logging.getLogger(__name__)
99
100 _GROUP_BY_CHOICES = ("author", "agent", "model", "branch")
101
102 # ---------------------------------------------------------------------------
103 # JSON wire format
104 # ---------------------------------------------------------------------------
105
106 class _CommitEntryJson(TypedDict):
107 """Per-commit entry inside a shortlog group."""
108
109 commit_id: str
110 message: str
111 committed_at: str
112 author: str | None
113 agent_id: str | None
114 model_id: str | None
115
116 class _GroupJson(TypedDict):
117 """One group (author / agent / model / branch) in the shortlog JSON."""
118
119 key: str
120 count: int
121 commits: list[_CommitEntryJson]
122
123 class _ShortlogJson(EnvelopeJson):
124 """Top-level JSON output for ``muse shortlog --json``."""
125
126 repo_id: str
127 branch: str
128 groups: list[_GroupJson]
129 truncated: bool
130
131 # ---------------------------------------------------------------------------
132 # Internal helpers
133 # ---------------------------------------------------------------------------
134
135 def _branch_names(root: pathlib.Path) -> list[str]:
136 """Return all branch names found under ``.muse/refs/heads/``.
137
138 Symlinks are silently skipped — a symlink inside the refs directory could
139 point to a file outside the repository and must not be followed.
140 """
141 heads_dir = _heads_dir(root)
142 if not heads_dir.exists():
143 return []
144 branches: list[str] = []
145 for ref_file in sorted(heads_dir.rglob("*")):
146 if ref_file.is_symlink():
147 logger.warning("⚠️ Skipping symlink ref: %s", ref_file)
148 continue
149 if ref_file.is_file():
150 branches.append(str(ref_file.relative_to(heads_dir).as_posix()))
151 return branches
152
153 def _group_key(commit: CommitRecord, group_by: str) -> str:
154 """Return the grouping key for *commit* based on *group_by*."""
155 if group_by == "author":
156 if commit.author:
157 return commit.author
158 if commit.agent_id:
159 return f"{commit.agent_id} (agent)"
160 return "(unknown)"
161 if group_by == "agent":
162 return commit.agent_id or "(no agent)"
163 if group_by == "model":
164 return commit.model_id or "(no model)"
165 if group_by == "branch":
166 return commit.branch or "(unknown branch)"
167 return commit.author or "(unknown)"
168
169 def _build_groups(
170 commits: list[CommitRecord],
171 *,
172 group_by: str,
173 by_email: bool,
174 ) -> _GroupMap:
175 """Partition *commits* into groups keyed by *group_by*.
176
177 When *by_email* is ``True`` and the commit has an ``agent_id`` distinct
178 from the author, the agent ID is appended to the key in angle brackets.
179 """
180 groups: _GroupMap = defaultdict(list)
181 for c in commits:
182 key = _group_key(c, group_by)
183 if by_email and c.agent_id and c.agent_id != c.author:
184 key = f"{key} <{c.agent_id}>"
185 groups[key].append(c)
186 return groups
187
188 def _parse_date(value: str, flag: str) -> datetime.datetime:
189 """Parse a YYYY-MM-DD date string into an aware UTC datetime.
190
191 Pure parser — raises :exc:`ValueError` on bad input so the CLI layer can
192 choose how to surface the error (JSON to stdout or plain text to stderr).
193 No I/O is performed here.
194
195 Args:
196 value: Date string to parse.
197 flag: Flag name for inclusion in the error message (e.g. ``"--since"``).
198
199 Returns:
200 Timezone-aware UTC :class:`datetime.datetime`.
201
202 Raises:
203 ValueError: If *value* is not a ``YYYY-MM-DD`` string.
204 """
205 try:
206 naive = datetime.datetime.strptime(value, "%Y-%m-%d")
207 except ValueError:
208 raise ValueError(
209 f"{flag} must be YYYY-MM-DD (got '{sanitize_display(value)}')"
210 )
211 return naive.replace(tzinfo=datetime.timezone.utc)
212
213 # ---------------------------------------------------------------------------
214 # Command registration
215 # ---------------------------------------------------------------------------
216
217 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
218 """Register the ``muse shortlog`` subcommand."""
219 parser = subparsers.add_parser(
220 "shortlog",
221 help="Summarise commit history grouped by author, agent, model, or branch.",
222 description=__doc__,
223 formatter_class=argparse.RawDescriptionHelpFormatter,
224 )
225 parser.add_argument(
226 "branch_opt",
227 nargs="?",
228 default=None,
229 metavar="BRANCH",
230 help="Branch to summarise (default: current branch).",
231 )
232 parser.add_argument(
233 "--all",
234 dest="all_branches",
235 action="store_true",
236 help="Include all branches.",
237 )
238 parser.add_argument(
239 "--numbered",
240 action="store_true",
241 help="Sort by commit count (most active first).",
242 )
243 parser.add_argument(
244 "--summary", "-s",
245 action="store_true",
246 help="Show commit counts only — suppress individual message lines.",
247 )
248 parser.add_argument(
249 "--email",
250 dest="by_email",
251 action="store_true",
252 help="Append agent_id to the group key when present.",
253 )
254 parser.add_argument(
255 "--group-by",
256 dest="group_by",
257 default="author",
258 choices=list(_GROUP_BY_CHOICES),
259 metavar="FIELD",
260 help=(
261 f"Dimension to group by: {', '.join(_GROUP_BY_CHOICES)} "
262 "(default: author)."
263 ),
264 )
265 parser.add_argument(
266 "--no-merges",
267 dest="no_merges",
268 action="store_true",
269 help="Exclude merge commits (commits with more than one parent).",
270 )
271 parser.add_argument(
272 "--since",
273 default=None,
274 metavar="YYYY-MM-DD",
275 help="Include only commits on or after this date (UTC).",
276 )
277 parser.add_argument(
278 "--until",
279 default=None,
280 metavar="YYYY-MM-DD",
281 help="Include only commits on or before this date (UTC).",
282 )
283 parser.add_argument(
284 "--limit",
285 type=int,
286 default=0,
287 metavar="N",
288 help="Cap the number of commits loaded per branch (0 = no limit).",
289 )
290 parser.add_argument(
291 "--json", "-j",
292 action="store_true",
293 dest="json_out",
294 help="Emit machine-readable JSON on stdout.",
295 )
296 parser.set_defaults(func=run)
297
298 # ---------------------------------------------------------------------------
299 # Main handler
300 # ---------------------------------------------------------------------------
301
302 def run(args: argparse.Namespace) -> None:
303 """Summarise commit history grouped by author, agent, model, or branch.
304
305 Each group shows the key, commit count, and (unless ``--summary``) each
306 commit message indented beneath. Muse commit metadata — ``author``,
307 ``agent_id``, ``model_id`` — makes ``--group-by agent`` and
308 ``--group-by model`` especially expressive for auditing agent contribution.
309
310 Agent quickstart::
311
312 muse shortlog --json
313 muse shortlog --group-by agent --json
314 muse shortlog --group-by model --numbered --json
315 muse shortlog --since 2026-01-01 --no-merges --json
316
317 JSON fields::
318
319 repo_id str Repository content-id (sha256:...)
320 branch str Branch name or "__all__" when --all is set
321 truncated bool True when --limit capped the commit load
322 groups list Per-group: key, count, commits[]
323 groups[].key str Group identifier (author/agent/model/branch)
324 groups[].count int Number of commits in this group
325 groups[].commits list Per-commit: commit_id, message, committed_at, author, agent_id, model_id
326
327 Exit codes::
328
329 0 Success (empty result is also success).
330 1 Bad date format (--since / --until) or branch not found.
331 2 Not inside a Muse repository.
332 """
333 branch_opt: str | None = args.branch_opt
334 all_branches: bool = args.all_branches
335 numbered: bool = args.numbered
336 summary: bool = args.summary
337 by_email: bool = args.by_email
338 group_by: str = args.group_by
339 no_merges: bool = args.no_merges
340 limit: int = clamp_int(args.limit, 0, 100_000, "limit")
341 json_out: bool = args.json_out
342
343 elapsed = start_timer()
344
345 def _emit_error(msg: str, code: int, error_key: str = "error") -> None:
346 """Emit a structured error to stdout (JSON) or stderr (text) then exit."""
347 if json_out:
348 print(json.dumps({
349 **make_envelope(elapsed, exit_code=code),
350 "error": error_key,
351 "message": msg,
352 }))
353 else:
354 print(f"❌ {msg}", file=sys.stderr)
355 raise SystemExit(code)
356
357 since: datetime.datetime | None = None
358 until: datetime.datetime | None = None
359 if args.since:
360 try:
361 since = _parse_date(args.since, "--since")
362 except ValueError as exc:
363 _emit_error(str(exc), ExitCode.USER_ERROR, "bad_date")
364 if args.until:
365 try:
366 # Treat --until as inclusive: advance to end-of-day.
367 until = _parse_date(args.until, "--until").replace(
368 hour=23, minute=59, second=59, microsecond=999999
369 )
370 except ValueError as exc:
371 _emit_error(str(exc), ExitCode.USER_ERROR, "bad_date")
372
373 root = require_repo()
374 repo_id = read_repo_id(root)
375
376 branches: list[str]
377 if all_branches:
378 branches = _branch_names(root)
379 if not branches:
380 _emit_empty(repo_id, "__all__", json_out, truncated=False, elapsed=elapsed)
381 return
382 else:
383 branches = [branch_opt or read_current_branch(root)]
384
385 branch_label = "__all__" if all_branches else branches[0]
386
387 # Collect all commits across selected branches (deduplicated by commit_id).
388 seen_ids: set[str] = set()
389 all_commits: list[CommitRecord] = []
390 for br in branches:
391 branch_commits = get_commits_for_branch(root, br)
392 for c in branch_commits:
393 if c.commit_id in seen_ids:
394 continue
395 seen_ids.add(c.commit_id)
396 all_commits.append(c)
397 if limit and len(all_commits) >= limit:
398 break
399 if limit and len(all_commits) >= limit:
400 break
401
402 # Record truncation before date filtering removes commits — truncated means
403 # the *load* was capped, not that filters reduced the visible set.
404 truncated = bool(limit and len(all_commits) >= limit)
405
406 if not all_commits:
407 _emit_empty(repo_id, branch_label, json_out, truncated=truncated, elapsed=elapsed)
408 return
409
410 # Apply date filters.
411 if since is not None:
412 all_commits = [
413 c for c in all_commits
414 if c.committed_at.replace(tzinfo=datetime.timezone.utc)
415 >= since
416 ]
417 if until is not None:
418 all_commits = [
419 c for c in all_commits
420 if c.committed_at.replace(tzinfo=datetime.timezone.utc)
421 <= until
422 ]
423
424 # Exclude merge commits when requested.
425 if no_merges:
426 all_commits = [
427 c for c in all_commits
428 if not c.parent2_commit_id
429 ]
430
431 if not all_commits:
432 _emit_empty(repo_id, branch_label, json_out, truncated=truncated, elapsed=elapsed)
433 return
434
435 groups = _build_groups(all_commits, group_by=group_by, by_email=by_email)
436
437 # Sort: by count descending (--numbered), then alphabetically.
438 sorted_keys: list[str]
439 if numbered:
440 sorted_keys = sorted(groups, key=lambda k: -len(groups[k]))
441 else:
442 sorted_keys = sorted(groups)
443
444 if json_out:
445 group_list: list[_GroupJson] = []
446 for key in sorted_keys:
447 commits_in_group = groups[key]
448 entries: list[_CommitEntryJson] = [
449 _CommitEntryJson(
450 commit_id=c.commit_id,
451 message=c.message,
452 committed_at=c.committed_at.isoformat(),
453 author=c.author,
454 agent_id=c.agent_id,
455 model_id=c.model_id,
456 )
457 for c in commits_in_group
458 ]
459 group_list.append(
460 _GroupJson(key=key, count=len(commits_in_group), commits=entries)
461 )
462 payload = _ShortlogJson(
463 **make_envelope(elapsed),
464 repo_id=repo_id,
465 branch=branch_label,
466 groups=group_list,
467 truncated=truncated,
468 )
469 print(json.dumps(payload))
470 else:
471 for key in sorted_keys:
472 commits_in_group = groups[key]
473 print(f"{sanitize_display(key)} ({len(commits_in_group)}):")
474 if not summary:
475 for c in commits_in_group:
476 print(f" {sanitize_display(c.message)}")
477 print("")
478
479 def _emit_empty(
480 repo_id: str,
481 branch: str,
482 json_out: bool,
483 *,
484 truncated: bool,
485 elapsed: Callable[[], float],
486 ) -> None:
487 """Emit an empty result in the requested format."""
488 if json_out:
489 payload = _ShortlogJson(
490 **make_envelope(elapsed), # type: ignore[arg-type]
491 repo_id=repo_id,
492 branch=branch,
493 groups=[],
494 truncated=truncated,
495 )
496 print(json.dumps(payload))
497 else:
498 print("No commits found.")
File History 1 commit
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972 docs: file follow-up issue for symlog filename-too-long (muse#61) Sonnet 4.6 22 days ago