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