test_comment_only_independence_merge.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """muse#92 — code domain's address-keyed merge falsely conflicts when both |
| 2 | sides add a trailing comment to different functions in the same file. |
| 3 | |
| 4 | Root cause: a comment-only edit to a function is AST-identical to the base |
| 5 | (content_id is computed via `ast.unparse()`, which strips comments by |
| 6 | design — see ast_parser.py's PythonAdapter docstring). `build_diff_ops` |
| 7 | sees no symbol-level `child_ops` for such a file and falls back to a |
| 8 | whole-file `ReplaceOp` labeled "reformatted — no semantic change" instead |
| 9 | of a symbol-addressed `PatchOp`. When BOTH branches independently add a |
| 10 | comment to a DIFFERENT function in the same file, `merge_ops()`'s Step 1.5 |
| 11 | (independence-aware blob reconstruction) can't find `PatchOp`s for either |
| 12 | side and used to just `continue`, skipping reconstruction entirely. Step 2 |
| 13 | (coarse OT over the two competing file-level `ReplaceOp`s) then flagged the |
| 14 | file as a hard conflict, even though the two edits don't overlap at all. |
| 15 | |
| 16 | Fix: when symbol-level patch info isn't available for a candidate path, |
| 17 | attempt a direct three-way LINE merge on the raw blobs (`three_way_merge_lines` |
| 18 | from `muse.core.cohen_transform`, the same primitive `_independence_merge_blob` |
| 19 | already uses for genuine independence merges). This is safe regardless of |
| 20 | symbol knowledge: if the base/ours/theirs line diffs don't overlap, combining |
| 21 | them is correct no matter what the file's AST looks like. A second bug was |
| 22 | found while verifying this fix live: Step 2's OT check re-flags the file as |
| 23 | conflicting independently of Step 1.5's resolution, so `conflict_addresses` |
| 24 | must also be filtered against `independence_resolved` before Step 3. |
| 25 | |
| 26 | Reproduces the exact scenario via the real CLI (not synthetic objects): |
| 27 | two branches, each adding a trailing comment to a different function in the |
| 28 | same file, must merge with zero conflicts and both comments present in the |
| 29 | committed result. |
| 30 | """ |
| 31 | from __future__ import annotations |
| 32 | |
| 33 | import json |
| 34 | import os |
| 35 | import pathlib |
| 36 | |
| 37 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 38 | |
| 39 | runner = CliRunner() |
| 40 | |
| 41 | |
| 42 | def _run(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 43 | saved = os.getcwd() |
| 44 | try: |
| 45 | os.chdir(repo) |
| 46 | return runner.invoke(None, list(args)) |
| 47 | finally: |
| 48 | os.chdir(saved) |
| 49 | |
| 50 | |
| 51 | _BASE_CALC = ( |
| 52 | "def add(a, b):\n" |
| 53 | " return a + b\n" |
| 54 | "\n" |
| 55 | "def subtract(a, b):\n" |
| 56 | " return a - b\n" |
| 57 | ) |
| 58 | |
| 59 | _ALICE_CALC = ( |
| 60 | "def add(a, b):\n" |
| 61 | " return a + b # comment\n" |
| 62 | "\n" |
| 63 | "def subtract(a, b):\n" |
| 64 | " return a - b\n" |
| 65 | ) |
| 66 | |
| 67 | _BOB_CALC = ( |
| 68 | "def add(a, b):\n" |
| 69 | " return a + b\n" |
| 70 | "\n" |
| 71 | "def subtract(a, b):\n" |
| 72 | " return a - b # comment\n" |
| 73 | ) |
| 74 | |
| 75 | |
| 76 | def test_trailing_comments_on_different_functions_merge_cleanly(tmp_path: pathlib.Path) -> None: |
| 77 | repo = tmp_path |
| 78 | r = _run(repo, "init") |
| 79 | assert r.exit_code == 0, r.stderr |
| 80 | |
| 81 | (repo / "calc.py").write_text(_BASE_CALC) |
| 82 | r = _run(repo, "code", "add", "calc.py") |
| 83 | assert r.exit_code == 0, r.stderr |
| 84 | r = _run(repo, "commit", "-m", "base") |
| 85 | assert r.exit_code == 0, r.stderr |
| 86 | |
| 87 | r = _run(repo, "branch", "feature/alice") |
| 88 | assert r.exit_code == 0, r.stderr |
| 89 | r = _run(repo, "checkout", "feature/alice") |
| 90 | assert r.exit_code == 0, r.stderr |
| 91 | (repo / "calc.py").write_text(_ALICE_CALC) |
| 92 | r = _run(repo, "code", "add", "calc.py") |
| 93 | assert r.exit_code == 0, r.stderr |
| 94 | r = _run(repo, "commit", "-m", "alice comments add") |
| 95 | assert r.exit_code == 0, r.stderr |
| 96 | |
| 97 | r = _run(repo, "checkout", "main") |
| 98 | assert r.exit_code == 0, r.stderr |
| 99 | r = _run(repo, "branch", "feature/bob") |
| 100 | assert r.exit_code == 0, r.stderr |
| 101 | r = _run(repo, "checkout", "feature/bob") |
| 102 | assert r.exit_code == 0, r.stderr |
| 103 | (repo / "calc.py").write_text(_BOB_CALC) |
| 104 | r = _run(repo, "code", "add", "calc.py") |
| 105 | assert r.exit_code == 0, r.stderr |
| 106 | r = _run(repo, "commit", "-m", "bob comments subtract") |
| 107 | assert r.exit_code == 0, r.stderr |
| 108 | |
| 109 | r = _run(repo, "checkout", "main") |
| 110 | assert r.exit_code == 0, r.stderr |
| 111 | r = _run(repo, "merge", "feature/alice") |
| 112 | assert r.exit_code == 0, r.stderr |
| 113 | |
| 114 | r = _run(repo, "merge", "feature/bob", "--json") |
| 115 | assert r.exit_code == 0, r.stderr |
| 116 | payload = json.loads(r.output) |
| 117 | assert payload["status"] == "merged", ( |
| 118 | f"independent comment-only edits to different functions must merge " |
| 119 | f"cleanly with zero conflicts, got: {payload}" |
| 120 | ) |
| 121 | assert payload["conflicts"] == [] |
| 122 | |
| 123 | r = _run(repo, "cat", "calc.py") |
| 124 | assert r.exit_code == 0, r.stderr |
| 125 | merged_content = r.output |
| 126 | assert "a + b # comment" in merged_content |
| 127 | assert "a - b # comment" in merged_content |