gabriel / muse public
log.py python
1,183 lines 43.3 KB Cold
Raw
sha256:8063bdadd934911129e35dee8db004797c136aa88c7c446248dc5f9d3fd0337a feat(log): support A..B range syntax — commits on B not rea… Sonnet 4.6 patch 100 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 "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 "--branch", "-b", default=None, dest="branch_flag",
780 help=(
781 "Branch or commit to start from — explicit alias for the "
782 "positional ref. Mutually exclusive with it."
783 ),
784 )
785 parser.add_argument(
786 "--oneline", action="store_true",
787 help="One line per commit.",
788 )
789 parser.add_argument(
790 "--graph", "-g", action="store_true",
791 help="ASCII DAG graph.",
792 )
793 parser.add_argument(
794 "--all", "-a", action="store_true", dest="all_branches",
795 help="Include all local branches in the graph (implies --graph).",
796 )
797 parser.add_argument(
798 "--stat", action="store_true",
799 help="Show added/removed file summary for each commit.",
800 )
801 parser.add_argument(
802 "--max-count", "--limit", "-n", type=int, default=_DEFAULT_LIMIT, dest="limit",
803 help="Limit number of commits shown (default: %(default)s).",
804 )
805 parser.add_argument("--since", default=None, help="Show commits after date.")
806 parser.add_argument("--until", default=None, help="Show commits before date.")
807 parser.add_argument("--author", default=None, help="Filter by author substring.")
808 parser.add_argument("--section", default=None, help="Filter by section metadata.")
809 parser.add_argument("--track", default=None, help="Filter by track metadata.")
810 parser.add_argument("--emotion", default=None, help="Filter by emotion metadata.")
811 parser.add_argument(
812 "--json", "-j", action="store_true", dest="json_out",
813 help="Emit machine-readable JSON instead of human text.",
814 )
815 parser.add_argument(
816 "pathspec", nargs="*", metavar="path",
817 help=(
818 "Limit output to commits that touched these paths. "
819 "Prefix-matched: 'src/' matches all files under src/. "
820 "Separate from options with '--' (e.g. muse log -- src/foo.py)."
821 ),
822 )
823 parser.set_defaults(func=run)
824
825 def run(args: argparse.Namespace) -> None:
826 """Display commit history.
827
828 Walks the commit DAG from HEAD (or a specified branch/ref) and emits
829 commit records. ``truncated`` is ``true`` when the walk hit the
830 ``-n`` cap. In ``--json`` mode all file changes are always included
831 without needing ``--stat``.
832
833 Agent quickstart
834 ----------------
835 ::
836
837 muse log --json
838 muse log -n 20 --json
839 muse log --branch feat/billing --json
840 muse log --author gabriel --since 2025-01-01 --json
841
842 JSON fields
843 -----------
844 status ``"ok"`` on success.
845 truncated ``true`` if the walk was capped by ``-n``.
846 total Number of commits returned.
847 branch Branch walked.
848 repo_id Repository identifier.
849 commits List of commit objects: ``commit_id``, ``message``,
850 ``author``, ``committed_at``, ``parent_commit_id``,
851 ``files_added``, ``files_modified``, ``files_removed``.
852
853 Exit codes
854 ----------
855 0 Success.
856 1 Invalid arguments (bad ``--format``, ``--since``/``--until``, etc.).
857 2 Not inside a Muse repository.
858 """
859 elapsed = start_timer()
860
861 ref: str | None = args.ref
862 branch_flag: str | None = getattr(args, "branch_flag", None)
863 json_out_for_ambiguity: bool = args.json_out
864 if branch_flag is not None and ref is not None:
865 _emit_error(
866 json_out_for_ambiguity,
867 "Pass either --branch/-b or a positional ref, not both.",
868 ExitCode.USER_ERROR,
869 elapsed,
870 )
871 return
872 if branch_flag is not None:
873 ref = branch_flag
874 oneline: bool = args.oneline
875 graph: bool = args.graph
876 all_branches: bool = args.all_branches
877 stat: bool = args.stat
878 # Track whether the user explicitly overrode the limit. Only the default
879 # limit being hit silently warrants a truncation warning in text mode.
880 explicit_limit: bool = (args.limit != _DEFAULT_LIMIT)
881 limit: int = clamp_int(args.limit, 1, 100000, "limit")
882 since: str | None = args.since
883 until: str | None = args.until
884 author: str | None = args.author
885 section: str | None = args.section
886 track: str | None = args.track
887 emotion: str | None = args.emotion
888 json_out: bool = args.json_out
889 pathspec: list[str] = list(args.pathspec) if args.pathspec else []
890
891 if all_branches:
892 graph = True
893
894 # Support git-style -<n> shorthand (e.g. `muse log -5` as alias for `-n 5`).
895 # argparse captures "-5" as the positional `ref`; detect and reinterpret it.
896 if ref is not None and ref.lstrip("-").isdigit() and ref.startswith("-"):
897 limit = int(ref.lstrip("-"))
898 explicit_limit = True
899 ref = None
900
901 # Support A..B range syntax: commits reachable from B but not from A.
902 exclude_ref: str | None = None
903 if ref is not None and ".." in ref:
904 exclude_ref, ref = ref.split("..", 1)
905
906 if limit < 1:
907 _emit_error(json_out, "--max-count must be at least 1.", ExitCode.USER_ERROR, elapsed)
908
909 # Validate date filters before touching the repository.
910 since_dt: datetime | None = None
911 until_dt: datetime | None = None
912 if since:
913 try:
914 since_dt = _parse_date(since)
915 except ValueError:
916 _emit_error(
917 json_out,
918 f"Cannot parse --since value: {since!r}. "
919 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
920 ExitCode.USER_ERROR,
921 elapsed,
922 )
923 if until:
924 try:
925 until_dt = _parse_date(until)
926 except ValueError:
927 _emit_error(
928 json_out,
929 f"Cannot parse --until value: {until!r}. "
930 "Use YYYY-MM-DD, 'today', 'yesterday', or '<n> days ago'.",
931 ExitCode.USER_ERROR,
932 elapsed,
933 )
934
935 root = require_repo()
936 repo_id = read_repo_id(root)
937
938 # Resolve the exclude side of an A..B range now that we have a root.
939 exclude_ids: set[str] = set()
940 if exclude_ref is not None:
941 excl_rec = resolve_commit_ref(root, read_current_branch(root), exclude_ref)
942 excl_cid = excl_rec.commit_id if excl_rec else None
943 if excl_cid is None:
944 excl_tip = get_head_commit_id(root, exclude_ref)
945 if excl_tip:
946 excl_cid = excl_tip
947 if excl_cid is None:
948 _emit_error(
949 json_out,
950 f"Cannot resolve exclude ref {sanitize_display(exclude_ref)!r}.",
951 ExitCode.USER_ERROR,
952 elapsed,
953 )
954 exclude_ids = ancestor_ids(root, excl_cid)
955 exclude_ids.add(excl_cid)
956
957 # When the -N shorthand consumed `ref` (e.g. `muse log -1 dev`), argparse
958 # puts the branch name in pathspec[0]. Reclaim it now that we have a repo
959 # root available to check whether it's a real ref.
960 if ref is None and pathspec and _is_known_ref(root, pathspec[0]):
961 ref = pathspec[0]
962 pathspec = pathspec[1:]
963
964 # Disambiguate: argparse assigns the first positional after `--` to `ref`
965 # (because ref is nargs="?"). If what landed in `ref` is not a known
966 # branch or commit ID, treat it as the first pathspec element.
967 # Preserve the original value for the "no commits on '<ref>'" message so
968 # that nonexistent-branch errors are still contextual.
969 # In range mode (A..B) the include side must resolve — don't silently
970 # treat an unknown B as a pathspec.
971 user_ref = ref # may be a nonexistent branch name — kept for error display
972 if ref is not None and not _is_known_ref(root, ref):
973 if exclude_ref is not None:
974 _emit_error(
975 json_out,
976 f"Cannot resolve include ref {sanitize_display(ref)!r}.",
977 ExitCode.USER_ERROR,
978 elapsed,
979 )
980 pathspec = [ref] + pathspec
981 ref = None
982
983 # Detect whether ref is a direct commit ID (sha256: prefixed or bare hex).
984 # Commit IDs must be routed through resolve_commit_ref + a manual walk;
985 # get_commits_for_branch only accepts branch names (validate_branch_name
986 # rejects ":" which appears in every sha256: commit ID).
987 ref_is_commit_id = ref is not None and (
988 (ref.startswith("sha256:") and len(ref) > 7 and all(c in _HEX_CHARS for c in long_id(ref, strip=True)))
989 or (len(ref) >= 8 and all(c in _HEX_CHARS for c in ref))
990 )
991
992 if ref_is_commit_id:
993 # Resolve the commit ID (full or abbreviated prefix) to a CommitRecord.
994 start_rec = resolve_commit_ref(root, read_current_branch(root), ref)
995 start_cid = start_rec.commit_id if start_rec else None
996 # Display the branch the resolved commit belongs to; fall back to current.
997 branch = (start_rec.branch if start_rec else None) or read_current_branch(root)
998 else:
999 start_cid = None
1000 branch = ref if ref is not None else read_current_branch(root)
1001
1002 if graph and not json_out:
1003 _render_graph(root, branch=branch, all_branches=all_branches, tty=sys.stdout.isatty())
1004 return
1005
1006 has_filters = any([since_dt, until_dt, author, section, track, emotion])
1007 walk_cap = get_limit("max_walk_commits", root)
1008
1009 def _walk_from_cid(start: str | None, max_count: int = 0) -> list[CommitRecord]:
1010 """Walk the parent chain from *start*, returning commits newest-first."""
1011 commits: list[CommitRecord] = []
1012 cid = start
1013 seen: set[str] = set()
1014 while cid and cid not in seen:
1015 if max_count > 0 and len(commits) >= max_count:
1016 break
1017 seen.add(cid)
1018 rec = read_commit(root, cid)
1019 if rec is None:
1020 break
1021 commits.append(rec)
1022 cid = rec.parent_commit_id
1023 return commits
1024
1025 # When filters or pathspec are active we must walk the full history first,
1026 # then apply the limit afterwards (same as git log). Without filters we
1027 # fetch limit+1 commits: if we get limit+1 back there are more commits
1028 # than the display cap — truncated=True; otherwise we got everything.
1029 # --all with JSON: walk from every local + remote tracking tip, deduplicate.
1030 if all_branches and not ref_is_commit_id:
1031 all_tips = _branch_tips(root, include_remote=True)
1032 seen_cids: set[str] = set()
1033 raw_commits = []
1034 for tip_cid in all_tips:
1035 for rec in _walk_from_cid(tip_cid, max_count=walk_cap):
1036 if rec.commit_id not in seen_cids:
1037 seen_cids.add(rec.commit_id)
1038 raw_commits.append(rec)
1039 raw_commits.sort(key=lambda c: c.committed_at, reverse=True)
1040 probe_truncated = False
1041 elif has_filters or pathspec:
1042 walk_limit = walk_cap
1043 if ref_is_commit_id:
1044 raw_commits = _walk_from_cid(start_cid, max_count=walk_limit)
1045 else:
1046 raw_commits = get_commits_for_branch(root, branch, max_count=walk_limit)
1047 probe_truncated = False
1048 else:
1049 probe_count = limit + 1
1050 if ref_is_commit_id:
1051 raw_commits = _walk_from_cid(start_cid, max_count=probe_count)
1052 else:
1053 raw_commits = get_commits_for_branch(root, branch, max_count=probe_count)
1054 probe_truncated = len(raw_commits) > limit
1055 if probe_truncated:
1056 raw_commits = raw_commits[:limit]
1057
1058 # Apply A..B exclusion: drop commits reachable from the exclude side.
1059 if exclude_ids:
1060 raw_commits = [c for c in raw_commits if c.commit_id not in exclude_ids]
1061
1062 # Shared manifest cache: each commit_id's snapshot is loaded at most once
1063 # per invocation regardless of how many callers need it (pathspec filter,
1064 # stat diff, JSON serialisation).
1065 manifest_cache: _ManifestCache = {}
1066
1067 # Apply pathspec filter: keep only commits that touched one of the given paths.
1068 if pathspec:
1069 raw_commits = [
1070 c for c in raw_commits
1071 if any(_commit_touches_path(root, c, p, manifest_cache) for p in pathspec)
1072 ]
1073
1074 walk_truncated = probe_truncated or ((has_filters or bool(pathspec)) and len(raw_commits) >= walk_cap)
1075
1076 filtered, filter_truncated = _apply_filters(
1077 raw_commits,
1078 since_dt=since_dt,
1079 until_dt=until_dt,
1080 author=author,
1081 section=section,
1082 track=track,
1083 emotion=emotion,
1084 limit=limit,
1085 )
1086 truncated = walk_truncated or filter_truncated
1087
1088 # ── JSON output ───────────────────────────────────────────────────────────
1089 if json_out:
1090 commit_list = [
1091 _commit_to_json(c, root, stat=True, manifest_cache=manifest_cache)
1092 for c in filtered
1093 ]
1094 print(json.dumps(_LogJson(
1095 **make_envelope(elapsed),
1096 status="ok",
1097 error="",
1098 truncated=truncated,
1099 total=len(filtered),
1100 branch=branch,
1101 repo_id=repo_id,
1102 commits=commit_list,
1103 ), default=str))
1104 return
1105
1106 # ── Text output ───────────────────────────────────────────────────────────
1107 if not filtered:
1108 if user_ref is not None:
1109 # Distinguish "branch exists but is empty" from "branch not found".
1110 print(f"(no commits on '{sanitize_display(user_ref)}')")
1111 else:
1112 print("(no commits)")
1113 return
1114
1115 # Only warn about truncation when the default cap was hit silently. If the
1116 # user explicitly passed -n they know they are asking for a subset — no warning.
1117 if truncated and not explicit_limit:
1118 print(
1119 f"⚠️ History truncated at {len(filtered):,} commits — "
1120 "use -n or raise [limits] max_walk_commits in .muse/config.toml to see more."
1121 )
1122
1123 head_commit_id = filtered[0].commit_id if filtered else None
1124 tty: bool = sys.stdout.isatty()
1125
1126 for c in filtered:
1127 is_head = c.commit_id == head_commit_id
1128 decoration = _ref_label(branch, is_head, tty)
1129 short_hash = _c(long_id(c.commit_id), _YELLOW, tty=tty)
1130 subject = sanitize_display(c.message.splitlines()[0])
1131 author_display = sanitize_display(c.author)
1132
1133 if oneline:
1134 print(f"{short_hash}{decoration} {subject}")
1135 continue
1136
1137 commit_word = _c("commit", _YELLOW, tty=tty)
1138 print(f"{commit_word} {short_hash}{decoration}")
1139 if author_display:
1140 print(f"Author: {author_display}")
1141 print(f"Date: {_c(_format_date(c.committed_at), _DIM, tty=tty)}")
1142
1143 if c.sem_ver_bump and c.sem_ver_bump != "none":
1144 bump_key = c.sem_ver_bump.lower()
1145 bump_colour = _SEMVER_COLOUR.get(bump_key, "")
1146 bump_label = (
1147 _c(c.sem_ver_bump.upper(), bump_colour, tty=tty)
1148 if bump_colour else c.sem_ver_bump.upper()
1149 )
1150 print(f"SemVer: {bump_label}")
1151 if c.breaking_changes:
1152 safe_breaks = [sanitize_display(b) for b in c.breaking_changes[:3]]
1153 breaking_text = ", ".join(safe_breaks)
1154 if len(c.breaking_changes) > 3:
1155 breaking_text += f" +{len(c.breaking_changes) - 3} more"
1156 print(f"Breaking: {_c(breaking_text, _RED, tty=tty)}")
1157
1158 if c.metadata:
1159 meta_parts = [
1160 f"{sanitize_display(k)}: {sanitize_display(str(v))}"
1161 for k, v in sorted(c.metadata.items())
1162 ]
1163 print(f"Meta: {', '.join(meta_parts)}")
1164
1165 # Indent every line of the message body uniformly.
1166 body = sanitize_display(c.message)
1167 indented = textwrap.indent(body, " ")
1168 print(f"\n{indented}\n")
1169
1170 if stat:
1171 added, removed, modified = _file_diff(root, c, manifest_cache)
1172 for p in added:
1173 print(_c(f" + {p}", _GREEN, tty=tty))
1174 for p in modified:
1175 print(_c(f" ~ {p}", _YELLOW, tty=tty))
1176 for p in removed:
1177 print(_c(f" - {p}", _RED, tty=tty))
1178 if added or removed or modified:
1179 summary = (
1180 f" {len(added)} added, {len(modified)} modified, "
1181 f"{len(removed)} removed"
1182 )
1183 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 100 days ago
sha256:5c98ba9dd33607ba1557d7c03c64020e71c27c1e7bbaa984e7a91f23d5297b14 feat: add signer_public_key to muse log --json output (VII … Sonnet 4.6 112 days ago