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