show.py python
272 lines 9.4 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """``muse show`` — 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 show
12
13 Inspect a specific commit or branch tip::
14
15 muse show <commit_id_or_branch>
16
17 Omit the file-change summary::
18
19 muse show --no-stat
20
21 Omit the stored ``structured_delta`` blob from JSON output (smaller payload)::
22
23 muse show --json --no-delta
24
25 Exit codes::
26
27 0 — commit found and displayed
28 1 — commit ref not found or other user error
29 3 — I/O error
30 """
31
32 from __future__ import annotations
33
34 import argparse
35 import json
36 import logging
37 import pathlib
38 import sys
39 import textwrap
40
41 from muse.core.errors import ExitCode
42 from muse.core.repo import read_repo_id, require_repo
43 from muse.core.store import (
44 get_commit_snapshot_manifest,
45 get_head_commit_id,
46 read_commit,
47 read_current_branch,
48 read_snapshot,
49 resolve_commit_ref,
50 )
51 from muse.core.validation import sanitize_display
52 from muse.domain import DomainOp
53
54
55 type _StrMap = dict[str, str]
56 logger = logging.getLogger(__name__)
57
58
59 def _format_op(op: DomainOp) -> list[str]:
60 """Return one or more display lines for a single domain op.
61
62 Each branch checks ``op["op"]`` directly so mypy can narrow the
63 TypedDict union to the specific subtype before accessing its fields.
64 """
65 if op["op"] == "insert":
66 return [f" A {op['address']}"]
67 if op["op"] == "delete":
68 return [f" D {op['address']}"]
69 if op["op"] == "replace":
70 return [f" M {op['address']}"]
71 if op["op"] == "move":
72 return [f" R {op['address']} ({op['from_position']} → {op['to_position']})"]
73 if op["op"] == "mutate":
74 fields = ", ".join(
75 f"{k}: {v['old']}→{v['new']}" for k, v in op.get("fields", {}).items()
76 )
77 return [f" ~ {op['address']} ({fields or op.get('old_summary', '')}→{op.get('new_summary', '')})"]
78 # op["op"] == "patch" — the only remaining variant.
79 lines = [f" M {op['address']}"]
80 if op["child_summary"]:
81 lines.append(f" └─ {op['child_summary']}")
82 return lines
83
84
85 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
86 """Register the ``muse show`` subcommand and its flags."""
87 parser = subparsers.add_parser(
88 "show",
89 help="Inspect a commit: metadata, delta, and provenance.",
90 description=__doc__,
91 formatter_class=argparse.RawDescriptionHelpFormatter,
92 )
93 parser.add_argument(
94 "ref", nargs="?", default=None,
95 help="Commit ID or branch name (default: HEAD).",
96 )
97 parser.add_argument(
98 "--no-stat", dest="stat", action="store_false", default=True,
99 help="Omit the file/symbol change summary from output.",
100 )
101 parser.add_argument(
102 "--no-delta", dest="include_delta", action="store_false", default=True,
103 help=(
104 "Exclude the ``structured_delta`` blob from JSON output. "
105 "Produces a smaller payload for agents that only need commit metadata."
106 ),
107 )
108 parser.add_argument(
109 "--format", "-f", default="text", dest="fmt",
110 help="Output format: text (default) or json.",
111 )
112 parser.add_argument(
113 "--json", action="store_const", const="json", dest="fmt",
114 help="Shorthand for --format json.",
115 )
116 parser.set_defaults(func=run)
117
118
119 def run(args: argparse.Namespace) -> None:
120 """Inspect a commit: metadata, delta, and provenance.
121
122 Agents should pass ``--json`` to receive a machine-readable result::
123
124 {
125 "commit_id": "<sha256>",
126 "branch": "main",
127 "message": "Add verse melody",
128 "author": "gabriel",
129 "agent_id": "",
130 "model_id": "",
131 "toolchain_id": "",
132 "committed_at": "2026-03-21T12:00:00+00:00",
133 "snapshot_id": "<sha256>",
134 "parent_commit_id": "<sha256> | null",
135 "parent2_commit_id": null,
136 "sem_ver_bump": "minor",
137 "breaking_changes": [],
138 "metadata": {},
139 "files_added": ["new_track.mid"],
140 "files_removed": [],
141 "files_modified": ["tracks/bass.mid"],
142 "structured_delta": { ... }
143 }
144
145 Pass ``--no-stat`` to omit ``files_added/removed/modified``.
146 Pass ``--no-delta`` to omit ``structured_delta`` (smaller payload).
147 """
148 ref: str | None = args.ref
149 stat: bool = args.stat
150 include_delta: bool = args.include_delta
151 fmt: str = args.fmt
152
153 if fmt not in ("text", "json"):
154 print(
155 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
156 file=sys.stderr,
157 )
158 raise SystemExit(ExitCode.USER_ERROR)
159
160 root = require_repo()
161 repo_id = read_repo_id(root)
162 branch = read_current_branch(root)
163
164 # Try branch-name resolution first: ``muse show feat/my-thing`` should
165 # display the HEAD commit of that branch, not attempt a SHA prefix scan.
166 if ref is not None and ref.upper() not in ("HEAD",):
167 branch_head_id = get_head_commit_id(root, ref)
168 if branch_head_id is not None:
169 commit = read_commit(root, branch_head_id)
170 else:
171 commit = resolve_commit_ref(root, repo_id, branch, ref)
172 else:
173 commit = resolve_commit_ref(root, repo_id, branch, ref)
174
175 if commit is None:
176 print(
177 f"❌ Commit '{sanitize_display(str(ref))}' not found.",
178 file=sys.stderr,
179 )
180 raise SystemExit(ExitCode.USER_ERROR)
181
182 if fmt == "json":
183 commit_data = commit.to_dict()
184
185 if not include_delta:
186 commit_data.pop("structured_delta", None)
187
188 if stat:
189 # Read current snapshot directly via snapshot_id (avoids re-reading
190 # the commit we already have in memory).
191 cur_snap = read_snapshot(root, commit.snapshot_id)
192 cur = cur_snap.manifest if cur_snap is not None else {}
193 par: _StrMap = {}
194 if commit.parent_commit_id:
195 par_manifest = get_commit_snapshot_manifest(root, commit.parent_commit_id)
196 par = par_manifest if par_manifest is not None else {}
197 stats = {
198 "files_added": sorted(set(cur) - set(par)),
199 "files_removed": sorted(set(par) - set(cur)),
200 "files_modified": sorted(
201 p for p in set(cur) & set(par) if cur[p] != par[p]
202 ),
203 }
204 print(json.dumps({**commit_data, **stats}, indent=2, default=str))
205 else:
206 print(json.dumps(commit_data, indent=2, default=str))
207 return
208
209 # ── Text output ────────────────────────────────────────────────────────────
210 print(f"commit {commit.commit_id}")
211 if commit.parent_commit_id:
212 print(f"Parent: {commit.parent_commit_id[:8]}")
213 if commit.parent2_commit_id:
214 print(f"Parent: {commit.parent2_commit_id[:8]} (merge)")
215 if commit.author:
216 print(f"Author: {sanitize_display(commit.author)}")
217 # Use ISO 8601 format (with T separator) for consistency with --json output.
218 print(f"Date: {commit.committed_at.isoformat()}")
219 if commit.sem_ver_bump and commit.sem_ver_bump != "none":
220 print(f"SemVer: {commit.sem_ver_bump}")
221 if commit.agent_id:
222 print(f"Agent: {sanitize_display(commit.agent_id)}")
223 if commit.metadata:
224 for k, v in sorted(commit.metadata.items()):
225 print(f" {sanitize_display(k)}: {sanitize_display(str(v))}")
226
227 # Render the commit message with consistent 4-space indentation for every
228 # line. Previously only the first line was indented; subsequent lines in
229 # multiline messages started at column 0, breaking readability.
230 raw_message = sanitize_display(commit.message) if commit.message else ""
231 indented_message = textwrap.indent(raw_message, " ") if raw_message else ""
232 print(f"\n{indented_message}\n")
233
234 if not stat:
235 return
236
237 # Prefer the structured delta stored on the commit.
238 # It carries rich note-level detail and is faster (no blob reloading).
239 if commit.structured_delta is not None:
240 delta = commit.structured_delta
241 if not delta["ops"]:
242 print(" (no changes)")
243 return
244 lines: list[str] = []
245 for op in delta["ops"]:
246 lines.extend(_format_op(op))
247 for line in lines:
248 print(line)
249 print(f"\n {delta['summary']}")
250 return
251
252 # Fallback for initial commits or pre-structured-delta commits: compute
253 # file-level diff from snapshot manifests directly.
254 current = get_commit_snapshot_manifest(root, commit.commit_id) or {}
255 parent: _StrMap = {}
256 if commit.parent_commit_id:
257 parent = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
258
259 added = sorted(set(current) - set(parent))
260 removed = sorted(set(parent) - set(current))
261 modified = sorted(p for p in set(current) & set(parent) if current[p] != parent[p])
262
263 for p in added:
264 print(f" A {p}")
265 for p in removed:
266 print(f" D {p}")
267 for p in modified:
268 print(f" M {p}")
269
270 total = len(added) + len(removed) + len(modified)
271 if total:
272 print(f"\n {total} file(s) changed")
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago