amend.py
python
| 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) |