commit_tree.py
python
| 1 | """muse commit-tree — create a raw commit object from an existing snapshot. |
| 2 | |
| 3 | This is a git-plumbing-style command that creates a commit row in the database |
| 4 | directly from a known ``snapshot_id`` plus explicit metadata. Unlike |
| 5 | ``muse commit``, it does NOT walk the filesystem, does NOT update any branch |
| 6 | ref, and does NOT touch ``.muse/HEAD``. |
| 7 | |
| 8 | Why this exists |
| 9 | --------------- |
| 10 | Scripting and advanced history manipulation require the ability to construct |
| 11 | commits programmatically — for example when replaying a merge, synthesising |
| 12 | history from an external source, or building tooling on top of Muse's commit |
| 13 | graph. Separating "create commit object" from "advance branch pointer" mirrors |
| 14 | the design of ``git commit-tree`` + ``git update-ref``. |
| 15 | |
| 16 | Idempotency contract |
| 17 | -------------------- |
| 18 | ``commit_id`` is derived deterministically from |
| 19 | ``(parent_ids, snapshot_id, message, author)`` with no timestamp component. |
| 20 | Repeating the same call returns the same ``commit_id`` without inserting a |
| 21 | duplicate row. |
| 22 | |
| 23 | Usage:: |
| 24 | |
| 25 | muse commit-tree <snapshot_id> -m "feat: re-record verse" \\ |
| 26 | -p <parent_commit_id> |
| 27 | |
| 28 | For a merge commit supply two ``-p`` flags:: |
| 29 | |
| 30 | muse commit-tree <snapshot_id> -m "Merge groove branch" \\ |
| 31 | -p <parent1> -p <parent2> |
| 32 | """ |
| 33 | from __future__ import annotations |
| 34 | |
| 35 | import asyncio |
| 36 | import datetime |
| 37 | import logging |
| 38 | from typing import Optional |
| 39 | |
| 40 | import typer |
| 41 | from sqlalchemy.ext.asyncio import AsyncSession |
| 42 | |
| 43 | from maestro.muse_cli._repo import require_repo |
| 44 | from maestro.muse_cli.db import insert_commit, open_session |
| 45 | from maestro.muse_cli.errors import ExitCode |
| 46 | from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot |
| 47 | from maestro.muse_cli.snapshot import compute_commit_tree_id |
| 48 | |
| 49 | logger = logging.getLogger(__name__) |
| 50 | |
| 51 | app = typer.Typer() |
| 52 | |
| 53 | _MAX_PARENTS = 2 |
| 54 | |
| 55 | |
| 56 | def _read_author_from_config(repo_root_str: str) -> str: |
| 57 | """Read ``[user] name`` from ``.muse/config.toml``, returning ``""`` on miss. |
| 58 | |
| 59 | Config.toml is optional — when absent or when ``[user] name`` is not set |
| 60 | the author field falls back to an empty string, which matches the behaviour |
| 61 | of ``muse commit``. |
| 62 | """ |
| 63 | import pathlib |
| 64 | import tomllib |
| 65 | |
| 66 | config_path = pathlib.Path(repo_root_str) / ".muse" / "config.toml" |
| 67 | if not config_path.is_file(): |
| 68 | return "" |
| 69 | try: |
| 70 | with config_path.open("rb") as fh: |
| 71 | data = tomllib.load(fh) |
| 72 | name: object = data.get("user", {}).get("name", "") |
| 73 | return str(name).strip() if isinstance(name, str) else "" |
| 74 | except Exception: |
| 75 | return "" |
| 76 | |
| 77 | |
| 78 | async def _commit_tree_async( |
| 79 | *, |
| 80 | snapshot_id: str, |
| 81 | message: str, |
| 82 | parent_ids: list[str], |
| 83 | author: str, |
| 84 | session: AsyncSession, |
| 85 | ) -> str: |
| 86 | """Create a raw commit object from an existing snapshot. |
| 87 | |
| 88 | Looks up *snapshot_id* in the database to verify it exists, computes a |
| 89 | deterministic ``commit_id``, and inserts a ``MuseCliCommit`` row if one |
| 90 | does not already exist. |
| 91 | |
| 92 | Args: |
| 93 | snapshot_id: Must reference an existing ``muse_cli_snapshots`` row. |
| 94 | message: Human-readable commit message (required, non-empty). |
| 95 | parent_ids: Zero, one, or two parent commit IDs. Order is irrelevant |
| 96 | for hashing (sorted internally) but at most two are stored in |
| 97 | ``parent_commit_id`` / ``parent2_commit_id``. |
| 98 | author: Author name string. Empty string is valid. |
| 99 | session: An open async DB session (committed by the caller). |
| 100 | |
| 101 | Returns: |
| 102 | The deterministic ``commit_id`` (64-char hex SHA-256). |
| 103 | |
| 104 | Raises: |
| 105 | ``typer.Exit(USER_ERROR)`` when *snapshot_id* is not found or inputs |
| 106 | are invalid. |
| 107 | ``typer.Exit(INTERNAL_ERROR)`` on unexpected DB failures. |
| 108 | """ |
| 109 | if len(parent_ids) > _MAX_PARENTS: |
| 110 | typer.echo( |
| 111 | f"❌ At most {_MAX_PARENTS} parent IDs are supported " |
| 112 | f"(got {len(parent_ids)})." |
| 113 | ) |
| 114 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 115 | |
| 116 | # Verify snapshot exists |
| 117 | snapshot = await session.get(MuseCliSnapshot, snapshot_id) |
| 118 | if snapshot is None: |
| 119 | typer.echo( |
| 120 | f"❌ Snapshot {snapshot_id[:12]!r} not found in the database.\n" |
| 121 | " Run 'muse commit' first to create a snapshot, or check the ID." |
| 122 | ) |
| 123 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 124 | |
| 125 | # Derive deterministic commit_id (no timestamp → truly idempotent) |
| 126 | commit_id = compute_commit_tree_id( |
| 127 | parent_ids=parent_ids, |
| 128 | snapshot_id=snapshot_id, |
| 129 | message=message, |
| 130 | author=author, |
| 131 | ) |
| 132 | |
| 133 | # Idempotency: if the commit already exists, return its ID without re-inserting |
| 134 | existing = await session.get(MuseCliCommit, commit_id) |
| 135 | if existing is not None: |
| 136 | logger.debug("⚠️ commit-tree: commit %s already exists — skipping insert", commit_id[:8]) |
| 137 | typer.echo(commit_id) |
| 138 | return commit_id |
| 139 | |
| 140 | # Derive parent columns |
| 141 | parent1: str | None = parent_ids[0] if len(parent_ids) >= 1 else None |
| 142 | parent2: str | None = parent_ids[1] if len(parent_ids) >= 2 else None |
| 143 | |
| 144 | # Branch is empty: commit-tree does not associate with any branch. |
| 145 | # Association is deferred to `muse update-ref` (a separate plumbing command). |
| 146 | new_commit = MuseCliCommit( |
| 147 | commit_id=commit_id, |
| 148 | repo_id="", # plumbing commits carry no repo_id until linked via update-ref |
| 149 | branch="", # not associated with any branch ref |
| 150 | parent_commit_id=parent1, |
| 151 | parent2_commit_id=parent2, |
| 152 | snapshot_id=snapshot_id, |
| 153 | message=message, |
| 154 | author=author, |
| 155 | committed_at=datetime.datetime.now(datetime.timezone.utc), |
| 156 | ) |
| 157 | await insert_commit(session, new_commit) |
| 158 | |
| 159 | logger.info("✅ muse commit-tree created %s", commit_id[:8]) |
| 160 | typer.echo(commit_id) |
| 161 | return commit_id |
| 162 | |
| 163 | |
| 164 | # --------------------------------------------------------------------------- |
| 165 | # Typer command |
| 166 | # --------------------------------------------------------------------------- |
| 167 | |
| 168 | |
| 169 | @app.callback(invoke_without_command=True) |
| 170 | def commit_tree( |
| 171 | ctx: typer.Context, |
| 172 | snapshot_id: str = typer.Argument( |
| 173 | ..., help="The snapshot_id to wrap in a new commit object." |
| 174 | ), |
| 175 | message: str = typer.Option( |
| 176 | ..., "-m", "--message", help="Commit message (required)." |
| 177 | ), |
| 178 | parents: Optional[list[str]] = typer.Option( |
| 179 | None, |
| 180 | "-p", |
| 181 | "--parent", |
| 182 | help=( |
| 183 | "Parent commit ID. Specify once for a regular commit, " |
| 184 | "twice for a merge commit." |
| 185 | ), |
| 186 | ), |
| 187 | author: Optional[str] = typer.Option( |
| 188 | None, |
| 189 | "--author", |
| 190 | help="Author name. Defaults to [user] name from .muse/config.toml.", |
| 191 | ), |
| 192 | ) -> None: |
| 193 | """Create a raw commit object from an existing snapshot_id. |
| 194 | |
| 195 | Prints the new (or pre-existing) commit_id to stdout. Does NOT |
| 196 | update .muse/HEAD or any branch ref. |
| 197 | """ |
| 198 | root = require_repo() |
| 199 | |
| 200 | resolved_author = author if author is not None else _read_author_from_config(str(root)) |
| 201 | resolved_parents: list[str] = list(parents) if parents else [] |
| 202 | |
| 203 | async def _run() -> None: |
| 204 | async with open_session() as session: |
| 205 | await _commit_tree_async( |
| 206 | snapshot_id=snapshot_id, |
| 207 | message=message, |
| 208 | parent_ids=resolved_parents, |
| 209 | author=resolved_author, |
| 210 | session=session, |
| 211 | ) |
| 212 | |
| 213 | try: |
| 214 | asyncio.run(_run()) |
| 215 | except typer.Exit: |
| 216 | raise |
| 217 | except Exception as exc: |
| 218 | typer.echo(f"❌ muse commit-tree failed: {exc}") |
| 219 | logger.error("❌ muse commit-tree error: %s", exc, exc_info=True) |
| 220 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |