commit_tree.py
python
| 1 | """muse plumbing commit-tree — create a commit from an explicit snapshot ID. |
| 2 | |
| 3 | Low-level commit creation: takes a snapshot ID (which must already exist in the |
| 4 | store), optional parent commit IDs, and a message, and writes a new |
| 5 | ``CommitRecord`` to the store. Does not touch ``HEAD`` or any branch ref. |
| 6 | |
| 7 | Analogous to ``git commit-tree``. Porcelain commands like ``muse commit`` call |
| 8 | this internally after staging changes and writing the snapshot. |
| 9 | |
| 10 | Output:: |
| 11 | |
| 12 | {"commit_id": "<sha256>"} |
| 13 | |
| 14 | Plumbing contract |
| 15 | ----------------- |
| 16 | |
| 17 | - Exit 0: commit written, commit_id printed. |
| 18 | - Exit 1: snapshot not found, parent commit not found, or repo.json unreadable. |
| 19 | - Exit 3: write failure. |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import datetime |
| 25 | import json |
| 26 | import logging |
| 27 | import pathlib |
| 28 | |
| 29 | import typer |
| 30 | |
| 31 | from muse.core.errors import ExitCode |
| 32 | from muse.core.repo import require_repo |
| 33 | from muse.core.snapshot import compute_commit_id |
| 34 | from muse.core.store import ( |
| 35 | CommitRecord, |
| 36 | read_commit, |
| 37 | read_current_branch, |
| 38 | read_snapshot, |
| 39 | write_commit, |
| 40 | ) |
| 41 | from muse.core.validation import validate_object_id |
| 42 | |
| 43 | logger = logging.getLogger(__name__) |
| 44 | |
| 45 | app = typer.Typer() |
| 46 | |
| 47 | |
| 48 | def _read_repo_id(root: pathlib.Path) -> str: |
| 49 | """Read the repo UUID from repo.json. |
| 50 | |
| 51 | Returns the repo_id string, or raises SystemExit if the file is missing, |
| 52 | malformed, or the field is absent — a commit without a valid repo_id would |
| 53 | be permanently corrupt. |
| 54 | """ |
| 55 | repo_json = root / ".muse" / "repo.json" |
| 56 | try: |
| 57 | data = json.loads(repo_json.read_text(encoding="utf-8")) |
| 58 | except (OSError, json.JSONDecodeError) as exc: |
| 59 | typer.echo(f"❌ Cannot read repo.json: {exc}", err=True) |
| 60 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 61 | repo_id = data.get("repo_id", "") |
| 62 | if not isinstance(repo_id, str) or not repo_id: |
| 63 | typer.echo("❌ repo.json is missing a valid 'repo_id' field.", err=True) |
| 64 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 65 | return repo_id |
| 66 | |
| 67 | |
| 68 | _FORMAT_CHOICES = ("json", "text") |
| 69 | |
| 70 | |
| 71 | @app.callback(invoke_without_command=True) |
| 72 | def commit_tree( |
| 73 | ctx: typer.Context, |
| 74 | snapshot_id: str = typer.Option(..., "--snapshot", "-s", help="SHA-256 snapshot ID."), |
| 75 | parent: list[str] = typer.Option( |
| 76 | [], "--parent", "-p", help="Parent commit ID (repeat for merge commits)." |
| 77 | ), |
| 78 | message: str = typer.Option("", "--message", "-m", help="Commit message."), |
| 79 | author: str = typer.Option("", "--author", "-a", help="Author name."), |
| 80 | branch: str | None = typer.Option( |
| 81 | None, "--branch", "-b", help="Branch name to record (default: current branch)." |
| 82 | ), |
| 83 | fmt: str = typer.Option( |
| 84 | "json", "--format", "-f", help="Output format: json (default) or text (bare commit_id)." |
| 85 | ), |
| 86 | ) -> None: |
| 87 | """Create a commit from an explicit snapshot ID. |
| 88 | |
| 89 | The snapshot must already exist in ``.muse/snapshots/``. Each ``--parent`` |
| 90 | flag adds a parent commit (use once for linear history, twice for merge |
| 91 | commits). The commit is written to ``.muse/commits/`` but no branch ref |
| 92 | is updated — use ``muse plumbing update-ref`` to advance a branch. |
| 93 | |
| 94 | Output (``--format json``, default):: |
| 95 | |
| 96 | {"commit_id": "<sha256>"} |
| 97 | |
| 98 | Output (``--format text``):: |
| 99 | |
| 100 | <sha256> |
| 101 | """ |
| 102 | if fmt not in _FORMAT_CHOICES: |
| 103 | typer.echo( |
| 104 | json.dumps({"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}) |
| 105 | ) |
| 106 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 107 | root = require_repo() |
| 108 | |
| 109 | try: |
| 110 | validate_object_id(snapshot_id) |
| 111 | except ValueError as exc: |
| 112 | typer.echo(json.dumps({"error": f"Invalid snapshot ID: {exc}"})) |
| 113 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 114 | |
| 115 | for pid in parent: |
| 116 | try: |
| 117 | validate_object_id(pid) |
| 118 | except ValueError as exc: |
| 119 | typer.echo(json.dumps({"error": f"Invalid parent commit ID: {exc}"})) |
| 120 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 121 | |
| 122 | snap = read_snapshot(root, snapshot_id) |
| 123 | if snap is None: |
| 124 | typer.echo(json.dumps({"error": f"Snapshot not found: {snapshot_id}"})) |
| 125 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 126 | |
| 127 | for pid in parent: |
| 128 | if read_commit(root, pid) is None: |
| 129 | typer.echo(json.dumps({"error": f"Parent commit not found: {pid}"})) |
| 130 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 131 | |
| 132 | repo_id = _read_repo_id(root) |
| 133 | branch_name = branch or read_current_branch(root) |
| 134 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 135 | |
| 136 | commit_id = compute_commit_id( |
| 137 | parent_ids=parent, |
| 138 | snapshot_id=snapshot_id, |
| 139 | message=message, |
| 140 | committed_at_iso=committed_at.isoformat(), |
| 141 | ) |
| 142 | |
| 143 | record = CommitRecord( |
| 144 | commit_id=commit_id, |
| 145 | repo_id=repo_id, |
| 146 | branch=branch_name, |
| 147 | snapshot_id=snapshot_id, |
| 148 | message=message, |
| 149 | committed_at=committed_at, |
| 150 | author=author, |
| 151 | parent_commit_id=parent[0] if len(parent) >= 1 else None, |
| 152 | parent2_commit_id=parent[1] if len(parent) >= 2 else None, |
| 153 | ) |
| 154 | write_commit(root, record) |
| 155 | |
| 156 | if fmt == "text": |
| 157 | typer.echo(commit_id) |
| 158 | return |
| 159 | |
| 160 | typer.echo(json.dumps({"commit_id": commit_id})) |