cgcardona / muse public
cherry_pick.py python
129 lines 4.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse cherry-pick — apply a specific commit's diff on top of HEAD.
2
3 Transplants the changes introduced by a single commit (from any branch)
4 onto the current branch, without bringing in other commits from that branch.
5
6 Domain analogy: a producer recorded the perfect guitar solo in
7 ``experiment/guitar-solo``. ``muse cherry-pick <commit>`` transplants just
8 that solo into main, leaving the other 20 unrelated commits behind.
9
10 Flags
11 -----
12 COMMIT TEXT Commit ID to cherry-pick (required, positional, accepts prefix).
13 --no-commit Apply the changes to muse-work/ without committing.
14 --continue Resume after resolving conflicts from a previous cherry-pick.
15 --abort Abort in-progress cherry-pick and restore pre-cherry-pick state.
16 """
17 from __future__ import annotations
18
19 import asyncio
20 import logging
21 from typing import Optional
22
23 import typer
24
25 from maestro.muse_cli._repo import require_repo
26 from maestro.muse_cli.db import open_session
27 from maestro.muse_cli.errors import ExitCode
28 from maestro.services.muse_cherry_pick import (
29 _cherry_pick_abort_async,
30 _cherry_pick_async,
31 _cherry_pick_continue_async,
32 )
33
34 logger = logging.getLogger(__name__)
35
36 app = typer.Typer()
37
38
39 @app.callback(invoke_without_command=True)
40 def cherry_pick(
41 ctx: typer.Context,
42 commit: Optional[str] = typer.Argument(
43 None,
44 help="Commit ID to cherry-pick (full or abbreviated SHA).",
45 metavar="COMMIT",
46 ),
47 no_commit: bool = typer.Option(
48 False,
49 "--no-commit",
50 is_flag=True,
51 help=(
52 "Apply the cherry-pick changes to muse-work/ without creating a new commit. "
53 "Useful for inspecting or further editing the result before committing."
54 ),
55 ),
56 cont: bool = typer.Option(
57 False,
58 "--continue",
59 is_flag=True,
60 help="Resume after resolving conflicts from a previous cherry-pick.",
61 ),
62 abort: bool = typer.Option(
63 False,
64 "--abort",
65 is_flag=True,
66 help=(
67 "Abort an in-progress cherry-pick and restore the branch to its "
68 "pre-cherry-pick state."
69 ),
70 ),
71 ) -> None:
72 """Apply a specific commit's diff on top of HEAD without merging the full branch."""
73 root = require_repo()
74
75 if abort:
76 async def _run_abort() -> None:
77 async with open_session() as session:
78 await _cherry_pick_abort_async(root=root, session=session)
79
80 try:
81 asyncio.run(_run_abort())
82 except typer.Exit:
83 raise
84 except Exception as exc:
85 typer.echo(f"❌ muse cherry-pick --abort failed: {exc}")
86 logger.error("❌ muse cherry-pick --abort error: %s", exc, exc_info=True)
87 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
88 return
89
90 if cont:
91 async def _run_continue() -> None:
92 async with open_session() as session:
93 await _cherry_pick_continue_async(root=root, session=session)
94
95 try:
96 asyncio.run(_run_continue())
97 except typer.Exit:
98 raise
99 except Exception as exc:
100 typer.echo(f"❌ muse cherry-pick --continue failed: {exc}")
101 logger.error(
102 "❌ muse cherry-pick --continue error: %s", exc, exc_info=True
103 )
104 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
105 return
106
107 if not commit:
108 typer.echo(
109 "❌ Commit ID required (or use --continue to resume, --abort to cancel)."
110 )
111 raise typer.Exit(code=ExitCode.USER_ERROR)
112
113 async def _run() -> None:
114 async with open_session() as session:
115 await _cherry_pick_async(
116 commit_ref=commit,
117 root=root,
118 session=session,
119 no_commit=no_commit,
120 )
121
122 try:
123 asyncio.run(_run())
124 except typer.Exit:
125 raise
126 except Exception as exc:
127 typer.echo(f"❌ muse cherry-pick failed: {exc}")
128 logger.error("❌ muse cherry-pick error: %s", exc, exc_info=True)
129 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)