test_phase2_conflict_granularity.py
python
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