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