gabriel / muse public
read_commit.py python
244 lines 7.9 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 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 "commit_id": "<sha256>",
12 "branch": "main",
13 "snapshot_id": "<sha256>",
14 "message": "Add verse melody",
15 "committed_at": "2026-03-18T12:00:00+00:00",
16 "parent_commit_id": "<sha256> | null",
17 "parent2_commit_id": null,
18 "author": "gabriel",
19 "agent_id": "",
20 "model_id": "",
21 "sem_ver_bump": "none",
22 "breaking_changes": [],
23 "reviewed_by": [],
24 "test_runs": 0,
25 ...
26 }
27
28 Output contract
29 ---------------
30
31 - Exit 0: commit found and printed.
32 - Exit 1: commit not found, ambiguous prefix, or invalid commit ID format.
33
34 Agent use
35 ---------
36
37 Fetch only the fields you need to keep agent context small::
38
39 muse read-commit <id> --fields commit_id,branch,message,committed_at
40 muse read-commit <id> --fields agent_id,model_id
41 """
42
43 import argparse
44 import json
45 import logging
46 import re
47 import sys
48 from typing import TypedDict
49 from muse.core.envelope import EnvelopeJson, make_envelope
50 from muse.core.errors import ExitCode
51 from muse.core.repo import require_repo
52 from muse.core.refs import (
53 get_head_commit_id,
54 read_head,
55 )
56 from muse.core.commits import (
57 CommitDict,
58 find_commits_by_prefix,
59 read_commit,
60 resolve_commit_ref,
61 )
62 from muse.domain import StructuredDelta
63 from muse.core.validation import sanitize_display
64 from muse.core.types import long_id, Metadata
65 from muse.core.timing import start_timer
66
67 _SHA256_FULL_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
68 _SHA256_PREFIX_RE = re.compile(r"^sha256:[0-9a-f]{1,63}$")
69 _BARE_HEX_RE = re.compile(r"^[0-9a-f]+$", re.IGNORECASE)
70
71 logger = logging.getLogger(__name__)
72
73 class _ReadCommitJson(EnvelopeJson, total=False):
74 """JSON envelope for ``muse read-commit --json`` output.
75
76 All CommitRecord fields are present; ``total=False`` reflects
77 dynamic field selection via ``--fields``.
78 """
79 commit_id: str
80 branch: str
81 snapshot_id: str
82 message: str
83 committed_at: str
84 parent_commit_id: str | None
85 parent2_commit_id: str | None
86 author: str
87 agent_id: str
88 model_id: str
89 sem_ver_bump: str
90 breaking_changes: list[str]
91 metadata: Metadata
92 structured_delta: StructuredDelta | None
93
94 # All fields exposed by CommitRecord.to_dict() — used to validate --fields input.
95 _ALL_FIELDS: frozenset[str] = frozenset(CommitDict.__annotations__.keys())
96
97 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
98 """Register the read-commit subcommand."""
99 parser = subparsers.add_parser(
100 "read-commit",
101 help="Emit full commit metadata as JSON.",
102 description=__doc__,
103 formatter_class=argparse.RawDescriptionHelpFormatter,
104 )
105 parser.add_argument(
106 "commit_id",
107 help="Full or abbreviated SHA-256 commit ID.",
108 )
109 parser.add_argument(
110 "--json", "-j",
111 action="store_true",
112 dest="json_out",
113 help="Emit machine-readable JSON.",
114 )
115 parser.add_argument(
116 "--fields",
117 default=None,
118 metavar="FIELD,…",
119 dest="fields",
120 help=(
121 "Comma-separated list of CommitDict fields to include in JSON output. "
122 "Reduces response size for agent pipelines. "
123 "Example: --fields commit_id,branch,message,committed_at"
124 ),
125 )
126 parser.set_defaults(func=run)
127
128 def run(args: argparse.Namespace) -> None:
129 """Emit full commit metadata as JSON (default) or a compact text summary.
130
131 Accepts a full 64-character commit ID or a unique prefix. The JSON output
132 schema matches ``CommitRecord.to_dict()`` and is stable across Muse
133 versions.
134
135 Use ``--fields`` to request only the fields you need — essential for
136 agents that must keep their context window small.
137
138 Text format (``--format text``)::
139
140 <commit_id[:12]> <branch> <author> <committed_at> <message>
141 """
142 elapsed = start_timer()
143 json_out: bool = args.json_out
144 commit_id: str = args.commit_id
145 fields_raw: str | None = args.fields
146
147 # Parse and validate --fields before touching the store.
148 requested_fields: frozenset[str] | None = None
149 if fields_raw is not None:
150 if not json_out:
151 print(
152 json.dumps({"error": "--fields is only valid with --json"}),
153 file=sys.stderr,
154 )
155 raise SystemExit(ExitCode.USER_ERROR)
156 parts = {f.strip() for f in fields_raw.split(",") if f.strip()}
157 unknown = parts - _ALL_FIELDS
158 if unknown:
159 print(
160 json.dumps({
161 "error": f"Unknown field(s): {', '.join(sorted(unknown))}. "
162 f"Valid fields: {', '.join(sorted(_ALL_FIELDS))}",
163 }),
164 file=sys.stderr,
165 )
166 raise SystemExit(ExitCode.USER_ERROR)
167 requested_fields = frozenset(parts)
168
169 root = require_repo()
170
171 record = None
172
173 if _SHA256_FULL_RE.match(commit_id):
174 # Exact canonical content address — look up directly.
175 record = read_commit(root, commit_id)
176
177 elif _SHA256_PREFIX_RE.match(commit_id):
178 # Canonical prefix form: sha256:<partial-hex> — strip algo tag for filesystem glob.
179 bare_prefix = long_id(commit_id, strip=True)
180 matches = find_commits_by_prefix(root, bare_prefix)
181 if len(matches) == 1:
182 record = matches[0]
183 elif len(matches) > 1:
184 print(
185 json.dumps({
186 "error": "ambiguous prefix",
187 "candidates": [m.commit_id for m in matches],
188 }),
189 file=sys.stderr,
190 )
191 raise SystemExit(ExitCode.USER_ERROR)
192
193 elif _BARE_HEX_RE.match(commit_id):
194 # Bare hex without the algo tag — always an error.
195 print(
196 json.dumps({
197 "error": (
198 f"Invalid commit reference {commit_id!r}: "
199 "use the canonical 'sha256:<hex>' form. "
200 "Bare hex IDs are not accepted."
201 ),
202 }),
203 file=sys.stderr,
204 )
205 raise SystemExit(ExitCode.USER_ERROR)
206
207 else:
208 # Symbolic ref: HEAD, HEAD~N, branch name, <sha256:...>~N.
209 try:
210 head_state = read_head(root)
211 branch = head_state["branch"] if head_state["kind"] == "branch" else ""
212 except ValueError:
213 branch = ""
214 if commit_id.upper() == "HEAD":
215 record = resolve_commit_ref(root, branch, None)
216 else:
217 try:
218 is_branch = get_head_commit_id(root, commit_id) is not None
219 except (ValueError, Exception):
220 is_branch = False
221 if is_branch:
222 # Input is a branch name — resolve its tip.
223 record = resolve_commit_ref(root, commit_id, None)
224 else:
225 # Tilde notation (HEAD~N, sha256:...~N) or other ref forms.
226 record = resolve_commit_ref(root, branch, commit_id)
227
228 if record is None:
229 print(json.dumps({"error": f"Commit not found: {commit_id}"}), file=sys.stderr)
230 raise SystemExit(ExitCode.USER_ERROR)
231
232 if not json_out:
233 msg = sanitize_display((record.message or "").replace("\n", " "))
234 print(
235 f"{record.commit_id} {sanitize_display(record.branch)} "
236 f"{sanitize_display(record.author or '')} "
237 f"{record.committed_at.isoformat()} {msg}"
238 )
239 return
240
241 record_data = record.to_dict()
242 if requested_fields is not None:
243 record_data = {k: v for k, v in record_data.items() if k in requested_fields}
244 print(json.dumps(_ReadCommitJson(**make_envelope(elapsed), **record_data), default=str))
File History 11 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 57 days ago