gabriel / muse public
read_commit.py python
241 lines 7.8 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 146 days ago
1 """muse read-commit — emit full commit metadata as JSON.
2
3 Reads a commit record by its SHA-256 ID and emits the complete JSON
4 representation including provenance fields, CRDT annotations, and the
5 structured delta. Equivalent to ``git cat-file commit`` but producing
6 the Muse JSON schema directly.
7
8 Output::
9
10 {
11 "format_version": 5,
12 "commit_id": "<sha256>",
13 "repo_id": "<uuid>",
14 "branch": "main",
15 "snapshot_id": "<sha256>",
16 "message": "Add verse melody",
17 "committed_at": "2026-03-18T12:00:00+00:00",
18 "parent_commit_id": "<sha256> | null",
19 "parent2_commit_id": null,
20 "author": "gabriel",
21 "agent_id": "",
22 "model_id": "",
23 "sem_ver_bump": "none",
24 "breaking_changes": [],
25 "reviewed_by": [],
26 "test_runs": 0,
27 ...
28 }
29
30 Output contract
31 ---------------
32
33 - Exit 0: commit found and printed.
34 - Exit 1: commit not found, ambiguous prefix, or invalid commit ID format.
35
36 Agent use
37 ---------
38
39 Fetch only the fields you need to keep agent context small::
40
41 muse read-commit <id> --fields commit_id,branch,message,committed_at
42 muse read-commit <id> --fields agent_id,model_id,format_version
43 """
44
45 from __future__ import annotations
46
47 import argparse
48 import json
49 import logging
50 import re
51 import sys
52 from muse.core.errors import ExitCode
53 from muse.core.repo import read_repo_id, require_repo
54 from muse.core.store import (
55 CommitDict,
56 find_commits_by_prefix,
57 get_head_commit_id,
58 read_commit,
59 read_head,
60 resolve_commit_ref,
61 )
62 from muse.core.validation import sanitize_display
63
64 _SHA256_FULL_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
65 _SHA256_PREFIX_RE = re.compile(r"^sha256:[0-9a-f]{1,63}$")
66 _BARE_HEX_RE = re.compile(r"^[0-9a-f]+$", re.IGNORECASE)
67
68 logger = logging.getLogger(__name__)
69
70 _FORMAT_CHOICES = ("json", "text")
71
72 # All fields exposed by CommitRecord.to_dict() — used to validate --fields input.
73 _ALL_FIELDS: frozenset[str] = frozenset(CommitDict.__annotations__.keys())
74
75
76 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
77 """Register the read-commit subcommand."""
78 parser = subparsers.add_parser(
79 "read-commit",
80 help="Emit full commit metadata as JSON.",
81 description=__doc__,
82 formatter_class=argparse.RawDescriptionHelpFormatter,
83 )
84 parser.add_argument(
85 "commit_id",
86 help="Full or abbreviated SHA-256 commit ID.",
87 )
88 parser.add_argument(
89 "--format", "-f",
90 dest="fmt",
91 default="json",
92 metavar="FORMAT",
93 help="Output format: json (default) or text.",
94 )
95 parser.add_argument(
96 "--json", action="store_const", const="json", dest="fmt",
97 help="Shorthand for --format json.",
98 )
99 parser.add_argument(
100 "--fields",
101 default=None,
102 metavar="FIELD,…",
103 dest="fields",
104 help=(
105 "Comma-separated list of CommitDict fields to include in JSON output. "
106 "Reduces response size for agent pipelines. "
107 "Example: --fields commit_id,branch,message,committed_at"
108 ),
109 )
110 parser.set_defaults(func=run)
111
112
113 def run(args: argparse.Namespace) -> None:
114 """Emit full commit metadata as JSON (default) or a compact text summary.
115
116 Accepts a full 64-character commit ID or a unique prefix. The JSON output
117 schema matches ``CommitRecord.to_dict()`` and is stable across Muse
118 versions (use ``format_version`` to detect schema changes).
119
120 Use ``--fields`` to request only the fields you need — essential for
121 agents that must keep their context window small.
122
123 Text format (``--format text``)::
124
125 <commit_id[:12]> <branch> <author> <committed_at> <message>
126 """
127 fmt: str = args.fmt
128 commit_id: str = args.commit_id
129 fields_raw: str | None = args.fields
130
131 if fmt not in _FORMAT_CHOICES:
132 print(
133 json.dumps({"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}),
134 file=sys.stderr,
135 )
136 raise SystemExit(ExitCode.USER_ERROR)
137
138 # Parse and validate --fields before touching the store.
139 requested_fields: frozenset[str] | None = None
140 if fields_raw is not None:
141 if fmt == "text":
142 print(
143 json.dumps({"error": "--fields is only valid with --format json"}),
144 file=sys.stderr,
145 )
146 raise SystemExit(ExitCode.USER_ERROR)
147 parts = {f.strip() for f in fields_raw.split(",") if f.strip()}
148 unknown = parts - _ALL_FIELDS
149 if unknown:
150 print(
151 json.dumps({
152 "error": f"Unknown field(s): {', '.join(sorted(unknown))}. "
153 f"Valid fields: {', '.join(sorted(_ALL_FIELDS))}",
154 }),
155 file=sys.stderr,
156 )
157 raise SystemExit(ExitCode.USER_ERROR)
158 requested_fields = frozenset(parts)
159
160 root = require_repo()
161 repo_id = read_repo_id(root)
162
163 record = None
164
165 if _SHA256_FULL_RE.match(commit_id):
166 # Exact canonical content address — look up directly.
167 record = read_commit(root, commit_id)
168
169 elif _SHA256_PREFIX_RE.match(commit_id):
170 # Canonical prefix form: sha256:<partial-hex> — strip algo tag for filesystem glob.
171 bare_prefix = commit_id[7:] # len("sha256:") == 7
172 matches = find_commits_by_prefix(root, bare_prefix)
173 if len(matches) == 1:
174 record = matches[0]
175 elif len(matches) > 1:
176 print(
177 json.dumps({
178 "error": "ambiguous prefix",
179 "candidates": [m.commit_id for m in matches],
180 }),
181 file=sys.stderr,
182 )
183 raise SystemExit(ExitCode.USER_ERROR)
184
185 elif _BARE_HEX_RE.match(commit_id):
186 # Bare hex without the algo tag — always an error.
187 print(
188 json.dumps({
189 "error": (
190 f"Invalid commit reference {commit_id!r}: "
191 "use the canonical 'sha256:<hex>' form. "
192 "Bare hex IDs are not accepted."
193 ),
194 }),
195 file=sys.stderr,
196 )
197 raise SystemExit(ExitCode.USER_ERROR)
198
199 else:
200 # Symbolic ref: HEAD, HEAD~N, branch name, <sha256:...>~N.
201 try:
202 head_state = read_head(root)
203 branch = head_state["branch"] if head_state["kind"] == "branch" else ""
204 except ValueError:
205 branch = ""
206 if commit_id.upper() == "HEAD":
207 record = resolve_commit_ref(root, repo_id, branch, None)
208 else:
209 try:
210 is_branch = get_head_commit_id(root, commit_id) is not None
211 except (ValueError, Exception):
212 is_branch = False
213 if is_branch:
214 # Input is a branch name — resolve its tip.
215 record = resolve_commit_ref(root, repo_id, commit_id, None)
216 else:
217 # Tilde notation (HEAD~N, sha256:...~N) or other ref forms.
218 record = resolve_commit_ref(root, repo_id, branch, commit_id)
219
220 if record is None:
221 print(json.dumps({"error": f"Commit not found: {commit_id}"}), file=sys.stderr)
222 raise SystemExit(ExitCode.USER_ERROR)
223
224 if fmt == "text":
225 msg = sanitize_display((record.message or "").replace("\n", " "))
226 print(
227 f"{record.commit_id[:12]} {sanitize_display(record.branch)} "
228 f"{sanitize_display(record.author or '')} "
229 f"{record.committed_at.isoformat()} {msg}"
230 )
231 return
232
233 record_data = record.to_dict()
234 if requested_fields is not None:
235 print(json.dumps(
236 {k: v for k, v in record_data.items() if k in requested_fields},
237 indent=2,
238 default=str,
239 ))
240 else:
241 print(json.dumps(record_data, indent=2, default=str))
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago