cgcardona / muse public
amend.py python
257 lines 10.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse amend — fold working-tree changes into the most recent commit.
2
3 Equivalent to ``git commit --amend``. The original HEAD commit is replaced
4 by a new commit that shares the same *parent* as the original, effectively
5 orphaning the original HEAD. The amended commit is a fresh object with a
6 new deterministic ``commit_id``.
7
8 Flag summary
9 ------------
10 - ``-m / --message TEXT`` — use TEXT as the new commit message.
11 - ``--no-edit`` — keep the original commit message (default when
12 ``-m`` is omitted). When both ``-m`` and
13 ``--no-edit`` are supplied, ``--no-edit`` wins.
14 - ``--reset-author`` — reset the author field to the current user
15 (stub: sets author to empty string until a user
16 identity system is implemented).
17
18 Behaviour
19 ---------
20 1. A new snapshot is taken of ``muse-work/`` using the same content-addressed
21 logic as ``muse commit``.
22 2. A new ``commit_id`` is computed with the *original commit's parent* as the
23 parent, the current timestamp, and the effective message.
24 3. ``.muse/refs/heads/<branch>`` is updated to the new commit ID.
25 4. Blocked when a merge is in progress (``.muse/MERGE_STATE.json`` exists).
26 5. Blocked when there are no commits yet on the current branch.
27
28 The original HEAD commit becomes an orphan: it is no longer reachable from
29 any branch ref but remains in the database for forensic traceability. A
30 future ``muse gc`` pass may prune it.
31 """
32 from __future__ import annotations
33
34 import asyncio
35 import datetime
36 import json
37 import logging
38 import pathlib
39 from typing import Optional
40
41 import typer
42 from sqlalchemy.ext.asyncio import AsyncSession
43
44 from maestro.muse_cli._repo import require_repo
45 from maestro.muse_cli.db import (
46 insert_commit,
47 open_session,
48 upsert_object,
49 upsert_snapshot,
50 )
51 from maestro.muse_cli.errors import ExitCode
52 from maestro.muse_cli.merge_engine import read_merge_state
53 from maestro.muse_cli.models import MuseCliCommit
54 from maestro.muse_cli.snapshot import (
55 build_snapshot_manifest,
56 compute_commit_id,
57 compute_snapshot_id,
58 )
59
60 logger = logging.getLogger(__name__)
61
62 app = typer.Typer()
63
64
65 # ---------------------------------------------------------------------------
66 # Testable async core
67 # ---------------------------------------------------------------------------
68
69
70 async def _amend_async(
71 *,
72 message: str | None,
73 no_edit: bool,
74 reset_author: bool,
75 root: pathlib.Path,
76 session: AsyncSession,
77 ) -> str:
78 """Run the amend pipeline and return the new ``commit_id``.
79
80 All filesystem and DB side-effects are isolated here so tests can inject
81 an in-memory SQLite session and a ``tmp_path`` root without touching a
82 real database.
83
84 Args:
85 message: New commit message, or ``None`` to keep the original.
86 Ignored when *no_edit* is ``True``.
87 no_edit: When ``True``, keep the original commit message even if
88 *message* is also supplied.
89 reset_author: When ``True``, reset the author field (stub: empty string
90 until a user-identity system is introduced).
91 root: Repository root (directory containing ``.muse/``).
92 session: An open async DB session.
93
94 Returns:
95 The new ``commit_id`` (64-char sha256 hex string).
96
97 Raises:
98 typer.Exit: On any user-facing error (merge in progress, no commits,
99 empty working tree, DB inconsistency).
100 """
101 muse_dir = root / ".muse"
102
103 # ── Guard: block amend while a conflicted merge is in progress ──────
104 merge_state = read_merge_state(root)
105 if merge_state is not None:
106 typer.echo(
107 "❌ A merge is in progress — amend is not allowed.\n"
108 " Resolve any conflicts and run 'muse commit', or abort the merge first."
109 )
110 raise typer.Exit(code=ExitCode.USER_ERROR)
111
112 # ── Repo identity ────────────────────────────────────────────────────
113 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
114 repo_id = repo_data["repo_id"]
115
116 # ── Current branch ───────────────────────────────────────────────────
117 head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main"
118 branch = head_ref.rsplit("/", 1)[-1] # "main"
119 ref_path = muse_dir / pathlib.Path(head_ref)
120
121 if not ref_path.exists() or not ref_path.read_text().strip():
122 typer.echo(
123 "❌ Nothing to amend — no commits yet on this branch.\n"
124 " Run 'muse commit -m <message>' to create the first commit."
125 )
126 raise typer.Exit(code=ExitCode.USER_ERROR)
127
128 head_commit_id = ref_path.read_text().strip()
129
130 # ── Load HEAD commit to get its parent and original message ──────────
131 head_commit = await session.get(MuseCliCommit, head_commit_id)
132 if head_commit is None:
133 typer.echo(
134 f"❌ HEAD commit {head_commit_id[:8]} not found in database.\n"
135 " Repository may be in an inconsistent state."
136 )
137 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
138
139 # ── Determine effective commit message ────────────────────────────────
140 # --no-edit (or no -m supplied) → keep original; -m TEXT → use TEXT.
141 if no_edit or message is None:
142 effective_message = head_commit.message
143 else:
144 effective_message = message
145
146 # ── Build new snapshot from muse-work/ ───────────────────────────────
147 workdir = root / "muse-work"
148 if not workdir.exists():
149 typer.echo(
150 "⚠️ No muse-work/ directory found — nothing to snapshot.\n"
151 " Generate some artifacts before running 'muse amend'."
152 )
153 raise typer.Exit(code=ExitCode.USER_ERROR)
154
155 manifest = build_snapshot_manifest(workdir)
156 if not manifest:
157 typer.echo("⚠️ muse-work/ is empty — cannot amend with an empty snapshot.")
158 raise typer.Exit(code=ExitCode.USER_ERROR)
159
160 snapshot_id = compute_snapshot_id(manifest)
161
162 # ── Compute new commit ID (same parent as the original HEAD) ─────────
163 # The amended commit inherits the original commit's *parent*, keeping
164 # the linear chain intact and orphaning the original HEAD.
165 parent_commit_id = head_commit.parent_commit_id
166 parent_ids = [parent_commit_id] if parent_commit_id else []
167
168 committed_at = datetime.datetime.now(datetime.timezone.utc)
169 new_commit_id = compute_commit_id(
170 parent_ids=parent_ids,
171 snapshot_id=snapshot_id,
172 message=effective_message,
173 committed_at_iso=committed_at.isoformat(),
174 )
175
176 # ── Persist objects ──────────────────────────────────────────────────
177 for rel_path, object_id in manifest.items():
178 file_path = workdir / rel_path
179 size = file_path.stat().st_size
180 await upsert_object(session, object_id=object_id, size_bytes=size)
181
182 # ── Persist snapshot ─────────────────────────────────────────────────
183 await upsert_snapshot(session, manifest=manifest, snapshot_id=snapshot_id)
184 # Flush so the snapshot FK constraint is satisfied before inserting the commit.
185 await session.flush()
186
187 # ── Persist amended commit ────────────────────────────────────────────
188 author = "" # stub: no user-identity system yet; reset_author is a no-op for now
189 new_commit = MuseCliCommit(
190 commit_id=new_commit_id,
191 repo_id=repo_id,
192 branch=branch,
193 parent_commit_id=parent_commit_id,
194 snapshot_id=snapshot_id,
195 message=effective_message,
196 author=author,
197 committed_at=committed_at,
198 )
199 await insert_commit(session, new_commit)
200
201 # ── Update branch HEAD pointer ─────────────────────────────────────────
202 ref_path.write_text(new_commit_id)
203
204 typer.echo(f"✅ [{branch} {new_commit_id[:8]}] {effective_message} (amended)")
205 logger.info(
206 "✅ muse amend %s → %s on %r: %s",
207 head_commit_id[:8],
208 new_commit_id[:8],
209 branch,
210 effective_message,
211 )
212 return new_commit_id
213
214
215 # ---------------------------------------------------------------------------
216 # Typer command
217 # ---------------------------------------------------------------------------
218
219
220 @app.callback(invoke_without_command=True)
221 def amend(
222 ctx: typer.Context,
223 message: Optional[str] = typer.Option(
224 None, "-m", "--message", help="Replace the commit message."
225 ),
226 no_edit: bool = typer.Option(
227 False,
228 "--no-edit",
229 help="Keep the original commit message. Takes precedence over -m.",
230 ),
231 reset_author: bool = typer.Option(
232 False,
233 "--reset-author",
234 help="Reset the author field to the current user.",
235 ),
236 ) -> None:
237 """Fold working-tree changes into the most recent commit."""
238 root = require_repo()
239
240 async def _run() -> None:
241 async with open_session() as session:
242 await _amend_async(
243 message=message,
244 no_edit=no_edit,
245 reset_author=reset_author,
246 root=root,
247 session=session,
248 )
249
250 try:
251 asyncio.run(_run())
252 except typer.Exit:
253 raise
254 except Exception as exc:
255 typer.echo(f"❌ muse amend failed: {exc}")
256 logger.error("❌ muse amend error: %s", exc, exc_info=True)
257 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)