restore.py
python
| 1 | """muse restore — restore specific files from a commit or index. |
| 2 | |
| 3 | Surgical file-level restore: bring back "the bass from take 3" without |
| 4 | touching any other track. Unlike ``muse reset --hard`` (which resets the |
| 5 | entire working tree), ``restore`` targets individual paths only. |
| 6 | |
| 7 | Usage patterns |
| 8 | -------------- |
| 9 | Restore from HEAD (default):: |
| 10 | |
| 11 | muse restore muse-work/bass/bassline.mid |
| 12 | |
| 13 | Restore the index entry from HEAD (``--staged``):: |
| 14 | |
| 15 | muse restore --staged muse-work/bass/bassline.mid |
| 16 | |
| 17 | Restore from a specific commit:: |
| 18 | |
| 19 | muse restore --source <commit> muse-work/drums/kick.mid |
| 20 | |
| 21 | Restore both worktree and staged (explicit ``--worktree``):: |
| 22 | |
| 23 | muse restore --worktree --source <commit> muse-work/drums/kick.mid muse-work/bass/bassline.mid |
| 24 | |
| 25 | Exit codes |
| 26 | ---------- |
| 27 | 0 success |
| 28 | 1 user error (path not in snapshot, ref not found, no commits) |
| 29 | 2 not a Muse repo |
| 30 | 3 internal error (DB inconsistency, missing object blobs) |
| 31 | """ |
| 32 | from __future__ import annotations |
| 33 | |
| 34 | import asyncio |
| 35 | import logging |
| 36 | from typing import Optional |
| 37 | |
| 38 | import typer |
| 39 | |
| 40 | from maestro.muse_cli._repo import require_repo |
| 41 | from maestro.muse_cli.db import open_session |
| 42 | from maestro.muse_cli.errors import ExitCode |
| 43 | from maestro.services.muse_reset import MissingObjectError |
| 44 | from maestro.services.muse_restore import PathNotInSnapshotError, perform_restore |
| 45 | |
| 46 | logger = logging.getLogger(__name__) |
| 47 | |
| 48 | app = typer.Typer() |
| 49 | |
| 50 | |
| 51 | @app.callback(invoke_without_command=True) |
| 52 | def restore( |
| 53 | ctx: typer.Context, |
| 54 | paths: list[str] = typer.Argument( |
| 55 | ..., |
| 56 | help=( |
| 57 | "One or more relative paths within muse-work/ to restore. " |
| 58 | "Accepts paths with or without the 'muse-work/' prefix." |
| 59 | ), |
| 60 | ), |
| 61 | staged: bool = typer.Option( |
| 62 | False, |
| 63 | "--staged", |
| 64 | help=( |
| 65 | "Restore the index (snapshot manifest) entry for the path from " |
| 66 | "the source commit rather than muse-work/. In the current Muse " |
| 67 | "model (no separate staging area) this is equivalent to --worktree." |
| 68 | ), |
| 69 | ), |
| 70 | worktree: bool = typer.Option( |
| 71 | False, |
| 72 | "--worktree", |
| 73 | help=( |
| 74 | "Restore muse-work/ files from the source snapshot. " |
| 75 | "This is the default behaviour when no mode flag is specified." |
| 76 | ), |
| 77 | ), |
| 78 | source: Optional[str] = typer.Option( |
| 79 | None, |
| 80 | "--source", |
| 81 | "-s", |
| 82 | help=( |
| 83 | "Commit reference to restore from: HEAD, HEAD~N, a full SHA, or " |
| 84 | "any unambiguous SHA prefix. Defaults to HEAD when omitted." |
| 85 | ), |
| 86 | ), |
| 87 | ) -> None: |
| 88 | """Restore specific files from a commit or index into muse-work/.""" |
| 89 | if not paths: |
| 90 | typer.echo("❌ At least one path is required.") |
| 91 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 92 | |
| 93 | root = require_repo() |
| 94 | |
| 95 | async def _run() -> None: |
| 96 | async with open_session() as session: |
| 97 | result = await perform_restore( |
| 98 | root=root, |
| 99 | session=session, |
| 100 | paths=paths, |
| 101 | source_ref=source, |
| 102 | staged=staged, |
| 103 | ) |
| 104 | |
| 105 | short_id = result.source_commit_id[:8] |
| 106 | if len(result.paths_restored) == 1: |
| 107 | typer.echo( |
| 108 | f"✅ Restored {result.paths_restored[0]!r} from commit {short_id}" |
| 109 | ) |
| 110 | else: |
| 111 | typer.echo( |
| 112 | f"✅ Restored {len(result.paths_restored)} files from commit {short_id}:" |
| 113 | ) |
| 114 | for p in result.paths_restored: |
| 115 | typer.echo(f" • {p}") |
| 116 | |
| 117 | try: |
| 118 | asyncio.run(_run()) |
| 119 | except typer.Exit: |
| 120 | raise |
| 121 | except PathNotInSnapshotError as exc: |
| 122 | typer.echo(f"❌ {exc}") |
| 123 | logger.error("❌ muse restore: %s", exc) |
| 124 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 125 | except MissingObjectError as exc: |
| 126 | typer.echo(f"❌ {exc}") |
| 127 | logger.error("❌ muse restore: missing object: %s", exc) |
| 128 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 129 | except Exception as exc: |
| 130 | typer.echo(f"❌ muse restore failed: {exc}") |
| 131 | logger.error("❌ muse restore error: %s", exc, exc_info=True) |
| 132 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |