muse_cherry_pick.py
python
| 1 | """Muse Cherry-Pick Service — apply a specific commit's diff on top of HEAD. |
| 2 | |
| 3 | Cherry-pick is the surgical transplant: given a source commit C with parent P, |
| 4 | compute diff(P → C) and apply that patch to the current HEAD snapshot. The |
| 5 | result is a new commit whose content is HEAD's snapshot plus the delta that C |
| 6 | introduced, without bringing in any other commits from C's branch. |
| 7 | |
| 8 | Algorithm (3-way merge model) |
| 9 | ------------------------------ |
| 10 | 1. Resolve C and its parent P. |
| 11 | 2. Load manifests: ``base`` = P, ``ours`` = HEAD, ``theirs`` = C. |
| 12 | 3. Compute ``cherry_diff`` = diff(P → C) — the set of paths C changed. |
| 13 | 4. Compute ``head_diff`` = diff(P → HEAD) — paths HEAD changed since P. |
| 14 | 5. Conflicts = paths in cherry_diff ∩ head_diff where both sides disagree. |
| 15 | 6. If conflicts: write ``.muse/CHERRY_PICK_STATE.json`` and exit 1. |
| 16 | 7. If clean: build result manifest (HEAD + cherry delta), persist, create commit. |
| 17 | |
| 18 | State file: ``.muse/CHERRY_PICK_STATE.json`` |
| 19 | --------------------------------------------- |
| 20 | Written when conflicts are detected, consumed by ``--continue`` and ``--abort``. |
| 21 | |
| 22 | .. code-block:: json |
| 23 | |
| 24 | { |
| 25 | "cherry_commit": "abc123...", |
| 26 | "head_commit": "def456...", |
| 27 | "conflict_paths": ["beat.mid"] |
| 28 | } |
| 29 | |
| 30 | Boundary rules: |
| 31 | - Must NOT import StateStore, EntityRegistry, or get_or_create_store. |
| 32 | - Must NOT import executor modules or maestro_* handlers. |
| 33 | - May import muse_cli.db, muse_cli.models, muse_cli.merge_engine, |
| 34 | muse_cli.snapshot. |
| 35 | |
| 36 | Domain analogy: a producer recorded the perfect guitar solo in an experimental |
| 37 | branch. ``muse cherry-pick <commit>`` transplants just that solo into main, |
| 38 | leaving the other 20 unrelated commits behind. |
| 39 | """ |
| 40 | from __future__ import annotations |
| 41 | |
| 42 | import datetime |
| 43 | import json |
| 44 | import logging |
| 45 | import pathlib |
| 46 | from dataclasses import dataclass, field |
| 47 | |
| 48 | from sqlalchemy.ext.asyncio import AsyncSession |
| 49 | |
| 50 | from maestro.muse_cli.db import ( |
| 51 | get_commit_snapshot_manifest, |
| 52 | insert_commit, |
| 53 | resolve_commit_ref, |
| 54 | upsert_snapshot, |
| 55 | ) |
| 56 | from maestro.muse_cli.merge_engine import diff_snapshots, read_merge_state |
| 57 | from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot |
| 58 | from maestro.muse_cli.snapshot import compute_commit_id, compute_snapshot_id |
| 59 | |
| 60 | logger = logging.getLogger(__name__) |
| 61 | |
| 62 | _CHERRY_PICK_STATE_FILENAME = "CHERRY_PICK_STATE.json" |
| 63 | |
| 64 | |
| 65 | # --------------------------------------------------------------------------- |
| 66 | # Result types |
| 67 | # --------------------------------------------------------------------------- |
| 68 | |
| 69 | |
| 70 | @dataclass(frozen=True) |
| 71 | class CherryPickState: |
| 72 | """Describes an in-progress cherry-pick with unresolved conflicts. |
| 73 | |
| 74 | Attributes: |
| 75 | cherry_commit: Commit ID being cherry-picked. |
| 76 | head_commit: Commit ID of HEAD when the cherry-pick was initiated. |
| 77 | conflict_paths: Relative POSIX paths that have unresolved conflicts. |
| 78 | """ |
| 79 | |
| 80 | cherry_commit: str |
| 81 | head_commit: str |
| 82 | conflict_paths: list[str] = field(default_factory=list) |
| 83 | |
| 84 | |
| 85 | @dataclass(frozen=True) |
| 86 | class CherryPickResult: |
| 87 | """Outcome of a ``muse cherry-pick`` operation. |
| 88 | |
| 89 | Attributes: |
| 90 | commit_id: New commit ID (empty when ``no_commit=True`` or conflict). |
| 91 | cherry_commit_id: Source commit that was cherry-picked. |
| 92 | head_commit_id: HEAD commit at cherry-pick time. |
| 93 | new_snapshot_id: Snapshot ID of the resulting state. |
| 94 | message: Commit message (prefixed with cherry-pick attribution). |
| 95 | no_commit: True when ``--no-commit`` was requested. |
| 96 | conflict: True when conflicts were detected (state file written). |
| 97 | conflict_paths: Conflicting paths (non-empty iff ``conflict=True``). |
| 98 | branch: Branch on which the new commit was created. |
| 99 | """ |
| 100 | |
| 101 | commit_id: str |
| 102 | cherry_commit_id: str |
| 103 | head_commit_id: str |
| 104 | new_snapshot_id: str |
| 105 | message: str |
| 106 | no_commit: bool |
| 107 | conflict: bool |
| 108 | conflict_paths: tuple[str, ...] |
| 109 | branch: str |
| 110 | |
| 111 | |
| 112 | # --------------------------------------------------------------------------- |
| 113 | # State file helpers |
| 114 | # --------------------------------------------------------------------------- |
| 115 | |
| 116 | |
| 117 | def read_cherry_pick_state(root: pathlib.Path) -> CherryPickState | None: |
| 118 | """Return :class:`CherryPickState` if a cherry-pick is in progress, else ``None``. |
| 119 | |
| 120 | Reads ``.muse/CHERRY_PICK_STATE.json``. Returns ``None`` when absent or unparseable. |
| 121 | |
| 122 | Args: |
| 123 | root: Repository root (directory containing ``.muse/``). |
| 124 | """ |
| 125 | path = root / ".muse" / _CHERRY_PICK_STATE_FILENAME |
| 126 | if not path.exists(): |
| 127 | return None |
| 128 | try: |
| 129 | data: dict[str, object] = json.loads(path.read_text()) |
| 130 | except (json.JSONDecodeError, OSError) as exc: |
| 131 | logger.warning("⚠️ Failed to read %s: %s", _CHERRY_PICK_STATE_FILENAME, exc) |
| 132 | return None |
| 133 | |
| 134 | raw_conflicts = data.get("conflict_paths", []) |
| 135 | conflict_paths: list[str] = ( |
| 136 | [str(c) for c in raw_conflicts] if isinstance(raw_conflicts, list) else [] |
| 137 | ) |
| 138 | return CherryPickState( |
| 139 | cherry_commit=str(data.get("cherry_commit", "")), |
| 140 | head_commit=str(data.get("head_commit", "")), |
| 141 | conflict_paths=conflict_paths, |
| 142 | ) |
| 143 | |
| 144 | |
| 145 | def write_cherry_pick_state( |
| 146 | root: pathlib.Path, |
| 147 | *, |
| 148 | cherry_commit: str, |
| 149 | head_commit: str, |
| 150 | conflict_paths: list[str], |
| 151 | ) -> None: |
| 152 | """Write ``.muse/CHERRY_PICK_STATE.json`` to record a paused cherry-pick. |
| 153 | |
| 154 | Args: |
| 155 | root: Repository root. |
| 156 | cherry_commit: Commit ID being cherry-picked. |
| 157 | head_commit: Commit ID of HEAD at cherry-pick time. |
| 158 | conflict_paths: Paths with unresolved conflicts. |
| 159 | """ |
| 160 | state_path = root / ".muse" / _CHERRY_PICK_STATE_FILENAME |
| 161 | data: dict[str, object] = { |
| 162 | "cherry_commit": cherry_commit, |
| 163 | "head_commit": head_commit, |
| 164 | "conflict_paths": sorted(conflict_paths), |
| 165 | } |
| 166 | state_path.write_text(json.dumps(data, indent=2)) |
| 167 | logger.info( |
| 168 | "✅ Wrote CHERRY_PICK_STATE.json with %d conflict(s)", len(conflict_paths) |
| 169 | ) |
| 170 | |
| 171 | |
| 172 | def clear_cherry_pick_state(root: pathlib.Path) -> None: |
| 173 | """Remove ``.muse/CHERRY_PICK_STATE.json`` after a successful or aborted cherry-pick.""" |
| 174 | state_path = root / ".muse" / _CHERRY_PICK_STATE_FILENAME |
| 175 | if state_path.exists(): |
| 176 | state_path.unlink() |
| 177 | logger.debug("✅ Cleared CHERRY_PICK_STATE.json") |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # Pure helpers |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | |
| 185 | def compute_cherry_manifest( |
| 186 | *, |
| 187 | base_manifest: dict[str, str], |
| 188 | head_manifest: dict[str, str], |
| 189 | cherry_manifest: dict[str, str], |
| 190 | cherry_diff: set[str], |
| 191 | head_diff: set[str], |
| 192 | ) -> tuple[dict[str, str], set[str]]: |
| 193 | """Apply the cherry-pick delta onto the HEAD manifest. |
| 194 | |
| 195 | For each path in ``cherry_diff``: |
| 196 | - If also in ``head_diff`` AND both sides have different values → conflict. |
| 197 | - Otherwise take the cherry version (or remove the path if deleted by cherry). |
| 198 | |
| 199 | Paths not in ``cherry_diff`` remain at their HEAD values. |
| 200 | |
| 201 | Args: |
| 202 | base_manifest: Manifest of the cherry commit's parent (P). |
| 203 | head_manifest: Manifest of HEAD (ours). |
| 204 | cherry_manifest: Manifest of the cherry commit (C). |
| 205 | cherry_diff: Paths changed by C relative to P. |
| 206 | head_diff: Paths changed by HEAD relative to P. |
| 207 | |
| 208 | Returns: |
| 209 | Tuple of (result_manifest, conflict_paths) where ``conflict_paths`` |
| 210 | is empty for a clean cherry-pick. |
| 211 | """ |
| 212 | result = dict(head_manifest) |
| 213 | conflicts: set[str] = set() |
| 214 | |
| 215 | for path in cherry_diff: |
| 216 | cherry_oid = cherry_manifest.get(path) |
| 217 | head_oid = head_manifest.get(path) |
| 218 | base_oid = base_manifest.get(path) |
| 219 | |
| 220 | if path in head_diff: |
| 221 | # Both sides changed this path since the base |
| 222 | if cherry_oid == head_oid: |
| 223 | # Same outcome on both sides — not a real conflict |
| 224 | pass |
| 225 | else: |
| 226 | conflicts.add(path) |
| 227 | continue # leave HEAD's version in result for now |
| 228 | |
| 229 | # Apply the cherry change: add/modify or delete |
| 230 | if cherry_oid is not None: |
| 231 | result[path] = cherry_oid |
| 232 | else: |
| 233 | # Cherry deleted this path |
| 234 | result.pop(path, None) |
| 235 | |
| 236 | return result, conflicts |
| 237 | |
| 238 | |
| 239 | # --------------------------------------------------------------------------- |
| 240 | # Async core |
| 241 | # --------------------------------------------------------------------------- |
| 242 | |
| 243 | |
| 244 | async def _cherry_pick_async( |
| 245 | *, |
| 246 | commit_ref: str, |
| 247 | root: pathlib.Path, |
| 248 | session: AsyncSession, |
| 249 | no_commit: bool = False, |
| 250 | ) -> CherryPickResult: |
| 251 | """Core cherry-pick pipeline — resolve, validate, apply, and commit. |
| 252 | |
| 253 | Called by the CLI callback and by tests. All filesystem and DB |
| 254 | side-effects are isolated here so tests can inject an in-memory SQLite |
| 255 | session and a ``tmp_path`` root. |
| 256 | |
| 257 | Args: |
| 258 | commit_ref: Commit ID (full or abbreviated) to cherry-pick. |
| 259 | root: Repo root (must contain ``.muse/``). |
| 260 | session: Async DB session (caller owns commit/rollback lifecycle). |
| 261 | no_commit: When ``True``, stage changes to muse-work/ but do not |
| 262 | create a new commit record. |
| 263 | |
| 264 | Returns: |
| 265 | :class:`CherryPickResult` describing what happened. |
| 266 | |
| 267 | Raises: |
| 268 | ``typer.Exit`` with an appropriate exit code on user-facing errors. |
| 269 | """ |
| 270 | import typer |
| 271 | |
| 272 | from maestro.muse_cli.errors import ExitCode |
| 273 | |
| 274 | muse_dir = root / ".muse" |
| 275 | |
| 276 | # ── Guard: block if merge is in progress ───────────────────────────── |
| 277 | merge_state = read_merge_state(root) |
| 278 | if merge_state is not None and merge_state.conflict_paths: |
| 279 | typer.echo( |
| 280 | "❌ Cherry-pick blocked: unresolved merge conflicts in progress.\n" |
| 281 | " Resolve all conflicts, then run 'muse commit' before cherry-picking." |
| 282 | ) |
| 283 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 284 | |
| 285 | # ── Guard: block if cherry-pick already in progress ────────────────── |
| 286 | existing_state = read_cherry_pick_state(root) |
| 287 | if existing_state is not None: |
| 288 | typer.echo( |
| 289 | "❌ Cherry-pick already in progress.\n" |
| 290 | " Resolve conflicts and run 'muse cherry-pick --continue', or\n" |
| 291 | " run 'muse cherry-pick --abort' to cancel." |
| 292 | ) |
| 293 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 294 | |
| 295 | # ── Repo identity ──────────────────────────────────────────────────── |
| 296 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 297 | repo_id = repo_data["repo_id"] |
| 298 | |
| 299 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 300 | branch = head_ref.rsplit("/", 1)[-1] |
| 301 | |
| 302 | # ── Resolve HEAD from the branch ref file (not DB ordering) ────────── |
| 303 | # Reading the ref file directly is the authoritative source for HEAD, |
| 304 | # because the DB committed_at ordering does not reflect manual resets. |
| 305 | branch_ref_path = muse_dir / pathlib.Path(head_ref) |
| 306 | head_commit_id = ( |
| 307 | branch_ref_path.read_text().strip() if branch_ref_path.exists() else "" |
| 308 | ) |
| 309 | if not head_commit_id: |
| 310 | typer.echo("❌ Current branch has no commits. Cannot cherry-pick onto an empty branch.") |
| 311 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 312 | |
| 313 | head_commit = await session.get(MuseCliCommit, head_commit_id) |
| 314 | if head_commit is None: |
| 315 | typer.echo(f"❌ HEAD commit {head_commit_id[:8]} not found in DB.") |
| 316 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 317 | |
| 318 | # ── Resolve cherry commit ──────────────────────────────────────────── |
| 319 | cherry_commit = await resolve_commit_ref(session, repo_id, branch, commit_ref) |
| 320 | if cherry_commit is None: |
| 321 | # resolve_commit_ref only searches the current branch; try by prefix across all |
| 322 | from sqlalchemy.future import select |
| 323 | |
| 324 | stmt = select(MuseCliCommit).where( |
| 325 | MuseCliCommit.repo_id == repo_id, |
| 326 | MuseCliCommit.commit_id.startswith(commit_ref), |
| 327 | ) |
| 328 | rows = (await session.execute(stmt)).scalars().all() |
| 329 | if not rows: |
| 330 | typer.echo(f"❌ Commit not found: {commit_ref!r}") |
| 331 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 332 | if len(rows) > 1: |
| 333 | typer.echo( |
| 334 | f"❌ Ambiguous commit ref {commit_ref!r} — matches {len(rows)} commits. " |
| 335 | "Use a longer prefix." |
| 336 | ) |
| 337 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 338 | cherry_commit = rows[0] |
| 339 | |
| 340 | cherry_commit_id = cherry_commit.commit_id |
| 341 | |
| 342 | # ── Guard: cherry-pick of HEAD itself is a noop ─────────────────────── |
| 343 | if cherry_commit_id == head_commit_id: |
| 344 | typer.echo( |
| 345 | f"⚠️ Commit {cherry_commit_id[:8]} is already HEAD — nothing to cherry-pick." |
| 346 | ) |
| 347 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 348 | |
| 349 | # ── Load manifests ─────────────────────────────────────────────────── |
| 350 | # base = cherry commit's parent (P) |
| 351 | base_manifest: dict[str, str] = {} |
| 352 | if cherry_commit.parent_commit_id: |
| 353 | loaded = await get_commit_snapshot_manifest(session, cherry_commit.parent_commit_id) |
| 354 | base_manifest = loaded or {} |
| 355 | |
| 356 | cherry_manifest = await get_commit_snapshot_manifest(session, cherry_commit_id) or {} |
| 357 | |
| 358 | head_manifest: dict[str, str] = {} |
| 359 | head_snap_row = await session.get(MuseCliSnapshot, head_commit.snapshot_id) |
| 360 | if head_snap_row is not None: |
| 361 | head_manifest = dict(head_snap_row.manifest) |
| 362 | |
| 363 | # ── Compute diffs ──────────────────────────────────────────────────── |
| 364 | cherry_diff = diff_snapshots(base_manifest, cherry_manifest) |
| 365 | head_diff = diff_snapshots(base_manifest, head_manifest) |
| 366 | |
| 367 | # ── Apply cherry delta ─────────────────────────────────────────────── |
| 368 | result_manifest, conflict_paths = compute_cherry_manifest( |
| 369 | base_manifest=base_manifest, |
| 370 | head_manifest=head_manifest, |
| 371 | cherry_manifest=cherry_manifest, |
| 372 | cherry_diff=cherry_diff, |
| 373 | head_diff=head_diff, |
| 374 | ) |
| 375 | |
| 376 | result_snapshot_id = compute_snapshot_id(result_manifest) |
| 377 | |
| 378 | # ── Conflict path ───────────────────────────────────────────────────── |
| 379 | if conflict_paths: |
| 380 | write_cherry_pick_state( |
| 381 | root, |
| 382 | cherry_commit=cherry_commit_id, |
| 383 | head_commit=head_commit_id, |
| 384 | conflict_paths=sorted(conflict_paths), |
| 385 | ) |
| 386 | typer.echo(f"❌ Cherry-pick conflict in {len(conflict_paths)} file(s):") |
| 387 | for path in sorted(conflict_paths): |
| 388 | typer.echo(f"\tboth modified: {path}") |
| 389 | typer.echo( |
| 390 | "Fix conflicts and run 'muse cherry-pick --continue' to create the commit." |
| 391 | ) |
| 392 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 393 | |
| 394 | # ── Auto-generate commit message ───────────────────────────────────── |
| 395 | short_id = cherry_commit_id[:8] |
| 396 | cherry_message = ( |
| 397 | f"{cherry_commit.message}\n\n(cherry picked from commit {short_id})" |
| 398 | ) |
| 399 | |
| 400 | # ── --no-commit: return without persisting ──────────────────────────── |
| 401 | if no_commit: |
| 402 | typer.echo( |
| 403 | f"✅ Cherry-pick applied (--no-commit). " |
| 404 | f"Changes from {short_id} staged in muse-work/." |
| 405 | ) |
| 406 | return CherryPickResult( |
| 407 | commit_id="", |
| 408 | cherry_commit_id=cherry_commit_id, |
| 409 | head_commit_id=head_commit_id, |
| 410 | new_snapshot_id=result_snapshot_id, |
| 411 | message=cherry_message, |
| 412 | no_commit=True, |
| 413 | conflict=False, |
| 414 | conflict_paths=(), |
| 415 | branch=branch, |
| 416 | ) |
| 417 | |
| 418 | # ── Persist snapshot ───────────────────────────────────────────────── |
| 419 | await upsert_snapshot(session, manifest=result_manifest, snapshot_id=result_snapshot_id) |
| 420 | await session.flush() |
| 421 | |
| 422 | # ── Persist commit ─────────────────────────────────────────────────── |
| 423 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 424 | new_commit_id = compute_commit_id( |
| 425 | parent_ids=[head_commit_id], |
| 426 | snapshot_id=result_snapshot_id, |
| 427 | message=cherry_message, |
| 428 | committed_at_iso=committed_at.isoformat(), |
| 429 | ) |
| 430 | |
| 431 | new_commit = MuseCliCommit( |
| 432 | commit_id=new_commit_id, |
| 433 | repo_id=repo_id, |
| 434 | branch=branch, |
| 435 | parent_commit_id=head_commit_id, |
| 436 | snapshot_id=result_snapshot_id, |
| 437 | message=cherry_message, |
| 438 | author="", |
| 439 | committed_at=committed_at, |
| 440 | ) |
| 441 | await insert_commit(session, new_commit) |
| 442 | |
| 443 | # ── Update branch HEAD pointer ──────────────────────────────────────── |
| 444 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 445 | ref_path.parent.mkdir(parents=True, exist_ok=True) |
| 446 | ref_path.write_text(new_commit_id) |
| 447 | |
| 448 | typer.echo( |
| 449 | f"✅ [{branch} {new_commit_id[:8]}] {cherry_commit.message}\n" |
| 450 | f" (cherry picked from commit {short_id})" |
| 451 | ) |
| 452 | logger.info( |
| 453 | "✅ muse cherry-pick %s → %s on %r", |
| 454 | short_id, |
| 455 | new_commit_id[:8], |
| 456 | branch, |
| 457 | ) |
| 458 | |
| 459 | return CherryPickResult( |
| 460 | commit_id=new_commit_id, |
| 461 | cherry_commit_id=cherry_commit_id, |
| 462 | head_commit_id=head_commit_id, |
| 463 | new_snapshot_id=result_snapshot_id, |
| 464 | message=cherry_message, |
| 465 | no_commit=False, |
| 466 | conflict=False, |
| 467 | conflict_paths=(), |
| 468 | branch=branch, |
| 469 | ) |
| 470 | |
| 471 | |
| 472 | async def _cherry_pick_continue_async( |
| 473 | *, |
| 474 | root: pathlib.Path, |
| 475 | session: AsyncSession, |
| 476 | ) -> CherryPickResult: |
| 477 | """Finalize a cherry-pick that was paused due to conflicts. |
| 478 | |
| 479 | Reads ``CHERRY_PICK_STATE.json``, verifies all conflicts are cleared, |
| 480 | builds a snapshot from the current ``muse-work/`` contents, inserts a |
| 481 | commit, advances the branch pointer, and clears the state file. |
| 482 | |
| 483 | Args: |
| 484 | root: Repository root. |
| 485 | session: Open async DB session. |
| 486 | |
| 487 | Raises: |
| 488 | :class:`typer.Exit`: If no cherry-pick is in progress, unresolved |
| 489 | conflicts remain, or ``muse-work/`` is empty. |
| 490 | """ |
| 491 | import typer |
| 492 | |
| 493 | from maestro.muse_cli.errors import ExitCode |
| 494 | from maestro.muse_cli.snapshot import build_snapshot_manifest |
| 495 | |
| 496 | state = read_cherry_pick_state(root) |
| 497 | if state is None: |
| 498 | typer.echo("❌ No cherry-pick in progress. Nothing to continue.") |
| 499 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 500 | |
| 501 | if state.conflict_paths: |
| 502 | typer.echo( |
| 503 | f"❌ {len(state.conflict_paths)} conflict(s) not yet resolved:\n" |
| 504 | + "\n".join(f"\tboth modified: {p}" for p in state.conflict_paths) |
| 505 | + "\nRun 'muse resolve <path> --ours/--theirs' for each file." |
| 506 | ) |
| 507 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 508 | |
| 509 | muse_dir = root / ".muse" |
| 510 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 511 | repo_id = repo_data["repo_id"] |
| 512 | |
| 513 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 514 | branch = head_ref.rsplit("/", 1)[-1] |
| 515 | our_ref_path = muse_dir / pathlib.Path(head_ref) |
| 516 | |
| 517 | head_commit_id = state.head_commit |
| 518 | cherry_commit_id = state.cherry_commit |
| 519 | |
| 520 | # Load cherry commit message for attribution |
| 521 | cherry_commit_row = await session.get(MuseCliCommit, cherry_commit_id) |
| 522 | if cherry_commit_row is None: |
| 523 | typer.echo(f"❌ Cherry commit {cherry_commit_id[:8]} not found in DB.") |
| 524 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 525 | |
| 526 | # Build snapshot from current muse-work/ (conflicts already resolved) |
| 527 | workdir = root / "muse-work" |
| 528 | if not workdir.exists(): |
| 529 | typer.echo("⚠️ muse-work/ is missing. Cannot create cherry-pick snapshot.") |
| 530 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 531 | |
| 532 | manifest = build_snapshot_manifest(workdir) |
| 533 | if not manifest: |
| 534 | typer.echo("⚠️ muse-work/ is empty. Nothing to commit for the cherry-pick.") |
| 535 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 536 | |
| 537 | snapshot_id = compute_snapshot_id(manifest) |
| 538 | await upsert_snapshot(session, manifest=manifest, snapshot_id=snapshot_id) |
| 539 | await session.flush() |
| 540 | |
| 541 | short_id = cherry_commit_id[:8] |
| 542 | cherry_message = ( |
| 543 | f"{cherry_commit_row.message}\n\n(cherry picked from commit {short_id})" |
| 544 | ) |
| 545 | |
| 546 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 547 | new_commit_id = compute_commit_id( |
| 548 | parent_ids=[head_commit_id], |
| 549 | snapshot_id=snapshot_id, |
| 550 | message=cherry_message, |
| 551 | committed_at_iso=committed_at.isoformat(), |
| 552 | ) |
| 553 | |
| 554 | new_commit = MuseCliCommit( |
| 555 | commit_id=new_commit_id, |
| 556 | repo_id=repo_id, |
| 557 | branch=branch, |
| 558 | parent_commit_id=head_commit_id, |
| 559 | snapshot_id=snapshot_id, |
| 560 | message=cherry_message, |
| 561 | author="", |
| 562 | committed_at=committed_at, |
| 563 | ) |
| 564 | await insert_commit(session, new_commit) |
| 565 | |
| 566 | our_ref_path.write_text(new_commit_id) |
| 567 | clear_cherry_pick_state(root) |
| 568 | |
| 569 | typer.echo( |
| 570 | f"✅ [{branch} {new_commit_id[:8]}] {cherry_commit_row.message}\n" |
| 571 | f" (cherry picked from commit {short_id})" |
| 572 | ) |
| 573 | logger.info( |
| 574 | "✅ muse cherry-pick --continue: commit %s on %r (cherry: %s)", |
| 575 | new_commit_id[:8], |
| 576 | branch, |
| 577 | short_id, |
| 578 | ) |
| 579 | |
| 580 | return CherryPickResult( |
| 581 | commit_id=new_commit_id, |
| 582 | cherry_commit_id=cherry_commit_id, |
| 583 | head_commit_id=head_commit_id, |
| 584 | new_snapshot_id=snapshot_id, |
| 585 | message=cherry_message, |
| 586 | no_commit=False, |
| 587 | conflict=False, |
| 588 | conflict_paths=(), |
| 589 | branch=branch, |
| 590 | ) |
| 591 | |
| 592 | |
| 593 | async def _cherry_pick_abort_async( |
| 594 | *, |
| 595 | root: pathlib.Path, |
| 596 | session: AsyncSession, |
| 597 | ) -> None: |
| 598 | """Abort an in-progress cherry-pick and restore pre-cherry-pick HEAD. |
| 599 | |
| 600 | Reads ``CHERRY_PICK_STATE.json`` to recover the original HEAD commit, |
| 601 | resets the branch pointer, and removes the state file. |
| 602 | |
| 603 | Args: |
| 604 | root: Repository root. |
| 605 | session: Open async DB session (unused but required for interface consistency). |
| 606 | |
| 607 | Raises: |
| 608 | :class:`typer.Exit`: If no cherry-pick is in progress. |
| 609 | """ |
| 610 | import typer |
| 611 | |
| 612 | from maestro.muse_cli.errors import ExitCode |
| 613 | |
| 614 | state = read_cherry_pick_state(root) |
| 615 | if state is None: |
| 616 | typer.echo("❌ No cherry-pick in progress. Nothing to abort.") |
| 617 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 618 | |
| 619 | muse_dir = root / ".muse" |
| 620 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 621 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 622 | |
| 623 | # Restore the branch pointer to HEAD at cherry-pick initiation time |
| 624 | ref_path.write_text(state.head_commit) |
| 625 | clear_cherry_pick_state(root) |
| 626 | |
| 627 | typer.echo( |
| 628 | f"✅ Cherry-pick aborted. HEAD restored to {state.head_commit[:8]}." |
| 629 | ) |
| 630 | logger.info( |
| 631 | "✅ muse cherry-pick --abort: restored HEAD to %s", |
| 632 | state.head_commit[:8], |
| 633 | ) |