gabriel / muse public
commit.py python
207 lines 8.2 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse commit — record the current muse-work/ 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``, current branch from ``.muse/HEAD``.
7 3. Walk ``muse-work/`` and hash each file → snapshot manifest.
8 4. If HEAD snapshot_id == current snapshot_id → "nothing to commit".
9 5. Compute deterministic commit_id = sha256(parents | snapshot | message | ts).
10 6. Write blob objects to ``.muse/objects/``.
11 7. Write snapshot JSON to ``.muse/snapshots/<snapshot_id>.json``.
12 8. Write commit JSON to ``.muse/commits/<commit_id>.json``.
13 9. Advance ``.muse/refs/heads/<branch>`` to the new commit_id.
14 """
15
16 import datetime
17 import json
18 import logging
19 import os
20 import pathlib
21
22 import typer
23
24 from muse.core.errors import ExitCode
25 from muse.core.merge_engine import read_merge_state
26 from muse.core.object_store import write_object_from_path
27 from muse.core.provenance import make_agent_identity, read_agent_key, sign_commit_hmac
28 from muse.core.repo import require_repo
29 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
30 from muse.core.store import (
31 CommitRecord,
32 SnapshotRecord,
33 get_head_snapshot_id,
34 read_commit,
35 read_snapshot,
36 write_commit,
37 write_snapshot,
38 )
39 from muse.domain import SemVerBump, SnapshotManifest, StructuredDelta, infer_sem_ver_bump
40 from muse.plugins.registry import read_domain, resolve_plugin
41
42 logger = logging.getLogger(__name__)
43
44 app = typer.Typer()
45
46
47 def _read_repo_id(root: pathlib.Path) -> str:
48 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
49
50
51 def _read_branch(root: pathlib.Path) -> tuple[str, pathlib.Path]:
52 """Return (branch_name, ref_file_path)."""
53 head_ref = (root / ".muse" / "HEAD").read_text().strip()
54 branch = head_ref.removeprefix("refs/heads/").strip()
55 ref_path = root / ".muse" / head_ref
56 return branch, ref_path
57
58
59 def _read_parent_id(ref_path: pathlib.Path) -> str | None:
60 if not ref_path.exists():
61 return None
62 raw = ref_path.read_text().strip()
63 return raw or None
64
65
66 @app.callback(invoke_without_command=True)
67 def commit(
68 ctx: typer.Context,
69 message: str | None = typer.Option(None, "-m", "--message", help="Commit message."),
70 allow_empty: bool = typer.Option(False, "--allow-empty", help="Allow committing with no changes."),
71 section: str | None = typer.Option(None, "--section", help="Tag this commit with a musical section (verse, chorus, bridge…)."),
72 track: str | None = typer.Option(None, "--track", help="Tag this commit with an instrument track (drums, bass, keys…)."),
73 emotion: str | None = typer.Option(None, "--emotion", help="Attach an emotion label (joyful, melancholic, tense…)."),
74 author: str | None = typer.Option(None, "--author", help="Override the commit author."),
75 agent_id: str | None = typer.Option(None, "--agent-id", help="Agent identity string (overrides MUSE_AGENT_ID env var)."),
76 model_id: str | None = typer.Option(None, "--model-id", help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var)."),
77 toolchain_id: str | None = typer.Option(None, "--toolchain-id", help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var)."),
78 sign: bool = typer.Option(False, "--sign", help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID)."),
79 ) -> None:
80 """Record the current muse-work/ state as a new version."""
81 if message is None and not allow_empty:
82 typer.echo("❌ Provide a commit message with -m MESSAGE.")
83 raise typer.Exit(code=ExitCode.USER_ERROR)
84
85 root = require_repo()
86
87 merge_state = read_merge_state(root)
88 if merge_state is not None and merge_state.conflict_paths:
89 typer.echo("❌ You have unresolved merge conflicts. Resolve them before committing.")
90 for p in sorted(merge_state.conflict_paths):
91 typer.echo(f" both modified: {p}")
92 raise typer.Exit(code=ExitCode.USER_ERROR)
93
94 repo_id = _read_repo_id(root)
95 branch, ref_path = _read_branch(root)
96 parent_id = _read_parent_id(ref_path)
97
98 workdir = root / "muse-work"
99 if not workdir.exists():
100 typer.echo("❌ No muse-work/ directory found. Run 'muse init' first.")
101 raise typer.Exit(code=ExitCode.USER_ERROR)
102
103 plugin = resolve_plugin(root)
104 snap = plugin.snapshot(workdir)
105 manifest = snap["files"]
106 if not manifest and not allow_empty:
107 typer.echo("⚠️ muse-work/ is empty — nothing to commit.")
108 raise typer.Exit(code=ExitCode.USER_ERROR)
109
110 snapshot_id = compute_snapshot_id(manifest)
111
112 if not allow_empty:
113 head_snapshot = get_head_snapshot_id(root, repo_id, branch)
114 if head_snapshot == snapshot_id:
115 typer.echo("Nothing to commit, working tree clean")
116 raise typer.Exit(code=ExitCode.SUCCESS)
117
118 committed_at = datetime.datetime.now(datetime.timezone.utc)
119 parent_ids = [parent_id] if parent_id else []
120 commit_id = compute_commit_id(
121 parent_ids=parent_ids,
122 snapshot_id=snapshot_id,
123 message=message or "",
124 committed_at_iso=committed_at.isoformat(),
125 )
126
127 metadata: dict[str, str] = {}
128 if section:
129 metadata["section"] = section
130 if track:
131 metadata["track"] = track
132 if emotion:
133 metadata["emotion"] = emotion
134
135 for rel_path, object_id in manifest.items():
136 write_object_from_path(root, object_id, workdir / rel_path)
137
138 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
139
140 # Compute a structured delta against the parent snapshot so muse show
141 # can display note-level changes without reloading blobs.
142 structured_delta: StructuredDelta | None = None
143 sem_ver_bump: SemVerBump = "none"
144 breaking_changes: list[str] = []
145 if parent_id is not None:
146 parent_commit_rec = read_commit(root, parent_id)
147 if parent_commit_rec is not None:
148 parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id)
149 if parent_snap_record is not None:
150 domain = read_domain(root)
151 base_snap = SnapshotManifest(
152 files=dict(parent_snap_record.manifest),
153 domain=domain,
154 )
155 try:
156 structured_delta = plugin.diff(base_snap, snap, repo_root=root)
157 except Exception:
158 structured_delta = None
159
160 # Infer semantic version bump from the structured delta.
161 if structured_delta is not None:
162 sem_ver_bump, breaking_changes = infer_sem_ver_bump(structured_delta)
163 structured_delta["sem_ver_bump"] = sem_ver_bump
164 structured_delta["breaking_changes"] = breaking_changes
165
166 # Resolve agent provenance: CLI flags take priority over environment vars.
167 resolved_agent_id = agent_id or os.environ.get("MUSE_AGENT_ID", "")
168 resolved_model_id = model_id or os.environ.get("MUSE_MODEL_ID", "")
169 resolved_toolchain_id = toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", "")
170 resolved_prompt_hash = os.environ.get("MUSE_PROMPT_HASH", "")
171
172 signature = ""
173 signer_key_id = ""
174 if sign and resolved_agent_id:
175 key = read_agent_key(root, resolved_agent_id)
176 if key is not None:
177 signature = sign_commit_hmac(commit_id, key)
178 from muse.core.provenance import key_fingerprint
179 signer_key_id = key_fingerprint(key)
180 else:
181 logger.warning("No signing key found for agent %r — commit will be unsigned.", resolved_agent_id)
182
183 write_commit(root, CommitRecord(
184 commit_id=commit_id,
185 repo_id=repo_id,
186 branch=branch,
187 snapshot_id=snapshot_id,
188 message=message or "",
189 committed_at=committed_at,
190 parent_commit_id=parent_id,
191 author=author or "",
192 metadata=metadata,
193 structured_delta=structured_delta,
194 sem_ver_bump=sem_ver_bump,
195 breaking_changes=breaking_changes,
196 agent_id=resolved_agent_id,
197 model_id=resolved_model_id,
198 toolchain_id=resolved_toolchain_id,
199 prompt_hash=resolved_prompt_hash,
200 signature=signature,
201 signer_key_id=signer_key_id,
202 ))
203
204 ref_path.parent.mkdir(parents=True, exist_ok=True)
205 ref_path.write_text(commit_id)
206
207 typer.echo(f"[{branch} {commit_id[:8]}] {message or ''}")