cgcardona / muse public
test_cherry_pick.py python
674 lines 20.9 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse cherry-pick`` — apply a specific commit's diff on top of HEAD.
2
3 Exercises:
4 - ``test_cherry_pick_clean_apply_creates_commit`` — regression: cherry-pick of a
5 non-conflicting commit creates a new commit with the correct snapshot delta.
6 - ``test_cherry_pick_conflict_detection_writes_state`` — conflict detection writes
7 CHERRY_PICK_STATE.json and exits 1.
8 - ``test_cherry_pick_abort_restores_head`` — --abort removes state file and restores HEAD.
9 - ``test_cherry_pick_continue_creates_commit_after_resolve`` — --continue creates
10 commit from muse-work/ after conflicts are resolved.
11 - ``test_cherry_pick_no_commit_does_not_create_commit`` — --no-commit returns result
12 without persisting a commit row.
13 - ``test_cherry_pick_blocked_when_merge_in_progress`` — blocked by active merge.
14 - ``test_cherry_pick_blocked_when_already_in_progress`` — blocked by existing
15 CHERRY_PICK_STATE.json.
16 - ``test_cherry_pick_self_is_noop`` — cherry-picking HEAD itself exits with SUCCESS.
17 - ``test_cherry_pick_unknown_commit_raises_exit`` — unknown commit ID exits USER_ERROR.
18 - ``compute_cherry_manifest_*`` — pure-function unit tests for the manifest logic.
19
20 All async tests use ``@pytest.mark.anyio``.
21 """
22 from __future__ import annotations
23
24 import json
25 import pathlib
26 import uuid
27
28 import pytest
29 import pytest_asyncio
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from maestro.muse_cli.commands.commit import _commit_async
33 from maestro.muse_cli.merge_engine import write_merge_state
34 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot
35 from maestro.services.muse_cherry_pick import (
36 CherryPickResult,
37 CherryPickState,
38 _cherry_pick_abort_async,
39 _cherry_pick_async,
40 _cherry_pick_continue_async,
41 clear_cherry_pick_state,
42 compute_cherry_manifest,
43 read_cherry_pick_state,
44 write_cherry_pick_state,
45 )
46
47
48 # ---------------------------------------------------------------------------
49 # Repo / workdir helpers
50 # ---------------------------------------------------------------------------
51
52
53 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
54 """Create a minimal .muse/ layout."""
55 rid = repo_id or str(uuid.uuid4())
56 muse = root / ".muse"
57 (muse / "refs" / "heads").mkdir(parents=True)
58 (muse / "repo.json").write_text(
59 json.dumps({"repo_id": rid, "schema_version": "1"})
60 )
61 (muse / "HEAD").write_text("refs/heads/main")
62 (muse / "refs" / "heads" / "main").write_text("")
63 return rid
64
65
66 def _populate_workdir(
67 root: pathlib.Path, files: dict[str, bytes] | None = None
68 ) -> None:
69 """Create muse-work/ with the specified files."""
70 workdir = root / "muse-work"
71 workdir.mkdir(exist_ok=True)
72 if files is None:
73 files = {"beat.mid": b"MIDI-DATA", "lead.mp3": b"MP3-DATA"}
74 for name, content in files.items():
75 path = workdir / name
76 path.parent.mkdir(parents=True, exist_ok=True)
77 path.write_bytes(content)
78
79
80 # ---------------------------------------------------------------------------
81 # Unit tests — pure functions
82 # ---------------------------------------------------------------------------
83
84
85 def test_compute_cherry_manifest_clean_apply() -> None:
86 """Cherry diff applies cleanly when HEAD did not touch the same paths."""
87 base = {"beat.mid": "aaa", "bass.mid": "bbb"}
88 head = {"beat.mid": "aaa", "bass.mid": "bbb", "keys.mid": "kkk"}
89 cherry = {"beat.mid": "ccc", "bass.mid": "bbb"} # cherry modified beat.mid
90
91 cherry_diff = {"beat.mid"} # changed in cherry vs base
92 head_diff = {"keys.mid"} # HEAD added keys.mid
93
94 result, conflicts = compute_cherry_manifest(
95 base_manifest=base,
96 head_manifest=head,
97 cherry_manifest=cherry,
98 cherry_diff=cherry_diff,
99 head_diff=head_diff,
100 )
101
102 assert conflicts == set()
103 assert result["beat.mid"] == "ccc" # cherry version
104 assert result["keys.mid"] == "kkk" # HEAD's addition preserved
105 assert result["bass.mid"] == "bbb" # unchanged
106
107
108 def test_compute_cherry_manifest_conflict_detection() -> None:
109 """Conflict detected when both cherry and HEAD modified the same path differently."""
110 base = {"beat.mid": "aaa"}
111 head = {"beat.mid": "head-version"}
112 cherry = {"beat.mid": "cherry-version"}
113
114 cherry_diff = {"beat.mid"}
115 head_diff = {"beat.mid"}
116
117 result, conflicts = compute_cherry_manifest(
118 base_manifest=base,
119 head_manifest=head,
120 cherry_manifest=cherry,
121 cherry_diff=cherry_diff,
122 head_diff=head_diff,
123 )
124
125 assert "beat.mid" in conflicts
126 # HEAD's version left in place during conflict
127 assert result["beat.mid"] == "head-version"
128
129
130 def test_compute_cherry_manifest_same_change_no_conflict() -> None:
131 """No conflict when both sides independently made the same change."""
132 base = {"beat.mid": "aaa"}
133 head = {"beat.mid": "same-oid"}
134 cherry = {"beat.mid": "same-oid"}
135
136 cherry_diff = {"beat.mid"}
137 head_diff = {"beat.mid"}
138
139 result, conflicts = compute_cherry_manifest(
140 base_manifest=base,
141 head_manifest=head,
142 cherry_manifest=cherry,
143 cherry_diff=cherry_diff,
144 head_diff=head_diff,
145 )
146
147 assert conflicts == set()
148 assert result["beat.mid"] == "same-oid"
149
150
151 def test_compute_cherry_manifest_cherry_deletion() -> None:
152 """Cherry-pick removes a path that cherry deleted and HEAD did not touch."""
153 base = {"beat.mid": "aaa", "old.mid": "old"}
154 head = {"beat.mid": "aaa", "old.mid": "old"}
155 cherry = {"beat.mid": "aaa"} # cherry deleted old.mid
156
157 cherry_diff = {"old.mid"} # old.mid deleted in cherry
158 head_diff: set[str] = set()
159
160 result, conflicts = compute_cherry_manifest(
161 base_manifest=base,
162 head_manifest=head,
163 cherry_manifest=cherry,
164 cherry_diff=cherry_diff,
165 head_diff=head_diff,
166 )
167
168 assert conflicts == set()
169 assert "old.mid" not in result
170
171
172 def test_read_write_clear_cherry_pick_state(tmp_path: pathlib.Path) -> None:
173 """State file round-trips correctly through write/read/clear."""
174 muse = tmp_path / ".muse"
175 muse.mkdir()
176
177 assert read_cherry_pick_state(tmp_path) is None
178
179 write_cherry_pick_state(
180 tmp_path,
181 cherry_commit="cherry-abc",
182 head_commit="head-def",
183 conflict_paths=["beat.mid", "lead.mp3"],
184 )
185
186 state = read_cherry_pick_state(tmp_path)
187 assert state is not None
188 assert state.cherry_commit == "cherry-abc"
189 assert state.head_commit == "head-def"
190 assert "beat.mid" in state.conflict_paths
191 assert "lead.mp3" in state.conflict_paths
192
193 clear_cherry_pick_state(tmp_path)
194 assert read_cherry_pick_state(tmp_path) is None
195
196
197 # ---------------------------------------------------------------------------
198 # Integration tests — async DB
199 # ---------------------------------------------------------------------------
200
201
202 @pytest.mark.anyio
203 async def test_cherry_pick_clean_apply_creates_commit(
204 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
205 ) -> None:
206 """Regression: cherry-pick of a non-conflicting commit creates a new commit."""
207 _init_muse_repo(tmp_path)
208
209 # Commit A — baseline on main
210 _populate_workdir(tmp_path, {"beat.mid": b"main-beat", "bass.mid": b"bass-v1"})
211 commit_a_id = await _commit_async(
212 message="main baseline",
213 root=tmp_path,
214 session=muse_cli_db_session,
215 )
216
217 # Commit B — adds a new file (simulates a commit from another branch)
218 _populate_workdir(
219 tmp_path,
220 {"beat.mid": b"main-beat", "bass.mid": b"bass-v1", "solo.mid": b"guitar-solo"},
221 )
222 commit_b_id = await _commit_async(
223 message="add guitar solo",
224 root=tmp_path,
225 session=muse_cli_db_session,
226 )
227
228 # Reset to A so B is not HEAD (simulate cherry-picking from another branch)
229 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
230 ref_path.write_text(commit_a_id)
231
232 # Cherry-pick B onto A
233 result = await _cherry_pick_async(
234 commit_ref=commit_b_id,
235 root=tmp_path,
236 session=muse_cli_db_session,
237 )
238
239 assert not result.conflict
240 assert not result.no_commit
241 assert result.commit_id != ""
242 assert result.cherry_commit_id == commit_b_id
243 assert result.head_commit_id == commit_a_id
244 assert "(cherry picked from commit" in result.message
245 assert commit_b_id[:8] in result.message
246
247 # New commit should be in DB with correct parent
248 new_commit_row = await muse_cli_db_session.get(MuseCliCommit, result.commit_id)
249 assert new_commit_row is not None
250 assert new_commit_row.parent_commit_id == commit_a_id
251
252 # New snapshot should include solo.mid
253 snap_row = await muse_cli_db_session.get(MuseCliSnapshot, new_commit_row.snapshot_id)
254 assert snap_row is not None
255 manifest: dict[str, str] = dict(snap_row.manifest)
256 assert "solo.mid" in manifest
257 assert "beat.mid" in manifest
258
259 # HEAD ref updated
260 assert ref_path.read_text().strip() == result.commit_id
261
262
263 @pytest.mark.anyio
264 async def test_cherry_pick_conflict_detection_writes_state(
265 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
266 ) -> None:
267 """Conflict: both HEAD and cherry modified the same path → state file written, exit 1."""
268 _init_muse_repo(tmp_path)
269
270 # Commit P — shared base
271 _populate_workdir(tmp_path, {"beat.mid": b"base-beat"})
272 commit_p_id = await _commit_async(
273 message="shared base",
274 root=tmp_path,
275 session=muse_cli_db_session,
276 )
277
278 # Commit C (cherry) — modifies beat.mid one way
279 _populate_workdir(tmp_path, {"beat.mid": b"cherry-beat"})
280 commit_c_id = await _commit_async(
281 message="cherry take",
282 root=tmp_path,
283 session=muse_cli_db_session,
284 )
285
286 # Simulate HEAD also modifying beat.mid differently (reset to P, then commit HEAD)
287 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
288 ref_path.write_text(commit_p_id)
289
290 _populate_workdir(tmp_path, {"beat.mid": b"head-beat"})
291 commit_head_id = await _commit_async(
292 message="head modification",
293 root=tmp_path,
294 session=muse_cli_db_session,
295 )
296
297 # Cherry-pick C onto HEAD — should conflict
298 import typer
299
300 with pytest.raises(typer.Exit) as exc_info:
301 await _cherry_pick_async(
302 commit_ref=commit_c_id,
303 root=tmp_path,
304 session=muse_cli_db_session,
305 )
306
307 from maestro.muse_cli.errors import ExitCode
308
309 assert exc_info.value.exit_code == ExitCode.USER_ERROR
310
311 # State file must exist
312 state = read_cherry_pick_state(tmp_path)
313 assert state is not None
314 assert state.cherry_commit == commit_c_id
315 assert state.head_commit == commit_head_id
316 assert "beat.mid" in state.conflict_paths
317
318
319 @pytest.mark.anyio
320 async def test_cherry_pick_abort_restores_head(
321 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
322 ) -> None:
323 """--abort removes CHERRY_PICK_STATE.json and restores HEAD pointer."""
324 _init_muse_repo(tmp_path)
325 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
326 commit_a_id = await _commit_async(
327 message="initial",
328 root=tmp_path,
329 session=muse_cli_db_session,
330 )
331
332 # Simulate a paused cherry-pick state
333 muse_dir = tmp_path / ".muse"
334 write_cherry_pick_state(
335 tmp_path,
336 cherry_commit="cherry-abc",
337 head_commit=commit_a_id,
338 conflict_paths=["beat.mid"],
339 )
340
341 # Move HEAD pointer forward artificially to simulate partial progress
342 ref_path = muse_dir / "refs" / "heads" / "main"
343 ref_path.write_text("some-partial-commit-id")
344
345 await _cherry_pick_abort_async(root=tmp_path, session=muse_cli_db_session)
346
347 # State file removed
348 assert read_cherry_pick_state(tmp_path) is None
349
350 # HEAD restored to pre-cherry-pick commit
351 assert ref_path.read_text().strip() == commit_a_id
352
353
354 @pytest.mark.anyio
355 async def test_cherry_pick_continue_creates_commit_after_resolve(
356 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
357 ) -> None:
358 """--continue creates a commit from muse-work/ after conflicts are manually resolved."""
359 _init_muse_repo(tmp_path)
360 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
361 commit_a_id = await _commit_async(
362 message="initial",
363 root=tmp_path,
364 session=muse_cli_db_session,
365 )
366
367 cherry_fake_id = "cherry" + "0" * 58 # fake 64-char hex ID
368
369 # Write state with empty conflict_paths (conflicts resolved)
370 write_cherry_pick_state(
371 tmp_path,
372 cherry_commit=cherry_fake_id,
373 head_commit=commit_a_id,
374 conflict_paths=[],
375 )
376
377 # Ensure muse-work/ has a resolved state and a real cherry commit in DB
378 _populate_workdir(tmp_path, {"beat.mid": b"resolved-beat", "solo.mid": b"solo"})
379
380 # Insert a dummy cherry commit row so the message lookup works
381 from maestro.muse_cli.models import MuseCliCommit as _MuseCliCommit
382 from maestro.muse_cli.snapshot import compute_snapshot_id
383 import datetime as _dt
384
385 dummy_snap_id = compute_snapshot_id({})
386 from maestro.muse_cli.db import upsert_snapshot as _upsert_snapshot
387
388 await _upsert_snapshot(
389 muse_cli_db_session, manifest={}, snapshot_id=dummy_snap_id
390 )
391 repo_data: dict[str, str] = json.loads(
392 (tmp_path / ".muse" / "repo.json").read_text()
393 )
394 dummy_commit = _MuseCliCommit(
395 commit_id=cherry_fake_id,
396 repo_id=repo_data["repo_id"],
397 branch="experiment",
398 parent_commit_id=None,
399 snapshot_id=dummy_snap_id,
400 message="the perfect guitar solo",
401 author="",
402 committed_at=_dt.datetime.now(_dt.timezone.utc),
403 )
404 muse_cli_db_session.add(dummy_commit)
405 await muse_cli_db_session.flush()
406
407 result = await _cherry_pick_continue_async(
408 root=tmp_path, session=muse_cli_db_session
409 )
410
411 assert result.commit_id != ""
412 assert result.cherry_commit_id == cherry_fake_id
413 assert "cherry picked from commit" in result.message
414
415 # State file cleared
416 assert read_cherry_pick_state(tmp_path) is None
417
418 # HEAD updated
419 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
420 assert ref_path.read_text().strip() == result.commit_id
421
422 # New commit in DB
423 new_row = await muse_cli_db_session.get(MuseCliCommit, result.commit_id)
424 assert new_row is not None
425 assert new_row.parent_commit_id == commit_a_id
426
427
428 @pytest.mark.anyio
429 async def test_cherry_pick_no_commit_does_not_create_commit(
430 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
431 ) -> None:
432 """--no-commit returns result without writing a commit row to the DB."""
433 _init_muse_repo(tmp_path)
434
435 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
436 commit_a_id = await _commit_async(
437 message="baseline",
438 root=tmp_path,
439 session=muse_cli_db_session,
440 )
441
442 _populate_workdir(tmp_path, {"beat.mid": b"v1", "new.mid": b"new-file"})
443 commit_b_id = await _commit_async(
444 message="add new file",
445 root=tmp_path,
446 session=muse_cli_db_session,
447 )
448
449 # Reset to A so B is not HEAD
450 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
451 ref_path.write_text(commit_a_id)
452
453 result = await _cherry_pick_async(
454 commit_ref=commit_b_id,
455 root=tmp_path,
456 session=muse_cli_db_session,
457 no_commit=True,
458 )
459
460 assert result.no_commit is True
461 assert result.commit_id == ""
462 assert result.cherry_commit_id == commit_b_id
463
464 # HEAD ref unchanged
465 assert ref_path.read_text().strip() == commit_a_id
466
467 # No new commit in DB
468 from sqlalchemy.future import select
469
470 rows = (
471 await muse_cli_db_session.execute(select(MuseCliCommit))
472 ).scalars().all()
473 assert len(rows) == 2 # only A and B
474
475
476 @pytest.mark.anyio
477 async def test_cherry_pick_blocked_when_merge_in_progress(
478 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
479 ) -> None:
480 """Cherry-pick blocked when a merge is in progress with conflicts."""
481 _init_muse_repo(tmp_path)
482 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
483 commit_a_id = await _commit_async(
484 message="initial",
485 root=tmp_path,
486 session=muse_cli_db_session,
487 )
488
489 write_merge_state(
490 tmp_path,
491 base_commit="base-abc",
492 ours_commit="ours-def",
493 theirs_commit="theirs-ghi",
494 conflict_paths=["beat.mid"],
495 )
496
497 import typer
498
499 with pytest.raises(typer.Exit) as exc_info:
500 await _cherry_pick_async(
501 commit_ref=commit_a_id,
502 root=tmp_path,
503 session=muse_cli_db_session,
504 )
505
506 from maestro.muse_cli.errors import ExitCode
507
508 assert exc_info.value.exit_code == ExitCode.USER_ERROR
509
510
511 @pytest.mark.anyio
512 async def test_cherry_pick_blocked_when_already_in_progress(
513 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
514 ) -> None:
515 """Cherry-pick blocked when CHERRY_PICK_STATE.json already exists."""
516 _init_muse_repo(tmp_path)
517 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
518 commit_a_id = await _commit_async(
519 message="initial",
520 root=tmp_path,
521 session=muse_cli_db_session,
522 )
523
524 write_cherry_pick_state(
525 tmp_path,
526 cherry_commit="cherry-abc",
527 head_commit=commit_a_id,
528 conflict_paths=["beat.mid"],
529 )
530
531 import typer
532
533 with pytest.raises(typer.Exit) as exc_info:
534 await _cherry_pick_async(
535 commit_ref=commit_a_id,
536 root=tmp_path,
537 session=muse_cli_db_session,
538 )
539
540 from maestro.muse_cli.errors import ExitCode
541
542 assert exc_info.value.exit_code == ExitCode.USER_ERROR
543
544
545 @pytest.mark.anyio
546 async def test_cherry_pick_self_is_noop(
547 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
548 ) -> None:
549 """Cherry-picking HEAD itself exits with SUCCESS (noop)."""
550 _init_muse_repo(tmp_path)
551 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
552 commit_a_id = await _commit_async(
553 message="head commit",
554 root=tmp_path,
555 session=muse_cli_db_session,
556 )
557
558 import typer
559
560 with pytest.raises(typer.Exit) as exc_info:
561 await _cherry_pick_async(
562 commit_ref=commit_a_id,
563 root=tmp_path,
564 session=muse_cli_db_session,
565 )
566
567 from maestro.muse_cli.errors import ExitCode
568
569 assert exc_info.value.exit_code == ExitCode.SUCCESS
570
571
572 @pytest.mark.anyio
573 async def test_cherry_pick_unknown_commit_raises_exit(
574 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
575 ) -> None:
576 """Unknown commit ID exits with USER_ERROR."""
577 _init_muse_repo(tmp_path)
578 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
579 await _commit_async(
580 message="initial",
581 root=tmp_path,
582 session=muse_cli_db_session,
583 )
584
585 import typer
586
587 with pytest.raises(typer.Exit) as exc_info:
588 await _cherry_pick_async(
589 commit_ref="deadbeef",
590 root=tmp_path,
591 session=muse_cli_db_session,
592 )
593
594 from maestro.muse_cli.errors import ExitCode
595
596 assert exc_info.value.exit_code == ExitCode.USER_ERROR
597
598
599 @pytest.mark.anyio
600 async def test_cherry_pick_abbreviated_ref(
601 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
602 ) -> None:
603 """Cherry-pick accepts an abbreviated commit SHA (prefix match)."""
604 _init_muse_repo(tmp_path)
605
606 _populate_workdir(tmp_path, {"beat.mid": b"v1"})
607 commit_a_id = await _commit_async(
608 message="baseline",
609 root=tmp_path,
610 session=muse_cli_db_session,
611 )
612
613 _populate_workdir(tmp_path, {"beat.mid": b"v1", "bonus.mid": b"bonus"})
614 commit_b_id = await _commit_async(
615 message="add bonus",
616 root=tmp_path,
617 session=muse_cli_db_session,
618 )
619
620 # Reset to A
621 ref_path = tmp_path / ".muse" / "refs" / "heads" / "main"
622 ref_path.write_text(commit_a_id)
623
624 result = await _cherry_pick_async(
625 commit_ref=commit_b_id[:8],
626 root=tmp_path,
627 session=muse_cli_db_session,
628 )
629
630 assert result.cherry_commit_id == commit_b_id
631 assert result.commit_id != ""
632
633
634 @pytest.mark.anyio
635 async def test_cherry_pick_continue_blocked_with_remaining_conflicts(
636 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
637 ) -> None:
638 """--continue exits USER_ERROR when conflict_paths list is non-empty."""
639 _init_muse_repo(tmp_path)
640 muse = tmp_path / ".muse"
641 muse.mkdir(exist_ok=True)
642
643 write_cherry_pick_state(
644 tmp_path,
645 cherry_commit="cherry-abc",
646 head_commit="head-def",
647 conflict_paths=["beat.mid"],
648 )
649
650 import typer
651
652 with pytest.raises(typer.Exit) as exc_info:
653 await _cherry_pick_continue_async(root=tmp_path, session=muse_cli_db_session)
654
655 from maestro.muse_cli.errors import ExitCode
656
657 assert exc_info.value.exit_code == ExitCode.USER_ERROR
658
659
660 @pytest.mark.anyio
661 async def test_cherry_pick_abort_when_nothing_in_progress(
662 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
663 ) -> None:
664 """--abort exits USER_ERROR when no cherry-pick is in progress."""
665 _init_muse_repo(tmp_path)
666
667 import typer
668
669 with pytest.raises(typer.Exit) as exc_info:
670 await _cherry_pick_abort_async(root=tmp_path, session=muse_cli_db_session)
671
672 from maestro.muse_cli.errors import ExitCode
673
674 assert exc_info.value.exit_code == ExitCode.USER_ERROR