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