gabriel / muse public
test_phase2_conflict_granularity.py python
484 lines 21.7 KB
Raw
sha256:e8b20c39f3247608af4212654b5c6476cec7034bc40f8a3eb714b7b4bfc41b7f test: add Phase 2 conflict granularity tests (CE_01-04, DE_… Sonnet 4.6 1 day ago
1 """TDD tests for Phase 2 — Conflict granularity specification.
2
3 Issue #86 Phase 2 deliverables:
4 CE_01: Untouched file → no conflict
5 CE_02: Convergent edit (both branches → same bytes) → no conflict
6 CE_03: Convergent symbol (both branches → same Python function body) → no conflict
7 CE_04: Both deleted → no conflict, file absent from merged snapshot
8 DE_01: File divergence (same path, different bytes) → conflict detected
9 DE_02: Symbol divergence (same function, different bodies) → conflict detected
10 DE_03: Add/add collision (both add same path with different content) → conflict detected
11 DE_04: Delete/modify (one side deletes, other modifies) → conflict detected
12 DIR_01: Directory-path conflict (delete/modify on file inside src/) → conflict detected
13 """
14 from __future__ import annotations
15
16 import datetime
17 import json
18 import pathlib
19
20 import pytest
21 from tests.cli_test_helper import CliRunner
22 from muse.core.types import blob_id, fake_id
23 from muse.core.object_store import write_object
24 from muse.core.paths import heads_dir, muse_dir, ref_path
25
26 runner = CliRunner()
27 cli = None
28
29
30 # ---------------------------------------------------------------------------
31 # Shared test helpers
32 # ---------------------------------------------------------------------------
33
34 def _env(root: pathlib.Path) -> dict:
35 return {"MUSE_REPO_ROOT": str(root)}
36
37
38 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
39 dot_muse = muse_dir(tmp_path)
40 dot_muse.mkdir()
41 repo_id = fake_id("repo")
42 (dot_muse / "repo.json").write_text(json.dumps({
43 "repo_id": repo_id,
44 "domain": "code",
45 "default_branch": "main",
46 "created_at": "2025-01-01T00:00:00+00:00",
47 }), encoding="utf-8")
48 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
49 (dot_muse / "refs" / "heads").mkdir(parents=True)
50 (dot_muse / "snapshots").mkdir()
51 (dot_muse / "commits").mkdir()
52 (dot_muse / "objects").mkdir()
53 return tmp_path, repo_id
54
55
56 def _write_obj(root: pathlib.Path, content: bytes) -> str:
57 oid = blob_id(content)
58 write_object(root, oid, content)
59 return oid
60
61
62 def _make_commit(
63 root: pathlib.Path,
64 repo_id: str,
65 branch: str = "main",
66 message: str = "test",
67 manifest: dict | None = None,
68 parent_id: str | None = None,
69 ) -> str:
70 from muse.core.commits import CommitRecord, write_commit
71 from muse.core.snapshots import SnapshotRecord, write_snapshot
72 from muse.core.ids import hash_snapshot, hash_commit
73
74 ref_file = ref_path(root, branch)
75 if parent_id is None:
76 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
77 m = manifest or {}
78 snap_id = hash_snapshot(m)
79 committed_at = datetime.datetime.now(datetime.timezone.utc)
80 commit_id = hash_commit(
81 parent_ids=[parent_id] if parent_id else [],
82 snapshot_id=snap_id,
83 message=message,
84 committed_at_iso=committed_at.isoformat(),
85 )
86 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
87 write_commit(root, CommitRecord(
88 commit_id=commit_id,
89 branch=branch,
90 snapshot_id=snap_id,
91 message=message,
92 committed_at=committed_at,
93 parent_commit_id=parent_id,
94 ))
95 ref_file.parent.mkdir(parents=True, exist_ok=True)
96 ref_file.write_text(commit_id, encoding="utf-8")
97 return commit_id
98
99
100 def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None:
101 """Set HEAD to branch and write manifest files to disk."""
102 (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8")
103 for path, oid in manifest.items():
104 from muse.core.object_store import read_object
105 content = read_object(root, oid)
106 if content is not None:
107 dest = root / path
108 dest.parent.mkdir(parents=True, exist_ok=True)
109 dest.write_bytes(content)
110
111
112 def _merged_snapshot(root: pathlib.Path, branch: str) -> dict:
113 """Read the manifest of the current HEAD commit on *branch*."""
114 from muse.core.commits import read_commit
115 from muse.core.snapshots import read_snapshot
116 from muse.core.refs import resolve_any_ref
117 commit_id = resolve_any_ref(root, branch)
118 assert commit_id is not None
119 rec = read_commit(root, commit_id)
120 assert rec is not None
121 snap = read_snapshot(root, rec.snapshot_id)
122 assert snap is not None
123 return snap.manifest
124
125
126 # ---------------------------------------------------------------------------
127 # Group 1 — Convergent sub-cases (must never produce a conflict)
128 # ---------------------------------------------------------------------------
129
130 class TestConvergentEdits:
131 """All four convergent sub-cases must produce conflicts == [] and a clean status."""
132
133 def test_CE_01_untouched_file_no_conflict(self, tmp_path: pathlib.Path) -> None:
134 """File unchanged from base on both sides (identical object IDs) → no conflict."""
135 root, repo_id = _init_repo(tmp_path)
136
137 shared_id = _write_obj(root, b"shared file unchanged")
138 x_base = _write_obj(root, b"file_x base")
139 base_id = _make_commit(root, repo_id, "main", "base",
140 {"file_x.py": x_base, "shared.py": shared_id})
141
142 # branch-a modifies file_x only; shared.py unchanged
143 x_v2a = _write_obj(root, b"file_x modified by branch-a")
144 (heads_dir(root) / "branch-a").write_text(base_id)
145 _make_commit(root, repo_id, "branch-a", "a modifies x",
146 {"file_x.py": x_v2a, "shared.py": shared_id}, parent_id=base_id)
147
148 # branch-b modifies file_x only; shared.py unchanged
149 x_v2b = _write_obj(root, b"file_x modified by branch-b")
150 (heads_dir(root) / "branch-b").write_text(base_id)
151 _make_commit(root, repo_id, "branch-b", "b modifies x",
152 {"file_x.py": x_v2b, "shared.py": shared_id}, parent_id=base_id)
153
154 _checkout(root, "branch-a", {"file_x.py": x_v2a, "shared.py": shared_id})
155 result = runner.invoke(cli, ["merge", "branch-b", "--json"],
156 env=_env(root), catch_exceptions=False)
157 data = json.loads(result.output.strip().splitlines()[-1])
158
159 assert "shared.py" not in data.get("conflicts", []), (
160 "CE_01: untouched shared.py must not appear in conflicts"
161 )
162
163 def test_CE_02_convergent_edit_no_conflict(self, tmp_path: pathlib.Path) -> None:
164 """Both branches independently write same bytes to a file → no conflict."""
165 root, repo_id = _init_repo(tmp_path)
166
167 x_v1 = _write_obj(root, b"file_x v1")
168 base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_v1})
169
170 # Both branches produce the exact same new content
171 x_v2 = _write_obj(root, b"file_x same on both branches")
172
173 (heads_dir(root) / "branch-a").write_text(base_id)
174 _make_commit(root, repo_id, "branch-a", "a to v2",
175 {"file_x.py": x_v2}, parent_id=base_id)
176
177 (heads_dir(root) / "branch-b").write_text(base_id)
178 _make_commit(root, repo_id, "branch-b", "b to v2",
179 {"file_x.py": x_v2}, parent_id=base_id)
180
181 _checkout(root, "branch-a", {"file_x.py": x_v2})
182 result = runner.invoke(cli, ["merge", "branch-b", "--json"],
183 env=_env(root), catch_exceptions=False)
184 data = json.loads(result.output.strip().splitlines()[-1])
185
186 assert data.get("conflicts", []) == [], (
187 "CE_02: convergent edit to same content must produce no conflicts"
188 )
189 assert data.get("status") in ("merged", "fast_forward", "up_to_date"), (
190 f"CE_02: merge must be clean, got {data.get('status')}"
191 )
192
193 # Merged snapshot must contain the convergent content
194 merged = _merged_snapshot(root, "branch-a")
195 assert merged.get("file_x.py") == x_v2, (
196 "CE_02: merged snapshot must contain the convergent file_x.py version"
197 )
198
199 def test_CE_03_convergent_symbol_no_conflict(self, tmp_path: pathlib.Path) -> None:
200 """Both branches update same Python function to identical body → no conflict.
201
202 Since the final file bytes are identical on both sides (same object ID),
203 the merge engine sees l == r and resolves cleanly at the file level.
204 The code plugin's symbol-level path is also exercised via merge_ops.
205 """
206 root, repo_id = _init_repo(tmp_path)
207
208 # Base: module with a simple function
209 base_src = b"def compute(x):\n return x\n"
210 base_id_obj = _write_obj(root, base_src)
211 base_commit = _make_commit(root, repo_id, "main", "base",
212 {"module.py": base_id_obj})
213
214 # Both branches independently update compute() to the SAME new body
215 new_src = b"def compute(x):\n return x * 2\n"
216 new_id_obj = _write_obj(root, new_src)
217
218 (heads_dir(root) / "branch-a").write_text(base_commit)
219 _make_commit(root, repo_id, "branch-a", "a updates compute",
220 {"module.py": new_id_obj}, parent_id=base_commit)
221
222 (heads_dir(root) / "branch-b").write_text(base_commit)
223 _make_commit(root, repo_id, "branch-b", "b also updates compute",
224 {"module.py": new_id_obj}, parent_id=base_commit)
225
226 _checkout(root, "branch-a", {"module.py": new_id_obj})
227 result = runner.invoke(cli, ["merge", "branch-b", "--json"],
228 env=_env(root), catch_exceptions=False)
229 data = json.loads(result.output.strip().splitlines()[-1])
230
231 assert data.get("conflicts", []) == [], (
232 "CE_03: convergent symbol update (same result on both sides) must not conflict"
233 )
234 assert data.get("status") in ("merged", "fast_forward", "up_to_date"), (
235 f"CE_03: merge must be clean, got {data.get('status')}"
236 )
237
238 # Merged snapshot must contain the new version
239 merged = _merged_snapshot(root, "branch-a")
240 assert merged.get("module.py") == new_id_obj, (
241 "CE_03: merged snapshot must contain the convergently-updated module.py"
242 )
243
244 def test_CE_04_both_deleted_no_conflict(self, tmp_path: pathlib.Path) -> None:
245 """File deleted on both branches → no conflict; file absent from merged snapshot."""
246 root, repo_id = _init_repo(tmp_path)
247
248 keep_id = _write_obj(root, b"keeper file")
249 gone_id = _write_obj(root, b"file to be deleted by both")
250 base_commit = _make_commit(root, repo_id, "main", "base",
251 {"keep.py": keep_id, "gone.py": gone_id})
252
253 # Both branches delete gone.py
254 (heads_dir(root) / "branch-a").write_text(base_commit)
255 _make_commit(root, repo_id, "branch-a", "a deletes gone.py",
256 {"keep.py": keep_id}, parent_id=base_commit)
257
258 (heads_dir(root) / "branch-b").write_text(base_commit)
259 _make_commit(root, repo_id, "branch-b", "b also deletes gone.py",
260 {"keep.py": keep_id}, parent_id=base_commit)
261
262 _checkout(root, "branch-a", {"keep.py": keep_id})
263 result = runner.invoke(cli, ["merge", "branch-b", "--json"],
264 env=_env(root), catch_exceptions=False)
265 data = json.loads(result.output.strip().splitlines()[-1])
266
267 assert data.get("conflicts", []) == [], (
268 "CE_04: both-deleted must produce no conflicts"
269 )
270 assert data.get("status") in ("merged", "fast_forward", "up_to_date"), (
271 f"CE_04: merge must be clean, got {data.get('status')}"
272 )
273
274 # gone.py must be absent from the merged snapshot
275 merged = _merged_snapshot(root, "branch-a")
276 assert "gone.py" not in merged, (
277 "CE_04: both-deleted file must be absent from merged snapshot"
278 )
279 assert merged.get("keep.py") == keep_id, (
280 "CE_04: unrelated keep.py must survive in merged snapshot"
281 )
282
283
284 # ---------------------------------------------------------------------------
285 # Group 2 — Divergent sub-cases (must produce conflicts)
286 # ---------------------------------------------------------------------------
287
288 class TestDivergentEdits:
289 """All four divergent sub-cases must surface the conflicting path in conflicts."""
290
291 def test_DE_01_file_divergence_detected(self, tmp_path: pathlib.Path) -> None:
292 """Same path modified to different bytes on each branch → path in conflicts."""
293 root, repo_id = _init_repo(tmp_path)
294
295 v1 = _write_obj(root, b"config v1")
296 base_commit = _make_commit(root, repo_id, "main", "base", {"config.py": v1})
297
298 v2a = _write_obj(root, b"config v2 branch-a")
299 (heads_dir(root) / "branch-a").write_text(base_commit)
300 _make_commit(root, repo_id, "branch-a", "a modifies config",
301 {"config.py": v2a}, parent_id=base_commit)
302
303 v2b = _write_obj(root, b"config v2 branch-b different")
304 (heads_dir(root) / "branch-b").write_text(base_commit)
305 _make_commit(root, repo_id, "branch-b", "b modifies config differently",
306 {"config.py": v2b}, parent_id=base_commit)
307
308 _checkout(root, "branch-a", {"config.py": v2a})
309 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
310 data = json.loads(result.output.strip().splitlines()[-1])
311
312 conflicts = data.get("conflicts", [])
313 assert any("config.py" in c for c in conflicts), (
314 f"DE_01: config.py file divergence must appear in conflicts, got {conflicts}"
315 )
316
317 def test_DE_02_symbol_divergence_detected(self, tmp_path: pathlib.Path) -> None:
318 """Same Python function updated to different bodies on each branch → conflict detected.
319
320 The conflict address is either the symbol address (module.py::compute)
321 or the file path (module.py) depending on whether merge_ops symbol-level
322 detection fires. Either form is acceptable — the key assertion is that
323 the file path appears somewhere in the conflicts list.
324 """
325 root, repo_id = _init_repo(tmp_path)
326
327 base_src = b"def compute(x):\n return x\n\ndef helper():\n pass\n"
328 base_obj = _write_obj(root, base_src)
329 base_commit = _make_commit(root, repo_id, "main", "base",
330 {"module.py": base_obj})
331
332 # branch-a changes compute() to multiply
333 src_a = b"def compute(x):\n return x * 2\n\ndef helper():\n pass\n"
334 obj_a = _write_obj(root, src_a)
335 (heads_dir(root) / "branch-a").write_text(base_commit)
336 _make_commit(root, repo_id, "branch-a", "a: compute multiplies",
337 {"module.py": obj_a}, parent_id=base_commit)
338
339 # branch-b changes compute() to add — different result
340 src_b = b"def compute(x):\n return x + 1\n\ndef helper():\n pass\n"
341 obj_b = _write_obj(root, src_b)
342 (heads_dir(root) / "branch-b").write_text(base_commit)
343 _make_commit(root, repo_id, "branch-b", "b: compute adds",
344 {"module.py": obj_b}, parent_id=base_commit)
345
346 _checkout(root, "branch-a", {"module.py": obj_a})
347 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
348 data = json.loads(result.output.strip().splitlines()[-1])
349
350 conflicts = data.get("conflicts", [])
351 # Accept either symbol-level ("module.py::compute") or file-level ("module.py")
352 assert any("module.py" in c for c in conflicts), (
353 f"DE_02: symbol divergence in module.py must appear in conflicts, got {conflicts}"
354 )
355
356 def test_DE_03_add_add_collision_detected(self, tmp_path: pathlib.Path) -> None:
357 """Both branches add the same new path with different content → conflict detected."""
358 root, repo_id = _init_repo(tmp_path)
359
360 existing_id = _write_obj(root, b"existing file")
361 base_commit = _make_commit(root, repo_id, "main", "base",
362 {"existing.py": existing_id})
363
364 # Both branches add new.py — with different content
365 new_a = _write_obj(root, b"new file from branch-a")
366 (heads_dir(root) / "branch-a").write_text(base_commit)
367 _make_commit(root, repo_id, "branch-a", "a adds new.py",
368 {"existing.py": existing_id, "new.py": new_a}, parent_id=base_commit)
369
370 new_b = _write_obj(root, b"new file from branch-b different content")
371 (heads_dir(root) / "branch-b").write_text(base_commit)
372 _make_commit(root, repo_id, "branch-b", "b also adds new.py",
373 {"existing.py": existing_id, "new.py": new_b}, parent_id=base_commit)
374
375 _checkout(root, "branch-a", {"existing.py": existing_id, "new.py": new_a})
376 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
377 data = json.loads(result.output.strip().splitlines()[-1])
378
379 conflicts = data.get("conflicts", [])
380 assert any("new.py" in c for c in conflicts), (
381 f"DE_03: add/add collision on new.py must appear in conflicts, got {conflicts}"
382 )
383
384 def test_DE_04_delete_modify_conflict_detected(self, tmp_path: pathlib.Path) -> None:
385 """One branch deletes a file; the other modifies it → conflict detected."""
386 root, repo_id = _init_repo(tmp_path)
387
388 v1 = _write_obj(root, b"service.py v1")
389 base_commit = _make_commit(root, repo_id, "main", "base", {"service.py": v1})
390
391 # branch-a DELETES service.py
392 (heads_dir(root) / "branch-a").write_text(base_commit)
393 _make_commit(root, repo_id, "branch-a", "a deletes service.py",
394 {}, parent_id=base_commit)
395
396 # branch-b MODIFIES service.py
397 v2 = _write_obj(root, b"service.py v2 modified")
398 (heads_dir(root) / "branch-b").write_text(base_commit)
399 _make_commit(root, repo_id, "branch-b", "b modifies service.py",
400 {"service.py": v2}, parent_id=base_commit)
401
402 # Checkout branch-a (service.py absent)
403 _checkout(root, "branch-a", {})
404 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
405 data = json.loads(result.output.strip().splitlines()[-1])
406
407 conflicts = data.get("conflicts", [])
408 assert any("service.py" in c for c in conflicts), (
409 f"DE_04: delete/modify on service.py must appear in conflicts, got {conflicts}"
410 )
411
412
413 # ---------------------------------------------------------------------------
414 # Group 3 — Directory-level conflict
415 # ---------------------------------------------------------------------------
416
417 class TestDirectoryLevel:
418 """Directory-path conflict: files inside nested paths conflict correctly."""
419
420 def test_DIR_01_delete_modify_inside_directory(self, tmp_path: pathlib.Path) -> None:
421 """Delete/modify conflict on a file inside src/ is correctly detected.
422
423 Scenario:
424 - base has src/core.py and src/utils.py
425 - branch-a deletes src/core.py (removes it from the directory)
426 - branch-b MODIFIES src/core.py (a different version) and adds src/new_module.py
427
428 Expected:
429 - src/core.py CONFLICTS (one side deleted, other modified)
430 - src/new_module.py merges cleanly (only branch-b added it)
431 - src/utils.py survives unchanged
432
433 Note: Muse's flat-manifest merge does not track directory objects as
434 first-class entities. A "directory deleted on one side, new file added
435 inside it on the other" scenario does NOT conflict for the new file —
436 the new file simply lands in the merged snapshot. Full directory-level
437 conflict awareness (where src/new_module.py would conflict because src/
438 was deleted as a unit) requires explicit directory tracking and is
439 planned for a future phase.
440 """
441 root, repo_id = _init_repo(tmp_path)
442
443 core_v1 = _write_obj(root, b"src/core.py v1")
444 utils_id = _write_obj(root, b"src/utils.py unchanged")
445 base_commit = _make_commit(root, repo_id, "main", "base",
446 {"src/core.py": core_v1,
447 "src/utils.py": utils_id})
448
449 # branch-a: delete src/core.py; keep src/utils.py
450 (heads_dir(root) / "branch-a").write_text(base_commit)
451 _make_commit(root, repo_id, "branch-a", "a deletes src/core.py",
452 {"src/utils.py": utils_id}, parent_id=base_commit)
453
454 # branch-b: modify src/core.py AND add src/new_module.py
455 core_v2 = _write_obj(root, b"src/core.py v2 modified by branch-b")
456 new_mod = _write_obj(root, b"src/new_module.py added by branch-b")
457 (heads_dir(root) / "branch-b").write_text(base_commit)
458 _make_commit(root, repo_id, "branch-b", "b modifies core and adds new_module",
459 {"src/core.py": core_v2,
460 "src/utils.py": utils_id,
461 "src/new_module.py": new_mod}, parent_id=base_commit)
462
463 # Checkout branch-a working tree
464 _checkout(root, "branch-a", {"src/utils.py": utils_id})
465 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
466 data = json.loads(result.output.strip().splitlines()[-1])
467
468 conflicts = data.get("conflicts", [])
469
470 # The delete/modify on src/core.py MUST conflict
471 assert any("src/core.py" in c for c in conflicts), (
472 f"DIR_01: delete/modify conflict on src/core.py must be detected, got {conflicts}"
473 )
474
475 # src/utils.py (unchanged on both sides) must NOT conflict
476 assert not any("src/utils.py" in c for c in conflicts), (
477 f"DIR_01: untouched src/utils.py must not appear in conflicts, got {conflicts}"
478 )
479
480 # src/new_module.py (added only by branch-b) must NOT conflict
481 # (flat-manifest model: new file from one side merges cleanly)
482 assert not any("src/new_module.py" in c for c in conflicts), (
483 f"DIR_01: new file src/new_module.py added only by branch-b must not conflict, got {conflicts}"
484 )
File History 1 commit
sha256:e8b20c39f3247608af4212654b5c6476cec7034bc40f8a3eb714b7b4bfc41b7f test: add Phase 2 conflict granularity tests (CE_01-04, DE_… Sonnet 4.6 1 day ago