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