gabriel / muse public
commit.py python
866 lines 35.4 KB Cold
Raw
sha256:99451767674c70e97323b61d5ef248ebe91530a91c2ab5902c2bb3e4acf250dd Run in a fresh repo so no stale MERGE_STATE.json can bleed in Human patch 134 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 # Completing a conflicted merge must never take the "nothing to commit"
416 # early return, even when the resolved content happens to match HEAD's
417 # current snapshot exactly (e.g. the user resolved every conflict by
418 # keeping "ours"). Skipping the merge commit in that case used to report
419 # a deceptively benign "Nothing to commit, working tree clean" (exit 0)
420 # while silently leaving MERGE_STATE.json in place — the merge was never
421 # finalized, so the second parent was never recorded and every
422 # subsequent `muse merge` re-surfaced the identical already-resolved
423 # conflict forever (muse#29). Previously the only workaround was to
424 # notice this and manually pass --allow-empty; now it happens
425 # automatically whenever a merge is actually in progress.
426 completing_merge = merge_state is not None and bool(merge_state.theirs_commit)
427 if not allow_empty and not completing_merge:
428 head_snapshot = get_head_snapshot_id(root, branch)
429 if head_snapshot == snapshot_id:
430 if dry_run:
431 if json_out:
432 print(json.dumps(_CommitCleanJson(
433 **make_envelope(elapsed, exit_code=1),
434 dry_run=True,
435 clean=True,
436 message="Nothing to commit, working tree clean",
437 )))
438 else:
439 print("Nothing to commit, working tree clean")
440 raise SystemExit(1)
441 if json_out:
442 print(json.dumps(_CommitCleanJson(
443 **make_envelope(elapsed),
444 dry_run=False,
445 clean=True,
446 message="Nothing to commit, working tree clean",
447 )))
448 else:
449 print("Nothing to commit, working tree clean")
450 raise SystemExit(ExitCode.SUCCESS)
451
452 committed_at = datetime.datetime.now(datetime.timezone.utc)
453 parent_ids = [parent_id] if parent_id else []
454
455 # When completing a conflicted merge, include the second parent so that
456 # the merge is recorded as a true two-parent merge commit. This ensures
457 # subsequent merge --dry-run correctly computes the LCA as the resolved
458 # merge commit rather than the pre-merge common ancestor, preventing the
459 # same conflicts from re-appearing on the next merge attempt.
460 #
461 # Exception: --history squash/rebase must still produce a single-parent
462 # commit even when the merge paused on a conflict — matching the shape
463 # muse merge's own clean-path squash/rebase code already produces
464 # (muse#29). Without this, a conflicted `--history squash` merge silently
465 # completed as a two-parent merge commit once conflicts forced
466 # completion through `muse commit` instead of muse merge's own
467 # commit-writing.
468 merge_parent2: str | None = None
469 if (
470 merge_state is not None
471 and merge_state.theirs_commit
472 and merge_state.history not in ("squash", "rebase")
473 ):
474 merge_parent2 = merge_state.theirs_commit
475 if merge_parent2 not in parent_ids:
476 parent_ids = parent_ids + [merge_parent2]
477
478 # Resolve agent provenance: CLI flags take priority over environment vars.
479 # 1. Truncate to _MAX_FIELD_LEN chars — prevents DoS via arbitrarily long values.
480 # 2. Strip all C0/DEL/C1 control characters — prevents terminal injection
481 # when provenance fields are rendered in display paths (muse log, muse read,
482 # agent dashboards), log-line splitting, and visual spoofing.
483 resolved_agent_id = sanitize_provenance(
484 (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_FIELD_LEN]
485 )
486 resolved_model_id = sanitize_provenance(
487 (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_FIELD_LEN]
488 )
489 resolved_toolchain_id = sanitize_provenance(
490 (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_FIELD_LEN]
491 )
492 _raw_prompt_hash = sanitize_provenance(
493 os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_FIELD_LEN]
494 )
495 resolved_prompt_hash = _normalize_prompt_hash(_raw_prompt_hash)
496
497 # Resolve signing identity early so signer_public_key is bound into the
498 # commit ID (v2 formula). The public key is deterministic from the private
499 # key — no side effects from resolving it here.
500 signing = None
501 pre_signer_public_key = ""
502 if sign:
503 from muse.cli.config import get_signing_identity
504 signing = get_signing_identity(root, agent_id=resolved_agent_id or None)
505 if signing is not None:
506 _, pre_signer_public_key = encode_public_key(signing.private_key)
507 elif resolved_agent_id:
508 logger.warning(
509 "No signing identity found for agent %r — commit will be unsigned. "
510 "Run `muse auth keygen && muse auth register` to set up a keypair.",
511 resolved_agent_id,
512 )
513 else:
514 logger.warning(
515 "No signing identity found — commit will be unsigned. "
516 "Run `muse auth keygen && muse auth register` to set up a keypair."
517 )
518
519 commit_id = hash_commit(
520 parent_ids=parent_ids,
521 snapshot_id=snapshot_id,
522 message=message or "",
523 committed_at_iso=committed_at.isoformat(),
524 author=author or "",
525 signer_public_key=pre_signer_public_key,
526 )
527
528 metadata: Metadata = {}
529 if section:
530 metadata["section"] = section
531 if track:
532 metadata["track"] = track
533 if emotion:
534 metadata["emotion"] = emotion
535
536 # Load the parent snapshot manifest once and reuse it for both
537 # structured_delta computation and file-count output. Previously the
538 # manifest was loaded independently in each section — two separate
539 # read_snapshot() calls per commit.
540 parent_manifest: Manifest = {}
541 parent_directories: list[str] = []
542 if parent_id is not None:
543 parent_commit_rec = read_commit(root, parent_id)
544 if parent_commit_rec is not None:
545 parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id)
546 if parent_snap_record is not None:
547 parent_manifest = dict(parent_snap_record.manifest)
548 parent_directories = list(parent_snap_record.directories)
549
550 # Compute a structured delta against the parent snapshot so muse read
551 # can display note-level changes without reloading blobs.
552 # For the genesis commit (no parent) diff against an empty snapshot so
553 # every tracked symbol appears as op=insert — indexers depend on this to
554 # record symbol births as op=add rather than op=modify.
555 structured_delta: StructuredDelta | None = None
556 sem_ver_bump: SemVerBump = "none"
557 breaking_changes: list[str] = []
558 domain = read_domain(root)
559 base_snap = SnapshotManifest(
560 files=parent_manifest,
561 domain=domain,
562 directories=parent_directories,
563 )
564 try:
565 structured_delta = plugin.diff(base_snap, snap, repo_root=root)
566 except Exception as exc:
567 # plugin.diff() is domain-specific and may fail on unsupported
568 # file types. The commit proceeds without a structured delta;
569 # sem_ver_bump defaults to "none".
570 logger.debug("plugin.diff() failed — structured delta omitted: %s", exc)
571 structured_delta = None
572
573 # Classify the structured delta into a semver bump and breaking-change list.
574 if structured_delta is not None:
575 classification = classify_delta(structured_delta, repo_root=root)
576 sem_ver_bump = classification.bump
577 breaking_changes = classification.breaking_addresses
578 structured_delta["sem_ver_bump"] = sem_ver_bump
579 structured_delta["breaking_changes"] = breaking_changes
580
581 # Compute file-level change counts from the (now single-read) parent manifest.
582 files_added = len(set(manifest) - set(parent_manifest))
583 files_deleted = len(set(parent_manifest) - set(manifest))
584 files_modified = sum(
585 1 for p in set(manifest) & set(parent_manifest)
586 if manifest[p] != parent_manifest[p]
587 )
588
589 # ── Pre-commit hooks (musehub#192) ────────────────────────────────────────
590 # Never run during --dry-run: dry-run is a pure preview with no writes, and
591 # hook commands are arbitrary shell commands that may themselves have side
592 # effects — running them speculatively would surprise callers.
593 commit_warnings: list[str] = []
594 if not dry_run:
595 if no_verify:
596 commit_warnings.append(
597 "Skipped pre-commit hooks (--no-verify)."
598 )
599 else:
600 try:
601 hooks_status = get_status(root)
602 except ValueError as exc:
603 if json_out:
604 print(json.dumps(_CommitErrorJson(
605 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
606 error="hooks_config_invalid",
607 message=str(exc),
608 )))
609 print(f"❌ {exc}", file=sys.stderr)
610 raise SystemExit(ExitCode.USER_ERROR) from exc
611
612 if hooks_status.state == "defined_not_installed":
613 commit_warnings.append(
614 "Hooks are defined in .musehooks.toml but not installed "
615 "for this clone. Run 'muse hooks install' to enable them."
616 )
617 elif hooks_status.state == "installed":
618 hook_result = run_hook_point(root, "pre-commit")
619 if not hook_result.passed:
620 msg = (
621 f"pre-commit hook failed: {hook_result.failed_command}"
622 )
623 if json_out:
624 print(json.dumps(_CommitHookFailedJson(
625 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
626 error="pre_commit_hook_failed",
627 failed_command=hook_result.failed_command or "",
628 output=hook_result.output,
629 message=msg,
630 )))
631 print(f"❌ {msg}", file=sys.stderr)
632 if hook_result.output:
633 print(hook_result.output, file=sys.stderr)
634 print(
635 " Use --no-verify to skip hooks (always logged, never silent).",
636 file=sys.stderr,
637 )
638 raise SystemExit(ExitCode.USER_ERROR)
639
640 # ── Dry-run path — no writes beyond this point ────────────────────────────
641 if dry_run:
642 if json_out:
643 print(json.dumps(_CommitJson(
644 **make_envelope(elapsed),
645 dry_run=True,
646 clean=False,
647 commit_id=commit_id,
648 branch=branch,
649 snapshot_id=snapshot_id,
650 message=message or "",
651 parent_commit_id=parent_id,
652 parent2_commit_id=merge_parent2,
653 committed_at=committed_at.isoformat(),
654 author=author or "",
655 agent_id=resolved_agent_id,
656 model_id=resolved_model_id,
657 toolchain_id=resolved_toolchain_id,
658 sem_ver_bump=sem_ver_bump,
659 breaking_changes=breaking_changes,
660 signer_public_key=pre_signer_public_key,
661 files_changed=_CommitFilesChangedJson(
662 added=files_added,
663 modified=files_modified,
664 deleted=files_deleted,
665 total=files_added + files_modified + files_deleted,
666 ),
667 )))
668 else:
669 total = files_added + files_deleted + files_modified
670 print(f"[dry-run] [{sanitize_display(branch)} {commit_id}] {sanitize_display(message or '')}")
671 if total:
672 parts: list[str] = []
673 if files_modified:
674 parts.append(f"{files_modified} modified")
675 if files_added:
676 parts.append(f"{files_added} added")
677 if files_deleted:
678 parts.append(f"{files_deleted} removed")
679 print(f" {total} file{'s' if total != 1 else ''} changed ({', '.join(parts)})")
680 print(" (dry-run: nothing written)")
681 return
682
683 # ── Actual writes ─────────────────────────────────────────────────────────
684 # Write objects for every file whose object is not yet in the store.
685 # We skip only when the parent manifest has the same ID *and* the object
686 # is actually present — parent objects may be absent after a clone without
687 # blobs, a gc run, or a prior commit that failed to write them.
688 for rel_path, object_id in manifest.items():
689 if parent_manifest.get(rel_path) == object_id and has_object(root, object_id):
690 continue
691 write_object_from_path(root, object_id, root / rel_path)
692
693 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=directories))
694
695 signature = ""
696 signer_public_key = pre_signer_public_key
697 signer_key_id = ""
698 if signing is not None:
699 result = sign_commit_record(
700 commit_id,
701 resolved_agent_id,
702 signing.private_key,
703 author=author or "",
704 model_id=resolved_model_id,
705 toolchain_id=resolved_toolchain_id,
706 prompt_hash=resolved_prompt_hash,
707 committed_at=committed_at.isoformat(),
708 )
709 if result is not None:
710 signature, signer_public_key, signer_key_id = result
711
712 _commit_record = CommitRecord(
713 commit_id=commit_id,
714 branch=branch,
715 snapshot_id=snapshot_id,
716 message=message or "",
717 committed_at=committed_at,
718 parent_commit_id=parent_id,
719 parent2_commit_id=merge_parent2,
720 author=author or "",
721 metadata=metadata,
722 structured_delta=structured_delta,
723 sem_ver_bump=sem_ver_bump,
724 breaking_changes=breaking_changes,
725 agent_id=resolved_agent_id,
726 model_id=resolved_model_id,
727 toolchain_id=resolved_toolchain_id,
728 prompt_hash=resolved_prompt_hash,
729 signature=signature,
730 signer_public_key=signer_public_key,
731 signer_key_id=signer_key_id,
732 )
733 try:
734 write_commit(root, _commit_record)
735 except MissingParentError:
736 write_commit(root, _commit_record, skip_parent_check=True)
737
738 try:
739 write_branch_ref(root, branch, commit_id, expected_id=parent_id)
740 except RefConflictError as exc:
741 msg = str(exc)
742 if json_out:
743 print(json.dumps(_CommitErrorJson(
744 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
745 error="branch_conflict",
746 message=msg,
747 )))
748 print(f"❌ {msg}", file=sys.stderr)
749 raise SystemExit(ExitCode.USER_ERROR)
750
751 # Clear the stage after a successful commit so the next muse commit
752 # returns to full-snapshot mode unless the user runs muse code add again.
753 # Wrapped in try/except: clear_stage failure must not hide a successful
754 # commit. A failure here leaves staged index entries for already-committed
755 # content — the next muse status would show them as staged, and a naive
756 # re-commit would produce a duplicate commit with identical snapshot_id.
757 # Logging the error is sufficient; the commit is already durable.
758 if isinstance(plugin, StagePlugin):
759 try:
760 plugin.clear_stage(root)
761 except Exception as _clear_exc:
762 logger.warning(
763 "⚠️ clear_stage failed after successful commit %s — "
764 "stage index may still list committed files: %s",
765 commit_id, _clear_exc,
766 )
767
768 append_reflog(
769 root,
770 branch,
771 old_id=parent_id,
772 new_id=commit_id,
773 author=author or "unknown",
774 operation=f"commit: {sanitize_display(message or '(no message)')}",
775 )
776
777 try:
778 _write_symlogs(
779 root,
780 parent_commit_id=parent_id,
781 new_snapshot_id=snapshot_id,
782 new_commit_id=commit_id,
783 author=author or "unknown",
784 commit_message=message or "",
785 )
786 except Exception as _symlog_exc:
787 logger.warning(
788 "⚠️ symlog write failed for commit %s — symbol journal may be incomplete: %s",
789 commit_id, _symlog_exc,
790 )
791
792 # If this commit completed a conflicted merge, record how each conflict
793 # was resolved so harmony can replay it on future identical conflicts.
794 # clear_merge_state is unconditional — harmony recording is optional
795 # bookkeeping, but cleanup must always happen after a successful commit.
796 if merge_state is not None:
797 if merge_state.ours_commit and merge_state.theirs_commit:
798 def _manifest_for(cid: str) -> Manifest:
799 cr = read_commit(root, cid)
800 if cr is None:
801 return {}
802 snap_rec = read_snapshot(root, cr.snapshot_id)
803 return snap_rec.manifest if snap_rec else {}
804
805 ours_manifest = _manifest_for(merge_state.ours_commit)
806 theirs_manifest = _manifest_for(merge_state.theirs_commit)
807 domain = read_domain(root)
808 # Use original_conflict_paths so harmony learns even when all conflicts
809 # were resolved via `muse checkout --ours/--theirs` before commit
810 # (which clears conflict_paths but preserves original_conflict_paths).
811 all_conflict_paths = (
812 merge_state.original_conflict_paths or merge_state.conflict_paths
813 )
814 harmony_record_resolutions(
815 root,
816 list(all_conflict_paths),
817 ours_manifest,
818 theirs_manifest,
819 manifest,
820 domain,
821 plugin,
822 manually_resolved=set(merge_state.manually_resolved) if merge_state.manually_resolved else None,
823 )
824 clear_merge_state(root)
825
826 # ── Output ────────────────────────────────────────────────────────────────
827 if json_out:
828 print(json.dumps(_CommitJson(
829 **make_envelope(elapsed, warnings=commit_warnings),
830 dry_run=False,
831 clean=False,
832 commit_id=commit_id,
833 branch=branch,
834 snapshot_id=snapshot_id,
835 message=message or "",
836 parent_commit_id=parent_id,
837 parent2_commit_id=merge_parent2,
838 committed_at=committed_at.isoformat(),
839 author=author or "",
840 agent_id=resolved_agent_id,
841 model_id=resolved_model_id,
842 toolchain_id=resolved_toolchain_id,
843 sem_ver_bump=sem_ver_bump,
844 breaking_changes=breaking_changes,
845 signer_public_key=signer_public_key,
846 files_changed=_CommitFilesChangedJson(
847 added=files_added,
848 modified=files_modified,
849 deleted=files_deleted,
850 total=files_added + files_modified + files_deleted,
851 ),
852 )))
853 else:
854 print(f"[{sanitize_display(branch)} {commit_id}] {sanitize_display(message or '')}")
855 total_files = files_added + files_deleted + files_modified
856 if total_files:
857 stat_parts: list[str] = []
858 if files_modified:
859 stat_parts.append(f"{files_modified} modified")
860 if files_added:
861 stat_parts.append(f"{files_added} added")
862 if files_deleted:
863 stat_parts.append(f"{files_deleted} removed")
864 print(f" {total_files} file{'s' if total_files != 1 else ''} changed ({', '.join(stat_parts)})")
865 for w in commit_warnings:
866 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 134 days ago
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 140 days ago
sha256:2a1cf861048b753a21d6ca853a83cdfc2a46f15dcbb561ee79ebb9dc40c03af6 switch same-commit fix, agent-config user-global config, an… Human patch 141 days ago