log.py python
909 lines 31.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """muse log — display commit history.
2
3 Output modes
4 ------------
5
6 Default (long form)::
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
32 1 added, 0 removed
33
34 --json (agent-native, always stable)::
35
36 {
37 "truncated": false,
38 "commits": [
39 {
40 "commit_id": "a1b2c3d4…",
41 "branch": "main",
42 "message": "Add verse melody",
43 "author": "gabriel",
44 "committed_at": "2026-03-16T12:00:00+00:00",
45 "parent_commit_id": "f9e8d7c6…",
46 "parent2_commit_id": null,
47 "snapshot_id": "…",
48 "sem_ver_bump": "minor",
49 "breaking_changes": [],
50 "metadata": {},
51 "files_added": [],
52 "files_removed": [],
53 "files_modified": []
54 }
55 ]
56 }
57
58 Combine ``--stat`` with ``--json`` to populate ``files_added``,
59 ``files_removed``, and ``files_modified`` on each commit object.
60 Without ``--stat`` all three lists are always present but empty.
61
62 SemVer bumps are coloured: PATCH MINOR MAJOR
63
64 Filters: --since, --until, --author, --section, --track, --emotion
65
66 Pathspec (git-compatible)::
67
68 muse log -- path/to/file.py # commits touching a specific file
69 muse log -n 5 -- src/ # last 5 commits touching anything in src/
70 muse log dev -- README.md # commits on branch dev that touched README.md
71
72 Paths are matched as prefixes: ``src/`` matches ``src/foo.py`` and ``src/bar/baz.py``.
73 Exact paths match only that exact file. The ``--`` separator is optional when the
74 path cannot be mistaken for a branch name, but is always safe to include.
75 """
76
77 from __future__ import annotations
78
79 import argparse
80 import heapq
81 import json
82 import logging
83 import pathlib
84 import re
85 import sys
86 import textwrap
87 from collections import deque
88 from datetime import datetime, timedelta, timezone
89 from typing import TypedDict
90
91 from muse.cli.config import get_limit
92 from muse.core.errors import ExitCode
93 from muse.core.repo import read_repo_id, require_repo
94 from muse.core.store import (
95 CommitRecord,
96 Metadata,
97 get_commit_snapshot_manifest,
98 get_commits_for_branch,
99 get_head_commit_id,
100 read_commit,
101 read_current_branch,
102 )
103 from muse.core.validation import clamp_int, sanitize_display
104
105 type CommitIndex = dict[str, CommitRecord]
106 type BranchTips = dict[str, list[str]]
107 type CounterMap = dict[str, int]
108 type _DeltaMap = dict[str, timedelta]
109
110 _HEX_CHARS = frozenset("0123456789abcdefABCDEF")
111
112
113 def _is_known_ref(root: pathlib.Path, ref: str) -> bool:
114 """Return True if *ref* resolves to a local branch, remote tracking branch, or commit ID.
115
116 Used to disambiguate ``muse log <ref>`` (branch/commit) from
117 ``muse log -- <path>`` (pathspec that argparse mistakenly assigned to ref).
118 """
119 # Short or full commit ID (hex string, min 8 chars).
120 if len(ref) >= 8 and all(c in _HEX_CHARS for c in ref):
121 return True
122 # Local branch: .muse/refs/heads/<ref>
123 if (root / ".muse" / "refs" / "heads" / ref).exists():
124 return True
125 # Remote-tracking branch: .muse/remotes/<remote>/<branch>
126 # ref may be "origin/dev" → remote="origin", branch="dev"
127 if "/" in ref:
128 remote, _, branch = ref.partition("/")
129 if branch and (root / ".muse" / "remotes" / remote / branch).exists():
130 return True
131 return False
132
133 logger = logging.getLogger(__name__)
134
135 _DEFAULT_LIMIT = 1000
136
137 # ANSI colour helpers — only emitted when stdout is a TTY.
138 _RESET = "\033[0m"
139 _BOLD = "\033[1m"
140 _YELLOW = "\033[33m"
141 _GREEN = "\033[32m"
142 _RED = "\033[31m"
143 _CYAN = "\033[36m"
144 _DIM = "\033[2m"
145
146 class _CommitJson(TypedDict):
147 """Stable JSON wire format for a single commit in ``muse log --json``.
148
149 ``files_added``, ``files_removed``, and ``files_modified`` are always
150 present. They are populated only when ``--stat`` is also passed;
151 otherwise they are empty lists.
152 """
153
154 commit_id: str
155 branch: str
156 message: str
157 author: str
158 committed_at: str
159 parent_commit_id: str | None
160 parent2_commit_id: str | None
161 snapshot_id: str | None
162 sem_ver_bump: str | None
163 breaking_changes: list[str]
164 metadata: Metadata
165 files_added: list[str]
166 files_removed: list[str]
167 files_modified: list[str]
168
169
170 _SEMVER_COLOUR: Metadata = {
171 "major": _RED,
172 "minor": _YELLOW,
173 "patch": _GREEN,
174 }
175
176
177 def _c(text: str, *codes: str, tty: bool) -> str:
178 """Wrap *text* in ANSI *codes* when *tty* is True."""
179 if not tty:
180 return text
181 return "".join(codes) + text + _RESET
182
183
184 def _ref_label(branch: str, is_head: bool, tty: bool) -> str:
185 """Format the ``(HEAD -> branch)`` decoration for a commit line."""
186 if not is_head:
187 return ""
188 if not tty:
189 return f" (HEAD -> {branch})"
190 head = _c("HEAD", _BOLD, _CYAN, tty=tty)
191 arrow = _c(" -> ", _RESET, tty=tty)
192 br = _c(branch, _BOLD, _GREEN, tty=tty)
193 paren_open = _c("(", _YELLOW, tty=tty)
194 paren_close = _c(")", _YELLOW, tty=tty)
195 return f" {paren_open}{head}{arrow}{br}{paren_close}"
196
197
198 def _parse_date(text: str) -> datetime:
199 """Parse a human-readable date string into an aware UTC datetime.
200
201 Accepted formats:
202 - ``"today"`` / ``"yesterday"``
203 - ``"<n> days|weeks|months|years ago"`` (months = 30 days, years = 365 days)
204 - ``"YYYY-MM-DD"``
205 - ``"YYYY-MM-DDTHH:MM:SS"`` or ``"YYYY-MM-DD HH:MM:SS"``
206
207 Raises:
208 ValueError: When the input does not match any recognised format.
209 """
210 text = text.strip().lower()
211 now = datetime.now(timezone.utc)
212 if text == "today":
213 return now.replace(hour=0, minute=0, second=0, microsecond=0)
214 if text == "yesterday":
215 return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
216 m = re.match(r"^(\d+)\s+(day|week|month|year)s?\s+ago$", text)
217 if m:
218 n = int(m.group(1))
219 unit = m.group(2)
220 deltas: _DeltaMap = {
221 "day": timedelta(days=n),
222 "week": timedelta(weeks=n),
223 "month": timedelta(days=n * 30),
224 "year": timedelta(days=n * 365),
225 }
226 return now - deltas[unit]
227 for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
228 try:
229 return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
230 except ValueError:
231 continue
232 raise ValueError(f"Cannot parse date: {text!r}")
233
234
235 def _commit_touches_path(root: pathlib.Path, commit: CommitRecord, path: str) -> bool:
236 """Return True if *commit* changed *path* (added, modified, or removed).
237
238 *path* is matched as a prefix so that ``src/`` matches any file under
239 ``src/``, while ``src/foo.py`` matches only that exact file. Trailing
240 slashes on directory prefixes are normalised away before comparison.
241 """
242 norm = path.rstrip("/")
243 current_manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
244 if commit.parent_commit_id:
245 parent_manifest = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
246 else:
247 parent_manifest = {}
248 all_paths = set(current_manifest) | set(parent_manifest)
249 for p in all_paths:
250 if p == norm or p.startswith(norm + "/"):
251 if current_manifest.get(p) != parent_manifest.get(p):
252 return True
253 return False
254
255
256 def _file_diff(
257 root: pathlib.Path,
258 commit: CommitRecord,
259 ) -> tuple[list[str], list[str], list[str]]:
260 """Return ``(added, removed, modified)`` file lists relative to the commit's parent.
261
262 Compares the commit's snapshot manifest against its first parent's manifest.
263 For merge commits the second parent is ignored — this is a ``--stat``-style
264 summary, not a three-way diff.
265
266 Returns:
267 A 3-tuple of sorted file-path lists:
268 ``added`` — files present in the commit but not in its parent.
269 ``removed`` — files present in the parent but not in the commit.
270 ``modified`` — files present in both with a different snapshot hash.
271 """
272 current_manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
273 if commit.parent_commit_id:
274 parent_manifest = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
275 else:
276 parent_manifest = {}
277 added = sorted(set(current_manifest) - set(parent_manifest))
278 removed = sorted(set(parent_manifest) - set(current_manifest))
279 modified = sorted(
280 f for f in set(current_manifest) & set(parent_manifest)
281 if current_manifest[f] != parent_manifest[f]
282 )
283 return added, removed, modified
284
285
286 def _format_date(dt: datetime) -> str:
287 """Format a datetime as ``YYYY-MM-DD HH:MM:SS UTC`` for display."""
288 return dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt.tzinfo else str(dt)
289
290
291 # ---------------------------------------------------------------------------
292 # Filter helpers — single implementation shared by JSON and text paths
293 # ---------------------------------------------------------------------------
294
295
296 def _apply_filters(
297 commits: list[CommitRecord],
298 *,
299 since_dt: datetime | None,
300 until_dt: datetime | None,
301 author: str | None,
302 section: str | None,
303 track: str | None,
304 emotion: str | None,
305 limit: int,
306 ) -> tuple[list[CommitRecord], bool]:
307 """Apply all active filters to *commits* and enforce *limit*.
308
309 Previously the filter loop was copy-pasted between the JSON path and the
310 text path. Any new filter would have required updating both. This
311 function is the single source of truth; both paths call it.
312
313 Args:
314 commits: Pre-fetched commit list from ``get_commits_for_branch``.
315 since_dt: Inclusive lower bound on ``committed_at``.
316 until_dt: Inclusive upper bound on ``committed_at``.
317 author: Substring match on ``author`` (case-insensitive).
318 section: Exact match on ``metadata["section"]``.
319 track: Exact match on ``metadata["track"]``.
320 emotion: Exact match on ``metadata["emotion"]``.
321 limit: Maximum number of commits to return (0 = unlimited).
322
323 Returns:
324 ``(filtered_commits, truncated)`` where *truncated* is True when the
325 walk was capped before the filter had seen all available commits.
326 """
327 filtered: list[CommitRecord] = []
328 for c in commits:
329 if since_dt and c.committed_at < since_dt:
330 continue
331 if until_dt and c.committed_at > until_dt:
332 continue
333 if author and author.lower() not in c.author.lower():
334 continue
335 if section and c.metadata.get("section") != section:
336 continue
337 if track and c.metadata.get("track") != track:
338 continue
339 if emotion and c.metadata.get("emotion") != emotion:
340 continue
341 filtered.append(c)
342 if limit > 0 and len(filtered) >= limit:
343 # We hit the display limit — the walk may have had more commits.
344 truncated = len(commits) > len(filtered)
345 return filtered, truncated
346 return filtered, False
347
348
349 def _commit_to_json(
350 c: CommitRecord,
351 root: pathlib.Path | None = None,
352 *,
353 stat: bool = False,
354 ) -> _CommitJson:
355 """Serialise *c* to the stable ``--json`` wire format.
356
357 All keys are always present so agents can read them without ``dict.get``
358 guards. Merge commits include ``parent2_commit_id``; linear commits have
359 it set to ``null``.
360
361 When *stat* is True and *root* is provided, ``files_added``,
362 ``files_removed``, and ``files_modified`` are populated by diffing the
363 commit's snapshot against its parent. Otherwise all three are empty lists.
364
365 The ``--json`` output path always calls this with ``stat=True`` so that
366 agents always receive populated file lists without needing to pass
367 ``--stat`` explicitly. The *stat* parameter is kept for callers that
368 intentionally want the lightweight (no-diff) form.
369
370 Args:
371 c: The commit to serialise.
372 root: Repository root directory. Required when *stat* is True.
373 stat: Whether to compute and include per-file change lists.
374
375 Returns:
376 JSON-serialisable dict matching the schema documented in the module
377 docstring.
378 """
379 files_added: list[str] = []
380 files_removed: list[str] = []
381 files_modified: list[str] = []
382 if stat and root is not None:
383 files_added, files_removed, files_modified = _file_diff(root, c)
384 return _CommitJson(
385 commit_id=c.commit_id,
386 branch=c.branch,
387 message=c.message,
388 author=c.author,
389 committed_at=c.committed_at.isoformat(),
390 parent_commit_id=c.parent_commit_id,
391 parent2_commit_id=c.parent2_commit_id,
392 snapshot_id=c.snapshot_id,
393 sem_ver_bump=c.sem_ver_bump,
394 breaking_changes=list(c.breaking_changes) if c.breaking_changes else [],
395 metadata=c.metadata,
396 files_added=files_added,
397 files_removed=files_removed,
398 files_modified=files_modified,
399 )
400
401
402 # ---------------------------------------------------------------------------
403 # DAG graph rendering helpers
404 # ---------------------------------------------------------------------------
405
406
407 def _branch_tips(root: pathlib.Path) -> BranchTips:
408 """Return ``{commit_id: [branch_name, …]}`` for all local branch tips."""
409 heads_dir = root / ".muse" / "refs" / "heads"
410 if not heads_dir.exists():
411 return {}
412 tips: BranchTips = {}
413 for p in heads_dir.rglob("*"):
414 if p.is_file():
415 cid = p.read_text().strip()
416 name = p.relative_to(heads_dir).as_posix()
417 if cid:
418 tips.setdefault(cid, []).append(name)
419 return tips
420
421
422 def _collect_all_commits(
423 root: pathlib.Path,
424 start_ids: list[str],
425 max_commits: int = 50_000,
426 ) -> tuple[dict[str, CommitRecord], bool]:
427 """BFS from *start_ids*, returning every reachable commit up to *max_commits*.
428
429 Uses a :class:`~collections.deque` for O(1) ``popleft`` — a ``list.pop(0)``
430 would be O(n) per call, making the full BFS O(n²) at scale.
431
432 Args:
433 root: Repository root.
434 start_ids: BFS seed commit IDs (branch tips).
435 max_commits: Safety cap — default 50 000.
436
437 Returns:
438 ``({commit_id: CommitRecord}, truncated)`` pair where *truncated* is
439 True when *max_commits* was reached before the graph was fully walked.
440 """
441 seen: CommitIndex = {}
442 queue: deque[str] = deque(start_ids)
443 while queue:
444 if len(seen) >= max_commits:
445 return seen, True
446 cid = queue.popleft()
447 if cid in seen:
448 continue
449 rec = read_commit(root, cid)
450 if rec is None:
451 continue
452 seen[cid] = rec
453 for parent in (rec.parent_commit_id, rec.parent2_commit_id):
454 if parent and parent not in seen:
455 queue.append(parent)
456 return seen, False
457
458
459 def _topo_sort(commits: CommitIndex) -> list[CommitRecord]:
460 """Return commits newest-first using Kahn's algorithm.
461
462 In-degree counts the number of *child* commits that reference each commit
463 as a parent, so commits with no children (branch tips) are processed first.
464 Ties are broken by timestamp (most recent first).
465 """
466 in_degree: CounterMap = {cid: 0 for cid in commits}
467 for rec in commits.values():
468 for parent in (rec.parent_commit_id, rec.parent2_commit_id):
469 if parent and parent in commits:
470 in_degree[parent] += 1
471
472 heap: list[tuple[float, str]] = []
473 for cid, deg in in_degree.items():
474 if deg == 0:
475 ts = -commits[cid].committed_at.timestamp()
476 heapq.heappush(heap, (ts, cid))
477
478 result: list[CommitRecord] = []
479 while heap:
480 _, cid = heapq.heappop(heap)
481 result.append(commits[cid])
482 rec = commits[cid]
483 for parent in (rec.parent_commit_id, rec.parent2_commit_id):
484 if parent and parent in commits:
485 in_degree[parent] -= 1
486 if in_degree[parent] == 0:
487 ts = -commits[parent].committed_at.timestamp()
488 heapq.heappush(heap, (ts, parent))
489 return result
490
491
492 def _deco_str(
493 cid: str,
494 head_cid: str,
495 current: str,
496 tips: BranchTips,
497 tty: bool,
498 ) -> str:
499 """Format the ``(HEAD -> branch, other-branch)`` decoration for a commit."""
500 branches = tips.get(cid, [])
501 if not branches:
502 return ""
503 labels: list[str] = []
504 if cid == head_cid and current in branches:
505 head = _c("HEAD", _BOLD, _CYAN, tty=tty)
506 br = _c(current, _BOLD, _GREEN, tty=tty)
507 labels.append(f"{head}{_c(' -> ', _RESET, tty=tty)}{br}")
508 for b in branches:
509 if b != current:
510 labels.append(_c(b, _BOLD, _GREEN, tty=tty))
511 else:
512 for b in branches:
513 labels.append(_c(b, _BOLD, _GREEN, tty=tty))
514 inner = ", ".join(labels)
515 return f" {_c('(', _YELLOW, tty=tty)}{inner}{_c(')', _YELLOW, tty=tty)}"
516
517
518 def _render_graph(
519 root: pathlib.Path,
520 branch: str,
521 all_branches: bool,
522 tty: bool,
523 ) -> None:
524 """Render a lane-based ASCII DAG, git-log-style."""
525 current = read_current_branch(root)
526 tips = _branch_tips(root)
527
528 if all_branches:
529 start_ids = list(tips.keys())
530 else:
531 head = get_head_commit_id(root, branch)
532 start_ids = [head] if head else []
533
534 if not start_ids:
535 print("(no commits)")
536 return
537
538 graph_cap = get_limit("max_graph_commits", root)
539 all_commits, graph_truncated = _collect_all_commits(root, start_ids, max_commits=graph_cap)
540 if not all_commits:
541 print("(no commits)")
542 return
543 if graph_truncated:
544 print(
545 f"⚠️ Graph truncated at {graph_cap:,} commits. "
546 "Raise [limits] max_graph_commits in .muse/config.toml to see more."
547 )
548
549 head_cid = get_head_commit_id(root, current) or ""
550 sorted_commits = _topo_sort(all_commits)
551
552 # lanes: list of commit IDs we're "awaiting" (open lines of descent).
553 # None marks a closed / empty column slot.
554 lanes: list[str | None] = []
555
556 for idx, commit in enumerate(sorted_commits):
557 cid = commit.commit_id
558 parents = [
559 p for p in (commit.parent_commit_id, commit.parent2_commit_id)
560 if p and p in all_commits
561 ]
562
563 # Assign this commit to a column.
564 col = lanes.index(cid) if cid in lanes else -1
565 if col == -1:
566 if None in lanes:
567 col = lanes.index(None)
568 lanes[col] = cid
569 else:
570 col = len(lanes)
571 lanes.append(cid)
572
573 width = len(lanes)
574 row: list[str] = []
575 for i in range(width):
576 if i == col:
577 row.append(_c("*", _BOLD, tty=tty))
578 elif lanes[i] is not None:
579 row.append("|")
580 else:
581 row.append(" ")
582
583 graph_prefix = " ".join(row).rstrip()
584 short_hash = _c(cid[:8], _YELLOW, tty=tty)
585 deco = _deco_str(cid, head_cid, current, tips, tty)
586 msg = sanitize_display(commit.message.splitlines()[0])
587 print(f"{graph_prefix} {short_hash}{deco} {msg}")
588
589 if parents:
590 lanes[col] = parents[0]
591 for extra in parents[1:]:
592 if extra not in lanes:
593 if None in lanes:
594 lanes[lanes.index(None)] = extra
595 else:
596 lanes.append(extra)
597 else:
598 lanes[col] = None
599
600 if idx < len(sorted_commits) - 1:
601 is_merge = len(parents) >= 2
602 connector: list[str] = []
603 for i in range(len(lanes)):
604 if i == col and is_merge:
605 connector.append("|\\")
606 elif lanes[i] is not None:
607 connector.append("| ")
608 else:
609 connector.append(" ")
610 line = "".join(connector).rstrip()
611 if line:
612 print(line)
613
614 while lanes and lanes[-1] is None:
615 lanes.pop()
616
617
618 # ---------------------------------------------------------------------------
619 # CLI registration
620 # ---------------------------------------------------------------------------
621
622
623 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
624 """Register the ``muse log`` subcommand and its flags."""
625 parser = subparsers.add_parser(
626 "log",
627 help="Display commit history.",
628 description=__doc__,
629 formatter_class=argparse.RawDescriptionHelpFormatter,
630 )
631 parser.add_argument(
632 "ref", nargs="?", default=None,
633 help="Branch or commit to start from (default: current branch).",
634 )
635 parser.add_argument(
636 "--oneline", action="store_true",
637 help="One line per commit.",
638 )
639 parser.add_argument(
640 "--graph", "-g", action="store_true",
641 help="ASCII DAG graph.",
642 )
643 parser.add_argument(
644 "--all", "-A", action="store_true", dest="all_branches",
645 help="Include all local branches in the graph (implies --graph).",
646 )
647 parser.add_argument(
648 "--stat", action="store_true",
649 help="Show added/removed file summary for each commit.",
650 )
651 parser.add_argument(
652 "-n", "--max-count", type=int, default=_DEFAULT_LIMIT, dest="limit",
653 help="Limit number of commits shown (default: %(default)s).",
654 )
655 parser.add_argument("--since", default=None, help="Show commits after date.")
656 parser.add_argument("--until", default=None, help="Show commits before date.")
657 parser.add_argument("--author", default=None, help="Filter by author substring.")
658 parser.add_argument("--section", default=None, help="Filter by section metadata.")
659 parser.add_argument("--track", default=None, help="Filter by track metadata.")
660 parser.add_argument("--emotion", default=None, help="Filter by emotion metadata.")
661 parser.add_argument(
662 "--format", "-f", default="text", dest="fmt",
663 help="Output format: text (default) or json.",
664 )
665 parser.add_argument(
666 "--json", action="store_const", const="json", dest="fmt",
667 help="Shorthand for --format json.",
668 )
669 parser.add_argument(
670 "pathspec", nargs="*", metavar="path",
671 help=(
672 "Limit output to commits that touched these paths. "
673 "Prefix-matched: 'src/' matches all files under src/. "
674 "Separate from options with '--' (e.g. muse log -- src/foo.py)."
675 ),
676 )
677 parser.set_defaults(func=run)
678
679
680 def run(args: argparse.Namespace) -> None:
681 """Display commit history.
682
683 Agents should pass ``--json`` to receive a stable JSON envelope::
684
685 {
686 "truncated": false,
687 "commits": [ { … }, … ]
688 }
689
690 Each commit object always contains: ``commit_id``, ``branch``,
691 ``message``, ``author``, ``committed_at``, ``parent_commit_id``,
692 ``parent2_commit_id`` (null for linear commits), ``snapshot_id``,
693 ``sem_ver_bump``, ``breaking_changes`` (list), ``metadata`` (dict), and
694 the per-file change lists ``files_added``, ``files_removed``, and
695 ``files_modified``. File lists are always populated in ``--json`` mode —
696 agents do not need to pass ``--stat`` to receive them.
697
698 The ``truncated`` flag is true when the walk was capped before the full
699 history was read. Raise ``[limits] max_walk_commits`` in
700 ``.muse/config.toml`` or pass ``-n <N>`` to control the cap.
701
702 Exit codes:
703 0 — success.
704 2 — usage error (invalid ``--format``, invalid ``--since``/``--until``
705 value, ``--max-count`` less than 1).
706 3 — internal error (repository not found).
707 """
708 ref: str | None = args.ref
709 oneline: bool = args.oneline
710 graph: bool = args.graph
711 all_branches: bool = args.all_branches
712 stat: bool = args.stat
713 limit: int = clamp_int(args.limit, 1, 100000, "limit")
714 since: str | None = args.since
715 until: str | None = args.until
716 author: str | None = args.author
717 section: str | None = args.section
718 track: str | None = args.track
719 emotion: str | None = args.emotion
720 fmt: str = args.fmt
721 pathspec: list[str] = list(args.pathspec) if args.pathspec else []
722
723 if all_branches:
724 graph = True
725
726 # Support git-style -<n> shorthand (e.g. `muse log -5` as alias for `-n 5`).
727 # argparse captures "-5" as the positional `ref`; detect and reinterpret it.
728 if ref is not None and ref.lstrip("-").isdigit() and ref.startswith("-"):
729 limit = int(ref.lstrip("-"))
730 ref = None
731
732 if fmt not in ("text", "json"):
733 print(
734 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
735 file=sys.stderr,
736 )
737 raise SystemExit(ExitCode.USER_ERROR)
738
739 if limit < 1:
740 print("❌ --max-count must be at least 1.", file=sys.stderr)
741 raise SystemExit(ExitCode.USER_ERROR)
742
743 # Validate date filters before touching the repository.
744 since_dt: datetime | None = None
745 until_dt: datetime | None = None
746 if since:
747 try:
748 since_dt = _parse_date(since)
749 except ValueError:
750 print(
751 f"❌ Cannot parse --since value: {sanitize_display(since)!r}. "
752 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
753 file=sys.stderr,
754 )
755 raise SystemExit(ExitCode.USER_ERROR)
756 if until:
757 try:
758 until_dt = _parse_date(until)
759 except ValueError:
760 print(
761 f"❌ Cannot parse --until value: {sanitize_display(until)!r}. "
762 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
763 file=sys.stderr,
764 )
765 raise SystemExit(ExitCode.USER_ERROR)
766
767 root = require_repo()
768 repo_id = read_repo_id(root)
769
770 # When the -N shorthand consumed `ref` (e.g. `muse log -1 dev`), argparse
771 # puts the branch name in pathspec[0]. Reclaim it now that we have a repo
772 # root available to check whether it's a real ref.
773 if ref is None and pathspec and _is_known_ref(root, pathspec[0]):
774 ref = pathspec[0]
775 pathspec = pathspec[1:]
776
777 # Disambiguate: argparse assigns the first positional after `--` to `ref`
778 # (because ref is nargs="?"). If what landed in `ref` is not a known
779 # branch or commit ID, treat it as the first pathspec element.
780 # Preserve the original value for the "no commits on '<ref>'" message so
781 # that nonexistent-branch errors are still contextual.
782 user_ref = ref # may be a nonexistent branch name — kept for error display
783 if ref is not None and not _is_known_ref(root, ref):
784 pathspec = [ref] + pathspec
785 ref = None
786
787 branch = ref if ref is not None else read_current_branch(root)
788
789 if graph and fmt == "text":
790 _render_graph(root, branch=branch, all_branches=all_branches, tty=sys.stdout.isatty())
791 return
792
793 has_filters = any([since_dt, until_dt, author, section, track, emotion])
794 walk_cap = get_limit("max_walk_commits", root)
795 # When pathspec is given we must walk all commits and filter; the limit is
796 # applied afterwards, just as git log does.
797 walk_limit = 0 if (has_filters or pathspec) else limit
798 raw_commits = get_commits_for_branch(root, repo_id, branch, max_count=walk_limit or walk_cap)
799
800 # Apply pathspec filter: keep only commits that touched one of the given paths.
801 if pathspec:
802 raw_commits = [
803 c for c in raw_commits
804 if any(_commit_touches_path(root, c, p) for p in pathspec)
805 ]
806
807 walk_truncated = (has_filters or bool(pathspec)) and len(raw_commits) >= walk_cap
808
809 filtered, filter_truncated = _apply_filters(
810 raw_commits,
811 since_dt=since_dt,
812 until_dt=until_dt,
813 author=author,
814 section=section,
815 track=track,
816 emotion=emotion,
817 limit=limit,
818 )
819 truncated = walk_truncated or filter_truncated
820
821 # ── JSON output ───────────────────────────────────────────────────────────
822 if fmt == "json":
823 out = sys.stdout
824 out.write('{"truncated":')
825 out.write(json.dumps(truncated))
826 out.write(',"commits":[\n')
827 for i, c in enumerate(filtered):
828 if i:
829 out.write(",\n")
830 out.write(json.dumps(_commit_to_json(c, root, stat=True), default=str))
831 out.write("\n]}\n")
832 return
833
834 # ── Text output ───────────────────────────────────────────────────────────
835 if not filtered:
836 if user_ref is not None:
837 # Distinguish "branch exists but is empty" from "branch not found".
838 print(f"(no commits on '{sanitize_display(user_ref)}')")
839 else:
840 print("(no commits)")
841 return
842
843 if truncated:
844 print(
845 f"⚠️ History truncated at {len(filtered):,} commits — "
846 "use -n or raise [limits] max_walk_commits in .muse/config.toml to see more."
847 )
848
849 head_commit_id = filtered[0].commit_id if filtered else None
850 tty: bool = sys.stdout.isatty()
851
852 for c in filtered:
853 is_head = c.commit_id == head_commit_id
854 decoration = _ref_label(branch, is_head, tty)
855 short_hash = _c(c.commit_id[:8], _YELLOW, tty=tty)
856 subject = sanitize_display(c.message.splitlines()[0])
857 author_display = sanitize_display(c.author)
858
859 if oneline:
860 print(f"{short_hash}{decoration} {subject}")
861 continue
862
863 commit_word = _c("commit", _YELLOW, tty=tty)
864 print(f"{commit_word} {short_hash}{decoration}")
865 if author_display:
866 print(f"Author: {author_display}")
867 print(f"Date: {_c(_format_date(c.committed_at), _DIM, tty=tty)}")
868
869 if c.sem_ver_bump and c.sem_ver_bump != "none":
870 bump_key = c.sem_ver_bump.lower()
871 bump_colour = _SEMVER_COLOUR.get(bump_key, "")
872 bump_label = (
873 _c(c.sem_ver_bump.upper(), bump_colour, tty=tty)
874 if bump_colour else c.sem_ver_bump.upper()
875 )
876 print(f"SemVer: {bump_label}")
877 if c.breaking_changes:
878 safe_breaks = [sanitize_display(b) for b in c.breaking_changes[:3]]
879 breaking_text = ", ".join(safe_breaks)
880 if len(c.breaking_changes) > 3:
881 breaking_text += f" +{len(c.breaking_changes) - 3} more"
882 print(f"Breaking: {_c(breaking_text, _RED, tty=tty)}")
883
884 if c.metadata:
885 meta_parts = [
886 f"{sanitize_display(k)}: {sanitize_display(str(v))}"
887 for k, v in sorted(c.metadata.items())
888 ]
889 print(f"Meta: {', '.join(meta_parts)}")
890
891 # Indent every line of the message body uniformly.
892 body = sanitize_display(c.message)
893 indented = textwrap.indent(body, " ")
894 print(f"\n{indented}\n")
895
896 if stat:
897 added, removed, modified = _file_diff(root, c)
898 for p in added:
899 print(_c(f" + {p}", _GREEN, tty=tty))
900 for p in modified:
901 print(_c(f" ~ {p}", _YELLOW, tty=tty))
902 for p in removed:
903 print(_c(f" - {p}", _RED, tty=tty))
904 if added or removed or modified:
905 summary = (
906 f" {len(added)} added, {len(modified)} modified, "
907 f"{len(removed)} removed"
908 )
909 print(_c(summary, _DIM, tty=tty) + "\n")
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago