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