muse_reset.py
python
| 1 | """Muse Reset Service — move the branch pointer to a prior commit. |
| 2 | |
| 3 | Implements three reset modes that mirror git's semantics, adapted for the |
| 4 | Muse VCS filesystem model (``muse-work/`` working tree, ``.muse/refs/`` branch |
| 5 | pointers, ``.muse/objects/`` content-addressed blob store): |
| 6 | |
| 7 | - **soft** — advance/retreat the branch ref; muse-work/ and the object |
| 8 | store are left completely untouched. A subsequent ``muse commit`` |
| 9 | captures the current working tree on top of the new HEAD. |
| 10 | |
| 11 | - **mixed** (default) — same as soft for the branch ref; semantically |
| 12 | marks the index as "unstaged". In the current Muse model (no explicit |
| 13 | staging area) this is equivalent to soft. Exists for API symmetry with |
| 14 | git and for forward-compatibility when a staging index is added. |
| 15 | |
| 16 | - **hard** — moves the branch ref AND overwrites ``muse-work/`` with the |
| 17 | exact file contents captured in the target commit's snapshot. Files are |
| 18 | restored via :mod:`maestro.muse_cli.object_store` (the canonical blob |
| 19 | store shared by all Muse commands). Any files in ``muse-work/`` that |
| 20 | are NOT in the target snapshot are deleted. |
| 21 | |
| 22 | HEAD~N syntax |
| 23 | ------------- |
| 24 | ``resolve_ref`` understands ``HEAD``, ``HEAD~N``, a full 64-char SHA, and |
| 25 | any SHA prefix of ≥ 4 characters. N-step parent traversal walks |
| 26 | ``parent_commit_id`` only (primary parent for linear history); merge |
| 27 | parents (``parent2_commit_id``) are ignored for the ``~N`` walk. |
| 28 | |
| 29 | Merge-in-progress guard |
| 30 | ----------------------- |
| 31 | Reset is blocked when ``.muse/MERGE_STATE.json`` exists. A merge in |
| 32 | progress must be completed or aborted before resetting. |
| 33 | |
| 34 | Object store contract |
| 35 | --------------------- |
| 36 | Hard reset requires that every object in the target snapshot's manifest |
| 37 | exists in ``.muse/objects/``. Objects are written there by ``muse commit`` |
| 38 | via :mod:`maestro.muse_cli.object_store`. If an object is missing, hard |
| 39 | reset raises ``MissingObjectError`` rather than silently leaving the working |
| 40 | tree in a partial state. |
| 41 | |
| 42 | This module is a pure service layer — no Typer, no CLI, no StateStore. |
| 43 | Import boundary: may import muse_cli.{db,models,merge_engine,snapshot, |
| 44 | object_store}, but NOT executor, maestro_handlers, mcp, or StateStore. |
| 45 | """ |
| 46 | from __future__ import annotations |
| 47 | |
| 48 | import enum |
| 49 | import logging |
| 50 | import pathlib |
| 51 | import re |
| 52 | from dataclasses import dataclass |
| 53 | |
| 54 | from sqlalchemy.ext.asyncio import AsyncSession |
| 55 | |
| 56 | from maestro.muse_cli.db import ( |
| 57 | get_commit_snapshot_manifest, |
| 58 | ) |
| 59 | from maestro.muse_cli.merge_engine import read_merge_state |
| 60 | from maestro.muse_cli.models import MuseCliCommit |
| 61 | from maestro.muse_cli.object_store import has_object, object_path, restore_object |
| 62 | |
| 63 | logger = logging.getLogger(__name__) |
| 64 | |
| 65 | # --------------------------------------------------------------------------- |
| 66 | # Public types |
| 67 | # --------------------------------------------------------------------------- |
| 68 | |
| 69 | _HEAD_TILDE_RE = re.compile(r"^HEAD~(\d+)$", re.IGNORECASE) |
| 70 | |
| 71 | |
| 72 | class ResetMode(str, enum.Enum): |
| 73 | """Three-level reset hierarchy, mirroring git semantics. |
| 74 | |
| 75 | Attributes: |
| 76 | SOFT: Move branch pointer only; working tree and object store unchanged. |
| 77 | MIXED: Move branch pointer and conceptually reset the index. |
| 78 | Equivalent to SOFT in the current Muse model (no staging area). |
| 79 | HARD: Move branch pointer AND overwrite muse-work/ with the target snapshot. |
| 80 | """ |
| 81 | |
| 82 | SOFT = "soft" |
| 83 | MIXED = "mixed" |
| 84 | HARD = "hard" |
| 85 | |
| 86 | |
| 87 | @dataclass(frozen=True) |
| 88 | class ResetResult: |
| 89 | """Outcome of a completed ``muse reset`` operation. |
| 90 | |
| 91 | Attributes: |
| 92 | target_commit_id: Full SHA of the commit the branch now points to. |
| 93 | mode: The reset mode that was applied. |
| 94 | branch: Name of the branch that was reset. |
| 95 | files_restored: Number of files written to muse-work/ (hard only). |
| 96 | files_deleted: Number of files deleted from muse-work/ (hard only). |
| 97 | """ |
| 98 | |
| 99 | target_commit_id: str |
| 100 | mode: ResetMode |
| 101 | branch: str |
| 102 | files_restored: int = 0 |
| 103 | files_deleted: int = 0 |
| 104 | |
| 105 | |
| 106 | class MissingObjectError(Exception): |
| 107 | """Raised when a hard reset cannot find required blob content. |
| 108 | |
| 109 | Attributes: |
| 110 | object_id: The missing content-addressed object SHA. |
| 111 | rel_path: File path in the snapshot that required this object. |
| 112 | """ |
| 113 | |
| 114 | def __init__(self, object_id: str, rel_path: str) -> None: |
| 115 | super().__init__( |
| 116 | f"Object {object_id[:8]} missing from .muse/objects/ " |
| 117 | f"(required by {rel_path!r}). " |
| 118 | "Commit the working tree first to populate the object store." |
| 119 | ) |
| 120 | self.object_id = object_id |
| 121 | self.rel_path = rel_path |
| 122 | |
| 123 | |
| 124 | # --------------------------------------------------------------------------- |
| 125 | # Ref resolution |
| 126 | # --------------------------------------------------------------------------- |
| 127 | |
| 128 | |
| 129 | async def resolve_ref( |
| 130 | session: AsyncSession, |
| 131 | repo_id: str, |
| 132 | branch: str, |
| 133 | ref: str, |
| 134 | ) -> MuseCliCommit | None: |
| 135 | """Resolve a user-supplied commit reference to a ``MuseCliCommit`` row. |
| 136 | |
| 137 | Understands the following ref syntaxes (all case-insensitive for keywords): |
| 138 | |
| 139 | - ``HEAD`` — most recent commit on *branch*. |
| 140 | - ``HEAD~N`` — N steps back from HEAD along the primary parent chain. |
| 141 | - ``<sha>`` — exact 64-character commit SHA. |
| 142 | - ``<prefix>`` — any prefix of ≥ 1 character; returns first match. |
| 143 | |
| 144 | Args: |
| 145 | session: Open async DB session. |
| 146 | repo_id: Repository ID (from ``.muse/repo.json``). |
| 147 | branch: Current branch name (used for HEAD resolution). |
| 148 | ref: User-supplied reference string. |
| 149 | |
| 150 | Returns: |
| 151 | The resolved ``MuseCliCommit`` row, or ``None`` when not found. |
| 152 | """ |
| 153 | from sqlalchemy.future import select |
| 154 | |
| 155 | # ── HEAD or HEAD~N ─────────────────────────────────────────────────── |
| 156 | tilde_match = _HEAD_TILDE_RE.match(ref) |
| 157 | is_head = ref.upper() == "HEAD" |
| 158 | |
| 159 | if is_head or tilde_match: |
| 160 | # Resolve HEAD first |
| 161 | result = await session.execute( |
| 162 | select(MuseCliCommit) |
| 163 | .where( |
| 164 | MuseCliCommit.repo_id == repo_id, |
| 165 | MuseCliCommit.branch == branch, |
| 166 | ) |
| 167 | .order_by(MuseCliCommit.committed_at.desc()) |
| 168 | .limit(1) |
| 169 | ) |
| 170 | head_commit = result.scalar_one_or_none() |
| 171 | if head_commit is None: |
| 172 | return None |
| 173 | if is_head: |
| 174 | return head_commit |
| 175 | |
| 176 | # Walk N parents back (primary parent only) |
| 177 | assert tilde_match is not None # guaranteed: tilde_match truthy → not None |
| 178 | n_steps = int(tilde_match.group(1)) |
| 179 | current: MuseCliCommit | None = head_commit |
| 180 | for _ in range(n_steps): |
| 181 | if current is None or not current.parent_commit_id: |
| 182 | return None |
| 183 | current = await session.get(MuseCliCommit, current.parent_commit_id) |
| 184 | return current |
| 185 | |
| 186 | # ── Exact SHA match ────────────────────────────────────────────────── |
| 187 | if len(ref) == 64: |
| 188 | return await session.get(MuseCliCommit, ref) |
| 189 | |
| 190 | # ── SHA prefix match ───────────────────────────────────────────────── |
| 191 | result2 = await session.execute( |
| 192 | select(MuseCliCommit).where( |
| 193 | MuseCliCommit.repo_id == repo_id, |
| 194 | MuseCliCommit.commit_id.startswith(ref), |
| 195 | ) |
| 196 | ) |
| 197 | return result2.scalars().first() |
| 198 | |
| 199 | |
| 200 | # --------------------------------------------------------------------------- |
| 201 | # Core reset logic |
| 202 | # --------------------------------------------------------------------------- |
| 203 | |
| 204 | |
| 205 | async def perform_reset( |
| 206 | *, |
| 207 | root: pathlib.Path, |
| 208 | session: AsyncSession, |
| 209 | ref: str, |
| 210 | mode: ResetMode, |
| 211 | ) -> ResetResult: |
| 212 | """Execute a Muse VCS reset operation. |
| 213 | |
| 214 | Moves the current branch's HEAD pointer to *ref* and, for hard mode, |
| 215 | overwrites ``muse-work/`` with the target snapshot's file content. |
| 216 | |
| 217 | This function is the testable async core — it performs all filesystem |
| 218 | and DB I/O. The Typer CLI wrapper in ``muse_cli/commands/reset.py`` |
| 219 | handles argument parsing, user confirmation, and error display. |
| 220 | |
| 221 | Raises: |
| 222 | typer.Exit: On user-facing errors (merge in progress, ref not found, |
| 223 | branch has no commits). |
| 224 | MissingObjectError: When ``--hard`` cannot find a required blob in the |
| 225 | object store. |
| 226 | |
| 227 | Args: |
| 228 | root: Muse repository root (directory containing ``.muse/``). |
| 229 | session: Open async DB session. |
| 230 | ref: Commit reference string (e.g. ``HEAD~2``, ``abc123``). |
| 231 | mode: Which reset mode to apply. |
| 232 | |
| 233 | Returns: |
| 234 | ``ResetResult`` describing the completed operation. |
| 235 | """ |
| 236 | import typer |
| 237 | from maestro.muse_cli.errors import ExitCode |
| 238 | |
| 239 | muse_dir = root / ".muse" |
| 240 | |
| 241 | # ── Guard: merge in progress ───────────────────────────────────────── |
| 242 | if read_merge_state(root) is not None: |
| 243 | typer.echo( |
| 244 | "❌ Merge in progress. Resolve conflicts or abort the merge before " |
| 245 | "running muse reset." |
| 246 | ) |
| 247 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 248 | |
| 249 | # ── Repo identity ──────────────────────────────────────────────────── |
| 250 | import json |
| 251 | |
| 252 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 253 | repo_id = repo_data["repo_id"] |
| 254 | |
| 255 | # ── Current branch ─────────────────────────────────────────────────── |
| 256 | head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main" |
| 257 | branch = head_ref.rsplit("/", 1)[-1] # "main" |
| 258 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 259 | |
| 260 | if not ref_path.exists() or not ref_path.read_text().strip(): |
| 261 | typer.echo("❌ Current branch has no commits. Nothing to reset.") |
| 262 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 263 | |
| 264 | # ── Resolve target commit ───────────────────────────────────────────── |
| 265 | target_commit = await resolve_ref(session, repo_id, branch, ref) |
| 266 | if target_commit is None: |
| 267 | typer.echo(f"❌ Could not resolve ref: {ref!r}") |
| 268 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 269 | |
| 270 | target_commit_id = target_commit.commit_id |
| 271 | |
| 272 | # ── soft / mixed: only move the branch pointer ──────────────────────── |
| 273 | if mode in (ResetMode.SOFT, ResetMode.MIXED): |
| 274 | ref_path.write_text(target_commit_id) |
| 275 | logger.info( |
| 276 | "✅ muse reset --%s: branch %r → %s", |
| 277 | mode.value, |
| 278 | branch, |
| 279 | target_commit_id[:8], |
| 280 | ) |
| 281 | return ResetResult( |
| 282 | target_commit_id=target_commit_id, |
| 283 | mode=mode, |
| 284 | branch=branch, |
| 285 | ) |
| 286 | |
| 287 | # ── hard: restore muse-work/ from the target snapshot ──────────────── |
| 288 | assert mode is ResetMode.HARD |
| 289 | |
| 290 | manifest = await get_commit_snapshot_manifest(session, target_commit_id) |
| 291 | if manifest is None: |
| 292 | typer.echo( |
| 293 | f"❌ Could not load snapshot for commit {target_commit_id[:8]}. " |
| 294 | "Database may be corrupt." |
| 295 | ) |
| 296 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 297 | |
| 298 | # Validate all objects exist before touching the working tree. |
| 299 | for rel_path, object_id in manifest.items(): |
| 300 | if not has_object(root, object_id): |
| 301 | raise MissingObjectError(object_id, rel_path) |
| 302 | |
| 303 | workdir = root / "muse-work" |
| 304 | workdir.mkdir(parents=True, exist_ok=True) |
| 305 | |
| 306 | # Build set of current files in muse-work/ for deletion tracking. |
| 307 | current_files: set[pathlib.Path] = { |
| 308 | f for f in workdir.rglob("*") if f.is_file() and not f.name.startswith(".") |
| 309 | } |
| 310 | |
| 311 | files_restored = 0 |
| 312 | target_paths: set[pathlib.Path] = set() |
| 313 | |
| 314 | for rel_path, object_id in manifest.items(): |
| 315 | dest = workdir / rel_path |
| 316 | restore_object(root, object_id, dest) |
| 317 | target_paths.add(dest) |
| 318 | files_restored += 1 |
| 319 | logger.debug("✅ Restored %s from object %s", rel_path, object_id[:8]) |
| 320 | |
| 321 | # Delete files not in the target snapshot. |
| 322 | files_deleted = 0 |
| 323 | for stale_file in current_files - target_paths: |
| 324 | stale_file.unlink(missing_ok=True) |
| 325 | files_deleted += 1 |
| 326 | logger.debug("🗑 Deleted stale file %s", stale_file) |
| 327 | |
| 328 | # Remove empty directories left after deletion. |
| 329 | for dirpath in sorted(workdir.rglob("*"), reverse=True): |
| 330 | if dirpath.is_dir() and not any(dirpath.iterdir()): |
| 331 | try: |
| 332 | dirpath.rmdir() |
| 333 | except OSError: |
| 334 | pass |
| 335 | |
| 336 | # Update branch pointer last (after successful worktree restoration). |
| 337 | ref_path.write_text(target_commit_id) |
| 338 | |
| 339 | logger.info( |
| 340 | "✅ muse reset --hard: branch %r → %s (%d restored, %d deleted)", |
| 341 | branch, |
| 342 | target_commit_id[:8], |
| 343 | files_restored, |
| 344 | files_deleted, |
| 345 | ) |
| 346 | return ResetResult( |
| 347 | target_commit_id=target_commit_id, |
| 348 | mode=mode, |
| 349 | branch=branch, |
| 350 | files_restored=files_restored, |
| 351 | files_deleted=files_deleted, |
| 352 | ) |