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