cgcardona / muse public
stash.py python
410 lines 12.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse stash — temporarily shelve uncommitted muse-work/ changes.
2
3 Stash is a per-producer scratch pad: changes in muse-work/ are saved to
4 ``.muse/stash/`` (filesystem, no DB table) and HEAD is restored so you can
5 start clean. Later, ``muse stash pop`` brings back the shelved state.
6
7 Subcommands
8 -----------
9 push (default) — save muse-work/ state; restore HEAD snapshot
10 pop — apply most recent stash, remove it from the stack
11 apply — apply a stash without removing it
12 list — list all stash entries
13 drop — remove a specific entry
14 clear — remove all entries
15
16 Usage examples::
17
18 muse stash # push (save + restore HEAD)
19 muse stash push -m "chorus WIP" # push with a label
20 muse stash push --track drums # scope to drums/ files only
21 muse stash pop # apply most recent, drop it
22 muse stash apply stash@{2} # apply index 2 without dropping
23 muse stash list # show all stash entries
24 muse stash drop stash@{1} # remove index 1
25 muse stash clear # remove all (with confirmation)
26 """
27 from __future__ import annotations
28
29 import asyncio
30 import json
31 import logging
32 import pathlib
33 from typing import Optional
34
35 import typer
36
37 from maestro.muse_cli._repo import require_repo
38 from maestro.muse_cli.db import open_session
39 from maestro.muse_cli.errors import ExitCode
40 from maestro.services.muse_stash import (
41 StashApplyResult,
42 StashPushResult,
43 apply_stash,
44 clear_stash,
45 drop_stash,
46 list_stash,
47 push_stash,
48 )
49
50 logger = logging.getLogger(__name__)
51
52 app = typer.Typer(
53 name="stash",
54 help="Temporarily shelve uncommitted muse-work/ changes.",
55 no_args_is_help=False,
56 )
57
58
59 # ---------------------------------------------------------------------------
60 # Helpers
61 # ---------------------------------------------------------------------------
62
63
64 def _parse_stash_ref(ref: str) -> int:
65 """Parse ``stash@{N}`` or a bare integer into a 0-based index.
66
67 Accepts ``stash@{0}``, ``stash@{2}``, or just ``0``, ``2``.
68
69 Raises:
70 typer.Exit: When *ref* cannot be parsed.
71 """
72 import re
73
74 match = re.fullmatch(r"stash@\{(\d+)\}", ref.strip())
75 if match:
76 return int(match.group(1))
77 try:
78 return int(ref.strip())
79 except ValueError:
80 typer.echo(f"❌ Invalid stash reference: {ref!r}. Expected stash@{{N}} or N.")
81 raise typer.Exit(code=ExitCode.USER_ERROR)
82
83
84 async def _get_head_manifest(
85 root: pathlib.Path,
86 ) -> dict[str, str] | None:
87 """Return the snapshot manifest for the current HEAD commit.
88
89 Returns ``None`` when the branch has no commits or the DB is unreachable.
90 """
91 from maestro.muse_cli.db import get_commit_snapshot_manifest
92 from maestro.muse_cli.models import MuseCliCommit
93
94 import json as _json
95
96 muse_dir = root / ".muse"
97
98 try:
99 repo_data: dict[str, str] = _json.loads((muse_dir / "repo.json").read_text())
100 repo_id = repo_data["repo_id"]
101
102 head_ref = (muse_dir / "HEAD").read_text().strip()
103 ref_path = muse_dir / pathlib.Path(head_ref)
104 if not ref_path.exists():
105 return None
106 commit_id = ref_path.read_text().strip()
107 if not commit_id:
108 return None
109
110 from sqlalchemy.future import select
111
112 async with open_session() as session:
113 result = await session.execute(
114 select(MuseCliCommit).where(
115 MuseCliCommit.repo_id == repo_id,
116 MuseCliCommit.commit_id == commit_id,
117 )
118 )
119 commit = result.scalar_one_or_none()
120 if commit is None:
121 return None
122 manifest = await get_commit_snapshot_manifest(session, commit_id)
123 return manifest
124 except Exception as exc:
125 logger.warning("⚠️ Could not load HEAD manifest: %s", exc)
126 return None
127
128
129 # ---------------------------------------------------------------------------
130 # push (default command)
131 # ---------------------------------------------------------------------------
132
133
134 @app.callback(invoke_without_command=True)
135 def stash_default(
136 ctx: typer.Context,
137 message: Optional[str] = typer.Option(
138 None,
139 "--message",
140 "-m",
141 help="Label for this stash entry.",
142 ),
143 track: Optional[str] = typer.Option(
144 None,
145 "--track",
146 help="Scope the stash to files under tracks/<TRACK>/.",
147 ),
148 section: Optional[str] = typer.Option(
149 None,
150 "--section",
151 help="Scope the stash to files under sections/<SECTION>/.",
152 ),
153 ) -> None:
154 """Save muse-work/ changes and restore HEAD snapshot (default: push)."""
155 if ctx.invoked_subcommand is not None:
156 return
157
158 root = require_repo()
159
160 # Load HEAD manifest to restore working tree after stashing.
161 head_manifest: dict[str, str] | None = asyncio.run(_get_head_manifest(root))
162
163 try:
164 result = push_stash(
165 root,
166 message=message,
167 track=track,
168 section=section,
169 head_manifest=head_manifest,
170 )
171 except Exception as exc:
172 typer.echo(f"❌ muse stash push failed: {exc}")
173 logger.error("❌ muse stash push error: %s", exc, exc_info=True)
174 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
175
176 if result.files_stashed == 0:
177 typer.echo("⚠️ No local changes to stash.")
178 return
179
180 typer.echo(f"Saved working directory and index state {result.stash_ref}")
181 typer.echo(f"{result.message}")
182
183 if result.missing_head:
184 typer.echo(
185 "⚠️ Some HEAD files could not be restored (object store incomplete):\n"
186 + "\n".join(f" {p}" for p in result.missing_head)
187 )
188
189
190 # ---------------------------------------------------------------------------
191 # push subcommand (explicit)
192 # ---------------------------------------------------------------------------
193
194
195 @app.command("push")
196 def stash_push(
197 message: Optional[str] = typer.Option(
198 None,
199 "--message",
200 "-m",
201 help="Label for this stash entry.",
202 ),
203 track: Optional[str] = typer.Option(
204 None,
205 "--track",
206 help="Scope the stash to files under tracks/<TRACK>/.",
207 ),
208 section: Optional[str] = typer.Option(
209 None,
210 "--section",
211 help="Scope the stash to files under sections/<SECTION>/.",
212 ),
213 ) -> None:
214 """Save muse-work/ changes and restore HEAD snapshot."""
215 root = require_repo()
216
217 head_manifest: dict[str, str] | None = asyncio.run(_get_head_manifest(root))
218
219 try:
220 result = push_stash(
221 root,
222 message=message,
223 track=track,
224 section=section,
225 head_manifest=head_manifest,
226 )
227 except Exception as exc:
228 typer.echo(f"❌ muse stash push failed: {exc}")
229 logger.error("❌ muse stash push error: %s", exc, exc_info=True)
230 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
231
232 if result.files_stashed == 0:
233 typer.echo("⚠️ No local changes to stash.")
234 return
235
236 typer.echo(f"Saved working directory and index state {result.stash_ref}")
237 typer.echo(f"{result.message}")
238
239 if result.missing_head:
240 typer.echo(
241 "⚠️ Some HEAD files could not be restored (object store incomplete):\n"
242 + "\n".join(f" {p}" for p in result.missing_head)
243 )
244
245
246 # ---------------------------------------------------------------------------
247 # pop
248 # ---------------------------------------------------------------------------
249
250
251 @app.command("pop")
252 def stash_pop(
253 stash_ref: str = typer.Argument(
254 "stash@{0}",
255 help="Which stash entry to pop (default: stash@{0}).",
256 ),
257 ) -> None:
258 """Apply the most recent stash and remove it from the stack."""
259 root = require_repo()
260 index = _parse_stash_ref(stash_ref)
261
262 try:
263 result = apply_stash(root, index, drop=True)
264 except IndexError as exc:
265 typer.echo(f"❌ {exc}")
266 raise typer.Exit(code=ExitCode.USER_ERROR)
267 except Exception as exc:
268 typer.echo(f"❌ muse stash pop failed: {exc}")
269 logger.error("❌ muse stash pop error: %s", exc, exc_info=True)
270 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
271
272 typer.echo(f"✅ Applied {result.stash_ref}: {result.message}")
273 typer.echo(f" {result.files_applied} file(s) restored.")
274
275 if result.missing:
276 typer.echo(
277 "⚠️ Some files could not be restored (object store incomplete):\n"
278 + "\n".join(f" missing: {p}" for p in result.missing)
279 )
280
281 typer.echo(f"Dropped {result.stash_ref}")
282
283
284 # ---------------------------------------------------------------------------
285 # apply
286 # ---------------------------------------------------------------------------
287
288
289 @app.command("apply")
290 def stash_apply(
291 stash_ref: str = typer.Argument(
292 "stash@{0}",
293 help="Stash reference to apply (e.g. stash@{0}, stash@{2}).",
294 ),
295 ) -> None:
296 """Apply a stash entry without removing it from the stack."""
297 root = require_repo()
298 index = _parse_stash_ref(stash_ref)
299
300 try:
301 result = apply_stash(root, index, drop=False)
302 except IndexError as exc:
303 typer.echo(f"❌ {exc}")
304 raise typer.Exit(code=ExitCode.USER_ERROR)
305 except Exception as exc:
306 typer.echo(f"❌ muse stash apply failed: {exc}")
307 logger.error("❌ muse stash apply error: %s", exc, exc_info=True)
308 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
309
310 typer.echo(f"✅ Applied {result.stash_ref}: {result.message}")
311 typer.echo(f" {result.files_applied} file(s) restored.")
312
313 if result.missing:
314 typer.echo(
315 "⚠️ Some files could not be restored (object store incomplete):\n"
316 + "\n".join(f" missing: {p}" for p in result.missing)
317 )
318
319
320 # ---------------------------------------------------------------------------
321 # list
322 # ---------------------------------------------------------------------------
323
324
325 @app.command("list")
326 def stash_list() -> None:
327 """List all stash entries."""
328 root = require_repo()
329
330 try:
331 entries = list_stash(root)
332 except Exception as exc:
333 typer.echo(f"❌ muse stash list failed: {exc}")
334 logger.error("❌ muse stash list error: %s", exc, exc_info=True)
335 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
336
337 if not entries:
338 typer.echo("No stash entries.")
339 return
340
341 for entry in entries:
342 typer.echo(f"stash@{{{entry.index}}}: On {entry.branch}: {entry.message}")
343
344
345 # ---------------------------------------------------------------------------
346 # drop
347 # ---------------------------------------------------------------------------
348
349
350 @app.command("drop")
351 def stash_drop(
352 stash_ref: str = typer.Argument(
353 "stash@{0}",
354 help="Stash reference to drop (e.g. stash@{0}, stash@{2}).",
355 ),
356 ) -> None:
357 """Remove a specific stash entry without applying it."""
358 root = require_repo()
359 index = _parse_stash_ref(stash_ref)
360
361 try:
362 entry = drop_stash(root, index)
363 except IndexError as exc:
364 typer.echo(f"❌ {exc}")
365 raise typer.Exit(code=ExitCode.USER_ERROR)
366 except Exception as exc:
367 typer.echo(f"❌ muse stash drop failed: {exc}")
368 logger.error("❌ muse stash drop error: %s", exc, exc_info=True)
369 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
370
371 typer.echo(f"✅ Dropped stash@{{{entry.index}}}: {entry.message}")
372
373
374 # ---------------------------------------------------------------------------
375 # clear
376 # ---------------------------------------------------------------------------
377
378
379 @app.command("clear")
380 def stash_clear(
381 yes: bool = typer.Option(
382 False,
383 "--yes",
384 "-y",
385 help="Skip confirmation prompt.",
386 ),
387 ) -> None:
388 """Remove all stash entries."""
389 root = require_repo()
390
391 if not yes:
392 confirmed = typer.confirm(
393 "⚠️ This will permanently remove ALL stash entries. Proceed?",
394 default=False,
395 )
396 if not confirmed:
397 typer.echo("Aborted.")
398 raise typer.Exit(code=ExitCode.SUCCESS)
399
400 try:
401 count = clear_stash(root)
402 except Exception as exc:
403 typer.echo(f"❌ muse stash clear failed: {exc}")
404 logger.error("❌ muse stash clear error: %s", exc, exc_info=True)
405 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
406
407 if count == 0:
408 typer.echo("No stash entries to clear.")
409 else:
410 typer.echo(f"✅ Cleared {count} stash entr{'y' if count == 1 else 'ies'}.")