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