gabriel / muse public

commit_tree.py file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 chore: trigger prebuild on 068c4d6f deployment · gabriel · Jun 21, 2026
1 """muse commit-tree — create a commit from an explicit snapshot ID.
2
3 Low-level commit creation: takes a snapshot ID (which must already exist in the
4 store), optional parent commit IDs, and a message, and writes a new
5 ``CommitRecord`` to the store. Does not touch ``HEAD`` or any branch ref.
6
7 Commands like ``muse commit`` call this internally after staging changes and
8 writing the snapshot.
9
10 Output (JSON, default)::
11
12 {
13 "commit_id": "sha256:<64hex>",
14 "snapshot_id": "sha256:<64hex>",
15 "branch": "main",
16 "message": "feat: add melody",
17 "committed_at": "2026-03-18T12:00:00+00:00",
18 "author": "gabriel",
19 "agent_id": "counterpoint-bot",
20 "model_id": "claude-opus-4",
21 "toolchain_id": "cursor-agent-v2",
22 "parent_commit_id": "sha256:<64hex> | null",
23 "parent2_commit_id": null,
24 "duration_ms": 0.003,
25 "exit_code": 0
26 }
27
28 Output (``--format text``)::
29
30 sha256:<64hex>
31
32 Output contract
33 ---------------
34
35 - Exit 0: commit written, commit record printed.
36 - Exit 1: snapshot not found, parent commit not found, or repo.json unreadable.
37 - Exit 3: write failure.
38
39 ``agent_id`` / ``model_id`` / ``toolchain_id``
40 Provenance fields embedded in the commit record. Empty string when not
41 supplied. Agents should always set these so their identity is auditable.
42
43 ``duration_ms``
44 Wall-clock time from argument parsing to output.
45
46 ``exit_code``
47 Mirrors the process exit code (always ``0`` in the success path).
48
49 Agent use
50 ---------
51
52 Agents must stamp their identity into every commit they create::
53
54 muse commit-tree \\
55 --snapshot <snap_id> \\
56 --message "feat: add melody" \\
57 --agent-id counterpoint-bot \\
58 --model-id claude-opus-4 \\
59 --toolchain-id cursor-agent-v2
60
61 Up to two parents are supported (for merge commits)::
62
63 muse commit-tree --snapshot <id> --parent <p1> --parent <p2>
64 """
65
66 import argparse
67 import datetime
68 import json
69 import logging
70 import sys
71 import time
72
73 from muse.core.errors import ExitCode
74 from muse.core.repo import require_repo
75 from muse.core.ids import hash_commit
76 from muse.core.refs import read_current_branch
77 from muse.core.commits import (
78 CommitRecord,
79 read_commit,
80 write_commit,
81 )
82 from muse.core.snapshots import read_snapshot
83 from muse.core.validation import validate_object_id
84 from muse.core.timing import start_timer
85 from muse.core.envelope import EnvelopeJson, make_envelope
86 from typing import TypedDict
87
88 logger = logging.getLogger(__name__)
89
90 class _CommitTreeErrorJson(EnvelopeJson):
91 """JSON output for ``muse commit-tree`` error paths."""
92
93 error: str
94
95 class _CommitTreeJson(EnvelopeJson):
96 """JSON output for ``muse commit-tree --json``."""
97
98 commit_id: str
99 snapshot_id: str
100 branch: str
101 message: str
102 committed_at: str
103 author: str
104 agent_id: str
105 model_id: str
106 toolchain_id: str
107 parent_commit_id: str | None
108 parent2_commit_id: str | None
109
110 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
111 """Register the commit-tree subcommand."""
112 parser = subparsers.add_parser(
113 "commit-tree",
114 help="Create a commit from an explicit snapshot ID.",
115 description=__doc__,
116 formatter_class=argparse.RawDescriptionHelpFormatter,
117 )
118 parser.add_argument(
119 "--snapshot", "-s",
120 required=True,
121 dest="snapshot_id",
122 metavar="SNAPSHOT_ID",
123 help="SHA-256 snapshot ID.",
124 )
125 parser.add_argument(
126 "--parent", "-p",
127 action="append",
128 default=[],
129 dest="parent",
130 metavar="COMMIT_ID",
131 help=(
132 "Parent commit ID. Repeat once for merge commits. "
133 "At most two parents are supported."
134 ),
135 )
136 parser.add_argument(
137 "--message", "-m",
138 default="",
139 dest="message",
140 metavar="MESSAGE",
141 help="Commit message.",
142 )
143 parser.add_argument(
144 "--author", "-a",
145 default="",
146 dest="author",
147 metavar="AUTHOR",
148 help="Author name.",
149 )
150 parser.add_argument(
151 "--branch", "-b",
152 default=None,
153 dest="branch",
154 metavar="BRANCH",
155 help="Branch name to record (default: current branch).",
156 )
157 # Agent provenance — agents must set these so their identity is auditable.
158 parser.add_argument(
159 "--agent-id",
160 default="",
161 dest="agent_id",
162 metavar="AGENT_ID",
163 help="Stable agent identifier (e.g. 'counterpoint-bot').",
164 )
165 parser.add_argument(
166 "--model-id",
167 default="",
168 dest="model_id",
169 metavar="MODEL_ID",
170 help="Model identifier (e.g. 'claude-opus-4'). Empty for human authors.",
171 )
172 parser.add_argument(
173 "--toolchain-id",
174 default="",
175 dest="toolchain_id",
176 metavar="TOOLCHAIN_ID",
177 help="Toolchain that produced this commit (e.g. 'cursor-agent-v2').",
178 )
179 parser.add_argument(
180 "--json", "-j", action="store_true", dest="json_out",
181 help="Shorthand for --format json.",
182 )
183 parser.set_defaults(func=run)
184
185 def run(args: argparse.Namespace) -> None:
186 """Create a commit record from an explicit snapshot ID.
187
188 The snapshot must already exist in the unified object store. The commit is
189 written to ``.muse/objects/sha256/`` but no branch ref is updated — use
190 ``muse update-ref`` to advance a branch. At most two parents (linear or
191 merge). Always pass ``--agent-id``, ``--model-id``, ``--toolchain-id`` for
192 auditable agent provenance.
193
194 Agent quickstart
195 ----------------
196 ::
197
198 muse commit-tree sha256:<snap-id> -m "feat: X" --agent-id claude-code --model-id claude-sonnet-4-6 --json
199 muse commit-tree sha256:<snap-id> -m "merge" --parent sha256:<a> --parent sha256:<b> --json
200
201 JSON fields
202 -----------
203 commit_id Full ``sha256:…`` commit ID written.
204 snapshot_id Snapshot ID used.
205 branch Branch context (not updated).
206 message Commit message.
207 committed_at ISO-8601 timestamp.
208 author Author handle.
209 agent_id Agent identifier.
210 model_id Model identifier.
211 toolchain_id Toolchain identifier.
212 parent_commit_id First parent; ``null`` for root commits.
213 parent2_commit_id Second parent; ``null`` for non-merge commits.
214
215 Exit codes
216 ----------
217 0 Commit record created.
218 1 Invalid snapshot ID, too many parents, or validation error.
219 2 Not inside a Muse repository.
220 """
221 elapsed = start_timer()
222 json_out: bool = args.json_out
223 snapshot_id: str = args.snapshot_id
224 parent: list[str] = args.parent
225 message: str = args.message
226 author: str = args.author
227 branch: str | None = args.branch
228 agent_id: str = args.agent_id
229 model_id: str = args.model_id
230 toolchain_id: str = args.toolchain_id
231 # CommitRecord only supports two parents (regular and merge).
232 if len(parent) > 2:
233 print(
234 json.dumps({"error": f"At most 2 parents supported; got {len(parent)}."}),
235 file=sys.stderr,
236 )
237 raise SystemExit(ExitCode.USER_ERROR)
238
239 root = require_repo()
240
241 try:
242 validate_object_id(snapshot_id)
243 except ValueError as exc:
244 print(json.dumps({"error": f"Invalid snapshot ID: {exc}"}), file=sys.stderr)
245 raise SystemExit(ExitCode.USER_ERROR)
246
247 for pid in parent:
248 try:
249 validate_object_id(pid)
250 except ValueError as exc:
251 print(json.dumps({"error": f"Invalid parent commit ID: {exc}"}), file=sys.stderr)
252 raise SystemExit(ExitCode.USER_ERROR)
253
254 snap = read_snapshot(root, snapshot_id)
255 if snap is None:
256 print(json.dumps({"error": f"Snapshot not found: {snapshot_id}"}), file=sys.stderr)
257 raise SystemExit(ExitCode.USER_ERROR)
258
259 for pid in parent:
260 if read_commit(root, pid) is None:
261 print(json.dumps({"error": f"Parent commit not found: {pid}"}), file=sys.stderr)
262 raise SystemExit(ExitCode.USER_ERROR)
263
264 branch_name = branch or read_current_branch(root)
265 committed_at = datetime.datetime.now(datetime.timezone.utc)
266
267 commit_id = hash_commit(
268 parent_ids=parent,
269 snapshot_id=snapshot_id,
270 message=message,
271 committed_at_iso=committed_at.isoformat(),
272 author=author or "",
273 )
274
275 record = CommitRecord(
276 commit_id=commit_id,
277 branch=branch_name,
278 snapshot_id=snapshot_id,
279 message=message,
280 committed_at=committed_at,
281 author=author,
282 parent_commit_id=parent[0] if len(parent) >= 1 else None,
283 parent2_commit_id=parent[1] if len(parent) >= 2 else None,
284 agent_id=agent_id,
285 model_id=model_id,
286 toolchain_id=toolchain_id,
287 )
288 write_commit(root, record)
289
290 if not json_out:
291 print(commit_id)
292 return
293
294 print(json.dumps(_CommitTreeJson(
295 **make_envelope(elapsed),
296 commit_id=commit_id,
297 snapshot_id=snapshot_id,
298 branch=branch_name,
299 message=message,
300 committed_at=committed_at.isoformat(),
301 author=author,
302 agent_id=agent_id,
303 model_id=model_id,
304 toolchain_id=toolchain_id,
305 parent_commit_id=parent[0] if len(parent) >= 1 else None,
306 parent2_commit_id=parent[1] if len(parent) >= 2 else None,
307 )))