shortlog.py file-level

at task/mp- · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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": "<repo uuid>",
29 "branch": "<branch name or '__all__'>",
30 "groups": [
31 {
32 "key": "<author | agent_id | model_id | branch>",
33 "count": <int>,
34 "commits": [
35 {
36 "commit_id": "<sha>",
37 "message": "<message>",
38 "committed_at": "<ISO-8601>",
39 "author": "<author>",
40 "agent_id": "<agent_id | null>",
41 "model_id": "<model_id | null>"
42 }
43 ]
44 }
45 ]
46 }
47
48 Exit codes::
49
50 0 — output produced (even if empty)
51 1 — branch not found or ref invalid
52 2 — not a Muse repository
53
54 Security model::
55
56 Branch names discovered via filesystem enumeration are checked for symlinks
57 before being added to the list; symlinks inside ``.muse/refs/heads/`` are
58 silently skipped. Author names, agent IDs, and commit messages are passed
59 through ``sanitize_display`` in text mode; JSON output is left raw so
60 callers receive the original values.
61 """
62
63 from __future__ import annotations
64
65 import argparse
66 import datetime
67 import json
68 import logging
69 import pathlib
70 import sys
71 from collections import defaultdict
72 from typing import TypedDict
73
74 from muse.core.errors import ExitCode
75 from muse.core.repo import read_repo_id, require_repo
76 from muse.core.store import (
77 CommitRecord,
78 get_commits_for_branch,
79 read_current_branch,
80 )
81 from muse.core.validation import clamp_int, sanitize_display
82
83
84 type _GroupMap = dict[str, list["CommitRecord"]]
85 logger = logging.getLogger(__name__)
86
87 _GROUP_BY_CHOICES = ("author", "agent", "model", "branch")
88
89
90 # ---------------------------------------------------------------------------
91 # JSON wire format
92 # ---------------------------------------------------------------------------
93
94
95 class _CommitEntryJson(TypedDict):
96 """Per-commit entry inside a shortlog group."""
97
98 commit_id: str
99 message: str
100 committed_at: str
101 author: str | None
102 agent_id: str | None
103 model_id: str | None
104
105
106 class _GroupJson(TypedDict):
107 """One group (author / agent / model / branch) in the shortlog JSON."""
108
109 key: str
110 count: int
111 commits: list[_CommitEntryJson]
112
113
114 class _ShortlogJson(TypedDict):
115 """Top-level JSON output for ``muse shortlog --json``."""
116
117 repo_id: str
118 branch: str
119 groups: list[_GroupJson]
120
121
122 # ---------------------------------------------------------------------------
123 # Internal helpers
124 # ---------------------------------------------------------------------------
125
126
127 def _branch_names(root: pathlib.Path) -> list[str]:
128 """Return all branch names found under ``.muse/refs/heads/``.
129
130 Symlinks are silently skipped — a symlink inside the refs directory could
131 point to a file outside the repository and must not be followed.
132 """
133 heads_dir = root / ".muse" / "refs" / "heads"
134 if not heads_dir.exists():
135 return []
136 branches: list[str] = []
137 for ref_file in sorted(heads_dir.rglob("*")):
138 if ref_file.is_symlink():
139 logger.warning("⚠️ Skipping symlink ref: %s", ref_file)
140 continue
141 if ref_file.is_file():
142 branches.append(str(ref_file.relative_to(heads_dir).as_posix()))
143 return branches
144
145
146 def _group_key(commit: CommitRecord, group_by: str) -> str:
147 """Return the grouping key for *commit* based on *group_by*."""
148 if group_by == "author":
149 if commit.author:
150 return commit.author
151 if commit.agent_id:
152 return f"{commit.agent_id} (agent)"
153 return "(unknown)"
154 if group_by == "agent":
155 return commit.agent_id or "(no agent)"
156 if group_by == "model":
157 return commit.model_id or "(no model)"
158 if group_by == "branch":
159 return commit.branch or "(unknown branch)"
160 return commit.author or "(unknown)"
161
162
163 def _build_groups(
164 commits: list[CommitRecord],
165 *,
166 group_by: str,
167 by_email: bool,
168 ) -> _GroupMap:
169 """Partition *commits* into groups keyed by *group_by*.
170
171 When *by_email* is ``True`` and the commit has an ``agent_id`` distinct
172 from the author, the agent ID is appended to the key in angle brackets.
173 """
174 groups: _GroupMap = defaultdict(list)
175 for c in commits:
176 key = _group_key(c, group_by)
177 if by_email and c.agent_id and c.agent_id != c.author:
178 key = f"{key} <{c.agent_id}>"
179 groups[key].append(c)
180 return groups
181
182
183 def _parse_date(value: str, flag: str) -> datetime.datetime:
184 """Parse a YYYY-MM-DD date string into an aware UTC datetime."""
185 try:
186 naive = datetime.datetime.strptime(value, "%Y-%m-%d")
187 except ValueError:
188 print(
189 f"❌ {flag} must be YYYY-MM-DD (got '{sanitize_display(value)}').",
190 file=sys.stderr,
191 )
192 raise SystemExit(ExitCode.USER_ERROR)
193 return naive.replace(tzinfo=datetime.timezone.utc)
194
195
196 # ---------------------------------------------------------------------------
197 # Command registration
198 # ---------------------------------------------------------------------------
199
200
201 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
202 """Register the ``muse shortlog`` subcommand."""
203 parser = subparsers.add_parser(
204 "shortlog",
205 help="Summarise commit history grouped by author, agent, model, or branch.",
206 description=__doc__,
207 formatter_class=argparse.RawDescriptionHelpFormatter,
208 )
209 parser.add_argument(
210 "branch_opt",
211 nargs="?",
212 default=None,
213 metavar="BRANCH",
214 help="Branch to summarise (default: current branch).",
215 )
216 parser.add_argument(
217 "--all",
218 dest="all_branches",
219 action="store_true",
220 help="Include all branches.",
221 )
222 parser.add_argument(
223 "--numbered", "-n",
224 action="store_true",
225 help="Sort by commit count (most active first).",
226 )
227 parser.add_argument(
228 "--summary", "-s",
229 action="store_true",
230 help="Show commit counts only — suppress individual message lines.",
231 )
232 parser.add_argument(
233 "--email",
234 dest="by_email",
235 action="store_true",
236 help="Append agent_id to the group key when present.",
237 )
238 parser.add_argument(
239 "--group-by",
240 dest="group_by",
241 default="author",
242 choices=list(_GROUP_BY_CHOICES),
243 metavar="FIELD",
244 help=(
245 f"Dimension to group by: {', '.join(_GROUP_BY_CHOICES)} "
246 "(default: author)."
247 ),
248 )
249 parser.add_argument(
250 "--no-merges",
251 dest="no_merges",
252 action="store_true",
253 help="Exclude merge commits (commits with more than one parent).",
254 )
255 parser.add_argument(
256 "--since",
257 default=None,
258 metavar="YYYY-MM-DD",
259 help="Include only commits on or after this date (UTC).",
260 )
261 parser.add_argument(
262 "--until",
263 default=None,
264 metavar="YYYY-MM-DD",
265 help="Include only commits on or before this date (UTC).",
266 )
267 parser.add_argument(
268 "--limit",
269 type=int,
270 default=0,
271 metavar="N",
272 help="Cap the number of commits loaded per branch (0 = no limit).",
273 )
274 parser.add_argument(
275 "--json",
276 action="store_true",
277 dest="output_json",
278 help="Emit machine-readable JSON on stdout.",
279 )
280 parser.set_defaults(func=run)
281
282
283 # ---------------------------------------------------------------------------
284 # Main handler
285 # ---------------------------------------------------------------------------
286
287
288 def run(args: argparse.Namespace) -> None:
289 """Summarise commit history grouped by author, agent, model, or branch.
290
291 Each group shows the key, commit count, and (unless ``--summary``) each
292 commit message indented beneath. Use ``--numbered`` to rank by activity.
293
294 In agent pipelines, ``--json`` returns a structured payload with full
295 commit provenance including ``author``, ``agent_id``, and ``model_id``.
296
297 With ``--json``::
298
299 {
300 "repo_id": "...",
301 "branch": "main",
302 "groups": [
303 {
304 "key": "Alice",
305 "count": 3,
306 "commits": [
307 {
308 "commit_id": "...",
309 "message": "fix: ...",
310 "committed_at": "2025-01-01T00:00:00+00:00",
311 "author": "Alice",
312 "agent_id": null,
313 "model_id": null
314 }
315 ]
316 }
317 ]
318 }
319
320 Examples::
321
322 muse shortlog # current branch
323 muse shortlog --all --numbered # all branches, ranked
324 muse shortlog --group-by agent # group by agent_id
325 muse shortlog --group-by model # which models contributed
326 muse shortlog --since 2025-01-01 # this year only
327 muse shortlog --no-merges # exclude merges
328 muse shortlog --summary --json # counts only, JSON
329 """
330 branch_opt: str | None = args.branch_opt
331 all_branches: bool = args.all_branches
332 numbered: bool = args.numbered
333 summary: bool = args.summary
334 by_email: bool = args.by_email
335 group_by: str = args.group_by
336 no_merges: bool = args.no_merges
337 limit: int = clamp_int(args.limit, 0, 100_000, "limit")
338 output_json: bool = args.output_json
339
340 since: datetime.datetime | None = None
341 until: datetime.datetime | None = None
342 if args.since:
343 since = _parse_date(args.since, "--since")
344 if args.until:
345 # Treat --until as inclusive: advance to end-of-day.
346 until = _parse_date(args.until, "--until").replace(
347 hour=23, minute=59, second=59, microsecond=999999
348 )
349
350 root = require_repo()
351 repo_id = read_repo_id(root)
352
353 branches: list[str]
354 if all_branches:
355 branches = _branch_names(root)
356 if not branches:
357 _emit_empty(repo_id, "__all__", output_json)
358 return
359 else:
360 branches = [branch_opt or read_current_branch(root)]
361
362 branch_label = "__all__" if all_branches else branches[0]
363
364 # Collect all commits across selected branches (deduplicated by commit_id).
365 seen_ids: set[str] = set()
366 all_commits: list[CommitRecord] = []
367 for br in branches:
368 branch_commits = get_commits_for_branch(root, repo_id, br)
369 for c in branch_commits:
370 if c.commit_id in seen_ids:
371 continue
372 seen_ids.add(c.commit_id)
373 all_commits.append(c)
374 if limit and len(all_commits) >= limit:
375 break
376 if limit and len(all_commits) >= limit:
377 break
378
379 if not all_commits:
380 _emit_empty(repo_id, branch_label, output_json)
381 return
382
383 # Apply date filters.
384 if since is not None:
385 all_commits = [
386 c for c in all_commits
387 if c.committed_at.replace(tzinfo=datetime.timezone.utc)
388 >= since
389 ]
390 if until is not None:
391 all_commits = [
392 c for c in all_commits
393 if c.committed_at.replace(tzinfo=datetime.timezone.utc)
394 <= until
395 ]
396
397 # Exclude merge commits when requested.
398 if no_merges:
399 all_commits = [
400 c for c in all_commits
401 if not c.parent2_commit_id
402 ]
403
404 if not all_commits:
405 _emit_empty(repo_id, branch_label, output_json)
406 return
407
408 groups = _build_groups(all_commits, group_by=group_by, by_email=by_email)
409
410 # Sort: by count descending (--numbered), then alphabetically.
411 sorted_keys: list[str]
412 if numbered:
413 sorted_keys = sorted(groups, key=lambda k: -len(groups[k]))
414 else:
415 sorted_keys = sorted(groups)
416
417 if output_json:
418 group_list: list[_GroupJson] = []
419 for key in sorted_keys:
420 commits_in_group = groups[key]
421 entries: list[_CommitEntryJson] = [
422 _CommitEntryJson(
423 commit_id=c.commit_id,
424 message=c.message,
425 committed_at=c.committed_at.isoformat(),
426 author=c.author,
427 agent_id=c.agent_id,
428 model_id=c.model_id,
429 )
430 for c in commits_in_group
431 ]
432 group_list.append(
433 _GroupJson(key=key, count=len(commits_in_group), commits=entries)
434 )
435 payload = _ShortlogJson(
436 repo_id=repo_id,
437 branch=branch_label,
438 groups=group_list,
439 )
440 print(json.dumps(payload, indent=2))
441 else:
442 for key in sorted_keys:
443 commits_in_group = groups[key]
444 print(f"{sanitize_display(key)} ({len(commits_in_group)}):")
445 if not summary:
446 for c in commits_in_group:
447 print(f" {sanitize_display(c.message)}")
448 print("")
449
450
451 def _emit_empty(repo_id: str, branch: str, output_json: bool) -> None:
452 """Emit an empty result in the requested format."""
453 if output_json:
454 payload = _ShortlogJson(repo_id=repo_id, branch=branch, groups=[])
455 print(json.dumps(payload, indent=2))
456 else:
457 print("No commits found.")