gabriel / muse public
read.py python
553 lines 21.9 KB
Raw
sha256:68c8fd843e189a819a72c4f5c218bc0be7e61db5f380cdb7e5dc54538a43a123 feat: muse read --manifest includes directories field Sonnet 4.6 patch 50 days ago
1 """``muse read`` — inspect a commit: metadata, delta, and provenance.
2
3 Display the full details of any commit: author, timestamp, semantic-version
4 impact, agent provenance, and a file/symbol change summary.
5
6 Usage
7 -----
8
9 Inspect HEAD::
10
11 muse read
12
13 Inspect a specific commit or branch tip::
14
15 muse read <commit_id_or_branch>
16
17 Omit the file-change summary::
18
19 muse read --no-stat
20
21 Omit the stored ``structured_delta`` blob from JSON output (smaller payload)::
22
23 muse read --json --no-delta
24
25 Include the full snapshot manifest (path → object_id) in JSON output::
26
27 muse read --json --manifest
28
29 The ``manifest`` key maps every tracked path to its content hash at this
30 commit. Use it when you need to inspect or verify the complete working-tree
31 state recorded by a commit, rather than just the files that changed.
32
33 JSON output schema (``--json``)::
34
35 {
36 "commit_id": "<sha256>",
37 "branch": "main",
38 "message": "...",
39 "author": "gabriel",
40 "agent_id": null,
41 "model_id": null,
42 "committed_at": "2026-01-01T00:00:00+00:00",
43 "snapshot_id": "<sha256>",
44 "parent_commit_id": "<sha256> | null",
45 "parent2_commit_id": null,
46 "sem_ver_bump": "none",
47 "breaking_changes": [],
48 "metadata": {},
49 "files_added": [],
50 "files_removed": [],
51 "files_modified": [],
52 "total_changes": 0,
53 "structured_delta": null,
54 "duration_ms": 1.2,
55 "exit_code": 0
56 }
57
58 Error output (``--json``, always to stdout so agents can parse failures)::
59
60 {
61 "error": "commit_not_found",
62 "ref": "<ref>",
63 "message": "commit '<ref>' not found",
64 "duration_ms": 0.3,
65 "exit_code": 1
66 }
67
68 Exit codes::
69
70 0 — commit found and displayed
71 1 — commit ref not found or other user error
72 3 — I/O error
73 """
74
75 import argparse
76 import json
77 import logging
78 import pathlib
79 import re
80 import sys
81 import textwrap
82
83 from muse.core.types import Manifest, Metadata, long_id
84 from muse.core.envelope import EnvelopeJson, make_envelope
85 from muse.core.errors import ExitCode
86 from muse.core.repo import require_repo
87 from muse.core.refs import (
88 get_head_commit_id,
89 read_current_branch,
90 )
91 from muse.core.commits import (
92 find_commits_by_prefix,
93 read_commit,
94 resolve_commit_ref,
95 )
96 from muse.core.snapshots import (
97 get_commit_snapshot_manifest,
98 read_snapshot,
99 )
100 from muse.core.timing import start_timer
101 from muse.core.validation import sanitize_display
102 from typing import TypedDict
103
104 _SHA256_FULL_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
105 _SHA256_PREFIX_RE = re.compile(r"^sha256:[0-9a-f]{1,63}$")
106 from muse.domain import DomainOp, StructuredDelta
107
108 type _StrMap = dict[str, str]
109 logger = logging.getLogger(__name__)
110
111 # ---------------------------------------------------------------------------
112 # Wire-format TypedDicts
113 # ---------------------------------------------------------------------------
114
115 class _ReadErrorJson(EnvelopeJson, total=False):
116 """JSON error envelope for ``muse read --json`` error output."""
117 error: str
118 message: str
119 ref: str
120
121 class _ReadJson(EnvelopeJson, total=False):
122 """JSON output envelope for ``muse read --json``.
123
124 All commit fields are present; ``manifest`` is only included when
125 ``--manifest`` is passed; ``structured_delta`` only when ``--no-delta``
126 is not passed. ``total=False`` reflects the dynamic build via
127 ``commit.to_dict()``.
128 """
129 commit_id: str
130 branch: str
131 message: str
132 author: str
133 agent_id: str | None
134 model_id: str | None
135 committed_at: str
136 snapshot_id: str
137 parent_commit_id: str | None
138 parent2_commit_id: str | None
139 sem_ver_bump: str
140 breaking_changes: list[str]
141 metadata: Metadata
142 structured_delta: StructuredDelta | None
143 files_added: list[str]
144 files_removed: list[str]
145 files_modified: list[str]
146 dirs_added: list[str]
147 dirs_removed: list[str]
148 total_changes: int
149 manifest: Manifest
150
151 _GREEN = "\033[32m"
152 _RED = "\033[31m"
153 _YELLOW = "\033[33m"
154 _CYAN = "\033[36m"
155 _BOLD = "\033[1m"
156 _RESET = "\033[0m"
157
158
159 def _c(text: str, ansi: str, is_tty: bool) -> str:
160 return f"{_BOLD}{ansi}{text}{_RESET}" if is_tty else text
161
162
163 def _color_op_line(letter: str, path: str, *, is_tty: bool) -> str:
164 """Return a coloured ` X path` line — both letter and path are colored."""
165 _ansi = {
166 "A": _GREEN,
167 "D": _RED,
168 "M": _YELLOW,
169 "R": _CYAN,
170 }.get(letter, "")
171 return _c(f" {letter} {path}", _ansi, is_tty) if _ansi else f" {letter} {path}"
172
173
174 def _format_op(op: DomainOp, *, is_tty: bool = False) -> list[str]:
175 """Return one or more display lines for a single domain op.
176
177 Each branch checks ``op["op"]`` directly so mypy can narrow the
178 TypedDict union to the specific subtype before accessing its fields.
179 """
180 if op["op"] == "insert":
181 return [_color_op_line("A", op["address"], is_tty=is_tty)]
182 if op["op"] == "delete":
183 return [_color_op_line("D", op["address"], is_tty=is_tty)]
184 if op["op"] == "replace":
185 return [_color_op_line("M", op["address"], is_tty=is_tty)]
186 if op["op"] == "move":
187 return [_c(f" R {op['address']} ({op['from_position']} → {op['to_position']})", _CYAN, is_tty)]
188 if op["op"] == "rename":
189 return [_c(f" R {op['from_address']} → {op['address']}", _CYAN, is_tty)]
190 if op["op"] == "mutate":
191 fields = ", ".join(
192 f"{k}: {v['old']}→{v['new']}" for k, v in op.get("fields", {}).items()
193 )
194 return [f" ~ {op['address']} ({fields or op.get('old_summary', '')}→{op.get('new_summary', '')})"]
195 # op["op"] == "patch" — the only remaining variant.
196 lines = [_color_op_line("M", op["address"], is_tty=is_tty)]
197 if op["child_summary"]:
198 lines.append(f" └─ {op['child_summary']}")
199 return lines
200
201 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
202 """Register the ``muse read`` subcommand and its flags."""
203 parser = subparsers.add_parser(
204 "read",
205 help="Inspect a commit: metadata, delta, and provenance.",
206 description=__doc__,
207 formatter_class=argparse.RawDescriptionHelpFormatter,
208 )
209 parser.add_argument(
210 "ref", nargs="?", default=None,
211 help="Commit ID or branch name (default: HEAD).",
212 )
213 parser.add_argument(
214 "--no-stat", dest="stat", action="store_false", default=True,
215 help="Omit the file/symbol change summary from output.",
216 )
217 parser.add_argument(
218 "--no-delta", dest="include_delta", action="store_false", default=True,
219 help=(
220 "Exclude the ``structured_delta`` blob from JSON output. "
221 "Produces a smaller payload for agents that only need commit metadata."
222 ),
223 )
224 parser.add_argument(
225 "--manifest", dest="include_manifest", action="store_true", default=False,
226 help=(
227 "Include the full snapshot manifest (path → object_id) in JSON output "
228 "under the ``manifest`` key. Lets agents inspect the complete "
229 "working-tree state recorded by this commit without a separate command. "
230 "Ignored in text mode."
231 ),
232 )
233 parser.add_argument(
234 "--no-manifest", dest="include_manifest", action="store_false",
235 help="Exclude the snapshot manifest from JSON output (default).",
236 )
237 parser.add_argument(
238 "--json", "-j", action="store_true", dest="json_out",
239 help="Emit machine-readable JSON instead of human text.",
240 )
241 parser.set_defaults(func=run)
242
243 def run(args: argparse.Namespace) -> None:
244 """Inspect a commit: metadata, delta, and provenance.
245
246 Agents should pass ``--json`` to receive a machine-readable result::
247
248 {
249 "commit_id": "<sha256>",
250 "branch": "main",
251 "message": "Add verse melody",
252 "author": "gabriel",
253 "agent_id": "",
254 "model_id": "",
255 "toolchain_id": "",
256 "committed_at": "2026-03-21T12:00:00+00:00",
257 "snapshot_id": "<sha256>",
258 "parent_commit_id": "<sha256> | null",
259 "parent2_commit_id": null,
260 "sem_ver_bump": "minor",
261 "breaking_changes": [],
262 "metadata": {},
263 "files_added": ["new_track.mid"],
264 "files_removed": [],
265 "files_modified": ["tracks/bass.mid"],
266 "total_changes": 1,
267 "structured_delta": { ... }
268 }
269
270 Pass ``--no-stat`` to omit ``files_added/removed/modified``.
271 Pass ``--no-delta`` to omit ``structured_delta`` (smaller payload).
272 Pass ``--manifest`` to include the full snapshot manifest::
273
274 {
275 ...
276 "manifest": {
277 "src/melody.py": "<object_id>",
278 "src/harmony.py": "<object_id>"
279 }
280 }
281
282 The ``manifest`` key maps every tracked path to its content hash at this
283 commit. Useful when you need to verify the full working-tree state
284 without a separate ``muse diff`` or file-read cycle.
285 """
286 ref: str | None = args.ref
287 stat: bool = args.stat
288 include_delta: bool = args.include_delta
289 include_manifest: bool = args.include_manifest
290 import sys as _sys
291 json_out: bool = args.json_out
292 is_tty: bool = _sys.stdout.isatty() and not json_out
293
294 # Bare hex is rejected at the CLI boundary — sha256: prefix is required.
295 # HEAD and branch names contain non-hex characters and are never caught here.
296 _HEX_CHARS = frozenset("0123456789abcdef")
297 if ref is not None and not ref.startswith("sha256:") and all(c in _HEX_CHARS for c in ref):
298 safe = sanitize_display(ref)
299 print(
300 f"❌ Bare hex IDs are not accepted — use 'sha256:{safe}' instead.\n"
301 f" Even a short prefix works: 'sha256:{safe[:12]}'",
302 file=sys.stderr,
303 )
304 raise SystemExit(ExitCode.USER_ERROR)
305
306 elapsed = start_timer()
307
308 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: str) -> None:
309 """Emit a structured error to stdout (JSON) or stderr (text) then exit."""
310 if json_out:
311 print(json.dumps(_ReadErrorJson(
312 **make_envelope(elapsed, exit_code=int(code)),
313 error=error_key,
314 message=msg,
315 **extra,
316 )))
317 else:
318 print(f"❌ {msg}", file=sys.stderr)
319 raise SystemExit(code)
320
321 root = require_repo()
322 branch = read_current_branch(root)
323
324 # Canonical content-addressed IDs — must be detected before branch-name
325 # resolution because ':' in the ref would raise ValueError in
326 # validate_branch_name.
327 if ref is not None and _SHA256_FULL_RE.match(ref):
328 commit = read_commit(root, ref)
329 elif ref is not None and _SHA256_PREFIX_RE.match(ref):
330 bare_prefix = long_id(ref, strip=True)
331 results = find_commits_by_prefix(root, bare_prefix)
332 commit = results[0] if len(results) == 1 else None
333 elif ref is not None and ref.upper() not in ("HEAD",):
334 # Branch name or tilde notation — guard against forbidden characters.
335 try:
336 branch_head_id = get_head_commit_id(root, ref)
337 except ValueError:
338 branch_head_id = None
339 if branch_head_id is not None:
340 commit = read_commit(root, branch_head_id)
341 else:
342 commit = resolve_commit_ref(root, branch, ref)
343 else:
344 commit = resolve_commit_ref(root, branch, ref)
345
346 if commit is None:
347 _emit_error(
348 f"commit '{ref}' not found",
349 ExitCode.USER_ERROR,
350 "commit_not_found",
351 ref=str(ref),
352 )
353
354 if json_out:
355 commit_data = commit.to_dict()
356
357 if not include_delta:
358 commit_data.pop("structured_delta", None)
359 elif commit.parent_commit_id is None:
360 # Genesis commit — structured_delta was computed for indexers but
361 # has no meaningful diff to surface (there is no parent to compare).
362 commit_data["structured_delta"] = None
363
364 # Read the snapshot once; reuse for both --stat and --manifest.
365 cur_snap = read_snapshot(root, commit.snapshot_id) if (stat or include_manifest) else None
366 cur: _StrMap = cur_snap.manifest if cur_snap is not None else {}
367
368 if stat:
369 par_snap = None
370 if commit.parent_commit_id:
371 par_commit = read_commit(root, commit.parent_commit_id)
372 if par_commit is not None:
373 par_snap = read_snapshot(root, par_commit.snapshot_id)
374 par: _StrMap = par_snap.manifest if par_snap is not None else {}
375 par_dirs: list[str] = par_snap.directories if par_snap is not None else []
376 cur_dirs: list[str] = cur_snap.directories if cur_snap is not None else []
377
378 files_added = sorted(set(cur) - set(par))
379 files_removed = sorted(set(par) - set(cur))
380 files_modified = sorted(
381 p for p in set(cur) & set(par) if cur[p] != par[p]
382 )
383 dirs_added = sorted(p + "/" for p in set(cur_dirs) - set(par_dirs))
384 dirs_removed = sorted(p + "/" for p in set(par_dirs) - set(cur_dirs))
385 commit_data.update({
386 "files_added": files_added,
387 "files_removed": files_removed,
388 "files_modified": files_modified,
389 "dirs_added": dirs_added,
390 "dirs_removed": dirs_removed,
391 "total_changes": (
392 len(files_added) + len(files_modified) + len(files_removed)
393 + len(dirs_added) + len(dirs_removed)
394 ),
395 })
396
397 if include_manifest:
398 # Emit path → object_id for every file tracked at this commit.
399 # Sorted for determinism; object_ids are content hashes (strings).
400 commit_data["manifest"] = dict(sorted(cur.items()))
401 # Also expose tracked empty directories — they live in
402 # snapshot.directories, not in the file manifest.
403 if cur_snap is not None and cur_snap.directories:
404 commit_data["directories"] = sorted(cur_snap.directories)
405
406 # Idiomatic JSON cleanup: "" → null for all optional string provenance
407 # fields; omit empty/zero collection and scalar fields entirely.
408 _NULL_WHEN_EMPTY = (
409 "prompt_hash", "signature", "signer_public_key", "signer_key_id",
410 "status", "agent_id", "model_id", "toolchain_id",
411 )
412 for _k in _NULL_WHEN_EMPTY:
413 if _k in commit_data and commit_data[_k] == "":
414 commit_data[_k] = None
415 # Omit fields that carry no information when absent/empty/zero.
416 for _k in ("labels", "notes", "metadata"):
417 if not commit_data.get(_k):
418 commit_data.pop(_k, None)
419 if not commit_data.get("reviewed_by"):
420 commit_data.pop("reviewed_by", None)
421 if not commit_data.get("test_runs"):
422 commit_data.pop("test_runs", None)
423 if commit_data.get("score") is None:
424 commit_data.pop("score", None)
425 if commit_data.get("parent2_commit_id") is None:
426 commit_data.pop("parent2_commit_id", None)
427
428 # Strip position:null from structured_delta ops — position is only
429 # meaningful for ordered-sequence domains (MIDI). Stored deltas from
430 # before the AddressedInsertOp/AddressedDeleteOp refactor have it set
431 # to null; omitting it makes the output schema-correct for all commits.
432 _delta = commit_data.get("structured_delta")
433 if isinstance(_delta, dict):
434 def _strip_position(ops: list[dict]) -> None:
435 for _op in ops:
436 if isinstance(_op, dict):
437 if _op.get("position") is None:
438 _op.pop("position", None)
439 _strip_position(_op.get("child_ops") or [])
440 _strip_position(_delta.get("ops") or [])
441
442 print(json.dumps(_ReadJson(**make_envelope(elapsed), **commit_data), default=str))
443 return
444
445 # ── Text output ────────────────────────────────────────────────────────────
446 print(f"commit {commit.commit_id}")
447 if commit.parent_commit_id:
448 print(f"Parent: {commit.parent_commit_id}")
449 if commit.parent2_commit_id:
450 print(f"Parent: {commit.parent2_commit_id} (merge)")
451 if commit.author:
452 print(f"Author: {sanitize_display(commit.author)}")
453 # Use ISO 8601 format (with T separator) for consistency with --json output.
454 print(f"Date: {commit.committed_at.isoformat()}")
455 if commit.sem_ver_bump and commit.sem_ver_bump != "none":
456 print(f"SemVer: {commit.sem_ver_bump}")
457 if commit.agent_id:
458 print(f"Agent: {sanitize_display(commit.agent_id)}")
459 if commit.metadata:
460 for k, v in sorted(commit.metadata.items()):
461 print(f" {sanitize_display(k)}: {sanitize_display(str(v))}")
462
463 # Render the commit message with consistent 4-space indentation for every
464 # line. Previously only the first line was indented; subsequent lines in
465 # multiline messages started at column 0, breaking readability.
466 raw_message = sanitize_display(commit.message) if commit.message else ""
467 indented_message = textwrap.indent(raw_message, " ") if raw_message else ""
468 print(f"\n{indented_message}\n")
469
470 if not stat:
471 return
472
473 # Prefer the structured delta stored on the commit.
474 # It carries rich note-level detail and is faster (no blob reloading).
475 if commit.structured_delta is not None:
476 delta = commit.structured_delta
477 if not delta["ops"]:
478 print(" (no changes)")
479 return
480 lines: list[str] = []
481 for op in delta["ops"]:
482 lines.extend(_format_op(op, is_tty=is_tty))
483 for line in lines:
484 print(line)
485 # Re-derive counts from ops for a clean three-line summary.
486 # Directories: address ends with "/" — canonical signal from the algebra.
487 _d_added = sum(1 for o in delta["ops"] if o.get("op") == "insert" and o.get("address", "").endswith("/"))
488 _d_removed = sum(1 for o in delta["ops"] if o.get("op") == "delete" and o.get("address", "").endswith("/"))
489 _d_renamed = sum(1 for o in delta["ops"] if o.get("op") == "rename" and o.get("address", "").endswith("/"))
490 _f_added = sum(1 for o in delta["ops"] if o.get("op") == "insert" and not o.get("child_ops") and not o.get("address", "").endswith("/"))
491 _f_removed = sum(1 for o in delta["ops"] if o.get("op") == "delete" and not o.get("child_ops") and not o.get("address", "").endswith("/"))
492 _f_modified = sum(1 for o in delta["ops"] if o.get("op") in ("patch", "modify"))
493 _sym_added = sum(sum(1 for c in o.get("child_ops", []) if c.get("op") == "insert") for o in delta["ops"])
494 _sym_removed = sum(sum(1 for c in o.get("child_ops", []) if c.get("op") == "delete") for o in delta["ops"])
495 _dir_parts = []
496 if _d_removed: _dir_parts.append(f"{_d_removed} removed")
497 if _d_renamed: _dir_parts.append(f"{_d_renamed} renamed")
498 if _d_added: _dir_parts.append(f"{_d_added} added")
499 _file_parts = []
500 if _f_removed: _file_parts.append(f"{_f_removed} removed")
501 if _f_modified: _file_parts.append(f"{_f_modified} modified")
502 if _f_added: _file_parts.append(f"{_f_added} added")
503 _sym_parts = []
504 if _sym_added: _sym_parts.append(f"{_sym_added} added")
505 if _sym_removed: _sym_parts.append(f"{_sym_removed} removed")
506 print()
507 if _dir_parts: print(f" {_c('Directories:', _CYAN, is_tty)} {', '.join(_dir_parts)}")
508 if _file_parts: print(f" {_c('Files:', _BOLD, is_tty)} {', '.join(_file_parts)}")
509 if _sym_parts: print(f" {_c('Symbols:', _BOLD, is_tty)} {', '.join(_sym_parts)}")
510 return
511
512 # Fallback for initial commits or pre-structured-delta commits: compute
513 # file-level diff from snapshot manifests directly.
514 current = get_commit_snapshot_manifest(root, commit.commit_id) or {}
515 parent: _StrMap = {}
516 cur_snap = read_snapshot(root, commit.snapshot_id)
517 cur_dirs: list[str] = cur_snap.directories if cur_snap else []
518 par_dirs: list[str] = []
519 if commit.parent_commit_id:
520 parent = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
521 par_commit = read_commit(root, commit.parent_commit_id)
522 if par_commit:
523 par_snap = read_snapshot(root, par_commit.snapshot_id)
524 par_dirs = par_snap.directories if par_snap else []
525
526 added = sorted(set(current) - set(parent))
527 removed = sorted(set(parent) - set(current))
528 modified = sorted(p for p in set(current) & set(parent) if current[p] != parent[p])
529 dirs_added = sorted(set(cur_dirs) - set(par_dirs))
530 dirs_removed = sorted(set(par_dirs) - set(cur_dirs))
531
532 for p in dirs_added:
533 print(_color_op_line("A", f"{p}/", is_tty=is_tty))
534 for p in dirs_removed:
535 print(_color_op_line("D", f"{p}/", is_tty=is_tty))
536 for p in added:
537 print(_color_op_line("A", p, is_tty=is_tty))
538 for p in removed:
539 print(_color_op_line("D", p, is_tty=is_tty))
540 for p in modified:
541 print(_color_op_line("M", p, is_tty=is_tty))
542
543 _dir_parts = []
544 if dirs_added: _dir_parts.append(f"{len(dirs_added)} added")
545 if dirs_removed: _dir_parts.append(f"{len(dirs_removed)} removed")
546 _file_parts = []
547 if added: _file_parts.append(f"{len(added)} added")
548 if removed: _file_parts.append(f"{len(removed)} removed")
549 if modified: _file_parts.append(f"{len(modified)} modified")
550 if _dir_parts or _file_parts:
551 print()
552 if _dir_parts: print(f" {_c('Directories:', _CYAN, is_tty)} {', '.join(_dir_parts)}")
553 if _file_parts: print(f" {_c('Files:', _BOLD, is_tty)} {', '.join(_file_parts)}")
File History 2 commits
sha256:68c8fd843e189a819a72c4f5c218bc0be7e61db5f380cdb7e5dc54538a43a123 feat: muse read --manifest includes directories field Sonnet 4.6 patch 50 days ago
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor 50 days ago