gabriel / muse public
test_resolve_phase3.py python
340 lines 13.3 KB
Raw
1 """Phase 3 of issue #8: commit guard alignment.
2
3 Coverage
4 --------
5 - commit guard reads conflict_paths (mutable), not original_conflict_paths
6 - after resolve_path clears all conflicts, muse commit succeeds
7 - after resolve_symbol clears all conflicts, muse commit succeeds
8 - muse commit is still blocked while conflicts remain
9 - original_conflict_paths is preserved in MERGE_STATE after resolve (harmony can learn)
10 - MERGE_STATE is cleared by a successful commit
11 - muse resolve CLI → muse commit CLI full flow exits 0
12 - muse resolve auto-stages the file (no separate code add needed before commit)
13 """
14
15 from __future__ import annotations
16
17 import json
18 import os
19 import pathlib
20
21 import pytest
22
23 from muse.core.merge_engine import (
24 MergeState,
25 read_merge_state,
26 resolve_path,
27 resolve_symbol,
28 write_merge_state,
29 )
30 from muse.core.types import MUSE_DIR, fake_id
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34
35 _BASE = fake_id("base")
36 _OURS = fake_id("ours")
37 _THEIRS = fake_id("theirs")
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44 def _invoke(repo: pathlib.Path, args: list[str]) -> "InvokeResult": # type: ignore[name-defined]
45 saved = os.getcwd()
46 try:
47 os.chdir(repo)
48 return runner.invoke(None, args)
49 finally:
50 os.chdir(saved)
51
52
53 @pytest.fixture()
54 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
55 """Initialised code repo with one committed file."""
56 _invoke(tmp_path, ["init"])
57 (tmp_path / "hello.md").write_text("# Hello\n")
58 _invoke(tmp_path, ["code", "add", "."])
59 _invoke(tmp_path, ["commit", "-m", "initial"])
60 return tmp_path
61
62
63 def _set_merge_state(root: pathlib.Path, conflicts: list[str]) -> None:
64 write_merge_state(
65 root,
66 base_commit=_BASE,
67 ours_commit=_OURS,
68 theirs_commit=_THEIRS,
69 conflict_paths=conflicts,
70 other_branch="feat/x",
71 )
72
73
74 def _state(root: pathlib.Path) -> MergeState:
75 s = read_merge_state(root)
76 assert s is not None
77 return s
78
79
80 # ---------------------------------------------------------------------------
81 # Commit guard alignment — unit verification
82 # ---------------------------------------------------------------------------
83
84 class TestCommitGuardReadsConflictPaths:
85 """The commit guard must read conflict_paths, not original_conflict_paths."""
86
87 def test_commit_blocked_while_conflict_paths_nonempty(
88 self, repo: pathlib.Path
89 ) -> None:
90 """Commit must fail when conflict_paths contains entries.
91
92 Staging a file clears it from conflict_paths (git parity), so the
93 merge state is written *after* staging to keep conflict_paths nonempty.
94 """
95 (repo / "hello.md").write_text("# Resolved\n")
96 _invoke(repo, ["code", "add", "."])
97 _set_merge_state(repo, ["hello.md"])
98 r = _invoke(repo, ["commit", "-m", "should fail"])
99 assert r.exit_code != 0
100
101 def test_commit_blocked_message_mentions_conflict(
102 self, repo: pathlib.Path
103 ) -> None:
104 """Commit failure message should reference the conflict."""
105 (repo / "hello.md").write_text("# Resolved\n")
106 _invoke(repo, ["code", "add", "."])
107 _set_merge_state(repo, ["hello.md"])
108 r = _invoke(repo, ["commit", "-m", "should fail"])
109 assert "conflict" in r.output.lower() or "conflict" in (r.stderr or "").lower()
110
111 def test_commit_succeeds_when_conflict_paths_empty(
112 self, repo: pathlib.Path
113 ) -> None:
114 """Commit must succeed when conflict_paths is [] even if original_conflict_paths is set."""
115 _set_merge_state(repo, [])
116 (repo / "hello.md").write_text("# Resolved\n")
117 _invoke(repo, ["code", "add", "."])
118 r = _invoke(repo, ["commit", "-m", "merge: manual resolve"])
119 assert r.exit_code == 0
120
121 def test_original_conflict_paths_does_not_block_commit(
122 self, repo: pathlib.Path
123 ) -> None:
124 """Clearing conflict_paths is sufficient — original_conflict_paths should not block."""
125 # Write state with conflicts, then clear them via resolve_path.
126 _set_merge_state(repo, ["hello.md"])
127 resolve_path(repo, "hello.md")
128 state = _state(repo)
129 assert state.conflict_paths == []
130 assert state.original_conflict_paths == ["hello.md"]
131
132 (repo / "hello.md").write_text("# Resolved\n")
133 _invoke(repo, ["code", "add", "."])
134 r = _invoke(repo, ["commit", "-m", "merge: manual resolve"])
135 assert r.exit_code == 0
136
137
138 # ---------------------------------------------------------------------------
139 # resolve_path → commit
140 # ---------------------------------------------------------------------------
141
142 class TestResolvePathThenCommit:
143 def test_resolve_path_unblocks_commit(self, repo: pathlib.Path) -> None:
144 _set_merge_state(repo, ["hello.md"])
145 resolve_path(repo, "hello.md")
146 (repo / "hello.md").write_text("# Resolved\n")
147 _invoke(repo, ["code", "add", "."])
148 r = _invoke(repo, ["commit", "-m", "merge: resolved"])
149 assert r.exit_code == 0
150
151 def test_resolve_path_partial_still_blocks(self, repo: pathlib.Path) -> None:
152 (repo / "other.md").write_text("other\n")
153 _invoke(repo, ["code", "add", "."])
154 _invoke(repo, ["commit", "-m", "add other"])
155
156 _set_merge_state(repo, ["hello.md", "other.md"])
157 resolve_path(repo, "hello.md")
158 # other.md is still conflicted — commit must fail
159 (repo / "hello.md").write_text("# Resolved\n")
160 _invoke(repo, ["code", "add", "."])
161 r = _invoke(repo, ["commit", "-m", "should still fail"])
162 assert r.exit_code != 0
163
164 def test_resolve_all_paths_unblocks_commit(self, repo: pathlib.Path) -> None:
165 (repo / "other.md").write_text("other\n")
166 _invoke(repo, ["code", "add", "."])
167 _invoke(repo, ["commit", "-m", "add other"])
168
169 _set_merge_state(repo, ["hello.md", "other.md"])
170 resolve_path(repo, "hello.md")
171 resolve_path(repo, "other.md")
172 (repo / "hello.md").write_text("# Resolved\n")
173 (repo / "other.md").write_text("resolved other\n")
174 _invoke(repo, ["code", "add", "."])
175 r = _invoke(repo, ["commit", "-m", "merge: all resolved"])
176 assert r.exit_code == 0
177
178 def test_merge_state_cleared_after_commit(self, repo: pathlib.Path) -> None:
179 _set_merge_state(repo, ["hello.md"])
180 resolve_path(repo, "hello.md")
181 (repo / "hello.md").write_text("# Resolved\n")
182 _invoke(repo, ["code", "add", "."])
183 _invoke(repo, ["commit", "-m", "merge: resolved"])
184 assert read_merge_state(repo) is None
185
186 def test_original_conflict_paths_preserved_until_commit(
187 self, repo: pathlib.Path
188 ) -> None:
189 """original_conflict_paths must survive resolve so Harmony can learn at commit."""
190 _set_merge_state(repo, ["hello.md"])
191 resolve_path(repo, "hello.md")
192 state = _state(repo)
193 assert state.original_conflict_paths == ["hello.md"]
194
195 def test_symbol_level_conflicts_cleared_by_resolve_path(
196 self, repo: pathlib.Path
197 ) -> None:
198 _set_merge_state(repo, ["hello.md::Hello World", "hello.md::Subtitle"])
199 resolve_path(repo, "hello.md")
200 (repo / "hello.md").write_text("# Resolved\n")
201 _invoke(repo, ["code", "add", "."])
202 r = _invoke(repo, ["commit", "-m", "merge: resolved"])
203 assert r.exit_code == 0
204
205
206 # ---------------------------------------------------------------------------
207 # resolve_symbol → commit
208 # ---------------------------------------------------------------------------
209
210 class TestResolveSymbolThenCommit:
211 def test_resolve_symbol_unblocks_commit_when_last(
212 self, repo: pathlib.Path
213 ) -> None:
214 _set_merge_state(repo, ["hello.md::Hello World"])
215 resolve_symbol(repo, "hello.md::Hello World")
216 (repo / "hello.md").write_text("# Resolved\n")
217 _invoke(repo, ["code", "add", "."])
218 r = _invoke(repo, ["commit", "-m", "merge: resolved"])
219 assert r.exit_code == 0
220
221 def test_resolve_symbol_partial_still_blocks(self, repo: pathlib.Path) -> None:
222 _set_merge_state(repo, ["hello.md::A", "hello.md::B"])
223 resolve_symbol(repo, "hello.md::A")
224 (repo / "hello.md").write_text("# Resolved\n")
225 _invoke(repo, ["code", "add", "."])
226 r = _invoke(repo, ["commit", "-m", "should still fail"])
227 assert r.exit_code != 0
228
229 def test_resolve_all_symbols_unblocks_commit(self, repo: pathlib.Path) -> None:
230 _set_merge_state(repo, ["hello.md::A", "hello.md::B"])
231 resolve_symbol(repo, "hello.md::A")
232 resolve_symbol(repo, "hello.md::B")
233 (repo / "hello.md").write_text("# Resolved\n")
234 _invoke(repo, ["code", "add", "."])
235 r = _invoke(repo, ["commit", "-m", "merge: all resolved"])
236 assert r.exit_code == 0
237
238
239 # ---------------------------------------------------------------------------
240 # muse resolve CLI → muse commit CLI — full end-to-end
241 # ---------------------------------------------------------------------------
242
243 class TestResolveCliThenCommitCli:
244 def test_resolve_cli_file_then_commit_succeeds(
245 self, repo: pathlib.Path
246 ) -> None:
247 _set_merge_state(repo, ["hello.md"])
248 (repo / "hello.md").write_text("# Manually resolved\n")
249 r = _invoke(repo, ["resolve", "hello.md"])
250 assert r.exit_code == 0
251 _invoke(repo, ["code", "add", "."])
252 r2 = _invoke(repo, ["commit", "-m", "merge: resolved"])
253 assert r2.exit_code == 0
254
255 def test_resolve_cli_symbol_then_commit_succeeds(
256 self, repo: pathlib.Path
257 ) -> None:
258 _set_merge_state(repo, ["hello.md::Hello World"])
259 (repo / "hello.md").write_text("# Manually resolved\n")
260 r = _invoke(repo, ["resolve", "hello.md::Hello World"])
261 assert r.exit_code == 0
262 _invoke(repo, ["code", "add", "."])
263 r2 = _invoke(repo, ["commit", "-m", "merge: resolved"])
264 assert r2.exit_code == 0
265
266 def test_resolve_cli_all_then_commit_succeeds(
267 self, repo: pathlib.Path
268 ) -> None:
269 _set_merge_state(repo, ["hello.md::A", "hello.md::B"])
270 (repo / "hello.md").write_text("# Manually resolved\n")
271 r = _invoke(repo, ["resolve", "--all"])
272 assert r.exit_code == 0
273 _invoke(repo, ["code", "add", "."])
274 r2 = _invoke(repo, ["commit", "-m", "merge: all resolved"])
275 assert r2.exit_code == 0
276
277 def test_resolve_cli_json_output(self, repo: pathlib.Path) -> None:
278 _set_merge_state(repo, ["hello.md::A", "hello.md::B"])
279 r = _invoke(repo, ["resolve", "hello.md", "--json"])
280 assert r.exit_code == 0
281 data = json.loads(r.output)
282 assert set(data["resolved"]) == {"hello.md::A", "hello.md::B"}
283 assert data["remaining"] == 0
284 assert data["ready_to_commit"] is True
285
286 def test_resolve_cli_no_merge_in_progress_exits_nonzero(
287 self, repo: pathlib.Path
288 ) -> None:
289 r = _invoke(repo, ["resolve", "hello.md"])
290 assert r.exit_code != 0
291
292 def test_resolve_cli_already_resolved_exits_zero(
293 self, repo: pathlib.Path
294 ) -> None:
295 _set_merge_state(repo, ["other.md"])
296 r = _invoke(repo, ["resolve", "hello.md"])
297 assert r.exit_code == 0 # warn, not error
298
299 def test_merge_state_absent_after_full_flow(
300 self, repo: pathlib.Path
301 ) -> None:
302 _set_merge_state(repo, ["hello.md"])
303 (repo / "hello.md").write_text("# Resolved\n")
304 _invoke(repo, ["resolve", "hello.md"])
305 _invoke(repo, ["code", "add", "."])
306 _invoke(repo, ["commit", "-m", "merge: done"])
307 assert read_merge_state(repo) is None
308
309 def test_resolve_auto_stages_file(self, repo: pathlib.Path) -> None:
310 """muse resolve stages the file automatically — no separate code add needed."""
311 _set_merge_state(repo, ["hello.md"])
312 (repo / "hello.md").write_text("# Manually resolved\n")
313 _invoke(repo, ["resolve", "hello.md"])
314 # Commit must succeed without any intervening muse code add.
315 r = _invoke(repo, ["commit", "-m", "merge: auto-staged"])
316 assert r.exit_code == 0
317
318 def test_resolve_all_auto_stages_files(self, repo: pathlib.Path) -> None:
319 """muse resolve --all stages every resolved file automatically."""
320 (repo / "world.md").write_text("# World\n")
321 _invoke(repo, ["code", "add", "."])
322 _invoke(repo, ["commit", "-m", "add world"])
323 _set_merge_state(repo, ["hello.md", "world.md"])
324 (repo / "hello.md").write_text("# Hello resolved\n")
325 (repo / "world.md").write_text("# World resolved\n")
326 _invoke(repo, ["resolve", "--all"])
327 r = _invoke(repo, ["commit", "-m", "merge: all auto-staged"])
328 assert r.exit_code == 0
329
330 def test_resolve_symbol_auto_stages_parent_file(
331 self, repo: pathlib.Path
332 ) -> None:
333 """muse resolve file::sym stages the parent file automatically."""
334 _set_merge_state(repo, ["hello.md::A", "hello.md::B"])
335 (repo / "hello.md").write_text("# All resolved\n")
336 _invoke(repo, ["resolve", "hello.md::A"])
337 _invoke(repo, ["resolve", "hello.md::B"])
338 # No code add — both resolve calls staged hello.md.
339 r = _invoke(repo, ["commit", "-m", "merge: symbol auto-staged"])
340 assert r.exit_code == 0
File History 1 commit