cgcardona / muse public
commit_tree.py python
220 lines 7.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)