update_ref.py
python
| 1 | """muse update-ref — write or delete a ref (branch or tag pointer). |
| 2 | |
| 3 | Plumbing command that directly updates ``refs/heads/<branch>`` or |
| 4 | ``refs/tags/<tag>`` inside the ``.muse/`` directory. Mirrors |
| 5 | ``git update-ref`` and is primarily intended for scripting scenarios |
| 6 | where a higher-level command (checkout, merge) is too heavy. |
| 7 | |
| 8 | Behaviour |
| 9 | --------- |
| 10 | ``muse update-ref <ref> <new-value>`` |
| 11 | Write *new-value* (a commit_id) to the ref file. |
| 12 | Validates that the commit exists in ``muse_cli_commits``. |
| 13 | |
| 14 | ``muse update-ref <ref> <new-value> --old-value <expected>`` |
| 15 | Compare-and-swap (CAS): only update if the current ref value matches |
| 16 | *expected*. Safe for scripting under concurrent access. |
| 17 | |
| 18 | ``muse update-ref <ref> -d`` |
| 19 | Delete the ref file. Exits ``USER_ERROR`` when the ref does not exist. |
| 20 | |
| 21 | Ref format |
| 22 | ---------- |
| 23 | *ref* must begin with ``refs/heads/`` or ``refs/tags/``. The |
| 24 | corresponding file inside ``.muse/`` stores the raw commit_id string. |
| 25 | """ |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import asyncio |
| 29 | import logging |
| 30 | import pathlib |
| 31 | from typing import Optional |
| 32 | |
| 33 | import typer |
| 34 | from sqlalchemy.ext.asyncio import AsyncSession |
| 35 | |
| 36 | from maestro.muse_cli._repo import require_repo |
| 37 | from maestro.muse_cli.db import open_session |
| 38 | from maestro.muse_cli.errors import ExitCode |
| 39 | from maestro.muse_cli.models import MuseCliCommit |
| 40 | |
| 41 | logger = logging.getLogger(__name__) |
| 42 | |
| 43 | app = typer.Typer(name="update-ref", invoke_without_command=True, no_args_is_help=False) |
| 44 | |
| 45 | # Allowed ref prefixes — matches git's plumbing conventions. |
| 46 | _VALID_PREFIXES = ("refs/heads/", "refs/tags/") |
| 47 | |
| 48 | |
| 49 | def _validate_ref_format(ref: str) -> None: |
| 50 | """Raise ``typer.Exit(USER_ERROR)`` when *ref* does not start with a valid prefix.""" |
| 51 | if not any(ref.startswith(p) for p in _VALID_PREFIXES): |
| 52 | typer.echo( |
| 53 | f"❌ Invalid ref '{ref}'. " |
| 54 | "Refs must start with 'refs/heads/' or 'refs/tags/'." |
| 55 | ) |
| 56 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 57 | |
| 58 | |
| 59 | def _ref_file(muse_dir: pathlib.Path, ref: str) -> pathlib.Path: |
| 60 | """Return the absolute path to the ref file inside *muse_dir*.""" |
| 61 | return muse_dir / pathlib.Path(ref) |
| 62 | |
| 63 | |
| 64 | def _read_current_value(ref_path: pathlib.Path) -> str | None: |
| 65 | """Read the current commit_id stored at *ref_path*, or ``None`` if absent/empty.""" |
| 66 | if not ref_path.exists(): |
| 67 | return None |
| 68 | raw = ref_path.read_text().strip() |
| 69 | return raw if raw else None |
| 70 | |
| 71 | |
| 72 | async def _assert_commit_exists(session: AsyncSession, commit_id: str) -> None: |
| 73 | """Exit ``USER_ERROR`` when *commit_id* is not in ``muse_cli_commits``.""" |
| 74 | row = await session.get(MuseCliCommit, commit_id) |
| 75 | if row is None: |
| 76 | typer.echo(f"❌ Commit {commit_id[:8]} not found in database.") |
| 77 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 78 | |
| 79 | |
| 80 | # --------------------------------------------------------------------------- |
| 81 | # Testable async cores |
| 82 | # --------------------------------------------------------------------------- |
| 83 | |
| 84 | |
| 85 | async def _update_ref_async( |
| 86 | *, |
| 87 | ref: str, |
| 88 | new_value: str, |
| 89 | old_value: str | None, |
| 90 | root: pathlib.Path, |
| 91 | session: AsyncSession, |
| 92 | ) -> None: |
| 93 | """Write *new_value* to the ref file, with optional CAS guard. |
| 94 | |
| 95 | Why: Plumbing commands are the building blocks scripting agents use to |
| 96 | manipulate the Muse object graph. Centralising the validation here keeps |
| 97 | higher-level commands (checkout, merge) simple — they delegate to this core |
| 98 | when they need to advance a branch pointer atomically. |
| 99 | |
| 100 | Args: |
| 101 | ref: Fully-qualified ref name (e.g. ``refs/heads/main``). |
| 102 | new_value: Commit ID to write. Must exist in ``muse_cli_commits``. |
| 103 | old_value: If provided, the current ref value must match exactly; |
| 104 | mismatch exits with ``USER_ERROR`` (compare-and-swap). |
| 105 | root: Repo root (directory containing ``.muse/``). |
| 106 | session: Open database session — caller controls commit/rollback. |
| 107 | |
| 108 | Raises: |
| 109 | typer.Exit(USER_ERROR): ref format invalid, commit not found, or CAS mismatch. |
| 110 | """ |
| 111 | _validate_ref_format(ref) |
| 112 | muse_dir = root / ".muse" |
| 113 | ref_path = _ref_file(muse_dir, ref) |
| 114 | |
| 115 | # Validate the new commit exists. |
| 116 | await _assert_commit_exists(session, new_value) |
| 117 | |
| 118 | # CAS guard — read current value and compare. |
| 119 | if old_value is not None: |
| 120 | current = _read_current_value(ref_path) |
| 121 | if current != old_value: |
| 122 | typer.echo( |
| 123 | f"❌ CAS failure: expected '{old_value[:8] if old_value else 'empty'}' " |
| 124 | f"but found '{current[:8] if current else 'empty'}'. " |
| 125 | "Ref not updated." |
| 126 | ) |
| 127 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 128 | |
| 129 | # Write the new value. |
| 130 | ref_path.parent.mkdir(parents=True, exist_ok=True) |
| 131 | ref_path.write_text(new_value) |
| 132 | typer.echo(f"✅ {ref} → {new_value[:8]}") |
| 133 | logger.info("✅ muse update-ref %s → %s", ref, new_value[:8]) |
| 134 | |
| 135 | |
| 136 | async def _delete_ref_async( |
| 137 | *, |
| 138 | ref: str, |
| 139 | root: pathlib.Path, |
| 140 | ) -> None: |
| 141 | """Delete the ref file for *ref*. |
| 142 | |
| 143 | Why: Scripting agents need an atomic delete primitive so they can clean up |
| 144 | stale branch or tag pointers without touching the commit graph. |
| 145 | |
| 146 | Args: |
| 147 | ref: Fully-qualified ref name (e.g. ``refs/heads/feature``). |
| 148 | root: Repo root (directory containing ``.muse/``). |
| 149 | |
| 150 | Raises: |
| 151 | typer.Exit(USER_ERROR): ref format invalid or ref file does not exist. |
| 152 | """ |
| 153 | _validate_ref_format(ref) |
| 154 | muse_dir = root / ".muse" |
| 155 | ref_path = _ref_file(muse_dir, ref) |
| 156 | |
| 157 | if not ref_path.exists(): |
| 158 | typer.echo(f"❌ Ref '{ref}' does not exist.") |
| 159 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 160 | |
| 161 | ref_path.unlink() |
| 162 | typer.echo(f"✅ Deleted ref '{ref}'.") |
| 163 | logger.info("✅ muse update-ref -d %s", ref) |
| 164 | |
| 165 | |
| 166 | # --------------------------------------------------------------------------- |
| 167 | # Typer entry point |
| 168 | # --------------------------------------------------------------------------- |
| 169 | |
| 170 | |
| 171 | @app.callback() |
| 172 | def update_ref( |
| 173 | ref: str = typer.Argument(..., help="Ref to update, e.g. refs/heads/main or refs/tags/v1.0"), |
| 174 | new_value: Optional[str] = typer.Argument( |
| 175 | None, |
| 176 | help="Commit ID to write to the ref. Required unless -d is given.", |
| 177 | ), |
| 178 | old_value: Optional[str] = typer.Option( |
| 179 | None, |
| 180 | "--old-value", |
| 181 | help=( |
| 182 | "Compare-and-swap guard. Only update if the current ref value matches this commit ID." |
| 183 | ), |
| 184 | ), |
| 185 | delete: bool = typer.Option( |
| 186 | False, |
| 187 | "-d", |
| 188 | "--delete", |
| 189 | help="Delete the ref instead of writing it.", |
| 190 | ), |
| 191 | ) -> None: |
| 192 | """Write or delete a Muse ref (branch or tag pointer). |
| 193 | |
| 194 | Updates .muse/refs/heads/<branch> or .muse/refs/tags/<tag> directly. |
| 195 | Validates that <new-value> is a real commit before writing. |
| 196 | """ |
| 197 | root = require_repo() |
| 198 | |
| 199 | if delete: |
| 200 | try: |
| 201 | asyncio.run(_delete_ref_async(ref=ref, root=root)) |
| 202 | except typer.Exit: |
| 203 | raise |
| 204 | except Exception as exc: |
| 205 | typer.echo(f"❌ muse update-ref -d failed: {exc}") |
| 206 | logger.error("❌ muse update-ref -d error: %s", exc, exc_info=True) |
| 207 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 208 | return |
| 209 | |
| 210 | if new_value is None: |
| 211 | typer.echo("❌ <new-value> is required when not using -d.") |
| 212 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 213 | |
| 214 | async def _run() -> None: |
| 215 | async with open_session() as session: |
| 216 | await _update_ref_async( |
| 217 | ref=ref, |
| 218 | new_value=new_value, |
| 219 | old_value=old_value, |
| 220 | root=root, |
| 221 | session=session, |
| 222 | ) |
| 223 | |
| 224 | try: |
| 225 | asyncio.run(_run()) |
| 226 | except typer.Exit: |
| 227 | raise |
| 228 | except Exception as exc: |
| 229 | typer.echo(f"❌ muse update-ref failed: {exc}") |
| 230 | logger.error("❌ muse update-ref error: %s", exc, exc_info=True) |
| 231 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |