gabriel / muse public
commit.py python
290 lines 11.3 KB
e8c4265e feat(hardening): final sweep — security, performance, API consistency, docs Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """``muse commit`` — record the current workspace state as a new version.
2
3 Algorithm
4 ---------
5 1. Resolve repo root (walk up for ``.muse/``).
6 2. Read ``repo_id`` from ``.muse/repo.json`` and the current branch from
7 ``.muse/HEAD``.
8 3. Invoke ``plugin.snapshot(root)`` to collect the workspace manifest
9 (domain-specific; the MIDI plugin walks MIDI/MusicXML files in ``state/``).
10 4. If the computed ``snapshot_id`` matches HEAD → "nothing to commit".
11 5. Compute a deterministic ``commit_id`` = SHA-256 of (parents | snapshot |
12 message | timestamp).
13 6. Write content-addressed blob objects to ``.muse/objects/``.
14 7. Write snapshot JSON to ``.muse/snapshots/<snapshot_id>.json``.
15 8. Write commit JSON to ``.muse/commits/<commit_id>.json``.
16 9. Advance ``.muse/refs/heads/<branch>`` to the new ``commit_id``.
17
18 Exit codes::
19
20 0 — commit created (or nothing to commit)
21 1 — validation error (no message, merge conflict, …)
22 3 — I/O error
23 """
24
25 from __future__ import annotations
26
27 import datetime
28 import json
29 import logging
30 import os
31 import pathlib
32
33 import typer
34
35 from muse.core.errors import ExitCode
36 from muse.core.merge_engine import clear_merge_state, read_merge_state
37 from muse.core.object_store import write_object_from_path
38 from muse.core.rerere import record_resolutions as rerere_record_resolutions
39 from muse.core.provenance import make_agent_identity, read_agent_key, sign_commit_hmac
40 from muse.core.repo import require_repo
41 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
42 from muse.core.store import (
43 CommitRecord,
44 SnapshotRecord,
45 get_head_snapshot_id,
46 read_commit,
47 read_current_branch,
48 read_snapshot,
49 write_commit,
50 write_snapshot,
51 )
52 from muse.core.reflog import append_reflog
53 from muse.core.validation import sanitize_display, validate_branch_name
54 from muse.domain import SemVerBump, SnapshotManifest, StructuredDelta, infer_sem_ver_bump
55 from muse.plugins.registry import read_domain, resolve_plugin
56
57 logger = logging.getLogger(__name__)
58
59 app = typer.Typer()
60
61
62 def _read_repo_id(root: pathlib.Path) -> str:
63 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
64
65
66 def _read_branch(root: pathlib.Path) -> tuple[str, pathlib.Path]:
67 """Return (branch_name, ref_file_path).
68
69 Delegates HEAD parsing and branch-name validation to the store so
70 that format details are not duplicated across commands.
71 """
72 branch = read_current_branch(root)
73 ref_path = root / ".muse" / "refs" / "heads" / branch
74 return branch, ref_path
75
76
77 def _read_parent_id(ref_path: pathlib.Path) -> str | None:
78 if not ref_path.exists():
79 return None
80 raw = ref_path.read_text().strip()
81 return raw or None
82
83
84 @app.callback(invoke_without_command=True)
85 def commit(
86 ctx: typer.Context,
87 message: str | None = typer.Option(None, "-m", "--message", help="Commit message."),
88 allow_empty: bool = typer.Option(False, "--allow-empty", help="Allow committing with no changes."),
89 section: str | None = typer.Option(None, "--section", help="Tag this commit with a musical section (verse, chorus, bridge…)."),
90 track: str | None = typer.Option(None, "--track", help="Tag this commit with an instrument track (drums, bass, keys…)."),
91 emotion: str | None = typer.Option(None, "--emotion", help="Attach an emotion label (joyful, melancholic, tense…)."),
92 author: str | None = typer.Option(None, "--author", help="Override the commit author."),
93 agent_id: str | None = typer.Option(None, "--agent-id", help="Agent identity string (overrides MUSE_AGENT_ID env var)."),
94 model_id: str | None = typer.Option(None, "--model-id", help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var)."),
95 toolchain_id: str | None = typer.Option(None, "--toolchain-id", help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var)."),
96 sign: bool = typer.Option(False, "--sign", help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID)."),
97 fmt: str = typer.Option("text", "--format", "-f", help="Output format: text or json."),
98 ) -> None:
99 """Record the current state as a new version.
100
101 Agents should pass ``--format json`` to receive a machine-readable result::
102
103 {
104 "commit_id": "<sha256>",
105 "branch": "main",
106 "snapshot_id": "<sha256>",
107 "message": "Add verse melody",
108 "parent_commit_id": "<sha256> | null",
109 "committed_at": "2026-03-21T12:00:00+00:00",
110 "author": "gabriel",
111 "sem_ver_bump": "none"
112 }
113 """
114 if fmt not in ("text", "json"):
115 typer.echo(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", err=True)
116 raise typer.Exit(code=ExitCode.USER_ERROR)
117
118 if message is None and not allow_empty:
119 typer.echo("❌ Provide a commit message with -m MESSAGE.")
120 raise typer.Exit(code=ExitCode.USER_ERROR)
121
122 root = require_repo()
123
124 # Read merge state before any writes — needed for rerere recording below.
125 merge_state = read_merge_state(root)
126 if merge_state is not None and merge_state.conflict_paths:
127 typer.echo("❌ You have unresolved merge conflicts. Resolve them before committing.")
128 for p in sorted(merge_state.conflict_paths):
129 typer.echo(f" both modified: {sanitize_display(p)}")
130 raise typer.Exit(code=ExitCode.USER_ERROR)
131
132 repo_id = _read_repo_id(root)
133 branch, ref_path = _read_branch(root)
134 parent_id = _read_parent_id(ref_path)
135
136 plugin = resolve_plugin(root)
137 snap = plugin.snapshot(root)
138 manifest = snap["files"]
139 if not manifest and not allow_empty:
140 typer.echo("⚠️ Nothing tracked — working tree is empty.")
141 raise typer.Exit(code=ExitCode.USER_ERROR)
142
143 snapshot_id = compute_snapshot_id(manifest)
144
145 if not allow_empty:
146 head_snapshot = get_head_snapshot_id(root, repo_id, branch)
147 if head_snapshot == snapshot_id:
148 typer.echo("Nothing to commit, working tree clean")
149 raise typer.Exit(code=ExitCode.SUCCESS)
150
151 committed_at = datetime.datetime.now(datetime.timezone.utc)
152 parent_ids = [parent_id] if parent_id else []
153 commit_id = compute_commit_id(
154 parent_ids=parent_ids,
155 snapshot_id=snapshot_id,
156 message=message or "",
157 committed_at_iso=committed_at.isoformat(),
158 )
159
160 metadata: dict[str, str] = {}
161 if section:
162 metadata["section"] = section
163 if track:
164 metadata["track"] = track
165 if emotion:
166 metadata["emotion"] = emotion
167
168 for rel_path, object_id in manifest.items():
169 write_object_from_path(root, object_id, root / rel_path)
170
171 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
172
173 # Compute a structured delta against the parent snapshot so muse show
174 # can display note-level changes without reloading blobs.
175 structured_delta: StructuredDelta | None = None
176 sem_ver_bump: SemVerBump = "none"
177 breaking_changes: list[str] = []
178 if parent_id is not None:
179 parent_commit_rec = read_commit(root, parent_id)
180 if parent_commit_rec is not None:
181 parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id)
182 if parent_snap_record is not None:
183 domain = read_domain(root)
184 base_snap = SnapshotManifest(
185 files=dict(parent_snap_record.manifest),
186 domain=domain,
187 )
188 try:
189 structured_delta = plugin.diff(base_snap, snap, repo_root=root)
190 except Exception:
191 structured_delta = None
192
193 # Infer semantic version bump from the structured delta.
194 if structured_delta is not None:
195 sem_ver_bump, breaking_changes = infer_sem_ver_bump(structured_delta)
196 structured_delta["sem_ver_bump"] = sem_ver_bump
197 structured_delta["breaking_changes"] = breaking_changes
198
199 # Resolve agent provenance: CLI flags take priority over environment vars.
200 # Truncate to 256 chars to prevent environment injection of arbitrarily
201 # long or control-character-laden strings into commit records.
202 _MAX_PROV = 256
203 resolved_agent_id = (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_PROV]
204 resolved_model_id = (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_PROV]
205 resolved_toolchain_id = (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_PROV]
206 resolved_prompt_hash = os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_PROV]
207
208 signature = ""
209 signer_key_id = ""
210 if sign and resolved_agent_id:
211 key = read_agent_key(root, resolved_agent_id)
212 if key is not None:
213 signature = sign_commit_hmac(commit_id, key)
214 from muse.core.provenance import key_fingerprint
215 signer_key_id = key_fingerprint(key)
216 else:
217 logger.warning("No signing key found for agent %r — commit will be unsigned.", resolved_agent_id)
218
219 write_commit(root, CommitRecord(
220 commit_id=commit_id,
221 repo_id=repo_id,
222 branch=branch,
223 snapshot_id=snapshot_id,
224 message=message or "",
225 committed_at=committed_at,
226 parent_commit_id=parent_id,
227 author=author or "",
228 metadata=metadata,
229 structured_delta=structured_delta,
230 sem_ver_bump=sem_ver_bump,
231 breaking_changes=breaking_changes,
232 agent_id=resolved_agent_id,
233 model_id=resolved_model_id,
234 toolchain_id=resolved_toolchain_id,
235 prompt_hash=resolved_prompt_hash,
236 signature=signature,
237 signer_key_id=signer_key_id,
238 ))
239
240 ref_path.parent.mkdir(parents=True, exist_ok=True)
241 ref_path.write_text(commit_id)
242
243 append_reflog(
244 root,
245 branch,
246 old_id=parent_id,
247 new_id=commit_id,
248 author=author or "unknown",
249 operation=f"commit: {sanitize_display(message or '(no message)')}",
250 )
251
252 # If this commit completed a conflicted merge, record how each conflict was
253 # resolved so rerere can replay it on future identical conflicts.
254 if merge_state is not None and merge_state.ours_commit and merge_state.theirs_commit:
255 from muse.core.store import read_commit as _read_commit, read_snapshot as _read_snap
256
257 def _manifest_for(cid: str) -> dict[str, str]:
258 cr = _read_commit(root, cid)
259 if cr is None:
260 return {}
261 snap = _read_snap(root, cr.snapshot_id)
262 return snap.manifest if snap else {}
263
264 ours_manifest = _manifest_for(merge_state.ours_commit)
265 theirs_manifest = _manifest_for(merge_state.theirs_commit)
266 domain = read_domain(root)
267 rerere_record_resolutions(
268 root,
269 list(merge_state.conflict_paths),
270 ours_manifest,
271 theirs_manifest,
272 manifest,
273 domain,
274 plugin,
275 )
276 clear_merge_state(root)
277
278 if fmt == "json":
279 typer.echo(json.dumps({
280 "commit_id": commit_id,
281 "branch": branch,
282 "snapshot_id": snapshot_id,
283 "message": message or "",
284 "parent_commit_id": parent_id,
285 "committed_at": committed_at.isoformat(),
286 "author": author or "",
287 "sem_ver_bump": sem_ver_bump,
288 }))
289 else:
290 typer.echo(f"[{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}")