gabriel / muse public
commit.py python
762 lines 30.5 KB
Raw
sha256:99451767674c70e97323b61d5ef248ebe91530a91c2ab5902c2bb3e4acf250dd Run in a fresh repo so no stale MERGE_STATE.json can bleed in Human patch 35 days 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 the current branch from ``.muse/HEAD``.
7 3. Invoke ``plugin.snapshot(root)`` to collect the workspace manifest
8 (domain-specific; the code plugin walks tracked source files).
9 4. If the computed ``snapshot_id`` matches HEAD → "nothing to commit".
10 5. Compute a deterministic ``commit_id`` = SHA-256 of (parents | snapshot |
11 message | timestamp).
12 6. Write content-addressed blob objects to ``.muse/objects/sha256/``.
13 7. Write snapshot record to ``.muse/objects/sha256/`` (unified store).
14 8. Write commit record to ``.muse/objects/sha256/`` (unified store).
15 9. Advance ``.muse/refs/heads/<branch>`` to the new ``commit_id``.
16
17 ``--dry-run``
18 Perform steps 1–5 (compute snapshot and commit_id) without writing
19 anything. Exits 0 when changes are present, 1 when the tree is clean.
20 Combine with ``--json`` for structured preflight output in agent pipelines.
21
22 Exit codes::
23
24 0 — commit created, OR nothing to commit (clean tree)
25 1 — validation error (no message, unresolved conflicts, clean tree with --dry-run)
26 3 — I/O error
27 """
28
29 import argparse
30 import datetime
31 import json
32 import logging
33 import os
34 import pathlib
35 import re
36 import sys
37
38 from muse.cli.config import get_config_value, get_protected_branches, is_branch_protected
39 from muse.core.types import long_id, short_id, split_id
40 from muse.core.errors import ExitCode
41 from muse.core.merge_engine import clear_merge_state, read_merge_state
42 from muse.core.object_store import has_object, write_object_from_path
43 from muse.core.provenance import (
44 encode_public_key,
45 make_agent_identity,
46 provenance_payload,
47 sign_commit_record,
48 )
49 from muse.core.reflog import append_reflog
50 from muse.core.symlog import _write_symlogs
51 from muse.core.repo import require_repo
52 from muse.core.harmony import record_resolutions as harmony_record_resolutions
53 from muse.core.ids import hash_commit, hash_snapshot
54 from muse.core.types import (
55 Manifest,
56 Metadata,
57 )
58 from muse.core.refs import (
59 RefConflictError,
60 get_head_commit_id,
61 read_current_branch,
62 write_branch_ref,
63 )
64 from muse.core.commits import (
65 CommitRecord,
66 MissingParentError,
67 get_head_snapshot_id,
68 read_commit,
69 write_commit,
70 )
71 from muse.core.snapshots import (
72 SnapshotRecord,
73 read_snapshot,
74 write_snapshot,
75 )
76 from muse.core.validation import sanitize_display, sanitize_provenance, validate_branch_name
77 from muse.core.semver_classifier import classify_delta
78 from muse.domain import SemVerBump, SnapshotManifest, StagePlugin, StructuredDelta
79 from muse.plugins.code.stage import read_stage
80 from muse.plugins.registry import read_domain, resolve_plugin
81 from muse.core.timing import start_timer
82 from muse.core.envelope import EnvelopeJson, make_envelope
83 from typing import TypedDict
84
85 logger = logging.getLogger(__name__)
86
87 class _CommitErrorJson(EnvelopeJson):
88 """JSON output for commit error paths."""
89
90 error: str
91 message: str
92
93 class _CommitConflictErrorJson(_CommitErrorJson):
94 """JSON output when there are unresolved merge conflicts."""
95
96 conflict_paths: list[str]
97
98 class _CommitCleanJson(EnvelopeJson):
99 """JSON output when working tree is clean (nothing to commit)."""
100
101 dry_run: bool
102 clean: bool
103 message: str
104
105 class _CommitFilesChangedJson(TypedDict):
106 """File-change counts embedded in commit output."""
107
108 added: int
109 modified: int
110 deleted: int
111 total: int
112
113 class _CommitJson(EnvelopeJson):
114 """JSON output for ``muse commit --json`` success and dry-run paths."""
115
116 dry_run: bool
117 clean: bool
118 commit_id: str
119 branch: str
120 snapshot_id: str
121 message: str
122 parent_commit_id: str | None
123 parent2_commit_id: str | None
124 committed_at: str
125 author: str
126 agent_id: str
127 model_id: str
128 toolchain_id: str
129 sem_ver_bump: str
130 breaking_changes: list[str]
131 signer_public_key: str
132 files_changed: _CommitFilesChangedJson
133
134 # Maximum length for author and agent-provenance fields.
135 # Prevents DoS via arbitrarily long values and keeps commit records bounded.
136 _MAX_FIELD_LEN = 256
137 def _normalize_prompt_hash(value: str) -> str:
138 """Canonicalize prompt_hash to sha256:<64-hex> or empty string.
139
140 Accepts a bare 64-char hex digest or an already-prefixed sha256: value.
141 Any other input is rejected and returns "" — the prompt_hash field must
142 be self-describing or absent.
143 """
144 if not value:
145 return ""
146 try:
147 _, hex_id = split_id(value)
148 return long_id(hex_id)
149 except ValueError:
150 return ""
151
152 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
153 """Register the ``muse commit`` subcommand and its flags."""
154 parser = subparsers.add_parser(
155 "commit",
156 help="Record the current state as a new version.",
157 description=__doc__,
158 formatter_class=argparse.RawDescriptionHelpFormatter,
159 )
160 parser.add_argument(
161 "-m", "--message", default=None,
162 help="Commit message (required unless --allow-empty is set).",
163 )
164 parser.add_argument(
165 "--allow-empty", action="store_true",
166 help="Allow committing with no changes (empty-message commits still warn).",
167 )
168 parser.add_argument(
169 "--dry-run", "-n", action="store_true", dest="dry_run",
170 help=(
171 "Compute snapshot and commit_id without writing anything. "
172 "Exits 0 when changes exist, 1 when the working tree is clean. "
173 "Combine with --json for structured preflight output in agent pipelines."
174 ),
175 )
176 parser.add_argument(
177 "--section", default=None,
178 help="Tag this commit with a section label (verse, chorus, bridge…).",
179 )
180 parser.add_argument(
181 "--track", default=None,
182 help="Tag this commit with an instrument track (drums, bass, keys…).",
183 )
184 parser.add_argument(
185 "--emotion", default=None,
186 help="Attach an emotion label (joyful, melancholic, tense…).",
187 )
188 parser.add_argument(
189 "--author", default=None,
190 help="Override the commit author.",
191 )
192 parser.add_argument(
193 "--agent-id", default=None, dest="agent_id",
194 help="Agent identity string (overrides MUSE_AGENT_ID env var).",
195 )
196 parser.add_argument(
197 "--model-id", default=None, dest="model_id",
198 help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var).",
199 )
200 parser.add_argument(
201 "--toolchain-id", default=None, dest="toolchain_id",
202 help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var).",
203 )
204 parser.add_argument(
205 "--sign", action="store_true",
206 help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID).",
207 )
208 parser.add_argument(
209 "--json", "-j", action="store_true", dest="json_out",
210 help="Emit machine-readable JSON.",
211 )
212 parser.set_defaults(func=run)
213
214 def run(args: argparse.Namespace) -> None:
215 """Record the current staged state as a new version.
216
217 Snapshots the stage, writes the commit record, and advances the branch
218 pointer. Agent commits must include ``--agent-id``, ``--model-id``, and
219 ``--sign`` for full provenance. Use ``--dry-run`` to preview without
220 writing anything.
221
222 Agent quickstart
223 ----------------
224 ::
225
226 muse commit -m "feat: add X" --agent-id claude-code --model-id claude-sonnet-4-6 --sign --json
227 muse commit -m "feat: add X" --dry-run --json
228
229 JSON fields
230 -----------
231 commit_id Full ``sha256:…`` commit ID (deterministic).
232 branch Branch the commit was written to.
233 snapshot_id Full ``sha256:…`` snapshot ID.
234 message Commit message.
235 parent_commit_id Parent commit ID; ``null`` for first commit.
236 parent2_commit_id Second parent; ``null`` for non-merge commits.
237 committed_at ISO-8601 timestamp.
238 author Author handle.
239 agent_id Agent identifier (empty string for human commits).
240 sem_ver_bump Inferred bump: ``"major"``, ``"minor"``, ``"patch"``,
241 or ``"none"``.
242 breaking_changes List of addresses of removed public symbols.
243 files_changed ``{added, modified, deleted}`` counts.
244 dry_run ``true`` when ``--dry-run`` was passed.
245
246 Exit codes
247 ----------
248 0 Commit created; or nothing to commit (clean tree, no ``--dry-run``).
249 1 Dry-run with clean tree; or validation error (missing message, conflicts).
250 3 I/O error or repository not found.
251 """
252 elapsed = start_timer()
253 message: str | None = args.message
254 allow_empty: bool = args.allow_empty
255 dry_run: bool = args.dry_run
256 section: str | None = args.section
257 track: str | None = args.track
258 emotion: str | None = args.emotion
259 raw_author: str | None = args.author
260 agent_id: str | None = args.agent_id
261 model_id: str | None = args.model_id
262 toolchain_id: str | None = args.toolchain_id
263 sign: bool = args.sign
264 json_out: bool = args.json_out
265
266 if message is None and not allow_empty:
267 if json_out:
268 print(json.dumps(_CommitErrorJson(
269 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
270 error="no_message",
271 message="provide a commit message with -m MESSAGE",
272 )))
273 print("❌ Provide a commit message with -m MESSAGE.", file=sys.stderr)
274 raise SystemExit(ExitCode.USER_ERROR)
275
276 if message is None and allow_empty:
277 logger.warning(
278 "⚠️ --allow-empty used without -m: commit will have an empty message."
279 )
280
281 # Sanitize and cap the author field. An explicit --author override is a
282 # potential impersonation vector (an agent could supply a human's name).
283 # We strip all C0/DEL/C1 control chars and cap at _MAX_FIELD_LEN.
284 # A warning is emitted when the caller explicitly passes --author so the
285 # act is always visible in logs.
286 author: str | None = (
287 sanitize_provenance(raw_author[:_MAX_FIELD_LEN]) if raw_author else None
288 )
289 if raw_author is not None:
290 logger.warning(
291 "⚠️ --author override supplied: %r — this is not verified against "
292 "the stored identity and may allow impersonation.",
293 author,
294 )
295
296 root = require_repo()
297
298 # config-based auto-sign: commit.sign = true → behave as if --sign was passed
299 if not sign and get_config_value("commit.sign", root) == "true":
300 sign = True
301
302 # When no explicit --author is provided, resolve user.handle from
303 # identity.toml for the active hub (via get_config_value delegation).
304 if author is None:
305 identity_handle = get_config_value("user.handle", root)
306 if identity_handle:
307 author = sanitize_provenance(identity_handle[:_MAX_FIELD_LEN])
308
309 # Read merge state before any writes — needed for conflict check and
310 # harmony recording later.
311 merge_state = read_merge_state(root)
312 if merge_state is not None and merge_state.conflict_paths:
313 conflict_paths = sorted(merge_state.conflict_paths)
314 if json_out:
315 print(json.dumps(_CommitConflictErrorJson(
316 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
317 error="unresolved_conflicts",
318 conflict_paths=conflict_paths,
319 message="you have unresolved merge conflicts — resolve them before committing",
320 )))
321 print(
322 "❌ You have unresolved merge conflicts. Resolve them before committing.",
323 file=sys.stderr,
324 )
325 for p in conflict_paths:
326 print(f" both modified: {sanitize_display(p)}", file=sys.stderr)
327 raise SystemExit(ExitCode.USER_ERROR)
328
329 branch = read_current_branch(root)
330
331 protected = get_protected_branches(root)
332 if is_branch_protected(branch, protected):
333 msg = f"Branch '{branch}' is protected — commit directly to it is not allowed. Use a feature branch and merge."
334 if json_out:
335 print(json.dumps({
336 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
337 "error": "protected_branch",
338 "message": msg,
339 }))
340 print(f"❌ {msg}", file=sys.stderr)
341 raise SystemExit(ExitCode.USER_ERROR)
342
343 parent_id = get_head_commit_id(root, branch)
344
345 # ── Guard: refuse when only unstaged changes exist (no staged entries) ────
346 # Matches git behaviour: unstaged tracked modifications are not committed.
347 # Exception: no parent commit yet (first commit) — stage not required then.
348 # Staging is a code-domain concept; non-code domains commit the full workdir.
349 if parent_id and not allow_empty and read_domain(root) == "code":
350 stage = read_stage(root)
351 if not stage:
352 from muse.core.snapshot import diff_workdir_vs_snapshot
353 from muse.core.snapshots import get_head_snapshot_manifest
354 head_manifest = get_head_snapshot_manifest(root, branch) or {}
355 if head_manifest:
356 added, modified, deleted, _, _, _ = diff_workdir_vs_snapshot(root, head_manifest)
357 if added or modified or deleted:
358 msg = (
359 "No changes staged for commit.\n"
360 " Use 'muse code add <file>' to stage changes before committing."
361 )
362 if json_out:
363 print(json.dumps(_CommitErrorJson(
364 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
365 error="nothing_staged",
366 message=msg,
367 )))
368 print(f"⚠️ {msg}", file=sys.stderr)
369 raise SystemExit(ExitCode.USER_ERROR)
370
371 plugin = resolve_plugin(root)
372 snap = plugin.snapshot(root)
373 manifest = snap["files"]
374 directories = list(snap.get("directories") or [])
375 if not manifest and not allow_empty:
376 # An empty snapshot is valid when staged deletions produced it —
377 # e.g. `muse rm` removed the last tracked file(s). Only reject if
378 # there are no staged changes at all (truly virgin working tree).
379 stage = read_stage(root)
380 has_staged_deletions = any(e["mode"] == "D" for e in stage.values())
381 if not has_staged_deletions:
382 if json_out:
383 print(json.dumps(_CommitErrorJson(
384 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
385 error="empty_workdir",
386 message="nothing tracked — working tree is empty",
387 )))
388 print("⚠️ Nothing tracked — working tree is empty.", file=sys.stderr)
389 raise SystemExit(ExitCode.USER_ERROR)
390
391 snapshot_id = hash_snapshot(manifest, directories)
392
393 if not allow_empty:
394 head_snapshot = get_head_snapshot_id(root, branch)
395 if head_snapshot == snapshot_id:
396 if dry_run:
397 if json_out:
398 print(json.dumps(_CommitCleanJson(
399 **make_envelope(elapsed, exit_code=1),
400 dry_run=True,
401 clean=True,
402 message="Nothing to commit, working tree clean",
403 )))
404 else:
405 print("Nothing to commit, working tree clean")
406 raise SystemExit(1)
407 if json_out:
408 print(json.dumps(_CommitCleanJson(
409 **make_envelope(elapsed),
410 dry_run=False,
411 clean=True,
412 message="Nothing to commit, working tree clean",
413 )))
414 else:
415 print("Nothing to commit, working tree clean")
416 raise SystemExit(ExitCode.SUCCESS)
417
418 committed_at = datetime.datetime.now(datetime.timezone.utc)
419 parent_ids = [parent_id] if parent_id else []
420
421 # When completing a conflicted merge, include the second parent so that
422 # the merge is recorded as a true two-parent merge commit. This ensures
423 # subsequent merge --dry-run correctly computes the LCA as the resolved
424 # merge commit rather than the pre-merge common ancestor, preventing the
425 # same conflicts from re-appearing on the next merge attempt.
426 merge_parent2: str | None = None
427 if merge_state is not None and merge_state.theirs_commit:
428 merge_parent2 = merge_state.theirs_commit
429 if merge_parent2 not in parent_ids:
430 parent_ids = parent_ids + [merge_parent2]
431
432 # Resolve agent provenance: CLI flags take priority over environment vars.
433 # 1. Truncate to _MAX_FIELD_LEN chars — prevents DoS via arbitrarily long values.
434 # 2. Strip all C0/DEL/C1 control characters — prevents terminal injection
435 # when provenance fields are rendered in display paths (muse log, muse read,
436 # agent dashboards), log-line splitting, and visual spoofing.
437 resolved_agent_id = sanitize_provenance(
438 (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_FIELD_LEN]
439 )
440 resolved_model_id = sanitize_provenance(
441 (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_FIELD_LEN]
442 )
443 resolved_toolchain_id = sanitize_provenance(
444 (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_FIELD_LEN]
445 )
446 _raw_prompt_hash = sanitize_provenance(
447 os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_FIELD_LEN]
448 )
449 resolved_prompt_hash = _normalize_prompt_hash(_raw_prompt_hash)
450
451 # Resolve signing identity early so signer_public_key is bound into the
452 # commit ID (v2 formula). The public key is deterministic from the private
453 # key — no side effects from resolving it here.
454 signing = None
455 pre_signer_public_key = ""
456 if sign and resolved_agent_id:
457 from muse.cli.config import get_signing_identity
458 signing = get_signing_identity(root, agent_id=resolved_agent_id)
459 if signing is not None:
460 _, pre_signer_public_key = encode_public_key(signing.private_key)
461 else:
462 logger.warning(
463 "No signing identity found for agent %r — commit will be unsigned. "
464 "Run `muse auth keygen && muse auth register` to set up a keypair.",
465 resolved_agent_id,
466 )
467
468 commit_id = hash_commit(
469 parent_ids=parent_ids,
470 snapshot_id=snapshot_id,
471 message=message or "",
472 committed_at_iso=committed_at.isoformat(),
473 author=author or "",
474 signer_public_key=pre_signer_public_key,
475 )
476
477 metadata: Metadata = {}
478 if section:
479 metadata["section"] = section
480 if track:
481 metadata["track"] = track
482 if emotion:
483 metadata["emotion"] = emotion
484
485 # Load the parent snapshot manifest once and reuse it for both
486 # structured_delta computation and file-count output. Previously the
487 # manifest was loaded independently in each section — two separate
488 # read_snapshot() calls per commit.
489 parent_manifest: Manifest = {}
490 parent_directories: list[str] = []
491 if parent_id is not None:
492 parent_commit_rec = read_commit(root, parent_id)
493 if parent_commit_rec is not None:
494 parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id)
495 if parent_snap_record is not None:
496 parent_manifest = dict(parent_snap_record.manifest)
497 parent_directories = list(parent_snap_record.directories)
498
499 # Compute a structured delta against the parent snapshot so muse read
500 # can display note-level changes without reloading blobs.
501 # For the genesis commit (no parent) diff against an empty snapshot so
502 # every tracked symbol appears as op=insert — indexers depend on this to
503 # record symbol births as op=add rather than op=modify.
504 structured_delta: StructuredDelta | None = None
505 sem_ver_bump: SemVerBump = "none"
506 breaking_changes: list[str] = []
507 domain = read_domain(root)
508 base_snap = SnapshotManifest(
509 files=parent_manifest,
510 domain=domain,
511 directories=parent_directories,
512 )
513 try:
514 structured_delta = plugin.diff(base_snap, snap, repo_root=root)
515 except Exception as exc:
516 # plugin.diff() is domain-specific and may fail on unsupported
517 # file types. The commit proceeds without a structured delta;
518 # sem_ver_bump defaults to "none".
519 logger.debug("plugin.diff() failed — structured delta omitted: %s", exc)
520 structured_delta = None
521
522 # Classify the structured delta into a semver bump and breaking-change list.
523 if structured_delta is not None:
524 classification = classify_delta(structured_delta, repo_root=root)
525 sem_ver_bump = classification.bump
526 breaking_changes = classification.breaking_addresses
527 structured_delta["sem_ver_bump"] = sem_ver_bump
528 structured_delta["breaking_changes"] = breaking_changes
529
530 # Compute file-level change counts from the (now single-read) parent manifest.
531 files_added = len(set(manifest) - set(parent_manifest))
532 files_deleted = len(set(parent_manifest) - set(manifest))
533 files_modified = sum(
534 1 for p in set(manifest) & set(parent_manifest)
535 if manifest[p] != parent_manifest[p]
536 )
537
538 # ── Dry-run path — no writes beyond this point ────────────────────────────
539 if dry_run:
540 if json_out:
541 print(json.dumps(_CommitJson(
542 **make_envelope(elapsed),
543 dry_run=True,
544 clean=False,
545 commit_id=commit_id,
546 branch=branch,
547 snapshot_id=snapshot_id,
548 message=message or "",
549 parent_commit_id=parent_id,
550 parent2_commit_id=merge_parent2,
551 committed_at=committed_at.isoformat(),
552 author=author or "",
553 agent_id=resolved_agent_id,
554 model_id=resolved_model_id,
555 toolchain_id=resolved_toolchain_id,
556 sem_ver_bump=sem_ver_bump,
557 breaking_changes=breaking_changes,
558 signer_public_key=pre_signer_public_key,
559 files_changed=_CommitFilesChangedJson(
560 added=files_added,
561 modified=files_modified,
562 deleted=files_deleted,
563 total=files_added + files_modified + files_deleted,
564 ),
565 )))
566 else:
567 total = files_added + files_deleted + files_modified
568 print(f"[dry-run] [{sanitize_display(branch)} {commit_id}] {sanitize_display(message or '')}")
569 if total:
570 parts: list[str] = []
571 if files_modified:
572 parts.append(f"{files_modified} modified")
573 if files_added:
574 parts.append(f"{files_added} added")
575 if files_deleted:
576 parts.append(f"{files_deleted} removed")
577 print(f" {total} file{'s' if total != 1 else ''} changed ({', '.join(parts)})")
578 print(" (dry-run: nothing written)")
579 return
580
581 # ── Actual writes ─────────────────────────────────────────────────────────
582 # Write objects for every file whose object is not yet in the store.
583 # We skip only when the parent manifest has the same ID *and* the object
584 # is actually present — parent objects may be absent after a clone without
585 # blobs, a gc run, or a prior commit that failed to write them.
586 for rel_path, object_id in manifest.items():
587 if parent_manifest.get(rel_path) == object_id and has_object(root, object_id):
588 continue
589 write_object_from_path(root, object_id, root / rel_path)
590
591 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=directories))
592
593 signature = ""
594 signer_public_key = pre_signer_public_key
595 signer_key_id = ""
596 if signing is not None:
597 result = sign_commit_record(
598 commit_id,
599 resolved_agent_id,
600 signing.private_key,
601 author=author or "",
602 model_id=resolved_model_id,
603 toolchain_id=resolved_toolchain_id,
604 prompt_hash=resolved_prompt_hash,
605 committed_at=committed_at.isoformat(),
606 )
607 if result is not None:
608 signature, signer_public_key, signer_key_id = result
609
610 _commit_record = CommitRecord(
611 commit_id=commit_id,
612 branch=branch,
613 snapshot_id=snapshot_id,
614 message=message or "",
615 committed_at=committed_at,
616 parent_commit_id=parent_id,
617 parent2_commit_id=merge_parent2,
618 author=author or "",
619 metadata=metadata,
620 structured_delta=structured_delta,
621 sem_ver_bump=sem_ver_bump,
622 breaking_changes=breaking_changes,
623 agent_id=resolved_agent_id,
624 model_id=resolved_model_id,
625 toolchain_id=resolved_toolchain_id,
626 prompt_hash=resolved_prompt_hash,
627 signature=signature,
628 signer_public_key=signer_public_key,
629 signer_key_id=signer_key_id,
630 )
631 try:
632 write_commit(root, _commit_record)
633 except MissingParentError:
634 write_commit(root, _commit_record, skip_parent_check=True)
635
636 try:
637 write_branch_ref(root, branch, commit_id, expected_id=parent_id)
638 except RefConflictError as exc:
639 msg = str(exc)
640 if json_out:
641 print(json.dumps(_CommitErrorJson(
642 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
643 error="branch_conflict",
644 message=msg,
645 )))
646 print(f"❌ {msg}", file=sys.stderr)
647 raise SystemExit(ExitCode.USER_ERROR)
648
649 # Clear the stage after a successful commit so the next muse commit
650 # returns to full-snapshot mode unless the user runs muse code add again.
651 # Wrapped in try/except: clear_stage failure must not hide a successful
652 # commit. A failure here leaves staged index entries for already-committed
653 # content — the next muse status would show them as staged, and a naive
654 # re-commit would produce a duplicate commit with identical snapshot_id.
655 # Logging the error is sufficient; the commit is already durable.
656 if isinstance(plugin, StagePlugin):
657 try:
658 plugin.clear_stage(root)
659 except Exception as _clear_exc:
660 logger.warning(
661 "⚠️ clear_stage failed after successful commit %s — "
662 "stage index may still list committed files: %s",
663 commit_id, _clear_exc,
664 )
665
666 append_reflog(
667 root,
668 branch,
669 old_id=parent_id,
670 new_id=commit_id,
671 author=author or "unknown",
672 operation=f"commit: {sanitize_display(message or '(no message)')}",
673 )
674
675 try:
676 _write_symlogs(
677 root,
678 parent_commit_id=parent_id,
679 new_snapshot_id=snapshot_id,
680 new_commit_id=commit_id,
681 author=author or "unknown",
682 commit_message=message or "",
683 )
684 except Exception as _symlog_exc:
685 logger.warning(
686 "⚠️ symlog write failed for commit %s — symbol journal may be incomplete: %s",
687 commit_id, _symlog_exc,
688 )
689
690 # If this commit completed a conflicted merge, record how each conflict
691 # was resolved so harmony can replay it on future identical conflicts.
692 # clear_merge_state is unconditional — harmony recording is optional
693 # bookkeeping, but cleanup must always happen after a successful commit.
694 if merge_state is not None:
695 if merge_state.ours_commit and merge_state.theirs_commit:
696 def _manifest_for(cid: str) -> Manifest:
697 cr = read_commit(root, cid)
698 if cr is None:
699 return {}
700 snap_rec = read_snapshot(root, cr.snapshot_id)
701 return snap_rec.manifest if snap_rec else {}
702
703 ours_manifest = _manifest_for(merge_state.ours_commit)
704 theirs_manifest = _manifest_for(merge_state.theirs_commit)
705 domain = read_domain(root)
706 # Use original_conflict_paths so harmony learns even when all conflicts
707 # were resolved via `muse checkout --ours/--theirs` before commit
708 # (which clears conflict_paths but preserves original_conflict_paths).
709 all_conflict_paths = (
710 merge_state.original_conflict_paths or merge_state.conflict_paths
711 )
712 harmony_record_resolutions(
713 root,
714 list(all_conflict_paths),
715 ours_manifest,
716 theirs_manifest,
717 manifest,
718 domain,
719 plugin,
720 manually_resolved=set(merge_state.manually_resolved) if merge_state.manually_resolved else None,
721 )
722 clear_merge_state(root)
723
724 # ── Output ────────────────────────────────────────────────────────────────
725 if json_out:
726 print(json.dumps(_CommitJson(
727 **make_envelope(elapsed),
728 dry_run=False,
729 clean=False,
730 commit_id=commit_id,
731 branch=branch,
732 snapshot_id=snapshot_id,
733 message=message or "",
734 parent_commit_id=parent_id,
735 parent2_commit_id=merge_parent2,
736 committed_at=committed_at.isoformat(),
737 author=author or "",
738 agent_id=resolved_agent_id,
739 model_id=resolved_model_id,
740 toolchain_id=resolved_toolchain_id,
741 sem_ver_bump=sem_ver_bump,
742 breaking_changes=breaking_changes,
743 signer_public_key=signer_public_key,
744 files_changed=_CommitFilesChangedJson(
745 added=files_added,
746 modified=files_modified,
747 deleted=files_deleted,
748 total=files_added + files_modified + files_deleted,
749 ),
750 )))
751 else:
752 print(f"[{sanitize_display(branch)} {commit_id}] {sanitize_display(message or '')}")
753 total_files = files_added + files_deleted + files_modified
754 if total_files:
755 stat_parts: list[str] = []
756 if files_modified:
757 stat_parts.append(f"{files_modified} modified")
758 if files_added:
759 stat_parts.append(f"{files_added} added")
760 if files_deleted:
761 stat_parts.append(f"{files_deleted} removed")
762 print(f" {total_files} file{'s' if total_files != 1 else ''} changed ({', '.join(stat_parts)})")
File History 3 commits
sha256:99451767674c70e97323b61d5ef248ebe91530a91c2ab5902c2bb3e4acf250dd Run in a fresh repo so no stale MERGE_STATE.json can bleed in Human patch 35 days ago
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 41 days ago
sha256:2a1cf861048b753a21d6ca853a83cdfc2a46f15dcbb561ee79ebb9dc40c03af6 switch same-commit fix, agent-config user-global config, an… Human patch 42 days ago