gabriel / muse public
log.py python
1,164 lines 42.7 KB
Raw
sha256:8063bdadd934911129e35dee8db004797c136aa88c7c446248dc5f9d3fd0337a feat(log): support A..B range syntax — commits on B not rea… Sonnet 4.6 patch 1 day 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 "status": "ok",
38 "error": "",
39 "truncated": false,
40 "total": 1,
41 "branch": "main",
42 "repo_id": "…",
43 "commits": [
44 {
45 "commit_id": "sha256:a1b2c3d4…",
46 "branch": "main",
47 "message": "Add verse melody",
48 "author": "gabriel",
49 "agent_id": "",
50 "model_id": "",
51 "committed_at": "2026-03-16T12:00:00+00:00",
52 "parent_commit_id": "sha256:f9e8d7c6…",
53 "parent2_commit_id": null,
54 "snapshot_id": "sha256:…",
55 "sem_ver_bump": "minor",
56 "breaking_changes": [],
57 "metadata": {},
58 "files_added": ["tracks/drums.mid"],
59 "files_removed": [],
60 "files_modified": [],
61 "structured_delta": {"ops": [...]}
62 }
63 ],
64 "duration_ms": 4.2,
65 "exit_code": 0
66 }
67
68 ``files_added``, ``files_removed``, and ``files_modified`` are always
69 populated in ``--json`` mode — agents do not need ``--stat``.
70
71 ``structured_delta`` is the domain-specific symbol-level diff computed at
72 commit time. It is a ``dict`` with an ``"ops"`` key for code commits, and
73 ``null`` when no code-intelligence data was produced (non-code domains or
74 commits with no tracked changes).
75
76 All keys are always present so agents can read them without ``dict.get``
77 guards. ``"status"`` is always ``"ok"`` on success.
78
79 JSON error schema (exit non-zero)::
80
81 {
82 "status": "error",
83 "error": "<human-readable message>",
84 "exit_code": 2
85 }
86
87 When ``--json`` is active all errors go to stdout as JSON — no prose on
88 stderr. Agents should parse stdout and check ``status``.
89
90 SemVer bumps are coloured: PATCH MINOR MAJOR
91
92 Filters: --since, --until, --author, --section, --track, --emotion
93
94 Pathspec (git-compatible)::
95
96 muse log -- path/to/file.py # commits touching a specific file
97 muse log -n 5 -- src/ # last 5 commits touching anything in src/
98 muse log dev -- README.md # commits on branch dev that touched README.md
99
100 Paths are matched as prefixes: ``src/`` matches ``src/foo.py`` and ``src/bar/baz.py``.
101 Exact paths match only that exact file. The ``--`` separator is optional when the
102 path cannot be mistaken for a branch name, but is always safe to include.
103 """
104
105 import argparse
106 import heapq
107 import json
108 import logging
109 import pathlib
110 import re
111 import sys
112 import textwrap
113 from collections.abc import Mapping
114 from datetime import datetime, timedelta, timezone
115 from typing import TypedDict
116
117 from muse.cli.config import get_limit
118 from muse.core.types import long_id
119 from muse.core.paths import ref_path as _ref_path, remote_ref_path as _remote_ref_path
120 from muse.core.envelope import EnvelopeJson, make_envelope
121 from muse.core.errors import ExitCode
122 from muse.core.repo import read_repo_id, require_repo
123 from muse.core.refs import iter_branch_refs
124 from muse.core.types import Metadata
125 from muse.core.refs import (
126 get_head_commit_id,
127 read_current_branch,
128 )
129 from muse.core.commits import (
130 CommitRecord,
131 get_commits_for_branch,
132 read_commit,
133 resolve_commit_ref,
134 )
135 from muse.core.graph import ancestor_ids
136 from muse.core.snapshots import get_commit_snapshot_manifest
137 from muse.core.validation import clamp_int, sanitize_display
138 from muse.core.timing import start_timer
139
140 type CommitIndex = dict[str, CommitRecord]
141 type BranchTips = dict[str, list[str]]
142 type CounterMap = dict[str, int]
143 type _DeltaMap = dict[str, timedelta]
144 type _FileManifest = dict[str, str]
145 type _ManifestCache = dict[str, _FileManifest]
146
147 def _get_manifest(
148 root: pathlib.Path, commit_id: str | None, cache: _ManifestCache
149 ) -> _FileManifest:
150 """Return the snapshot manifest for *commit_id*, using *cache* to avoid re-reads.
151
152 Each commit_id is fetched from disk at most once per *cache* lifetime.
153 An empty dict is returned (and cached) for ``None`` commit_ids so callers
154 can treat initial commits uniformly.
155 """
156 if commit_id is None:
157 return {}
158 if commit_id not in cache:
159 cache[commit_id] = get_commit_snapshot_manifest(root, commit_id) or {}
160 return cache[commit_id]
161
162 _HEX_CHARS = frozenset("0123456789abcdefABCDEF")
163
164 def _is_known_ref(root: pathlib.Path, ref: str) -> bool:
165 """Return True if *ref* resolves to a local branch, remote tracking branch, or commit ID.
166
167 Used to disambiguate ``muse log <ref>`` (branch/commit) from
168 ``muse log -- <path>`` (pathspec that argparse mistakenly assigned to ref).
169
170 Accepts both bare hex commit IDs (legacy) and canonical ``sha256:<hex>``
171 prefixed IDs produced by ``hash_commit``.
172 """
173 # Canonical sha256:-prefixed commit ID (full or abbreviated, min 8 hex chars).
174 if ref.startswith("sha256:"):
175 hex_part = long_id(ref, strip=True)
176 if len(hex_part) >= 8 and all(c in _HEX_CHARS for c in hex_part):
177 return True
178 # Bare hex commit ID (min 8 chars) — accepted for backwards compatibility.
179 if len(ref) >= 8 and all(c in _HEX_CHARS for c in ref):
180 return True
181 # Local branch: .muse/refs/heads/<ref>
182 if _ref_path(root, ref).exists():
183 return True
184 # Remote-tracking branch: .muse/remotes/<remote>/<branch>
185 # ref may be "origin/dev" → remote="origin", branch="dev"
186 if "/" in ref:
187 remote, _, branch = ref.partition("/")
188 if branch and _remote_ref_path(root, remote, branch).exists():
189 return True
190 return False
191
192 logger = logging.getLogger(__name__)
193
194 _DEFAULT_LIMIT = 1000
195
196 # ANSI colour helpers — only emitted when stdout is a TTY.
197 _RESET = "\033[0m"
198 _BOLD = "\033[1m"
199 _YELLOW = "\033[33m"
200 _GREEN = "\033[32m"
201 _RED = "\033[31m"
202 _CYAN = "\033[36m"
203 _DIM = "\033[2m"
204
205 class _CommitJson(TypedDict):
206 """Stable JSON wire format for a single commit in ``muse log --json``.
207
208 All keys are always present so agents can read them without ``dict.get``
209 guards.
210
211 ``files_added``, ``files_removed``, and ``files_modified`` are always
212 populated in ``--json`` mode — agents do not need to pass ``--stat``.
213
214 ``agent_id`` and ``model_id`` are empty strings for human commits and
215 populated for agent-authored commits (committed with ``--agent-id`` /
216 ``--model-id``).
217 """
218
219 commit_id: str
220 branch: str
221 message: str
222 author: str
223 agent_id: str
224 model_id: str
225 committed_at: str
226 parent_commit_id: str | None
227 parent2_commit_id: str | None
228 snapshot_id: str | None
229 sem_ver_bump: str | None
230 breaking_changes: list[str]
231 metadata: Metadata
232 files_added: list[str]
233 files_removed: list[str]
234 files_modified: list[str]
235 structured_delta: Mapping[str, object] | None
236 signer_public_key: str
237
238 class _LogJson(EnvelopeJson):
239 """Stable top-level JSON envelope for ``muse log --json``.
240
241 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
242
243 All keys are always present so agents can read them without ``dict.get``
244 guards. ``status`` is ``"ok"`` on success.
245 """
246 status: str # "ok"
247 error: str # always "" on success
248 truncated: bool
249 total: int # len(commits)
250 branch: str # branch that was walked
251 repo_id: str
252 commits: list[_CommitJson]
253
254 class _LogErrorJson(EnvelopeJson):
255 """Error payload for ``muse log --json`` when a usage error occurs."""
256 status: str # "error"
257 error: str
258
259 _SEMVER_COLOUR: Metadata = {
260 "major": _RED,
261 "minor": _YELLOW,
262 "patch": _GREEN,
263 }
264
265 def _c(text: str, *codes: str, tty: bool) -> str:
266 """Wrap *text* in ANSI *codes* when *tty* is True."""
267 if not tty:
268 return text
269 return "".join(codes) + text + _RESET
270
271 def _ref_label(branch: str, is_head: bool, tty: bool) -> str:
272 """Format the ``(HEAD -> branch)`` decoration for a commit line."""
273 if not is_head:
274 return ""
275 if not tty:
276 return f" (HEAD -> {branch})"
277 head = _c("HEAD", _BOLD, _CYAN, tty=tty)
278 arrow = _c(" -> ", _RESET, tty=tty)
279 br = _c(branch, _BOLD, _GREEN, tty=tty)
280 paren_open = _c("(", _YELLOW, tty=tty)
281 paren_close = _c(")", _YELLOW, tty=tty)
282 return f" {paren_open}{head}{arrow}{br}{paren_close}"
283
284 def _parse_date(text: str) -> datetime:
285 """Parse a human-readable date string into an aware UTC datetime.
286
287 Accepted formats:
288 - ``"today"`` / ``"yesterday"``
289 - ``"<n> days|weeks|months|years ago"`` (months = 30 days, years = 365 days)
290 - ``"YYYY-MM-DD"``
291 - ``"YYYY-MM-DDTHH:MM:SS"`` or ``"YYYY-MM-DD HH:MM:SS"``
292
293 Raises:
294 ValueError: When the input does not match any recognised format.
295 """
296 text = text.strip().lower()
297 now = datetime.now(timezone.utc)
298 if text == "today":
299 return now.replace(hour=0, minute=0, second=0, microsecond=0)
300 if text == "yesterday":
301 return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
302 m = re.match(r"^(\d+)\s+(day|week|month|year)s?\s+ago$", text)
303 if m:
304 n = int(m.group(1))
305 unit = m.group(2)
306 deltas: _DeltaMap = {
307 "day": timedelta(days=n),
308 "week": timedelta(weeks=n),
309 "month": timedelta(days=n * 30),
310 "year": timedelta(days=n * 365),
311 }
312 return now - deltas[unit]
313 for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
314 try:
315 return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
316 except ValueError:
317 continue
318 raise ValueError(f"Cannot parse date: {text!r}")
319
320 def _commit_touches_path(
321 root: pathlib.Path,
322 commit: CommitRecord,
323 path: str,
324 manifest_cache: _ManifestCache | None = None,
325 ) -> bool:
326 """Return True if *commit* changed *path* (added, modified, or removed).
327
328 *path* is matched as a prefix so that ``src/`` matches any file under
329 ``src/``, while ``src/foo.py`` matches only that exact file. Trailing
330 slashes on directory prefixes are normalised away before comparison.
331
332 *manifest_cache* is a shared dict keyed by commit_id. Pass one from the
333 ``run`` call site to avoid re-reading the same snapshot for the same commit
334 across multiple calls (e.g. once for pathspec filtering, once for stat).
335 """
336 if manifest_cache is None:
337 manifest_cache = {}
338 norm = path.rstrip("/")
339 current_manifest = _get_manifest(root, commit.commit_id, manifest_cache)
340 parent_manifest = _get_manifest(root, commit.parent_commit_id, manifest_cache)
341 all_paths = set(current_manifest) | set(parent_manifest)
342 for p in all_paths:
343 if p == norm or p.startswith(f"{norm}/"):
344 if current_manifest.get(p) != parent_manifest.get(p):
345 return True
346 return False
347
348 def _file_diff(
349 root: pathlib.Path,
350 commit: CommitRecord,
351 manifest_cache: _ManifestCache | None = None,
352 ) -> tuple[list[str], list[str], list[str]]:
353 """Return ``(added, removed, modified)`` file lists relative to the commit's parent.
354
355 Compares the commit's snapshot manifest against its first parent's manifest.
356 For merge commits the second parent is ignored — this is a ``--stat``-style
357 summary, not a three-way diff.
358
359 *manifest_cache* is a shared dict keyed by commit_id. Passing one from
360 the ``run`` call site means the same manifest is never read twice within a
361 single ``muse log`` invocation.
362
363 Returns:
364 A 3-tuple of sorted file-path lists:
365 ``added`` — files present in the commit but not in its parent.
366 ``removed`` — files present in the parent but not in the commit.
367 ``modified`` — files present in both with a different snapshot hash.
368 """
369 if manifest_cache is None:
370 manifest_cache = {}
371 current_manifest = _get_manifest(root, commit.commit_id, manifest_cache)
372 parent_manifest = _get_manifest(root, commit.parent_commit_id, manifest_cache)
373 added = sorted(set(current_manifest) - set(parent_manifest))
374 removed = sorted(set(parent_manifest) - set(current_manifest))
375 modified = sorted(
376 f for f in set(current_manifest) & set(parent_manifest)
377 if current_manifest[f] != parent_manifest[f]
378 )
379 return added, removed, modified
380
381 def _format_date(dt: datetime) -> str:
382 """Format a datetime as ``YYYY-MM-DD HH:MM:SS UTC`` for display."""
383 return dt.strftime("%Y-%m-%d %H:%M:%S UTC") if dt.tzinfo else str(dt)
384
385 # ---------------------------------------------------------------------------
386 # Filter helpers — single implementation shared by JSON and text paths
387 # ---------------------------------------------------------------------------
388
389 def _apply_filters(
390 commits: list[CommitRecord],
391 *,
392 since_dt: datetime | None,
393 until_dt: datetime | None,
394 author: str | None,
395 section: str | None,
396 track: str | None,
397 emotion: str | None,
398 limit: int,
399 ) -> tuple[list[CommitRecord], bool]:
400 """Apply all active filters to *commits* and enforce *limit*.
401
402 Previously the filter loop was copy-pasted between the JSON path and the
403 text path. Any new filter would have required updating both. This
404 function is the single source of truth; both paths call it.
405
406 Args:
407 commits: Pre-fetched commit list from ``get_commits_for_branch``.
408 since_dt: Inclusive lower bound on ``committed_at``.
409 until_dt: Inclusive upper bound on ``committed_at``.
410 author: Substring match on ``author`` (case-insensitive).
411 section: Exact match on ``metadata["section"]``.
412 track: Exact match on ``metadata["track"]``.
413 emotion: Exact match on ``metadata["emotion"]``.
414 limit: Maximum number of commits to return (0 = unlimited).
415
416 Returns:
417 ``(filtered_commits, truncated)`` where *truncated* is True when the
418 walk was capped before the filter had seen all available commits.
419 """
420 filtered: list[CommitRecord] = []
421 for c in commits:
422 if since_dt and c.committed_at < since_dt:
423 continue
424 if until_dt and c.committed_at > until_dt:
425 continue
426 if author and author.lower() not in c.author.lower():
427 continue
428 if section and c.metadata.get("section") != section:
429 continue
430 if track and c.metadata.get("track") != track:
431 continue
432 if emotion and c.metadata.get("emotion") != emotion:
433 continue
434 filtered.append(c)
435 if limit > 0 and len(filtered) >= limit:
436 # We hit the display limit — the walk may have had more commits.
437 truncated = len(commits) > len(filtered)
438 return filtered, truncated
439 return filtered, False
440
441 def _commit_to_json(
442 c: CommitRecord,
443 root: pathlib.Path | None = None,
444 *,
445 stat: bool = False,
446 manifest_cache: _ManifestCache | None = None,
447 ) -> _CommitJson:
448 """Serialise *c* to the stable ``--json`` wire format.
449
450 All keys are always present so agents can read them without ``dict.get``
451 guards. Merge commits include ``parent2_commit_id``; linear commits have
452 it set to ``null``.
453
454 When *stat* is True and *root* is provided, ``files_added``,
455 ``files_removed``, and ``files_modified`` are populated by diffing the
456 commit's snapshot against its parent. Otherwise all three are empty lists.
457
458 The ``--json`` output path always calls this with ``stat=True`` so that
459 agents always receive populated file lists without needing to pass
460 ``--stat`` explicitly. The *stat* parameter is kept for callers that
461 intentionally want the lightweight (no-diff) form.
462
463 *manifest_cache* is a shared dict keyed by commit_id passed from ``run``
464 so that manifests already fetched during pathspec filtering are not
465 re-read during stat computation.
466
467 Args:
468 c: The commit to serialise.
469 root: Repository root directory. Required when *stat* is True.
470 stat: Whether to compute and include per-file change lists.
471 manifest_cache: Shared manifest cache from the ``run`` call site.
472
473 Returns:
474 JSON-serialisable dict matching the schema documented in the module
475 docstring.
476 """
477 files_added: list[str] = []
478 files_removed: list[str] = []
479 files_modified: list[str] = []
480 if stat and root is not None:
481 files_added, files_removed, files_modified = _file_diff(root, c, manifest_cache)
482 return _CommitJson(
483 commit_id=c.commit_id,
484 branch=c.branch,
485 message=c.message,
486 author=c.author,
487 agent_id=c.agent_id,
488 model_id=c.model_id,
489 committed_at=c.committed_at.isoformat(),
490 parent_commit_id=c.parent_commit_id,
491 parent2_commit_id=c.parent2_commit_id,
492 snapshot_id=c.snapshot_id,
493 sem_ver_bump=c.sem_ver_bump,
494 breaking_changes=list(c.breaking_changes) if c.breaking_changes else [],
495 metadata=c.metadata,
496 files_added=files_added,
497 files_removed=files_removed,
498 files_modified=files_modified,
499 structured_delta=c.structured_delta if isinstance(c.structured_delta, dict) else None,
500 signer_public_key=c.signer_public_key or "",
501 )
502
503 # ---------------------------------------------------------------------------
504 # DAG graph rendering helpers
505 # ---------------------------------------------------------------------------
506
507 def _branch_tips(root: pathlib.Path, *, include_remote: bool = False) -> BranchTips:
508 """Return ``{commit_id: [branch_name, …]}`` for all local branch tips.
509
510 When *include_remote* is True, also walks ``.muse/remotes/`` and adds
511 remote tracking refs using ``remotes/<remote>/<branch>`` names — matching
512 the output of ``muse branch -a`` and git's ``--all`` semantics.
513 """
514 from muse.core.paths import remotes_dir as _remotes_dir
515 from muse.core.refs import read_ref as _read_ref
516
517 tips: BranchTips = {}
518 for name, cid in iter_branch_refs(root):
519 tips.setdefault(cid, []).append(name)
520
521 if include_remote:
522 rd = _remotes_dir(root)
523 if rd.is_dir():
524 for ref_file in sorted(rd.rglob("*")):
525 if ref_file.is_symlink() or not ref_file.is_file():
526 continue
527 cid = _read_ref(ref_file)
528 if cid:
529 rel = ref_file.relative_to(rd)
530 label = f"remotes/{rel}"
531 tips.setdefault(cid, []).append(label)
532 return tips
533
534 def _collect_all_commits(
535 root: pathlib.Path,
536 start_ids: list[str],
537 max_commits: int = 50_000,
538 ) -> tuple[CommitIndex, bool]:
539 """BFS from *start_ids*, returning every reachable commit up to *max_commits*.
540
541 Args:
542 root: Repository root.
543 start_ids: BFS seed commit IDs (branch tips).
544 max_commits: Safety cap — default 50 000.
545
546 Returns:
547 ``({commit_id: CommitRecord}, truncated)`` pair where *truncated* is
548 True when *max_commits* was reached before the graph was fully walked.
549 """
550 from muse.core.graph import iter_ancestors
551 seen: CommitIndex = {}
552 for rec in iter_ancestors(root, start_ids, max_commits=max_commits + 1):
553 seen[rec.commit_id] = rec
554 truncated = len(seen) > max_commits
555 if truncated:
556 last_key = next(reversed(seen))
557 del seen[last_key]
558 return seen, truncated
559
560 def _topo_sort(commits: CommitIndex) -> list[CommitRecord]:
561 """Return commits newest-first using Kahn's algorithm.
562
563 In-degree counts the number of *child* commits that reference each commit
564 as a parent, so commits with no children (branch tips) are processed first.
565 Ties are broken by timestamp (most recent first).
566 """
567 in_degree: CounterMap = {cid: 0 for cid in commits}
568 for rec in commits.values():
569 for parent in (rec.parent_commit_id, rec.parent2_commit_id):
570 if parent and parent in commits:
571 in_degree[parent] += 1
572
573 heap: list[tuple[float, str]] = []
574 for cid, deg in in_degree.items():
575 if deg == 0:
576 ts = -commits[cid].committed_at.timestamp()
577 heapq.heappush(heap, (ts, cid))
578
579 result: list[CommitRecord] = []
580 while heap:
581 _, cid = heapq.heappop(heap)
582 result.append(commits[cid])
583 rec = commits[cid]
584 for parent in (rec.parent_commit_id, rec.parent2_commit_id):
585 if parent and parent in commits:
586 in_degree[parent] -= 1
587 if in_degree[parent] == 0:
588 ts = -commits[parent].committed_at.timestamp()
589 heapq.heappush(heap, (ts, parent))
590 return result
591
592 def _deco_str(
593 cid: str,
594 head_cid: str,
595 current: str,
596 tips: BranchTips,
597 tty: bool,
598 ) -> str:
599 """Format the ``(HEAD -> branch, other-branch)`` decoration for a commit."""
600 branches = tips.get(cid, [])
601 if not branches:
602 return ""
603 labels: list[str] = []
604 if cid == head_cid and current in branches:
605 head = _c("HEAD", _BOLD, _CYAN, tty=tty)
606 br = _c(current, _BOLD, _GREEN, tty=tty)
607 labels.append(f"{head}{_c(' -> ', _RESET, tty=tty)}{br}")
608 for b in branches:
609 if b != current:
610 labels.append(_c(b, _BOLD, _GREEN, tty=tty))
611 else:
612 for b in branches:
613 labels.append(_c(b, _BOLD, _GREEN, tty=tty))
614 inner = ", ".join(labels)
615 return f" {_c('(', _YELLOW, tty=tty)}{inner}{_c(')', _YELLOW, tty=tty)}"
616
617 def _render_graph(
618 root: pathlib.Path,
619 branch: str,
620 all_branches: bool,
621 tty: bool,
622 ) -> None:
623 """Render a lane-based ASCII DAG, git-log-style."""
624 current = read_current_branch(root)
625 tips = _branch_tips(root, include_remote=all_branches)
626
627 if all_branches:
628 start_ids = list(tips.keys())
629 else:
630 head = get_head_commit_id(root, branch)
631 start_ids = [head] if head else []
632
633 if not start_ids:
634 print("(no commits)")
635 return
636
637 graph_cap = get_limit("max_graph_commits", root)
638 all_commits, graph_truncated = _collect_all_commits(root, start_ids, max_commits=graph_cap)
639 if not all_commits:
640 print("(no commits)")
641 return
642 if graph_truncated:
643 print(
644 f"⚠️ Graph truncated at {graph_cap:,} commits. "
645 "Raise [limits] max_graph_commits in .muse/config.toml to see more."
646 )
647
648 head_cid = get_head_commit_id(root, current) or ""
649 sorted_commits = _topo_sort(all_commits)
650
651 # lanes: list of commit IDs we're "awaiting" (open lines of descent).
652 # None marks a closed / empty column slot.
653 lanes: list[str | None] = []
654
655 for idx, commit in enumerate(sorted_commits):
656 cid = commit.commit_id
657 parents = [
658 p for p in (commit.parent_commit_id, commit.parent2_commit_id)
659 if p and p in all_commits
660 ]
661
662 # Assign this commit to a column.
663 col = lanes.index(cid) if cid in lanes else -1
664 if col == -1:
665 if None in lanes:
666 col = lanes.index(None)
667 lanes[col] = cid
668 else:
669 col = len(lanes)
670 lanes.append(cid)
671
672 width = len(lanes)
673 row: list[str] = []
674 for i in range(width):
675 if i == col:
676 row.append(_c("*", _BOLD, tty=tty))
677 elif lanes[i] is not None:
678 row.append("|")
679 else:
680 row.append(" ")
681
682 graph_prefix = " ".join(row).rstrip()
683 short_hash = _c(long_id(cid), _YELLOW, tty=tty)
684 deco = _deco_str(cid, head_cid, current, tips, tty)
685 msg = sanitize_display(commit.message.splitlines()[0])
686 print(f"{graph_prefix} {short_hash}{deco} {msg}")
687
688 if parents:
689 lanes[col] = parents[0]
690 newly_opened: set[int] = set()
691 for extra in parents[1:]:
692 if extra not in lanes:
693 if None in lanes:
694 ni = lanes.index(None)
695 lanes[ni] = extra
696 newly_opened.add(ni)
697 else:
698 ni = len(lanes)
699 lanes.append(extra)
700 newly_opened.add(ni)
701 else:
702 lanes[col] = None
703 newly_opened = set()
704
705 # Detect lanes that share a target — keep lowest-index, mark others converging.
706 _seen: dict[str, int] = {}
707 converging: set[int] = set()
708 for i, v in enumerate(lanes):
709 if v is None:
710 continue
711 if v in _seen:
712 converging.add(i)
713 else:
714 _seen[v] = i
715
716 if idx < len(sorted_commits) - 1:
717 is_merge = len(parents) >= 2
718 # Use a character array so "/" can slide into the half-position
719 # between two lanes (position 2*i-1) rather than starting a new block.
720 char_count = len(lanes) * 2
721 chars = [" "] * char_count
722 for i in range(len(lanes)):
723 pos = 2 * i
724 if i == col and is_merge:
725 chars[pos] = "|"
726 if pos + 1 < char_count:
727 chars[pos + 1] = "\\"
728 elif i in converging:
729 # "/" slides left into the trailing space of lane i-1
730 if pos > 0:
731 chars[pos - 1] = "/"
732 elif i in newly_opened:
733 pass # leave as spaces — lane just opened, no pipe yet
734 elif lanes[i] is not None:
735 chars[pos] = "|"
736 line = "".join(chars).rstrip()
737 if line:
738 print(line)
739
740 for i in converging:
741 lanes[i] = None
742 while lanes and lanes[-1] is None:
743 lanes.pop()
744
745 # ---------------------------------------------------------------------------
746 # CLI registration
747 # ---------------------------------------------------------------------------
748
749 def _emit_error(json_out: bool, msg: str, code: "ExitCode", elapsed: float) -> None:
750 """Print an error and raise SystemExit. Never returns.
751
752 In ``--json`` mode the error is emitted as a JSON payload to stdout so
753 that machine consumers can always parse stdout — no prose leaks to stderr.
754 In text mode the error is printed to stderr as a human-readable message.
755 """
756 if json_out:
757 print(json.dumps(_LogErrorJson(
758 **make_envelope(elapsed, exit_code=int(code)),
759 status="error",
760 error=msg,
761 )))
762 else:
763 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
764 raise SystemExit(code)
765
766 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
767 """Register the ``muse log`` subcommand and its flags."""
768 parser = subparsers.add_parser(
769 "log",
770 help="Display commit history.",
771 description=__doc__,
772 formatter_class=argparse.RawDescriptionHelpFormatter,
773 )
774 parser.add_argument(
775 "ref", nargs="?", default=None,
776 help="Branch or commit to start from (default: current branch).",
777 )
778 parser.add_argument(
779 "--oneline", action="store_true",
780 help="One line per commit.",
781 )
782 parser.add_argument(
783 "--graph", "-g", action="store_true",
784 help="ASCII DAG graph.",
785 )
786 parser.add_argument(
787 "--all", "-a", action="store_true", dest="all_branches",
788 help="Include all local branches in the graph (implies --graph).",
789 )
790 parser.add_argument(
791 "--stat", action="store_true",
792 help="Show added/removed file summary for each commit.",
793 )
794 parser.add_argument(
795 "--max-count", "--limit", "-n", type=int, default=_DEFAULT_LIMIT, dest="limit",
796 help="Limit number of commits shown (default: %(default)s).",
797 )
798 parser.add_argument("--since", default=None, help="Show commits after date.")
799 parser.add_argument("--until", default=None, help="Show commits before date.")
800 parser.add_argument("--author", default=None, help="Filter by author substring.")
801 parser.add_argument("--section", default=None, help="Filter by section metadata.")
802 parser.add_argument("--track", default=None, help="Filter by track metadata.")
803 parser.add_argument("--emotion", default=None, help="Filter by emotion metadata.")
804 parser.add_argument(
805 "--json", "-j", action="store_true", dest="json_out",
806 help="Emit machine-readable JSON instead of human text.",
807 )
808 parser.add_argument(
809 "pathspec", nargs="*", metavar="path",
810 help=(
811 "Limit output to commits that touched these paths. "
812 "Prefix-matched: 'src/' matches all files under src/. "
813 "Separate from options with '--' (e.g. muse log -- src/foo.py)."
814 ),
815 )
816 parser.set_defaults(func=run)
817
818 def run(args: argparse.Namespace) -> None:
819 """Display commit history.
820
821 Walks the commit DAG from HEAD (or a specified branch/ref) and emits
822 commit records. ``truncated`` is ``true`` when the walk hit the
823 ``-n`` cap. In ``--json`` mode all file changes are always included
824 without needing ``--stat``.
825
826 Agent quickstart
827 ----------------
828 ::
829
830 muse log --json
831 muse log -n 20 --json
832 muse log --branch feat/billing --json
833 muse log --author gabriel --since 2025-01-01 --json
834
835 JSON fields
836 -----------
837 status ``"ok"`` on success.
838 truncated ``true`` if the walk was capped by ``-n``.
839 total Number of commits returned.
840 branch Branch walked.
841 repo_id Repository identifier.
842 commits List of commit objects: ``commit_id``, ``message``,
843 ``author``, ``committed_at``, ``parent_commit_id``,
844 ``files_added``, ``files_modified``, ``files_removed``.
845
846 Exit codes
847 ----------
848 0 Success.
849 1 Invalid arguments (bad ``--format``, ``--since``/``--until``, etc.).
850 2 Not inside a Muse repository.
851 """
852 elapsed = start_timer()
853
854 ref: str | None = args.ref
855 oneline: bool = args.oneline
856 graph: bool = args.graph
857 all_branches: bool = args.all_branches
858 stat: bool = args.stat
859 # Track whether the user explicitly overrode the limit. Only the default
860 # limit being hit silently warrants a truncation warning in text mode.
861 explicit_limit: bool = (args.limit != _DEFAULT_LIMIT)
862 limit: int = clamp_int(args.limit, 1, 100000, "limit")
863 since: str | None = args.since
864 until: str | None = args.until
865 author: str | None = args.author
866 section: str | None = args.section
867 track: str | None = args.track
868 emotion: str | None = args.emotion
869 json_out: bool = args.json_out
870 pathspec: list[str] = list(args.pathspec) if args.pathspec else []
871
872 if all_branches:
873 graph = True
874
875 # Support git-style -<n> shorthand (e.g. `muse log -5` as alias for `-n 5`).
876 # argparse captures "-5" as the positional `ref`; detect and reinterpret it.
877 if ref is not None and ref.lstrip("-").isdigit() and ref.startswith("-"):
878 limit = int(ref.lstrip("-"))
879 explicit_limit = True
880 ref = None
881
882 # Support A..B range syntax: commits reachable from B but not from A.
883 exclude_ref: str | None = None
884 if ref is not None and ".." in ref:
885 exclude_ref, ref = ref.split("..", 1)
886
887 if limit < 1:
888 _emit_error(json_out, "--max-count must be at least 1.", ExitCode.USER_ERROR, elapsed)
889
890 # Validate date filters before touching the repository.
891 since_dt: datetime | None = None
892 until_dt: datetime | None = None
893 if since:
894 try:
895 since_dt = _parse_date(since)
896 except ValueError:
897 _emit_error(
898 json_out,
899 f"Cannot parse --since value: {since!r}. "
900 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
901 ExitCode.USER_ERROR,
902 elapsed,
903 )
904 if until:
905 try:
906 until_dt = _parse_date(until)
907 except ValueError:
908 _emit_error(
909 json_out,
910 f"Cannot parse --until value: {until!r}. "
911 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
912 ExitCode.USER_ERROR,
913 elapsed,
914 )
915
916 root = require_repo()
917 repo_id = read_repo_id(root)
918
919 # Resolve the exclude side of an A..B range now that we have a root.
920 exclude_ids: set[str] = set()
921 if exclude_ref is not None:
922 excl_rec = resolve_commit_ref(root, read_current_branch(root), exclude_ref)
923 excl_cid = excl_rec.commit_id if excl_rec else None
924 if excl_cid is None:
925 excl_tip = get_head_commit_id(root, exclude_ref)
926 if excl_tip:
927 excl_cid = excl_tip
928 if excl_cid is None:
929 _emit_error(
930 json_out,
931 f"Cannot resolve exclude ref {sanitize_display(exclude_ref)!r}.",
932 ExitCode.USER_ERROR,
933 elapsed,
934 )
935 exclude_ids = ancestor_ids(root, excl_cid)
936 exclude_ids.add(excl_cid)
937
938 # When the -N shorthand consumed `ref` (e.g. `muse log -1 dev`), argparse
939 # puts the branch name in pathspec[0]. Reclaim it now that we have a repo
940 # root available to check whether it's a real ref.
941 if ref is None and pathspec and _is_known_ref(root, pathspec[0]):
942 ref = pathspec[0]
943 pathspec = pathspec[1:]
944
945 # Disambiguate: argparse assigns the first positional after `--` to `ref`
946 # (because ref is nargs="?"). If what landed in `ref` is not a known
947 # branch or commit ID, treat it as the first pathspec element.
948 # Preserve the original value for the "no commits on '<ref>'" message so
949 # that nonexistent-branch errors are still contextual.
950 # In range mode (A..B) the include side must resolve — don't silently
951 # treat an unknown B as a pathspec.
952 user_ref = ref # may be a nonexistent branch name — kept for error display
953 if ref is not None and not _is_known_ref(root, ref):
954 if exclude_ref is not None:
955 _emit_error(
956 json_out,
957 f"Cannot resolve include ref {sanitize_display(ref)!r}.",
958 ExitCode.USER_ERROR,
959 elapsed,
960 )
961 pathspec = [ref] + pathspec
962 ref = None
963
964 # Detect whether ref is a direct commit ID (sha256: prefixed or bare hex).
965 # Commit IDs must be routed through resolve_commit_ref + a manual walk;
966 # get_commits_for_branch only accepts branch names (validate_branch_name
967 # rejects ":" which appears in every sha256: commit ID).
968 ref_is_commit_id = ref is not None and (
969 (ref.startswith("sha256:") and len(ref) > 7 and all(c in _HEX_CHARS for c in long_id(ref, strip=True)))
970 or (len(ref) >= 8 and all(c in _HEX_CHARS for c in ref))
971 )
972
973 if ref_is_commit_id:
974 # Resolve the commit ID (full or abbreviated prefix) to a CommitRecord.
975 start_rec = resolve_commit_ref(root, read_current_branch(root), ref)
976 start_cid = start_rec.commit_id if start_rec else None
977 # Display the branch the resolved commit belongs to; fall back to current.
978 branch = (start_rec.branch if start_rec else None) or read_current_branch(root)
979 else:
980 start_cid = None
981 branch = ref if ref is not None else read_current_branch(root)
982
983 if graph and not json_out:
984 _render_graph(root, branch=branch, all_branches=all_branches, tty=sys.stdout.isatty())
985 return
986
987 has_filters = any([since_dt, until_dt, author, section, track, emotion])
988 walk_cap = get_limit("max_walk_commits", root)
989
990 def _walk_from_cid(start: str | None, max_count: int = 0) -> list[CommitRecord]:
991 """Walk the parent chain from *start*, returning commits newest-first."""
992 commits: list[CommitRecord] = []
993 cid = start
994 seen: set[str] = set()
995 while cid and cid not in seen:
996 if max_count > 0 and len(commits) >= max_count:
997 break
998 seen.add(cid)
999 rec = read_commit(root, cid)
1000 if rec is None:
1001 break
1002 commits.append(rec)
1003 cid = rec.parent_commit_id
1004 return commits
1005
1006 # When filters or pathspec are active we must walk the full history first,
1007 # then apply the limit afterwards (same as git log). Without filters we
1008 # fetch limit+1 commits: if we get limit+1 back there are more commits
1009 # than the display cap — truncated=True; otherwise we got everything.
1010 # --all with JSON: walk from every local + remote tracking tip, deduplicate.
1011 if all_branches and not ref_is_commit_id:
1012 all_tips = _branch_tips(root, include_remote=True)
1013 seen_cids: set[str] = set()
1014 raw_commits = []
1015 for tip_cid in all_tips:
1016 for rec in _walk_from_cid(tip_cid, max_count=walk_cap):
1017 if rec.commit_id not in seen_cids:
1018 seen_cids.add(rec.commit_id)
1019 raw_commits.append(rec)
1020 raw_commits.sort(key=lambda c: c.committed_at, reverse=True)
1021 probe_truncated = False
1022 elif has_filters or pathspec:
1023 walk_limit = walk_cap
1024 if ref_is_commit_id:
1025 raw_commits = _walk_from_cid(start_cid, max_count=walk_limit)
1026 else:
1027 raw_commits = get_commits_for_branch(root, branch, max_count=walk_limit)
1028 probe_truncated = False
1029 else:
1030 probe_count = limit + 1
1031 if ref_is_commit_id:
1032 raw_commits = _walk_from_cid(start_cid, max_count=probe_count)
1033 else:
1034 raw_commits = get_commits_for_branch(root, branch, max_count=probe_count)
1035 probe_truncated = len(raw_commits) > limit
1036 if probe_truncated:
1037 raw_commits = raw_commits[:limit]
1038
1039 # Apply A..B exclusion: drop commits reachable from the exclude side.
1040 if exclude_ids:
1041 raw_commits = [c for c in raw_commits if c.commit_id not in exclude_ids]
1042
1043 # Shared manifest cache: each commit_id's snapshot is loaded at most once
1044 # per invocation regardless of how many callers need it (pathspec filter,
1045 # stat diff, JSON serialisation).
1046 manifest_cache: _ManifestCache = {}
1047
1048 # Apply pathspec filter: keep only commits that touched one of the given paths.
1049 if pathspec:
1050 raw_commits = [
1051 c for c in raw_commits
1052 if any(_commit_touches_path(root, c, p, manifest_cache) for p in pathspec)
1053 ]
1054
1055 walk_truncated = probe_truncated or ((has_filters or bool(pathspec)) and len(raw_commits) >= walk_cap)
1056
1057 filtered, filter_truncated = _apply_filters(
1058 raw_commits,
1059 since_dt=since_dt,
1060 until_dt=until_dt,
1061 author=author,
1062 section=section,
1063 track=track,
1064 emotion=emotion,
1065 limit=limit,
1066 )
1067 truncated = walk_truncated or filter_truncated
1068
1069 # ── JSON output ───────────────────────────────────────────────────────────
1070 if json_out:
1071 commit_list = [
1072 _commit_to_json(c, root, stat=True, manifest_cache=manifest_cache)
1073 for c in filtered
1074 ]
1075 print(json.dumps(_LogJson(
1076 **make_envelope(elapsed),
1077 status="ok",
1078 error="",
1079 truncated=truncated,
1080 total=len(filtered),
1081 branch=branch,
1082 repo_id=repo_id,
1083 commits=commit_list,
1084 ), default=str))
1085 return
1086
1087 # ── Text output ───────────────────────────────────────────────────────────
1088 if not filtered:
1089 if user_ref is not None:
1090 # Distinguish "branch exists but is empty" from "branch not found".
1091 print(f"(no commits on '{sanitize_display(user_ref)}')")
1092 else:
1093 print("(no commits)")
1094 return
1095
1096 # Only warn about truncation when the default cap was hit silently. If the
1097 # user explicitly passed -n they know they are asking for a subset — no warning.
1098 if truncated and not explicit_limit:
1099 print(
1100 f"⚠️ History truncated at {len(filtered):,} commits — "
1101 "use -n or raise [limits] max_walk_commits in .muse/config.toml to see more."
1102 )
1103
1104 head_commit_id = filtered[0].commit_id if filtered else None
1105 tty: bool = sys.stdout.isatty()
1106
1107 for c in filtered:
1108 is_head = c.commit_id == head_commit_id
1109 decoration = _ref_label(branch, is_head, tty)
1110 short_hash = _c(long_id(c.commit_id), _YELLOW, tty=tty)
1111 subject = sanitize_display(c.message.splitlines()[0])
1112 author_display = sanitize_display(c.author)
1113
1114 if oneline:
1115 print(f"{short_hash}{decoration} {subject}")
1116 continue
1117
1118 commit_word = _c("commit", _YELLOW, tty=tty)
1119 print(f"{commit_word} {short_hash}{decoration}")
1120 if author_display:
1121 print(f"Author: {author_display}")
1122 print(f"Date: {_c(_format_date(c.committed_at), _DIM, tty=tty)}")
1123
1124 if c.sem_ver_bump and c.sem_ver_bump != "none":
1125 bump_key = c.sem_ver_bump.lower()
1126 bump_colour = _SEMVER_COLOUR.get(bump_key, "")
1127 bump_label = (
1128 _c(c.sem_ver_bump.upper(), bump_colour, tty=tty)
1129 if bump_colour else c.sem_ver_bump.upper()
1130 )
1131 print(f"SemVer: {bump_label}")
1132 if c.breaking_changes:
1133 safe_breaks = [sanitize_display(b) for b in c.breaking_changes[:3]]
1134 breaking_text = ", ".join(safe_breaks)
1135 if len(c.breaking_changes) > 3:
1136 breaking_text += f" +{len(c.breaking_changes) - 3} more"
1137 print(f"Breaking: {_c(breaking_text, _RED, tty=tty)}")
1138
1139 if c.metadata:
1140 meta_parts = [
1141 f"{sanitize_display(k)}: {sanitize_display(str(v))}"
1142 for k, v in sorted(c.metadata.items())
1143 ]
1144 print(f"Meta: {', '.join(meta_parts)}")
1145
1146 # Indent every line of the message body uniformly.
1147 body = sanitize_display(c.message)
1148 indented = textwrap.indent(body, " ")
1149 print(f"\n{indented}\n")
1150
1151 if stat:
1152 added, removed, modified = _file_diff(root, c, manifest_cache)
1153 for p in added:
1154 print(_c(f" + {p}", _GREEN, tty=tty))
1155 for p in modified:
1156 print(_c(f" ~ {p}", _YELLOW, tty=tty))
1157 for p in removed:
1158 print(_c(f" - {p}", _RED, tty=tty))
1159 if added or removed or modified:
1160 summary = (
1161 f" {len(added)} added, {len(modified)} modified, "
1162 f"{len(removed)} removed"
1163 )
1164 print(f"{_c(summary, _DIM, tty=tty)}\n")
File History 3 commits
sha256:8063bdadd934911129e35dee8db004797c136aa88c7c446248dc5f9d3fd0337a feat(log): support A..B range syntax — commits on B not rea… Sonnet 4.6 patch 1 day ago
sha256:5c98ba9dd33607ba1557d7c03c64020e71c27c1e7bbaa984e7a91f23d5297b14 feat: add signer_public_key to muse log --json output (VII … Sonnet 4.6 13 days ago