gabriel / muse public

test_phase2_conflict_granularity.py file-level

at sha256:a · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:e Merge 'bump/muse-v0.2.1' into 'dev' β€” proposal: chore: bump muse to v0.2.1 · gabriel · Aug 28, 2026
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 Manifest, Metadata, 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) -> Metadata:
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: Manifest | 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: Manifest) -> 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) -> Manifest:
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 )