gabriel / muse public
commit_tree.py python
160 lines 5.1 KB
e8c4265e feat(hardening): final sweep — security, performance, API consistency, docs Gabriel Cardona <gabriel@tellurstori.com> 2d ago
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}))