cgcardona / muse public
test_merge.py python
680 lines 25.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Integration tests for ``muse merge``.
2
3 Tests exercise ``_merge_async`` directly with an in-memory SQLite session and
4 a ``tmp_path`` root so no real Postgres instance is required.
5
6 All async tests use ``@pytest.mark.anyio``.
7 """
8 from __future__ import annotations
9
10 import datetime
11 import json
12 import pathlib
13 import uuid
14
15 import pytest
16 import typer
17 from sqlalchemy.ext.asyncio import AsyncSession
18 from sqlalchemy.future import select
19
20 from maestro.muse_cli.commands.commit import _commit_async
21 from maestro.muse_cli.commands.merge import _merge_async
22 from maestro.muse_cli.errors import ExitCode
23 from maestro.muse_cli.merge_engine import read_merge_state, write_merge_state
24 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot
25 from maestro.muse_cli.snapshot import compute_snapshot_id
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _init_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
34 """Create minimal ``.muse/`` layout for testing."""
35 rid = repo_id or str(uuid.uuid4())
36 muse = root / ".muse"
37 (muse / "refs" / "heads").mkdir(parents=True)
38 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
39 (muse / "HEAD").write_text("refs/heads/main")
40 (muse / "refs" / "heads" / "main").write_text("")
41 return rid
42
43
44 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
45 """Overwrite muse-work/ with exactly the given files (cleans stale files)."""
46 import shutil
47
48 workdir = root / "muse-work"
49 if workdir.exists():
50 shutil.rmtree(workdir)
51 workdir.mkdir()
52 for name, content in files.items():
53 (workdir / name).write_bytes(content)
54
55
56 def _create_branch(root: pathlib.Path, branch: str, from_branch: str = "main") -> None:
57 """Create a new branch pointing at the same commit as from_branch."""
58 muse = root / ".muse"
59 src = muse / "refs" / "heads" / from_branch
60 dst = muse / "refs" / "heads" / branch
61 dst.parent.mkdir(parents=True, exist_ok=True)
62 dst.write_text(src.read_text() if src.exists() else "")
63
64
65 def _switch_branch(root: pathlib.Path, branch: str) -> None:
66 """Update HEAD to point at branch."""
67 (root / ".muse" / "HEAD").write_text(f"refs/heads/{branch}")
68
69
70 def _head_commit(root: pathlib.Path, branch: str | None = None) -> str:
71 """Return current HEAD commit_id for the branch (default: current branch)."""
72 muse = root / ".muse"
73 if branch is None:
74 head_ref = (muse / "HEAD").read_text().strip()
75 branch = head_ref.rsplit("/", 1)[-1]
76 ref_path = muse / "refs" / "heads" / branch
77 return ref_path.read_text().strip() if ref_path.exists() else ""
78
79
80 async def _persist_empty_snapshot(session: AsyncSession) -> str:
81 """Upsert the canonical empty-manifest snapshot so FK constraints pass."""
82 sid = compute_snapshot_id({})
83 existing = await session.get(MuseCliSnapshot, sid)
84 if existing is None:
85 session.add(MuseCliSnapshot(snapshot_id=sid, manifest={}))
86 await session.flush()
87 return sid
88
89
90 # ---------------------------------------------------------------------------
91 # Fast-forward merge tests
92 # ---------------------------------------------------------------------------
93
94
95 @pytest.mark.anyio
96 async def test_merge_fast_forward_moves_pointer(
97 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
98 ) -> None:
99 """FF merge: when target is ahead, HEAD advances without a new commit."""
100 rid = _init_repo(tmp_path)
101 _write_workdir(tmp_path, {"beat.mid": b"V1"})
102 # First commit on main.
103 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
104 initial_commit = _head_commit(tmp_path)
105
106 # Create experiment branch from main and advance it.
107 _create_branch(tmp_path, "experiment")
108 _switch_branch(tmp_path, "experiment")
109 _write_workdir(tmp_path, {"beat.mid": b"V2"})
110 await _commit_async(message="experiment step", root=tmp_path, session=muse_cli_db_session)
111 experiment_commit = _head_commit(tmp_path, "experiment")
112
113 # Switch back to main and merge experiment → should fast-forward.
114 _switch_branch(tmp_path, "main")
115 await _merge_async(branch="experiment", root=tmp_path, session=muse_cli_db_session)
116
117 # main HEAD should now point at experiment's commit.
118 assert _head_commit(tmp_path, "main") == experiment_commit
119 # No new merge commit created — DB still has exactly 2 commits.
120 result = await muse_cli_db_session.execute(select(MuseCliCommit))
121 commits = result.scalars().all()
122 assert len(commits) == 2 # initial + experiment (no merge commit added)
123
124
125 @pytest.mark.anyio
126 async def test_merge_already_up_to_date_exits_0(
127 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
128 ) -> None:
129 """Merging a branch that is behind current HEAD exits 0."""
130 _init_repo(tmp_path)
131 _write_workdir(tmp_path, {"a.mid": b"V1"})
132 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
133
134 # Create stale branch pointing at same commit.
135 _create_branch(tmp_path, "stale")
136
137 # Advance main.
138 _write_workdir(tmp_path, {"a.mid": b"V2"})
139 await _commit_async(message="ahead", root=tmp_path, session=muse_cli_db_session)
140
141 # Merging stale into main → already up-to-date.
142 with pytest.raises(typer.Exit) as exc_info:
143 await _merge_async(branch="stale", root=tmp_path, session=muse_cli_db_session)
144
145 assert exc_info.value.exit_code == ExitCode.SUCCESS
146
147
148 # ---------------------------------------------------------------------------
149 # 3-way merge tests
150 # ---------------------------------------------------------------------------
151
152
153 @pytest.mark.anyio
154 async def test_merge_creates_merge_commit_two_parents(
155 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
156 ) -> None:
157 """3-way merge creates a commit with exactly two parent IDs."""
158 _init_repo(tmp_path)
159 _write_workdir(tmp_path, {"base.mid": b"BASE"})
160 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
161 base_commit = _head_commit(tmp_path)
162
163 # Branch off: create 'feature' from main.
164 _create_branch(tmp_path, "feature")
165
166 # Advance main with a unique change.
167 _write_workdir(tmp_path, {"base.mid": b"BASE", "main_only.mid": b"MAIN"})
168 await _commit_async(message="main step", root=tmp_path, session=muse_cli_db_session)
169 ours_commit = _head_commit(tmp_path)
170
171 # Advance feature with a different unique change.
172 _switch_branch(tmp_path, "feature")
173 _write_workdir(tmp_path, {"base.mid": b"BASE", "feature_only.mid": b"FEAT"})
174 await _commit_async(
175 message="feature step", root=tmp_path, session=muse_cli_db_session
176 )
177 theirs_commit = _head_commit(tmp_path, "feature")
178
179 # Merge feature into main (both diverged from base).
180 _switch_branch(tmp_path, "main")
181 await _merge_async(branch="feature", root=tmp_path, session=muse_cli_db_session)
182
183 # A new merge commit must exist.
184 merge_commit_id = _head_commit(tmp_path, "main")
185 assert merge_commit_id != ours_commit
186
187 result = await muse_cli_db_session.execute(
188 select(MuseCliCommit).where(MuseCliCommit.commit_id == merge_commit_id)
189 )
190 merge_commit = result.scalar_one()
191 assert merge_commit.parent_commit_id == ours_commit
192 assert merge_commit.parent2_commit_id == theirs_commit
193
194
195 @pytest.mark.anyio
196 async def test_merge_auto_merges_non_conflicting(
197 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
198 ) -> None:
199 """Files changed on only one branch are taken without conflict."""
200 _init_repo(tmp_path)
201 _write_workdir(tmp_path, {"shared.mid": b"BASE"})
202 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
203
204 # Feature adds a new file.
205 _create_branch(tmp_path, "feature")
206 _switch_branch(tmp_path, "feature")
207 _write_workdir(tmp_path, {"shared.mid": b"BASE", "new.mid": b"NEW"})
208 await _commit_async(message="feature adds new.mid", root=tmp_path, session=muse_cli_db_session)
209 theirs_commit = _head_commit(tmp_path, "feature")
210
211 # Main modifies the shared file (different from feature).
212 _switch_branch(tmp_path, "main")
213 _write_workdir(tmp_path, {"shared.mid": b"MAIN_CHANGE"})
214 await _commit_async(message="main changes shared", root=tmp_path, session=muse_cli_db_session)
215
216 # Merge should succeed (no conflicts).
217 await _merge_async(branch="feature", root=tmp_path, session=muse_cli_db_session)
218
219 # No MERGE_STATE.json written.
220 assert read_merge_state(tmp_path) is None
221
222 # The merge commit's snapshot must contain both the main change and the new file.
223 merge_commit_id = _head_commit(tmp_path, "main")
224 from maestro.muse_cli.db import get_commit_snapshot_manifest
225 merged_manifest = await get_commit_snapshot_manifest(
226 muse_cli_db_session, merge_commit_id
227 )
228 assert merged_manifest is not None
229 assert "new.mid" in merged_manifest
230
231
232 @pytest.mark.anyio
233 async def test_merge_detects_conflict_same_path(
234 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
235 ) -> None:
236 """Both branches changed same file → MERGE_STATE.json written, exit 1."""
237 _init_repo(tmp_path)
238 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
239 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
240
241 _create_branch(tmp_path, "experiment")
242
243 # Main modifies beat.mid.
244 _write_workdir(tmp_path, {"beat.mid": b"MAIN_VERSION"})
245 await _commit_async(message="main changes beat", root=tmp_path, session=muse_cli_db_session)
246
247 # Experiment also modifies beat.mid.
248 _switch_branch(tmp_path, "experiment")
249 _write_workdir(tmp_path, {"beat.mid": b"EXPERIMENT_VERSION"})
250 await _commit_async(message="experiment changes beat", root=tmp_path, session=muse_cli_db_session)
251
252 # Try to merge back into main → conflict expected.
253 _switch_branch(tmp_path, "main")
254 with pytest.raises(typer.Exit) as exc_info:
255 await _merge_async(
256 branch="experiment", root=tmp_path, session=muse_cli_db_session
257 )
258
259 assert exc_info.value.exit_code == ExitCode.USER_ERROR
260
261
262 @pytest.mark.anyio
263 async def test_merge_state_json_structure(
264 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
265 ) -> None:
266 """MERGE_STATE.json contains all required fields on conflict."""
267 _init_repo(tmp_path)
268 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
269 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
270
271 _create_branch(tmp_path, "experiment")
272
273 _write_workdir(tmp_path, {"beat.mid": b"MAIN_V"})
274 await _commit_async(message="main", root=tmp_path, session=muse_cli_db_session)
275 ours_commit = _head_commit(tmp_path, "main")
276
277 _switch_branch(tmp_path, "experiment")
278 _write_workdir(tmp_path, {"beat.mid": b"EXP_V"})
279 await _commit_async(message="exp", root=tmp_path, session=muse_cli_db_session)
280 theirs_commit = _head_commit(tmp_path, "experiment")
281
282 _switch_branch(tmp_path, "main")
283 with pytest.raises(typer.Exit):
284 await _merge_async(
285 branch="experiment", root=tmp_path, session=muse_cli_db_session
286 )
287
288 state = read_merge_state(tmp_path)
289 assert state is not None
290 assert state.ours_commit == ours_commit
291 assert state.theirs_commit == theirs_commit
292 assert state.base_commit is not None
293 assert "beat.mid" in state.conflict_paths
294
295 # Validate the raw JSON has all required keys.
296 raw = json.loads((tmp_path / ".muse" / "MERGE_STATE.json").read_text())
297 for key in ("base_commit", "ours_commit", "theirs_commit", "conflict_paths"):
298 assert key in raw, f"Missing key: {key}"
299
300
301 @pytest.mark.anyio
302 async def test_merge_conflict_blocks_further_commit(
303 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
304 ) -> None:
305 """``muse commit`` while in conflicted state exits 1."""
306 _init_repo(tmp_path)
307 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
308 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
309
310 # Write a MERGE_STATE.json with conflicts.
311 write_merge_state(
312 tmp_path,
313 base_commit="base000",
314 ours_commit="ours111",
315 theirs_commit="their222",
316 conflict_paths=["beat.mid"],
317 )
318
319 # Attempt to commit while conflicts exist.
320 with pytest.raises(typer.Exit) as exc_info:
321 await _commit_async(
322 message="should fail", root=tmp_path, session=muse_cli_db_session
323 )
324
325 assert exc_info.value.exit_code == ExitCode.USER_ERROR
326
327
328 @pytest.mark.anyio
329 async def test_merge_in_progress_blocks_second_merge(
330 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
331 ) -> None:
332 """Second ``muse merge`` during a conflict exits 1 with clear message."""
333 _init_repo(tmp_path)
334 _write_workdir(tmp_path, {"a.mid": b"BASE"})
335 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
336
337 # Simulate a merge already in progress.
338 write_merge_state(
339 tmp_path,
340 base_commit="base000",
341 ours_commit="ours111",
342 theirs_commit="their222",
343 conflict_paths=["a.mid"],
344 other_branch="feature",
345 )
346
347 with pytest.raises(typer.Exit) as exc_info:
348 await _merge_async(
349 branch="feature", root=tmp_path, session=muse_cli_db_session
350 )
351
352 assert exc_info.value.exit_code == ExitCode.USER_ERROR
353
354
355 # ---------------------------------------------------------------------------
356 # Error / edge cases
357 # ---------------------------------------------------------------------------
358
359
360 @pytest.mark.anyio
361 async def test_merge_outside_repo_exits_2(
362 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
363 ) -> None:
364 """Invoking the merge Typer command outside a repo exits 2."""
365 from typer.testing import CliRunner
366 from maestro.muse_cli.app import cli
367
368 runner = CliRunner()
369 result = runner.invoke(cli, ["merge", "feature"], catch_exceptions=False)
370 assert result.exit_code == ExitCode.REPO_NOT_FOUND
371
372
373 @pytest.mark.anyio
374 async def test_merge_target_branch_no_commits_exits_1(
375 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
376 ) -> None:
377 """Merging a branch that doesn't exist / has no commits exits 1."""
378 _init_repo(tmp_path)
379 _write_workdir(tmp_path, {"a.mid": b"V"})
380 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
381
382 with pytest.raises(typer.Exit) as exc_info:
383 await _merge_async(
384 branch="nonexistent", root=tmp_path, session=muse_cli_db_session
385 )
386
387 assert exc_info.value.exit_code == ExitCode.USER_ERROR
388
389
390 @pytest.mark.anyio
391 async def test_merge_same_branch_exits_0(
392 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
393 ) -> None:
394 """Merging a branch into itself (same HEAD) exits 0 — already up-to-date."""
395 _init_repo(tmp_path)
396 _write_workdir(tmp_path, {"a.mid": b"V"})
397 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
398 # Create an alias branch pointing at the same commit.
399 _create_branch(tmp_path, "alias")
400
401 with pytest.raises(typer.Exit) as exc_info:
402 await _merge_async(
403 branch="alias", root=tmp_path, session=muse_cli_db_session
404 )
405
406 assert exc_info.value.exit_code == ExitCode.SUCCESS
407
408
409 # ---------------------------------------------------------------------------
410 # --no-ff tests
411 # ---------------------------------------------------------------------------
412
413
414 @pytest.mark.anyio
415 async def test_merge_no_ff_creates_merge_commit(
416 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
417 ) -> None:
418 """--no-ff forces a merge commit even when fast-forward is possible."""
419 _init_repo(tmp_path)
420 _write_workdir(tmp_path, {"beat.mid": b"V1"})
421 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
422 ours_commit = _head_commit(tmp_path)
423
424 # Create feature branch and advance it — fast-forward would normally apply.
425 _create_branch(tmp_path, "feature")
426 _switch_branch(tmp_path, "feature")
427 _write_workdir(tmp_path, {"beat.mid": b"V2"})
428 await _commit_async(message="feature step", root=tmp_path, session=muse_cli_db_session)
429 theirs_commit = _head_commit(tmp_path, "feature")
430
431 # Merge with --no-ff: a merge commit must be created, not a fast-forward.
432 _switch_branch(tmp_path, "main")
433 await _merge_async(
434 branch="feature",
435 root=tmp_path,
436 session=muse_cli_db_session,
437 no_ff=True,
438 )
439
440 merge_commit_id = _head_commit(tmp_path, "main")
441 # HEAD must be a NEW commit (not the feature tip).
442 assert merge_commit_id != theirs_commit
443 assert merge_commit_id != ours_commit
444
445 # The new commit must carry both parents.
446 result = await muse_cli_db_session.execute(
447 select(MuseCliCommit).where(MuseCliCommit.commit_id == merge_commit_id)
448 )
449 merge_commit = result.scalar_one()
450 assert merge_commit.parent_commit_id == ours_commit
451 assert merge_commit.parent2_commit_id == theirs_commit
452
453
454 @pytest.mark.anyio
455 async def test_merge_no_ff_diverged_branches_creates_merge_commit(
456 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
457 ) -> None:
458 """--no-ff on diverged branches still creates a merge commit (normal path)."""
459 _init_repo(tmp_path)
460 _write_workdir(tmp_path, {"base.mid": b"BASE"})
461 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
462
463 _create_branch(tmp_path, "feature")
464
465 _write_workdir(tmp_path, {"base.mid": b"BASE", "main_only.mid": b"MAIN"})
466 await _commit_async(message="main step", root=tmp_path, session=muse_cli_db_session)
467 ours_commit = _head_commit(tmp_path)
468
469 _switch_branch(tmp_path, "feature")
470 _write_workdir(tmp_path, {"base.mid": b"BASE", "feat_only.mid": b"FEAT"})
471 await _commit_async(message="feature step", root=tmp_path, session=muse_cli_db_session)
472 theirs_commit = _head_commit(tmp_path, "feature")
473
474 _switch_branch(tmp_path, "main")
475 await _merge_async(
476 branch="feature", root=tmp_path, session=muse_cli_db_session, no_ff=True
477 )
478
479 merge_commit_id = _head_commit(tmp_path, "main")
480 assert merge_commit_id != ours_commit
481
482 result = await muse_cli_db_session.execute(
483 select(MuseCliCommit).where(MuseCliCommit.commit_id == merge_commit_id)
484 )
485 mc = result.scalar_one()
486 assert mc.parent_commit_id == ours_commit
487 assert mc.parent2_commit_id == theirs_commit
488
489
490 # ---------------------------------------------------------------------------
491 # --squash tests
492 # ---------------------------------------------------------------------------
493
494
495 @pytest.mark.anyio
496 async def test_merge_squash_single_commit_no_parent2(
497 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
498 ) -> None:
499 """--squash creates a single commit with no parent2_commit_id."""
500 _init_repo(tmp_path)
501 _write_workdir(tmp_path, {"base.mid": b"BASE"})
502 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
503
504 _create_branch(tmp_path, "feature")
505
506 _write_workdir(tmp_path, {"base.mid": b"BASE", "main_only.mid": b"MAIN"})
507 await _commit_async(message="main step", root=tmp_path, session=muse_cli_db_session)
508 ours_commit = _head_commit(tmp_path)
509
510 _switch_branch(tmp_path, "feature")
511 _write_workdir(tmp_path, {"base.mid": b"BASE", "feat_only.mid": b"FEAT"})
512 await _commit_async(message="feature step", root=tmp_path, session=muse_cli_db_session)
513
514 _switch_branch(tmp_path, "main")
515 await _merge_async(
516 branch="feature",
517 root=tmp_path,
518 session=muse_cli_db_session,
519 squash=True,
520 )
521
522 squash_commit_id = _head_commit(tmp_path, "main")
523 assert squash_commit_id != ours_commit
524
525 result = await muse_cli_db_session.execute(
526 select(MuseCliCommit).where(MuseCliCommit.commit_id == squash_commit_id)
527 )
528 sc = result.scalar_one()
529 # Single parent — this is NOT a merge commit in the DAG.
530 assert sc.parent_commit_id == ours_commit
531 assert sc.parent2_commit_id is None
532
533
534 @pytest.mark.anyio
535 async def test_merge_squash_fast_forward_eligible_creates_single_commit(
536 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
537 ) -> None:
538 """--squash on a fast-forward-eligible pair still creates a single commit."""
539 _init_repo(tmp_path)
540 _write_workdir(tmp_path, {"beat.mid": b"V1"})
541 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
542 ours_commit = _head_commit(tmp_path)
543
544 _create_branch(tmp_path, "feature")
545 _switch_branch(tmp_path, "feature")
546 _write_workdir(tmp_path, {"beat.mid": b"V2"})
547 await _commit_async(message="feature", root=tmp_path, session=muse_cli_db_session)
548 theirs_commit = _head_commit(tmp_path, "feature")
549
550 _switch_branch(tmp_path, "main")
551 await _merge_async(
552 branch="feature",
553 root=tmp_path,
554 session=muse_cli_db_session,
555 squash=True,
556 )
557
558 squash_commit_id = _head_commit(tmp_path, "main")
559 # Must not be the feature tip (that would be a fast-forward).
560 assert squash_commit_id != theirs_commit
561
562 result = await muse_cli_db_session.execute(
563 select(MuseCliCommit).where(MuseCliCommit.commit_id == squash_commit_id)
564 )
565 sc = result.scalar_one()
566 assert sc.parent_commit_id == ours_commit
567 assert sc.parent2_commit_id is None
568
569
570 # ---------------------------------------------------------------------------
571 # --strategy tests
572 # ---------------------------------------------------------------------------
573
574
575 @pytest.mark.anyio
576 async def test_merge_strategy_ours(
577 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
578 ) -> None:
579 """--strategy ours keeps all files from current branch, ignores theirs."""
580 _init_repo(tmp_path)
581 _write_workdir(tmp_path, {"shared.mid": b"BASE"})
582 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
583
584 _create_branch(tmp_path, "feature")
585
586 # Both sides modify shared.mid (would conflict without a strategy).
587 _write_workdir(tmp_path, {"shared.mid": b"MAIN_VERSION"})
588 await _commit_async(message="main changes shared", root=tmp_path, session=muse_cli_db_session)
589 ours_commit = _head_commit(tmp_path)
590
591 _switch_branch(tmp_path, "feature")
592 _write_workdir(tmp_path, {"shared.mid": b"FEATURE_VERSION"})
593 await _commit_async(message="feature changes shared", root=tmp_path, session=muse_cli_db_session)
594 theirs_commit = _head_commit(tmp_path, "feature")
595
596 _switch_branch(tmp_path, "main")
597 # --strategy ours should succeed without conflicts.
598 await _merge_async(
599 branch="feature",
600 root=tmp_path,
601 session=muse_cli_db_session,
602 strategy="ours",
603 )
604
605 # No MERGE_STATE.json — no conflicts written.
606 assert read_merge_state(tmp_path) is None
607
608 merge_commit_id = _head_commit(tmp_path, "main")
609 from maestro.muse_cli.db import get_commit_snapshot_manifest
610 manifest = await get_commit_snapshot_manifest(muse_cli_db_session, merge_commit_id)
611 assert manifest is not None
612 # The snapshot should reflect ours (MAIN_VERSION).
613 from maestro.muse_cli.snapshot import compute_snapshot_id
614 ours_manifest = await get_commit_snapshot_manifest(muse_cli_db_session, ours_commit)
615 assert manifest == ours_manifest
616
617
618 @pytest.mark.anyio
619 async def test_merge_strategy_theirs(
620 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
621 ) -> None:
622 """--strategy theirs takes all files from target branch, ignores ours."""
623 _init_repo(tmp_path)
624 _write_workdir(tmp_path, {"shared.mid": b"BASE"})
625 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
626
627 _create_branch(tmp_path, "feature")
628
629 _write_workdir(tmp_path, {"shared.mid": b"MAIN_VERSION"})
630 await _commit_async(message="main changes shared", root=tmp_path, session=muse_cli_db_session)
631
632 _switch_branch(tmp_path, "feature")
633 _write_workdir(tmp_path, {"shared.mid": b"FEATURE_VERSION"})
634 await _commit_async(message="feature changes shared", root=tmp_path, session=muse_cli_db_session)
635 theirs_commit = _head_commit(tmp_path, "feature")
636
637 _switch_branch(tmp_path, "main")
638 await _merge_async(
639 branch="feature",
640 root=tmp_path,
641 session=muse_cli_db_session,
642 strategy="theirs",
643 )
644
645 assert read_merge_state(tmp_path) is None
646
647 merge_commit_id = _head_commit(tmp_path, "main")
648 from maestro.muse_cli.db import get_commit_snapshot_manifest
649 manifest = await get_commit_snapshot_manifest(muse_cli_db_session, merge_commit_id)
650 assert manifest is not None
651 # The snapshot should reflect theirs (FEATURE_VERSION).
652 theirs_manifest = await get_commit_snapshot_manifest(muse_cli_db_session, theirs_commit)
653 assert manifest == theirs_manifest
654
655
656 @pytest.mark.anyio
657 async def test_merge_strategy_invalid_exits_1(
658 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
659 ) -> None:
660 """Unknown --strategy value exits 1 with a clear error."""
661 _init_repo(tmp_path)
662 _write_workdir(tmp_path, {"a.mid": b"V"})
663 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
664 _create_branch(tmp_path, "feature")
665
666 # Advance feature so merge would proceed.
667 _switch_branch(tmp_path, "feature")
668 _write_workdir(tmp_path, {"a.mid": b"V2"})
669 await _commit_async(message="feature step", root=tmp_path, session=muse_cli_db_session)
670 _switch_branch(tmp_path, "main")
671
672 with pytest.raises(typer.Exit) as exc_info:
673 await _merge_async(
674 branch="feature",
675 root=tmp_path,
676 session=muse_cli_db_session,
677 strategy="recursive",
678 )
679
680 assert exc_info.value.exit_code == ExitCode.USER_ERROR