test_merge_integration.py
python
| 1 | """End-to-end integration tests for the full conflict resolution workflow. |
| 2 | |
| 3 | These tests exercise the complete cycle: |
| 4 | |
| 5 | muse merge <branch> → conflict detected, MERGE_STATE.json written |
| 6 | muse resolve <path> ... → path removed from conflict list |
| 7 | muse merge --continue → merge commit created, MERGE_STATE.json cleared |
| 8 | muse log → merge commit visible in history |
| 9 | |
| 10 | A separate test covers the abort path: |
| 11 | |
| 12 | muse merge <branch> → conflict detected |
| 13 | muse merge --abort → pre-merge state restored |
| 14 | |
| 15 | Both tests require two real branches with divergent commits, making them true |
| 16 | integration tests rather than unit tests. They use in-memory SQLite and |
| 17 | ``tmp_path`` — no real database or Docker is needed. |
| 18 | |
| 19 | All async tests use ``@pytest.mark.anyio``. |
| 20 | """ |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import json |
| 24 | import pathlib |
| 25 | import uuid |
| 26 | |
| 27 | import pytest |
| 28 | import typer |
| 29 | from sqlalchemy.ext.asyncio import AsyncSession |
| 30 | from sqlalchemy.future import select |
| 31 | |
| 32 | from maestro.muse_cli.commands.commit import _commit_async |
| 33 | from maestro.muse_cli.commands.merge import _merge_abort_async, _merge_async, _merge_continue_async |
| 34 | from maestro.muse_cli.commands.resolve import resolve_conflict_async |
| 35 | from maestro.muse_cli.db import get_commit_snapshot_manifest |
| 36 | from maestro.muse_cli.errors import ExitCode |
| 37 | from maestro.muse_cli.merge_engine import read_merge_state |
| 38 | from maestro.muse_cli.models import MuseCliCommit |
| 39 | |
| 40 | |
| 41 | # --------------------------------------------------------------------------- |
| 42 | # Helpers |
| 43 | # --------------------------------------------------------------------------- |
| 44 | |
| 45 | |
| 46 | def _init_repo(root: pathlib.Path) -> str: |
| 47 | rid = str(uuid.uuid4()) |
| 48 | muse = root / ".muse" |
| 49 | (muse / "refs" / "heads").mkdir(parents=True) |
| 50 | (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"})) |
| 51 | (muse / "HEAD").write_text("refs/heads/main") |
| 52 | (muse / "refs" / "heads" / "main").write_text("") |
| 53 | return rid |
| 54 | |
| 55 | |
| 56 | def _write_workdir(root: pathlib.Path, files: dict[str, bytes]) -> None: |
| 57 | import shutil |
| 58 | |
| 59 | workdir = root / "muse-work" |
| 60 | if workdir.exists(): |
| 61 | shutil.rmtree(workdir) |
| 62 | workdir.mkdir() |
| 63 | for name, content in files.items(): |
| 64 | (workdir / name).write_bytes(content) |
| 65 | |
| 66 | |
| 67 | def _create_branch(root: pathlib.Path, branch: str, from_branch: str = "main") -> None: |
| 68 | muse = root / ".muse" |
| 69 | src = muse / "refs" / "heads" / from_branch |
| 70 | dst = muse / "refs" / "heads" / branch |
| 71 | dst.parent.mkdir(parents=True, exist_ok=True) |
| 72 | dst.write_text(src.read_text() if src.exists() else "") |
| 73 | |
| 74 | |
| 75 | def _switch_branch(root: pathlib.Path, branch: str) -> None: |
| 76 | (root / ".muse" / "HEAD").write_text(f"refs/heads/{branch}") |
| 77 | |
| 78 | |
| 79 | def _head_commit(root: pathlib.Path, branch: str | None = None) -> str: |
| 80 | muse = root / ".muse" |
| 81 | if branch is None: |
| 82 | head_ref = (muse / "HEAD").read_text().strip() |
| 83 | branch = head_ref.rsplit("/", 1)[-1] |
| 84 | ref_path = muse / "refs" / "heads" / branch |
| 85 | return ref_path.read_text().strip() if ref_path.exists() else "" |
| 86 | |
| 87 | |
| 88 | # --------------------------------------------------------------------------- |
| 89 | # Full conflict → resolve → continue cycle |
| 90 | # --------------------------------------------------------------------------- |
| 91 | |
| 92 | |
| 93 | @pytest.mark.anyio |
| 94 | async def test_full_conflict_resolve_cycle( |
| 95 | tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession |
| 96 | ) -> None: |
| 97 | """End-to-end: merge → conflict → resolve → continue → log shows merge commit. |
| 98 | |
| 99 | Scenario: |
| 100 | 1. Create base commit on ``main`` with ``beat.mid``. |
| 101 | 2. Branch ``experiment`` from ``main``. |
| 102 | 3. Advance ``main``: modify ``beat.mid`` → OURS_VERSION. |
| 103 | 4. Advance ``experiment``: modify ``beat.mid`` → THEIRS_VERSION. |
| 104 | 5. Merge ``experiment`` into ``main`` → conflict on ``beat.mid``. |
| 105 | 6. Resolve via ``--theirs`` (file content replaced in muse-work/). |
| 106 | 7. Run ``muse merge --continue`` → merge commit created. |
| 107 | 8. Verify: MERGE_STATE.json cleared, merge commit has two parents, log |
| 108 | shows all three commits (base, ours, merge). |
| 109 | """ |
| 110 | _init_repo(tmp_path) |
| 111 | |
| 112 | # --- Step 1: base commit --- |
| 113 | _write_workdir(tmp_path, {"beat.mid": b"BASE"}) |
| 114 | await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session) |
| 115 | base_commit = _head_commit(tmp_path, "main") |
| 116 | |
| 117 | # --- Step 2: branch experiment --- |
| 118 | _create_branch(tmp_path, "experiment") |
| 119 | |
| 120 | # --- Step 3: advance main --- |
| 121 | _write_workdir(tmp_path, {"beat.mid": b"OURS_VERSION"}) |
| 122 | await _commit_async(message="main: ours version", root=tmp_path, session=muse_cli_db_session) |
| 123 | ours_commit = _head_commit(tmp_path, "main") |
| 124 | |
| 125 | # --- Step 4: advance experiment --- |
| 126 | _switch_branch(tmp_path, "experiment") |
| 127 | _write_workdir(tmp_path, {"beat.mid": b"THEIRS_VERSION"}) |
| 128 | await _commit_async( |
| 129 | message="experiment: theirs version", root=tmp_path, session=muse_cli_db_session |
| 130 | ) |
| 131 | theirs_commit = _head_commit(tmp_path, "experiment") |
| 132 | |
| 133 | # --- Step 5: merge → conflict --- |
| 134 | _switch_branch(tmp_path, "main") |
| 135 | _write_workdir(tmp_path, {"beat.mid": b"OURS_VERSION"}) # restore ours in workdir |
| 136 | |
| 137 | with pytest.raises(typer.Exit) as exc_info: |
| 138 | await _merge_async( |
| 139 | branch="experiment", root=tmp_path, session=muse_cli_db_session |
| 140 | ) |
| 141 | assert exc_info.value.exit_code == ExitCode.USER_ERROR |
| 142 | |
| 143 | merge_state = read_merge_state(tmp_path) |
| 144 | assert merge_state is not None |
| 145 | assert "beat.mid" in merge_state.conflict_paths |
| 146 | |
| 147 | # --- Step 6: resolve --theirs (copies THEIRS_VERSION to muse-work) --- |
| 148 | await resolve_conflict_async( |
| 149 | file_path="beat.mid", |
| 150 | ours=False, |
| 151 | root=tmp_path, |
| 152 | session=muse_cli_db_session, |
| 153 | ) |
| 154 | |
| 155 | # File must now contain theirs content. |
| 156 | assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"THEIRS_VERSION" |
| 157 | |
| 158 | # Conflict list must be empty (MERGE_STATE.json still present for --continue). |
| 159 | state_after_resolve = read_merge_state(tmp_path) |
| 160 | assert state_after_resolve is not None |
| 161 | assert state_after_resolve.conflict_paths == [] |
| 162 | |
| 163 | # --- Step 7: merge --continue --- |
| 164 | await _merge_continue_async(root=tmp_path, session=muse_cli_db_session) |
| 165 | |
| 166 | # MERGE_STATE.json must be gone. |
| 167 | assert read_merge_state(tmp_path) is None |
| 168 | |
| 169 | # --- Step 8: verify merge commit --- |
| 170 | merge_commit_id = _head_commit(tmp_path, "main") |
| 171 | assert merge_commit_id not in (base_commit, ours_commit, theirs_commit) |
| 172 | |
| 173 | result = await muse_cli_db_session.execute( |
| 174 | select(MuseCliCommit).where(MuseCliCommit.commit_id == merge_commit_id) |
| 175 | ) |
| 176 | merge_commit = result.scalar_one() |
| 177 | # Two parents: ours and theirs. |
| 178 | assert merge_commit.parent_commit_id == ours_commit |
| 179 | assert merge_commit.parent2_commit_id == theirs_commit |
| 180 | |
| 181 | # Merged snapshot must contain the resolved content (theirs version). |
| 182 | merged_manifest = await get_commit_snapshot_manifest(muse_cli_db_session, merge_commit_id) |
| 183 | assert merged_manifest is not None |
| 184 | assert "beat.mid" in merged_manifest |
| 185 | |
| 186 | # Total commits in DB: base + ours + theirs + merge = 4. |
| 187 | all_commits_result = await muse_cli_db_session.execute(select(MuseCliCommit)) |
| 188 | all_commits = all_commits_result.scalars().all() |
| 189 | assert len(all_commits) == 4 |
| 190 | |
| 191 | |
| 192 | # --------------------------------------------------------------------------- |
| 193 | # Full conflict → abort cycle |
| 194 | # --------------------------------------------------------------------------- |
| 195 | |
| 196 | |
| 197 | @pytest.mark.anyio |
| 198 | async def test_full_conflict_abort_cycle( |
| 199 | tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession |
| 200 | ) -> None: |
| 201 | """End-to-end: merge → conflict → abort → pre-merge state restored. |
| 202 | |
| 203 | After abort: |
| 204 | - ``muse-work/`` contains the ours version. |
| 205 | - ``MERGE_STATE.json`` is gone. |
| 206 | - No merge commit was created. |
| 207 | """ |
| 208 | _init_repo(tmp_path) |
| 209 | |
| 210 | # Base commit. |
| 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_CLEAN"}) |
| 217 | await _commit_async(message="main", 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_CLEAN"}) |
| 223 | await _commit_async(message="experiment", root=tmp_path, session=muse_cli_db_session) |
| 224 | |
| 225 | # Trigger conflict. |
| 226 | _switch_branch(tmp_path, "main") |
| 227 | _write_workdir(tmp_path, {"beat.mid": b"OURS_CLEAN"}) |
| 228 | with pytest.raises(typer.Exit): |
| 229 | await _merge_async( |
| 230 | branch="experiment", root=tmp_path, session=muse_cli_db_session |
| 231 | ) |
| 232 | |
| 233 | assert read_merge_state(tmp_path) is not None |
| 234 | |
| 235 | # Simulate partial manual edits (user started editing but wants to abort). |
| 236 | (tmp_path / "muse-work" / "beat.mid").write_bytes(b"MESSY_PARTIAL_EDIT") |
| 237 | |
| 238 | # Abort. |
| 239 | await _merge_abort_async(root=tmp_path, session=muse_cli_db_session) |
| 240 | |
| 241 | # Post-abort: MERGE_STATE.json cleared. |
| 242 | assert read_merge_state(tmp_path) is None |
| 243 | |
| 244 | # Post-abort: muse-work restored to ours (pre-merge) content. |
| 245 | assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"OURS_CLEAN" |
| 246 | |
| 247 | # No merge commit created — DB has exactly 3 commits (base + main + experiment). |
| 248 | all_commits_result = await muse_cli_db_session.execute(select(MuseCliCommit)) |
| 249 | all_commits = all_commits_result.scalars().all() |
| 250 | assert len(all_commits) == 3 |
| 251 | |
| 252 | # main HEAD unchanged after abort. |
| 253 | assert _head_commit(tmp_path, "main") == ours_commit |
| 254 | |
| 255 | |
| 256 | # --------------------------------------------------------------------------- |
| 257 | # Multiple conflicts — partial resolution then continue |
| 258 | # --------------------------------------------------------------------------- |
| 259 | |
| 260 | |
| 261 | @pytest.mark.anyio |
| 262 | async def test_partial_resolution_then_continue( |
| 263 | tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession |
| 264 | ) -> None: |
| 265 | """Resolving conflicts one at a time then --continue works correctly.""" |
| 266 | _init_repo(tmp_path) |
| 267 | |
| 268 | # Base with two files. |
| 269 | _write_workdir(tmp_path, {"a.mid": b"BASE_A", "b.mid": b"BASE_B"}) |
| 270 | await _commit_async(message="base", root=tmp_path, session=muse_cli_db_session) |
| 271 | _create_branch(tmp_path, "feature") |
| 272 | |
| 273 | # Main modifies both files. |
| 274 | _write_workdir(tmp_path, {"a.mid": b"MAIN_A", "b.mid": b"MAIN_B"}) |
| 275 | await _commit_async(message="main", root=tmp_path, session=muse_cli_db_session) |
| 276 | ours_commit = _head_commit(tmp_path, "main") |
| 277 | |
| 278 | # Feature also modifies both files. |
| 279 | _switch_branch(tmp_path, "feature") |
| 280 | _write_workdir(tmp_path, {"a.mid": b"FEAT_A", "b.mid": b"FEAT_B"}) |
| 281 | await _commit_async(message="feature", root=tmp_path, session=muse_cli_db_session) |
| 282 | theirs_commit = _head_commit(tmp_path, "feature") |
| 283 | |
| 284 | # Trigger conflict. |
| 285 | _switch_branch(tmp_path, "main") |
| 286 | _write_workdir(tmp_path, {"a.mid": b"MAIN_A", "b.mid": b"MAIN_B"}) |
| 287 | with pytest.raises(typer.Exit): |
| 288 | await _merge_async(branch="feature", root=tmp_path, session=muse_cli_db_session) |
| 289 | |
| 290 | state = read_merge_state(tmp_path) |
| 291 | assert state is not None |
| 292 | assert sorted(state.conflict_paths) == ["a.mid", "b.mid"] |
| 293 | |
| 294 | # Resolve a.mid --ours. |
| 295 | await resolve_conflict_async( |
| 296 | file_path="a.mid", ours=True, root=tmp_path, session=muse_cli_db_session |
| 297 | ) |
| 298 | # b.mid still in conflict. |
| 299 | state2 = read_merge_state(tmp_path) |
| 300 | assert state2 is not None |
| 301 | assert state2.conflict_paths == ["b.mid"] |
| 302 | |
| 303 | # Resolve b.mid --theirs. |
| 304 | await resolve_conflict_async( |
| 305 | file_path="b.mid", ours=False, root=tmp_path, session=muse_cli_db_session |
| 306 | ) |
| 307 | # All clear. |
| 308 | state3 = read_merge_state(tmp_path) |
| 309 | assert state3 is not None |
| 310 | assert state3.conflict_paths == [] |
| 311 | assert (tmp_path / "muse-work" / "b.mid").read_bytes() == b"FEAT_B" |
| 312 | |
| 313 | # Continue. |
| 314 | await _merge_continue_async(root=tmp_path, session=muse_cli_db_session) |
| 315 | assert read_merge_state(tmp_path) is None |
| 316 | |
| 317 | merge_commit_id = _head_commit(tmp_path, "main") |
| 318 | assert merge_commit_id not in (ours_commit, theirs_commit) |