gabriel / muse public
read.py python
424 lines 15.1 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 126 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 read_repo_id, require_repo
87 from muse.core.store import (
88 find_commits_by_prefix,
89 get_commit_snapshot_manifest,
90 get_head_commit_id,
91 read_commit,
92 read_current_branch,
93 read_snapshot,
94 resolve_commit_ref,
95 )
96 from muse.core.timing import start_timer
97 from muse.core.validation import sanitize_display
98 from typing import TypedDict
99
100 _SHA256_FULL_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
101 _SHA256_PREFIX_RE = re.compile(r"^sha256:[0-9a-f]{1,63}$")
102 from muse.domain import DomainOp, StructuredDelta
103
104 type _StrMap = dict[str, str]
105 logger = logging.getLogger(__name__)
106
107 # ---------------------------------------------------------------------------
108 # Wire-format TypedDicts
109 # ---------------------------------------------------------------------------
110
111 class _ReadErrorJson(EnvelopeJson, total=False):
112 """JSON error envelope for ``muse read --json`` error output."""
113 error: str
114 message: str
115 ref: str
116
117 class _ReadJson(EnvelopeJson, total=False):
118 """JSON output envelope for ``muse read --json``.
119
120 All commit fields are present; ``manifest`` is only included when
121 ``--manifest`` is passed; ``structured_delta`` only when ``--no-delta``
122 is not passed. ``total=False`` reflects the dynamic build via
123 ``commit.to_dict()``.
124 """
125 commit_id: str
126 branch: str
127 message: str
128 author: str
129 agent_id: str | None
130 model_id: str | None
131 committed_at: str
132 snapshot_id: str
133 parent_commit_id: str | None
134 parent2_commit_id: str | None
135 sem_ver_bump: str
136 breaking_changes: list[str]
137 metadata: Metadata
138 structured_delta: StructuredDelta | None
139 files_added: list[str]
140 files_removed: list[str]
141 files_modified: list[str]
142 total_changes: int
143 manifest: Manifest
144
145 def _format_op(op: DomainOp) -> list[str]:
146 """Return one or more display lines for a single domain op.
147
148 Each branch checks ``op["op"]`` directly so mypy can narrow the
149 TypedDict union to the specific subtype before accessing its fields.
150 """
151 if op["op"] == "insert":
152 return [f" A {op['address']}"]
153 if op["op"] == "delete":
154 return [f" D {op['address']}"]
155 if op["op"] == "replace":
156 return [f" M {op['address']}"]
157 if op["op"] == "move":
158 return [f" R {op['address']} ({op['from_position']} → {op['to_position']})"]
159 if op["op"] == "mutate":
160 fields = ", ".join(
161 f"{k}: {v['old']}→{v['new']}" for k, v in op.get("fields", {}).items()
162 )
163 return [f" ~ {op['address']} ({fields or op.get('old_summary', '')}→{op.get('new_summary', '')})"]
164 # op["op"] == "patch" — the only remaining variant.
165 lines = [f" M {op['address']}"]
166 if op["child_summary"]:
167 lines.append(f" └─ {op['child_summary']}")
168 return lines
169
170 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
171 """Register the ``muse read`` subcommand and its flags."""
172 parser = subparsers.add_parser(
173 "read",
174 help="Inspect a commit: metadata, delta, and provenance.",
175 description=__doc__,
176 formatter_class=argparse.RawDescriptionHelpFormatter,
177 )
178 parser.add_argument(
179 "ref", nargs="?", default=None,
180 help="Commit ID or branch name (default: HEAD).",
181 )
182 parser.add_argument(
183 "--no-stat", dest="stat", action="store_false", default=True,
184 help="Omit the file/symbol change summary from output.",
185 )
186 parser.add_argument(
187 "--no-delta", dest="include_delta", action="store_false", default=True,
188 help=(
189 "Exclude the ``structured_delta`` blob from JSON output. "
190 "Produces a smaller payload for agents that only need commit metadata."
191 ),
192 )
193 parser.add_argument(
194 "--manifest", dest="include_manifest", action="store_true", default=False,
195 help=(
196 "Include the full snapshot manifest (path → object_id) in JSON output "
197 "under the ``manifest`` key. Lets agents inspect the complete "
198 "working-tree state recorded by this commit without a separate command. "
199 "Ignored in text mode."
200 ),
201 )
202 parser.add_argument(
203 "--no-manifest", dest="include_manifest", action="store_false",
204 help="Exclude the snapshot manifest from JSON output (default).",
205 )
206 parser.add_argument(
207 "--json", "-j", action="store_true", dest="json_out",
208 help="Emit machine-readable JSON instead of human text.",
209 )
210 parser.set_defaults(func=run)
211
212 def run(args: argparse.Namespace) -> None:
213 """Inspect a commit: metadata, delta, and provenance.
214
215 Agents should pass ``--json`` to receive a machine-readable result::
216
217 {
218 "commit_id": "<sha256>",
219 "branch": "main",
220 "message": "Add verse melody",
221 "author": "gabriel",
222 "agent_id": "",
223 "model_id": "",
224 "toolchain_id": "",
225 "committed_at": "2026-03-21T12:00:00+00:00",
226 "snapshot_id": "<sha256>",
227 "parent_commit_id": "<sha256> | null",
228 "parent2_commit_id": null,
229 "sem_ver_bump": "minor",
230 "breaking_changes": [],
231 "metadata": {},
232 "files_added": ["new_track.mid"],
233 "files_removed": [],
234 "files_modified": ["tracks/bass.mid"],
235 "total_changes": 1,
236 "structured_delta": { ... }
237 }
238
239 Pass ``--no-stat`` to omit ``files_added/removed/modified``.
240 Pass ``--no-delta`` to omit ``structured_delta`` (smaller payload).
241 Pass ``--manifest`` to include the full snapshot manifest::
242
243 {
244 ...
245 "manifest": {
246 "src/melody.py": "<object_id>",
247 "src/harmony.py": "<object_id>"
248 }
249 }
250
251 The ``manifest`` key maps every tracked path to its content hash at this
252 commit. Useful when you need to verify the full working-tree state
253 without a separate ``muse diff`` or file-read cycle.
254 """
255 ref: str | None = args.ref
256 stat: bool = args.stat
257 include_delta: bool = args.include_delta
258 include_manifest: bool = args.include_manifest
259 json_out: bool = args.json_out
260
261 # Bare hex is rejected at the CLI boundary — sha256: prefix is required.
262 # HEAD and branch names contain non-hex characters and are never caught here.
263 _HEX_CHARS = frozenset("0123456789abcdef")
264 if ref is not None and not ref.startswith("sha256:") and all(c in _HEX_CHARS for c in ref):
265 safe = sanitize_display(ref)
266 print(
267 f"❌ Bare hex IDs are not accepted — use 'sha256:{safe}' instead.\n"
268 f" Even a short prefix works: 'sha256:{safe[:12]}'",
269 file=sys.stderr,
270 )
271 raise SystemExit(ExitCode.USER_ERROR)
272
273 elapsed = start_timer()
274
275 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: str) -> None:
276 """Emit a structured error to stdout (JSON) or stderr (text) then exit."""
277 if json_out:
278 print(json.dumps(_ReadErrorJson(
279 **make_envelope(elapsed, exit_code=int(code)),
280 error=error_key,
281 message=msg,
282 **extra,
283 )))
284 else:
285 print(f"❌ {msg}", file=sys.stderr)
286 raise SystemExit(code)
287
288 root = require_repo()
289 repo_id = read_repo_id(root)
290 branch = read_current_branch(root)
291
292 # Canonical content-addressed IDs — must be detected before branch-name
293 # resolution because ':' in the ref would raise ValueError in
294 # validate_branch_name.
295 if ref is not None and _SHA256_FULL_RE.match(ref):
296 commit = read_commit(root, ref)
297 elif ref is not None and _SHA256_PREFIX_RE.match(ref):
298 bare_prefix = long_id(ref, strip=True)
299 results = find_commits_by_prefix(root, bare_prefix)
300 commit = results[0] if len(results) == 1 else None
301 elif ref is not None and ref.upper() not in ("HEAD",):
302 # Branch name or tilde notation — guard against forbidden characters.
303 try:
304 branch_head_id = get_head_commit_id(root, ref)
305 except ValueError:
306 branch_head_id = None
307 if branch_head_id is not None:
308 commit = read_commit(root, branch_head_id)
309 else:
310 commit = resolve_commit_ref(root, repo_id, branch, ref)
311 else:
312 commit = resolve_commit_ref(root, repo_id, branch, ref)
313
314 if commit is None:
315 _emit_error(
316 f"commit '{ref}' not found",
317 ExitCode.USER_ERROR,
318 "commit_not_found",
319 ref=str(ref),
320 )
321
322 if json_out:
323 commit_data = commit.to_dict()
324
325 if not include_delta:
326 commit_data.pop("structured_delta", None)
327 elif commit.parent_commit_id is None:
328 # Genesis commit — structured_delta was computed for indexers but
329 # has no meaningful diff to surface (there is no parent to compare).
330 commit_data["structured_delta"] = None
331
332 # Read the snapshot once; reuse for both --stat and --manifest.
333 cur_snap = read_snapshot(root, commit.snapshot_id) if (stat or include_manifest) else None
334 cur: _StrMap = cur_snap.manifest if cur_snap is not None else {}
335
336 if stat:
337 par: _StrMap = {}
338 if commit.parent_commit_id:
339 par_manifest = get_commit_snapshot_manifest(root, commit.parent_commit_id)
340 par = par_manifest if par_manifest is not None else {}
341 files_added = sorted(set(cur) - set(par))
342 files_removed = sorted(set(par) - set(cur))
343 files_modified = sorted(
344 p for p in set(cur) & set(par) if cur[p] != par[p]
345 )
346 commit_data.update({
347 "files_added": files_added,
348 "files_removed": files_removed,
349 "files_modified": files_modified,
350 "total_changes": len(files_added) + len(files_modified) + len(files_removed),
351 })
352
353 if include_manifest:
354 # Emit path → object_id for every file tracked at this commit.
355 # Sorted for determinism; object_ids are content hashes (strings).
356 commit_data["manifest"] = dict(sorted(cur.items()))
357
358 print(json.dumps(_ReadJson(**make_envelope(elapsed), **commit_data), default=str))
359 return
360
361 # ── Text output ────────────────────────────────────────────────────────────
362 print(f"commit {commit.commit_id}")
363 if commit.parent_commit_id:
364 print(f"Parent: {commit.parent_commit_id}")
365 if commit.parent2_commit_id:
366 print(f"Parent: {commit.parent2_commit_id} (merge)")
367 if commit.author:
368 print(f"Author: {sanitize_display(commit.author)}")
369 # Use ISO 8601 format (with T separator) for consistency with --json output.
370 print(f"Date: {commit.committed_at.isoformat()}")
371 if commit.sem_ver_bump and commit.sem_ver_bump != "none":
372 print(f"SemVer: {commit.sem_ver_bump}")
373 if commit.agent_id:
374 print(f"Agent: {sanitize_display(commit.agent_id)}")
375 if commit.metadata:
376 for k, v in sorted(commit.metadata.items()):
377 print(f" {sanitize_display(k)}: {sanitize_display(str(v))}")
378
379 # Render the commit message with consistent 4-space indentation for every
380 # line. Previously only the first line was indented; subsequent lines in
381 # multiline messages started at column 0, breaking readability.
382 raw_message = sanitize_display(commit.message) if commit.message else ""
383 indented_message = textwrap.indent(raw_message, " ") if raw_message else ""
384 print(f"\n{indented_message}\n")
385
386 if not stat:
387 return
388
389 # Prefer the structured delta stored on the commit.
390 # It carries rich note-level detail and is faster (no blob reloading).
391 if commit.structured_delta is not None:
392 delta = commit.structured_delta
393 if not delta["ops"]:
394 print(" (no changes)")
395 return
396 lines: list[str] = []
397 for op in delta["ops"]:
398 lines.extend(_format_op(op))
399 for line in lines:
400 print(line)
401 print(f"\n {delta['summary']}")
402 return
403
404 # Fallback for initial commits or pre-structured-delta commits: compute
405 # file-level diff from snapshot manifests directly.
406 current = get_commit_snapshot_manifest(root, commit.commit_id) or {}
407 parent: _StrMap = {}
408 if commit.parent_commit_id:
409 parent = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
410
411 added = sorted(set(current) - set(parent))
412 removed = sorted(set(parent) - set(current))
413 modified = sorted(p for p in set(current) & set(parent) if current[p] != parent[p])
414
415 for p in added:
416 print(f" A {p}")
417 for p in removed:
418 print(f" D {p}")
419 for p in modified:
420 print(f" M {p}")
421
422 total = len(added) + len(removed) + len(modified)
423 if total:
424 print(f"\n {total} file(s) changed")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 126 days ago