reset.py
python
| 1 | """muse reset <commit> — reset the branch pointer to a prior commit. |
| 2 | |
| 3 | Algorithm |
| 4 | --------- |
| 5 | 1. Block if ``.muse/MERGE_STATE.json`` exists (merge in progress). |
| 6 | 2. Resolve repo root via ``require_repo()``. |
| 7 | 3. Read current branch from ``.muse/HEAD``. |
| 8 | 4. Resolve *commit* argument (``HEAD~N``, full/abbreviated SHA) to a |
| 9 | ``MuseCliCommit`` row via :func:`~maestro.services.muse_reset.resolve_ref`. |
| 10 | 5. Apply the chosen mode: |
| 11 | |
| 12 | ``--soft`` — update ``.muse/refs/heads/<branch>`` only. muse-work/ |
| 13 | files and the object store are untouched. The producer |
| 14 | can immediately ``muse commit`` a new snapshot on top of |
| 15 | the rewound head. |
| 16 | |
| 17 | ``--mixed`` (default) — same as ``--soft`` in the current Muse model |
| 18 | (no explicit staging area). Included for API symmetry |
| 19 | and forward-compatibility. |
| 20 | |
| 21 | ``--hard`` — update the branch ref AND overwrite ``muse-work/`` with |
| 22 | the file content recorded in the target snapshot. Objects |
| 23 | are read from ``.muse/objects/`` (the blob store populated |
| 24 | by ``muse commit``). Prompts for confirmation unless |
| 25 | ``--yes`` is given, because this operation discards any |
| 26 | uncommitted changes in muse-work/. |
| 27 | |
| 28 | HEAD~N |
| 29 | ------ |
| 30 | muse reset HEAD~1 # one parent back |
| 31 | muse reset HEAD~3 # three parents back |
| 32 | muse reset abc123 # abbreviated SHA |
| 33 | muse reset --hard HEAD~2 # two parents back + restore working tree |
| 34 | |
| 35 | Exit codes |
| 36 | ---------- |
| 37 | 0 success |
| 38 | 1 user error (ref not found, merge in progress, no commits, abort) |
| 39 | 2 not a Muse repo |
| 40 | 3 internal error (DB inconsistency, missing object blobs) |
| 41 | """ |
| 42 | from __future__ import annotations |
| 43 | |
| 44 | import asyncio |
| 45 | import logging |
| 46 | |
| 47 | import typer |
| 48 | |
| 49 | from maestro.muse_cli._repo import require_repo |
| 50 | from maestro.muse_cli.db import open_session |
| 51 | from maestro.muse_cli.errors import ExitCode |
| 52 | from maestro.services.muse_reset import ( |
| 53 | MissingObjectError, |
| 54 | ResetMode, |
| 55 | perform_reset, |
| 56 | ) |
| 57 | |
| 58 | logger = logging.getLogger(__name__) |
| 59 | |
| 60 | app = typer.Typer() |
| 61 | |
| 62 | |
| 63 | @app.callback(invoke_without_command=True) |
| 64 | def reset( |
| 65 | ctx: typer.Context, |
| 66 | commit: str = typer.Argument( |
| 67 | ..., |
| 68 | help=( |
| 69 | "Target commit reference. Accepts: HEAD, HEAD~N, " |
| 70 | "a full 64-char SHA, or any unambiguous SHA prefix." |
| 71 | ), |
| 72 | ), |
| 73 | soft: bool = typer.Option( |
| 74 | False, |
| 75 | "--soft", |
| 76 | help="Move branch pointer only; muse-work/ unchanged.", |
| 77 | ), |
| 78 | mixed: bool = typer.Option( |
| 79 | False, |
| 80 | "--mixed", |
| 81 | help="Move branch pointer and reset index (default mode).", |
| 82 | ), |
| 83 | hard: bool = typer.Option( |
| 84 | False, |
| 85 | "--hard", |
| 86 | help="Move branch pointer AND overwrite muse-work/ with target snapshot.", |
| 87 | ), |
| 88 | yes: bool = typer.Option( |
| 89 | False, |
| 90 | "--yes", |
| 91 | "-y", |
| 92 | help="Skip confirmation prompt for --hard reset.", |
| 93 | ), |
| 94 | ) -> None: |
| 95 | """Reset the branch pointer to a prior commit.""" |
| 96 | # ── Resolve mode (default: mixed) ──────────────────────────────────── |
| 97 | mode_count = sum([soft, mixed, hard]) |
| 98 | if mode_count > 1: |
| 99 | typer.echo("❌ Specify at most one of --soft, --mixed, or --hard.") |
| 100 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 101 | |
| 102 | if hard: |
| 103 | mode = ResetMode.HARD |
| 104 | elif soft: |
| 105 | mode = ResetMode.SOFT |
| 106 | else: |
| 107 | mode = ResetMode.MIXED # default |
| 108 | |
| 109 | # ── Hard-mode confirmation ──────────────────────────────────────────── |
| 110 | if mode is ResetMode.HARD and not yes: |
| 111 | typer.echo( |
| 112 | "⚠️ muse reset --hard will OVERWRITE muse-work/ with the target snapshot.\n" |
| 113 | " All uncommitted changes will be LOST." |
| 114 | ) |
| 115 | confirmed = typer.confirm("Proceed?", default=False) |
| 116 | if not confirmed: |
| 117 | typer.echo("Reset aborted.") |
| 118 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 119 | |
| 120 | root = require_repo() |
| 121 | |
| 122 | async def _run() -> None: |
| 123 | async with open_session() as session: |
| 124 | result = await perform_reset( |
| 125 | root=root, |
| 126 | session=session, |
| 127 | ref=commit, |
| 128 | mode=mode, |
| 129 | ) |
| 130 | |
| 131 | if mode is ResetMode.HARD: |
| 132 | typer.echo( |
| 133 | f"✅ HEAD is now at {result.target_commit_id[:8]} " |
| 134 | f"({result.files_restored} files restored, " |
| 135 | f"{result.files_deleted} files deleted)" |
| 136 | ) |
| 137 | else: |
| 138 | typer.echo( |
| 139 | f"✅ HEAD is now at {result.target_commit_id[:8]}" |
| 140 | ) |
| 141 | |
| 142 | try: |
| 143 | asyncio.run(_run()) |
| 144 | except typer.Exit: |
| 145 | raise |
| 146 | except MissingObjectError as exc: |
| 147 | typer.echo(f"❌ {exc}") |
| 148 | logger.error("❌ muse reset hard: %s", exc) |
| 149 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 150 | except Exception as exc: |
| 151 | typer.echo(f"❌ muse reset failed: {exc}") |
| 152 | logger.error("❌ muse reset error: %s", exc, exc_info=True) |
| 153 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |