cgcardona / muse public
test_resolve.py python
611 lines 21.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Unit and integration tests for the ``muse resolve``, ``muse merge --continue``,
2 ``muse merge --abort``, and conflict-aware ``muse status`` commands.
3
4 All async tests use ``@pytest.mark.anyio``. Tests exercise the testable async
5 cores directly with in-memory SQLite and ``tmp_path`` so no real Postgres or
6 Docker instance is required.
7 """
8 from __future__ import annotations
9
10 import json
11 import pathlib
12 import uuid
13
14 import pytest
15 import typer
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from maestro.muse_cli.commands.commit import _commit_async
19 from maestro.muse_cli.commands.merge import _merge_abort_async, _merge_async, _merge_continue_async
20 from maestro.muse_cli.commands.resolve import resolve_conflict_async
21 from maestro.muse_cli.commands.status import _status_async
22 from maestro.muse_cli.errors import ExitCode
23 from maestro.muse_cli.merge_engine import (
24 apply_resolution,
25 is_conflict_resolved,
26 read_merge_state,
27 write_merge_state,
28 )
29 from maestro.muse_cli.object_store import write_object
30
31
32 # ---------------------------------------------------------------------------
33 # Test helpers (shared with other muse_cli test modules)
34 # ---------------------------------------------------------------------------
35
36
37 def _init_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
38 """Create minimal ``.muse/`` layout for testing."""
39 rid = repo_id or str(uuid.uuid4())
40 muse = root / ".muse"
41 (muse / "refs" / "heads").mkdir(parents=True)
42 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
43 (muse / "HEAD").write_text("refs/heads/main")
44 (muse / "refs" / "heads" / "main").write_text("")
45 return rid
46
47
48 def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None:
49 """Overwrite muse-work/ with exactly the given files."""
50 import shutil
51
52 workdir = root / "muse-work"
53 if workdir.exists():
54 shutil.rmtree(workdir)
55 workdir.mkdir()
56 for name, content in files.items():
57 (workdir / name).write_bytes(content)
58
59
60 def _create_branch(root: pathlib.Path, branch: str, from_branch: str = "main") -> None:
61 muse = root / ".muse"
62 src = muse / "refs" / "heads" / from_branch
63 dst = muse / "refs" / "heads" / branch
64 dst.parent.mkdir(parents=True, exist_ok=True)
65 dst.write_text(src.read_text() if src.exists() else "")
66
67
68 def _switch_branch(root: pathlib.Path, branch: str) -> None:
69 (root / ".muse" / "HEAD").write_text(f"refs/heads/{branch}")
70
71
72 def _head_commit(root: pathlib.Path, branch: str | None = None) -> str:
73 muse = root / ".muse"
74 if branch is None:
75 head_ref = (muse / "HEAD").read_text().strip()
76 branch = head_ref.rsplit("/", 1)[-1]
77 ref_path = muse / "refs" / "heads" / branch
78 return ref_path.read_text().strip() if ref_path.exists() else ""
79
80
81 # ---------------------------------------------------------------------------
82 # muse status — conflict display during merge
83 # ---------------------------------------------------------------------------
84
85
86 @pytest.mark.anyio
87 async def test_status_shows_conflicts_during_merge(
88 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession, capsys: pytest.CaptureFixture[str]
89 ) -> None:
90 """Status during a merge shows unmerged paths and the --continue hint."""
91 _init_repo(tmp_path)
92 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
93 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
94
95 write_merge_state(
96 tmp_path,
97 base_commit="base000",
98 ours_commit="ours111",
99 theirs_commit="their222",
100 conflict_paths=["beat.mid", "lead.mid"],
101 other_branch="experiment",
102 )
103
104 await _status_async(root=tmp_path, session=muse_cli_db_session)
105 output = capsys.readouterr().out
106
107 assert "You have unmerged paths." in output
108 assert "muse merge --continue" in output
109 assert "beat.mid" in output
110 assert "lead.mid" in output
111
112
113 # ---------------------------------------------------------------------------
114 # muse resolve --ours
115 # ---------------------------------------------------------------------------
116
117
118 @pytest.mark.anyio
119 async def test_resolve_ours_removes_from_conflict_list(
120 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
121 ) -> None:
122 """``--ours`` removes the path from MERGE_STATE.json conflict_paths."""
123 _init_repo(tmp_path)
124 _write_workdir(tmp_path, {"beat.mid": b"OURS"})
125 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
126
127 write_merge_state(
128 tmp_path,
129 base_commit="base000",
130 ours_commit="ours111",
131 theirs_commit="their222",
132 conflict_paths=["beat.mid"],
133 )
134
135 await resolve_conflict_async(
136 file_path="beat.mid", ours=True, root=tmp_path, session=muse_cli_db_session
137 )
138
139 state = read_merge_state(tmp_path)
140 assert state is not None
141 # Path must be gone from conflict_paths but MERGE_STATE.json still present.
142 assert "beat.mid" not in state.conflict_paths
143
144
145 @pytest.mark.anyio
146 async def test_resolve_ours_does_not_modify_file(
147 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
148 ) -> None:
149 """``--ours`` leaves muse-work/ untouched."""
150 _init_repo(tmp_path)
151 _write_workdir(tmp_path, {"beat.mid": b"OUR_CONTENT"})
152 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
153
154 write_merge_state(
155 tmp_path,
156 base_commit="base000",
157 ours_commit="ours111",
158 theirs_commit="their222",
159 conflict_paths=["beat.mid"],
160 )
161
162 await resolve_conflict_async(
163 file_path="beat.mid", ours=True, root=tmp_path, session=muse_cli_db_session
164 )
165
166 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"OUR_CONTENT"
167
168
169 @pytest.mark.anyio
170 async def test_resolve_ours_all_cleared_state_preserved_for_continue(
171 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
172 ) -> None:
173 """When all conflicts resolved via --ours, MERGE_STATE.json stays (for --continue)."""
174 _init_repo(tmp_path)
175 _write_workdir(tmp_path, {"beat.mid": b"OURS"})
176 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
177
178 write_merge_state(
179 tmp_path,
180 base_commit="base000",
181 ours_commit="ours111",
182 theirs_commit="their222",
183 conflict_paths=["beat.mid"],
184 )
185
186 await resolve_conflict_async(
187 file_path="beat.mid", ours=True, root=tmp_path, session=muse_cli_db_session
188 )
189
190 # MERGE_STATE.json must still exist (--continue reads ours/theirs commit IDs).
191 state = read_merge_state(tmp_path)
192 assert state is not None
193 assert state.ours_commit == "ours111"
194 assert state.theirs_commit == "their222"
195 assert state.conflict_paths == []
196
197
198 # ---------------------------------------------------------------------------
199 # muse resolve --theirs
200 # ---------------------------------------------------------------------------
201
202
203 @pytest.mark.anyio
204 async def test_resolve_theirs_applies_their_file(
205 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
206 ) -> None:
207 """``--theirs`` copies the theirs branch's object to muse-work/."""
208 _init_repo(tmp_path)
209
210 # Set up both branches with a conflict on beat.mid.
211 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
212 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
213 _create_branch(tmp_path, "experiment")
214
215 # Advance main.
216 _write_workdir(tmp_path, {"beat.mid": b"OURS_VERSION"})
217 await _commit_async(message="main step", root=tmp_path, session=muse_cli_db_session)
218 ours_commit = _head_commit(tmp_path, "main")
219
220 # Advance experiment.
221 _switch_branch(tmp_path, "experiment")
222 _write_workdir(tmp_path, {"beat.mid": b"THEIRS_VERSION"})
223 await _commit_async(message="exp step", root=tmp_path, session=muse_cli_db_session)
224 theirs_commit = _head_commit(tmp_path, "experiment")
225
226 # Put ours version back in muse-work (simulating the state after merge conflict).
227 _switch_branch(tmp_path, "main")
228 _write_workdir(tmp_path, {"beat.mid": b"OURS_VERSION"})
229
230 write_merge_state(
231 tmp_path,
232 base_commit="base000",
233 ours_commit=ours_commit,
234 theirs_commit=theirs_commit,
235 conflict_paths=["beat.mid"],
236 )
237
238 await resolve_conflict_async(
239 file_path="beat.mid", ours=False, root=tmp_path, session=muse_cli_db_session
240 )
241
242 # The file in muse-work must now contain the theirs content.
243 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"THEIRS_VERSION"
244 state = read_merge_state(tmp_path)
245 assert state is not None
246 assert "beat.mid" not in state.conflict_paths
247
248
249 @pytest.mark.anyio
250 async def test_resolve_theirs_missing_object_exits_1(
251 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
252 ) -> None:
253 """``--theirs`` exits 1 when the object is not in the local store."""
254 _init_repo(tmp_path)
255 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
256 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
257
258 # Build a theirs commit that has a snapshot with beat.mid → some object_id.
259 _create_branch(tmp_path, "experiment")
260 _switch_branch(tmp_path, "experiment")
261 _write_workdir(tmp_path, {"beat.mid": b"THEIRS_CONTENT"})
262 await _commit_async(message="exp step", root=tmp_path, session=muse_cli_db_session)
263 theirs_commit = _head_commit(tmp_path, "experiment")
264
265 # Now delete the object from the local store so it's missing.
266 from maestro.muse_cli.db import get_commit_snapshot_manifest
267 from maestro.muse_cli.object_store import object_path
268
269 theirs_manifest = await get_commit_snapshot_manifest(muse_cli_db_session, theirs_commit)
270 assert theirs_manifest is not None
271 obj_id = theirs_manifest["beat.mid"]
272 obj_file = object_path(tmp_path, obj_id)
273 obj_file.unlink()
274
275 _switch_branch(tmp_path, "main")
276 _write_workdir(tmp_path, {"beat.mid": b"OURS_VERSION"})
277 ours_commit = _head_commit(tmp_path, "main")
278
279 write_merge_state(
280 tmp_path,
281 base_commit="base000",
282 ours_commit=ours_commit,
283 theirs_commit=theirs_commit,
284 conflict_paths=["beat.mid"],
285 )
286
287 with pytest.raises(typer.Exit) as exc_info:
288 await resolve_conflict_async(
289 file_path="beat.mid", ours=False, root=tmp_path, session=muse_cli_db_session
290 )
291
292 assert exc_info.value.exit_code == ExitCode.USER_ERROR
293
294
295 # ---------------------------------------------------------------------------
296 # muse resolve — error cases
297 # ---------------------------------------------------------------------------
298
299
300 @pytest.mark.anyio
301 async def test_resolve_nonexistent_path_exits_1(
302 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
303 ) -> None:
304 """Resolving a path not in conflict_paths exits 1 with a clear error."""
305 _init_repo(tmp_path)
306 _write_workdir(tmp_path, {"beat.mid": b"V"})
307 await _commit_async(message="initial", root=tmp_path, session=muse_cli_db_session)
308
309 write_merge_state(
310 tmp_path,
311 base_commit="base000",
312 ours_commit="ours111",
313 theirs_commit="their222",
314 conflict_paths=["beat.mid"],
315 )
316
317 with pytest.raises(typer.Exit) as exc_info:
318 await resolve_conflict_async(
319 file_path="nonexistent.mid",
320 ours=True,
321 root=tmp_path,
322 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_resolve_no_merge_in_progress_exits_1(
330 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
331 ) -> None:
332 """Resolving when no merge is in progress exits 1."""
333 _init_repo(tmp_path)
334 (tmp_path / ".muse").mkdir(exist_ok=True) # ensure .muse exists
335
336 with pytest.raises(typer.Exit) as exc_info:
337 await resolve_conflict_async(
338 file_path="beat.mid",
339 ours=True,
340 root=tmp_path,
341 session=muse_cli_db_session,
342 )
343
344 assert exc_info.value.exit_code == ExitCode.USER_ERROR
345
346
347 # ---------------------------------------------------------------------------
348 # muse merge --continue
349 # ---------------------------------------------------------------------------
350
351
352 @pytest.mark.anyio
353 async def test_merge_continue_creates_commit_when_clean(
354 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
355 ) -> None:
356 """``--continue`` creates a merge commit once all conflicts are resolved."""
357 _init_repo(tmp_path)
358 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
359 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
360 base_commit = _head_commit(tmp_path)
361 _create_branch(tmp_path, "experiment")
362
363 # Diverge both branches.
364 _write_workdir(tmp_path, {"beat.mid": b"OURS"})
365 await _commit_async(message="main", root=tmp_path, session=muse_cli_db_session)
366 ours_commit = _head_commit(tmp_path, "main")
367
368 _switch_branch(tmp_path, "experiment")
369 _write_workdir(tmp_path, {"beat.mid": b"THEIRS"})
370 await _commit_async(message="exp", root=tmp_path, session=muse_cli_db_session)
371 theirs_commit = _head_commit(tmp_path, "experiment")
372
373 # Simulate post-conflict state: MERGE_STATE with no remaining conflicts.
374 _switch_branch(tmp_path, "main")
375 _write_workdir(tmp_path, {"beat.mid": b"RESOLVED"})
376 write_merge_state(
377 tmp_path,
378 base_commit=base_commit,
379 ours_commit=ours_commit,
380 theirs_commit=theirs_commit,
381 conflict_paths=[], # all resolved
382 other_branch="experiment",
383 )
384
385 await _merge_continue_async(root=tmp_path, session=muse_cli_db_session)
386
387 # MERGE_STATE.json must be gone.
388 assert read_merge_state(tmp_path) is None
389
390 # A new merge commit must exist at main HEAD.
391 merge_commit_id = _head_commit(tmp_path, "main")
392 assert merge_commit_id != ours_commit
393
394
395 @pytest.mark.anyio
396 async def test_merge_continue_fails_with_remaining_conflicts(
397 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
398 ) -> None:
399 """``--continue`` exits 1 when unresolved conflicts remain."""
400 _init_repo(tmp_path)
401 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
402 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
403
404 write_merge_state(
405 tmp_path,
406 base_commit="base000",
407 ours_commit="ours111",
408 theirs_commit="their222",
409 conflict_paths=["beat.mid"], # still has conflict
410 )
411
412 with pytest.raises(typer.Exit) as exc_info:
413 await _merge_continue_async(root=tmp_path, session=muse_cli_db_session)
414
415 assert exc_info.value.exit_code == ExitCode.USER_ERROR
416
417
418 @pytest.mark.anyio
419 async def test_merge_continue_no_merge_in_progress_exits_1(
420 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
421 ) -> None:
422 """``--continue`` exits 1 when no merge is in progress."""
423 _init_repo(tmp_path)
424 (tmp_path / ".muse").mkdir(exist_ok=True)
425
426 with pytest.raises(typer.Exit) as exc_info:
427 await _merge_continue_async(root=tmp_path, session=muse_cli_db_session)
428
429 assert exc_info.value.exit_code == ExitCode.USER_ERROR
430
431
432 # ---------------------------------------------------------------------------
433 # muse merge --abort
434 # ---------------------------------------------------------------------------
435
436
437 @pytest.mark.anyio
438 async def test_merge_abort_restores_state(
439 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
440 ) -> None:
441 """``--abort`` restores ours version of conflicted files and deletes MERGE_STATE.json."""
442 _init_repo(tmp_path)
443 _write_workdir(tmp_path, {"beat.mid": b"BASE"})
444 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
445 _create_branch(tmp_path, "experiment")
446
447 # Advance main.
448 _write_workdir(tmp_path, {"beat.mid": b"OURS_PRE_MERGE"})
449 await _commit_async(message="main step", root=tmp_path, session=muse_cli_db_session)
450 ours_commit = _head_commit(tmp_path, "main")
451
452 # Advance experiment.
453 _switch_branch(tmp_path, "experiment")
454 _write_workdir(tmp_path, {"beat.mid": b"THEIRS_VERSION"})
455 await _commit_async(message="exp step", root=tmp_path, session=muse_cli_db_session)
456 theirs_commit = _head_commit(tmp_path, "experiment")
457
458 # Simulate post-conflict state: workdir has a messy partially-resolved file.
459 _switch_branch(tmp_path, "main")
460 _write_workdir(tmp_path, {"beat.mid": b"PARTIALLY_RESOLVED_MESS"})
461 write_merge_state(
462 tmp_path,
463 base_commit="base000",
464 ours_commit=ours_commit,
465 theirs_commit=theirs_commit,
466 conflict_paths=["beat.mid"],
467 other_branch="experiment",
468 )
469
470 await _merge_abort_async(root=tmp_path, session=muse_cli_db_session)
471
472 # MERGE_STATE.json must be cleared.
473 assert read_merge_state(tmp_path) is None
474
475 # muse-work/beat.mid must be restored to the ours (pre-merge) version.
476 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"OURS_PRE_MERGE"
477
478
479 @pytest.mark.anyio
480 async def test_merge_abort_no_merge_in_progress_exits_1(
481 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
482 ) -> None:
483 """``--abort`` exits 1 when no merge is in progress."""
484 _init_repo(tmp_path)
485 (tmp_path / ".muse").mkdir(exist_ok=True)
486
487 with pytest.raises(typer.Exit) as exc_info:
488 await _merge_abort_async(root=tmp_path, session=muse_cli_db_session)
489
490 assert exc_info.value.exit_code == ExitCode.USER_ERROR
491
492
493 @pytest.mark.anyio
494 async def test_merge_abort_removes_theirs_only_file(
495 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
496 ) -> None:
497 """``--abort`` removes files that exist only on the theirs branch (not in ours manifest)."""
498 _init_repo(tmp_path)
499 _write_workdir(tmp_path, {"base.mid": b"BASE"})
500 await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session)
501 _create_branch(tmp_path, "experiment")
502
503 # Main makes no change to theirs-only-file.
504 ours_commit = _head_commit(tmp_path, "main")
505
506 # Experiment adds a new file that main doesn't have.
507 _switch_branch(tmp_path, "experiment")
508 _write_workdir(tmp_path, {"base.mid": b"BASE", "theirs_only.mid": b"EXTRA"})
509 await _commit_async(message="exp adds file", root=tmp_path, session=muse_cli_db_session)
510 theirs_commit = _head_commit(tmp_path, "experiment")
511
512 # Simulate: theirs_only.mid was copied into workdir before the conflict was detected.
513 _switch_branch(tmp_path, "main")
514 _write_workdir(tmp_path, {"base.mid": b"BASE", "theirs_only.mid": b"EXTRA"})
515 write_merge_state(
516 tmp_path,
517 base_commit="base000",
518 ours_commit=ours_commit,
519 theirs_commit=theirs_commit,
520 conflict_paths=["theirs_only.mid"],
521 other_branch="experiment",
522 )
523
524 await _merge_abort_async(root=tmp_path, session=muse_cli_db_session)
525
526 # theirs_only.mid must be gone (it wasn't in ours manifest).
527 assert not (tmp_path / "muse-work" / "theirs_only.mid").exists()
528 assert read_merge_state(tmp_path) is None
529
530
531 # ---------------------------------------------------------------------------
532 # merge_engine helpers — unit tests
533 # ---------------------------------------------------------------------------
534
535
536 def test_apply_resolution_writes_file(tmp_path: pathlib.Path) -> None:
537 """apply_resolution() copies object content to muse-work/<rel_path>."""
538 content = b"RESOLVED_CONTENT"
539 object_id = "a" * 64 # fake sha256 (64 hex chars)
540 write_object(tmp_path, object_id, content)
541 (tmp_path / "muse-work").mkdir()
542
543 apply_resolution(tmp_path, "beat.mid", object_id)
544
545 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == content
546
547
548 def test_apply_resolution_missing_object_raises(tmp_path: pathlib.Path) -> None:
549 """apply_resolution() raises FileNotFoundError for missing objects."""
550 (tmp_path / ".muse" / "objects").mkdir(parents=True)
551 (tmp_path / "muse-work").mkdir()
552
553 with pytest.raises(FileNotFoundError):
554 apply_resolution(tmp_path, "beat.mid", "b" * 64)
555
556
557 def test_is_conflict_resolved_true_when_absent(tmp_path: pathlib.Path) -> None:
558 """is_conflict_resolved() returns True when path not in conflict list."""
559 (tmp_path / ".muse").mkdir()
560 write_merge_state(
561 tmp_path,
562 base_commit="b",
563 ours_commit="o",
564 theirs_commit="t",
565 conflict_paths=["other.mid"],
566 )
567 state = read_merge_state(tmp_path)
568 assert state is not None
569 assert is_conflict_resolved(state, "beat.mid") is True
570
571
572 def test_is_conflict_resolved_false_when_present(tmp_path: pathlib.Path) -> None:
573 """is_conflict_resolved() returns False when path still in conflict list."""
574 (tmp_path / ".muse").mkdir()
575 write_merge_state(
576 tmp_path,
577 base_commit="b",
578 ours_commit="o",
579 theirs_commit="t",
580 conflict_paths=["beat.mid"],
581 )
582 state = read_merge_state(tmp_path)
583 assert state is not None
584 assert is_conflict_resolved(state, "beat.mid") is False
585
586
587 # ---------------------------------------------------------------------------
588 # CLI — outside-repo guard
589 # ---------------------------------------------------------------------------
590
591
592 def test_resolve_outside_repo_exits_2() -> None:
593 """``muse resolve`` outside a Muse repo exits 2."""
594 from typer.testing import CliRunner
595
596 from maestro.muse_cli.app import cli
597
598 runner = CliRunner()
599 result = runner.invoke(cli, ["resolve", "beat.mid", "--ours"], catch_exceptions=False)
600 assert result.exit_code == ExitCode.REPO_NOT_FOUND
601
602
603 def test_merge_abort_outside_repo_exits_2() -> None:
604 """``muse merge --abort`` outside a Muse repo exits 2."""
605 from typer.testing import CliRunner
606
607 from maestro.muse_cli.app import cli
608
609 runner = CliRunner()
610 result = runner.invoke(cli, ["merge", "--abort"], catch_exceptions=False)
611 assert result.exit_code == ExitCode.REPO_NOT_FOUND