gabriel / muse public
test_phantom_conflicts.py python
264 lines 11.0 KB
Raw
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1 feat: Phase 1 — MergeEngine class, --on-conflict, --history… Sonnet 4.6 patch 20 hours ago
1 """TDD regression tests for issue #85 -- phantom merge conflicts.
2
3 All tests in this file must be RED before the fix and GREEN after.
4 """
5 from __future__ import annotations
6
7 import datetime
8 import json
9 import pathlib
10
11 import pytest
12 from tests.cli_test_helper import CliRunner
13 from muse.core.types import blob_id, fake_id
14 from muse.core.object_store import write_object, read_object
15 from muse.core.paths import heads_dir, muse_dir, ref_path
16
17 runner = CliRunner()
18 cli = None
19
20
21 def _env(root: pathlib.Path) -> dict:
22 return {"MUSE_REPO_ROOT": str(root)}
23
24
25 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
26 dot_muse = muse_dir(tmp_path)
27 dot_muse.mkdir()
28 repo_id = fake_id("repo")
29 (dot_muse / "repo.json").write_text(json.dumps({
30 "repo_id": repo_id,
31 "domain": "code",
32 "default_branch": "main",
33 "created_at": "2025-01-01T00:00:00+00:00",
34 }), encoding="utf-8")
35 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
36 (dot_muse / "refs" / "heads").mkdir(parents=True)
37 (dot_muse / "snapshots").mkdir()
38 (dot_muse / "commits").mkdir()
39 (dot_muse / "objects").mkdir()
40 return tmp_path, repo_id
41
42
43 def _write_obj(root: pathlib.Path, content: bytes) -> str:
44 oid = blob_id(content)
45 write_object(root, oid, content)
46 return oid
47
48
49 def _make_commit(
50 root: pathlib.Path,
51 repo_id: str,
52 branch: str = "main",
53 message: str = "test",
54 manifest: dict | None = None,
55 parent_id: str | None = None,
56 ) -> str:
57 from muse.core.commits import CommitRecord, write_commit
58 from muse.core.snapshots import SnapshotRecord, write_snapshot
59 from muse.core.ids import hash_snapshot, hash_commit
60
61 ref_file = ref_path(root, branch)
62 if parent_id is None:
63 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
64 m = manifest or {}
65 snap_id = hash_snapshot(m)
66 committed_at = datetime.datetime.now(datetime.timezone.utc)
67 commit_id = hash_commit(
68 parent_ids=[parent_id] if parent_id else [],
69 snapshot_id=snap_id,
70 message=message,
71 committed_at_iso=committed_at.isoformat(),
72 )
73 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
74 write_commit(root, CommitRecord(
75 commit_id=commit_id,
76 branch=branch,
77 snapshot_id=snap_id,
78 message=message,
79 committed_at=committed_at,
80 parent_commit_id=parent_id,
81 ))
82 ref_file.parent.mkdir(parents=True, exist_ok=True)
83 ref_file.write_text(commit_id, encoding="utf-8")
84 return commit_id
85
86
87 def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None:
88 """Set HEAD to branch and write manifest files to disk (simulate checkout)."""
89 (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8")
90 for path, oid in manifest.items():
91 content = read_object(root, oid)
92 if content is not None:
93 dest = root / path
94 dest.parent.mkdir(parents=True, exist_ok=True)
95 dest.write_bytes(content)
96
97
98 class TestPhantomConflicts:
99 """PHANTOM_01 through PHANTOM_05 -- all must be RED before the fix."""
100
101 def test_PHANTOM_01_untouched_file_never_conflicts(self, tmp_path: pathlib.Path) -> None:
102 """File untouched by either branch must not appear in conflicts."""
103 root, repo_id = _init_repo(tmp_path)
104
105 x_base = _write_obj(root, b"file_x v1")
106 y_base = _write_obj(root, b"file_y unchanged")
107 base_id = _make_commit(root, repo_id, "main", "base",
108 {"file_x.py": x_base, "file_y.py": y_base})
109
110 x_v2a = _write_obj(root, b"file_x v2a")
111 (heads_dir(root) / "branch-a").write_text(base_id)
112 _make_commit(root, repo_id, "branch-a", "a changes x",
113 {"file_x.py": x_v2a, "file_y.py": y_base},
114 parent_id=base_id)
115
116 x_v2b = _write_obj(root, b"file_x v2b")
117 (heads_dir(root) / "branch-b").write_text(base_id)
118 _make_commit(root, repo_id, "branch-b", "b changes x",
119 {"file_x.py": x_v2b, "file_y.py": y_base},
120 parent_id=base_id)
121
122 _checkout(root, "branch-a", {"file_x.py": x_v2a, "file_y.py": y_base})
123 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False)
124 data = json.loads(result.output.strip().splitlines()[-1])
125
126 assert "file_y.py" not in data.get("conflicts", []), (
127 "PHANTOM_01: untouched file_y.py must not appear in conflicts"
128 )
129
130 def test_PHANTOM_02_convergent_edit_no_conflict(self, tmp_path: pathlib.Path) -> None:
131 """Both branches arrive at identical content -- no conflict."""
132 root, repo_id = _init_repo(tmp_path)
133
134 x_v1 = _write_obj(root, b"file_x v1")
135 base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_v1})
136
137 x_v2 = _write_obj(root, b"file_x v2 same on both branches")
138
139 (heads_dir(root) / "branch-a").write_text(base_id)
140 _make_commit(root, repo_id, "branch-a", "a to v2",
141 {"file_x.py": x_v2}, parent_id=base_id)
142
143 (heads_dir(root) / "branch-b").write_text(base_id)
144 _make_commit(root, repo_id, "branch-b", "b to v2",
145 {"file_x.py": x_v2}, parent_id=base_id)
146
147 _checkout(root, "branch-a", {"file_x.py": x_v2})
148 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False)
149 data = json.loads(result.output.strip().splitlines()[-1])
150
151 assert data.get("conflicts", []) == [], (
152 "PHANTOM_02: convergent edit to same content must produce no conflicts"
153 )
154 assert data.get("status") in ("merged", "fast_forward", "up_to_date"), (
155 f"PHANTOM_02: merge must be clean, got {data.get('status')}"
156 )
157
158 def test_PHANTOM_03_state_merge_strategy_untouched_file(self, tmp_path: pathlib.Path) -> None:
159 """state_merge strategy: untouched file must not appear in conflicts."""
160 root, repo_id = _init_repo(tmp_path)
161
162 x_base = _write_obj(root, b"file_x v1")
163 y_base = _write_obj(root, b"file_y unchanged")
164 base_id = _make_commit(root, repo_id, "main", "base",
165 {"file_x.py": x_base, "file_y.py": y_base})
166
167 x_v2a = _write_obj(root, b"file_x v2a")
168 (heads_dir(root) / "branch-a").write_text(base_id)
169 _make_commit(root, repo_id, "branch-a", "a changes x",
170 {"file_x.py": x_v2a, "file_y.py": y_base}, parent_id=base_id)
171
172 x_v2b = _write_obj(root, b"file_x v2b")
173 (heads_dir(root) / "branch-b").write_text(base_id)
174 _make_commit(root, repo_id, "branch-b", "b changes x",
175 {"file_x.py": x_v2b, "file_y.py": y_base}, parent_id=base_id)
176
177 _checkout(root, "branch-a", {"file_x.py": x_v2a, "file_y.py": y_base})
178 result = runner.invoke(cli, ["merge", "branch-b", "--strategy", "state_merge", "--json"],
179 env=_env(root), catch_exceptions=False)
180 data = json.loads(result.output.strip().splitlines()[-1])
181
182 assert "file_y.py" not in data.get("conflicts", []), (
183 "PHANTOM_03: state_merge -- untouched file_y.py must not appear in conflicts"
184 )
185
186 def test_PHANTOM_04_real_conflict_still_detected(self, tmp_path: pathlib.Path) -> None:
187 """Genuinely divergent edits must still be reported as a conflict."""
188 root, repo_id = _init_repo(tmp_path)
189
190 x_v1 = _write_obj(root, b"file_x v1")
191 base_id = _make_commit(root, repo_id, "main", "base", {"file_x.py": x_v1})
192
193 x_v2 = _write_obj(root, b"file_x v2 branch A")
194 (heads_dir(root) / "branch-a").write_text(base_id)
195 _make_commit(root, repo_id, "branch-a", "a to v2",
196 {"file_x.py": x_v2}, parent_id=base_id)
197
198 x_v3 = _write_obj(root, b"file_x v3 branch B different")
199 (heads_dir(root) / "branch-b").write_text(base_id)
200 _make_commit(root, repo_id, "branch-b", "b to v3",
201 {"file_x.py": x_v3}, parent_id=base_id)
202
203 _checkout(root, "branch-a", {"file_x.py": x_v2})
204 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root))
205 data = json.loads(result.output.strip().splitlines()[-1])
206
207 conflicts = data.get("conflicts", [])
208 assert any("file_x.py" in c for c in conflicts), (
209 f"PHANTOM_04: real conflict on file_x.py must be detected, got conflicts={conflicts}"
210 )
211
212 def test_PHANTOM_05_clean_merge_snapshot_has_both_branches(self, tmp_path: pathlib.Path) -> None:
213 """Clean merge commit snapshot must contain changes from both branches."""
214 root, repo_id = _init_repo(tmp_path)
215
216 x_base = _write_obj(root, b"file_x base")
217 z_base = _write_obj(root, b"file_z base")
218 y_base = _write_obj(root, b"file_y unchanged")
219 base_id = _make_commit(root, repo_id, "main", "base",
220 {"file_x.py": x_base, "file_z.py": z_base, "file_y.py": y_base})
221
222 # Branch A modifies file_x.py only
223 x_v2 = _write_obj(root, b"file_x modified by branch-a")
224 (heads_dir(root) / "branch-a").write_text(base_id)
225 _make_commit(root, repo_id, "branch-a", "a changes x",
226 {"file_x.py": x_v2, "file_z.py": z_base, "file_y.py": y_base},
227 parent_id=base_id)
228
229 # Branch B modifies file_z.py only
230 z_v2 = _write_obj(root, b"file_z modified by branch-b")
231 (heads_dir(root) / "branch-b").write_text(base_id)
232 _make_commit(root, repo_id, "branch-b", "b changes z",
233 {"file_x.py": x_base, "file_z.py": z_v2, "file_y.py": y_base},
234 parent_id=base_id)
235
236 _checkout(root, "branch-a", {"file_x.py": x_v2, "file_z.py": z_base, "file_y.py": y_base})
237 result = runner.invoke(cli, ["merge", "branch-b", "--json"], env=_env(root), catch_exceptions=False)
238 data = json.loads(result.output.strip().splitlines()[-1])
239
240 # No --allow-empty needed: merge must be clean
241 assert data.get("conflicts", []) == [], (
242 f"PHANTOM_05: merge must be clean, got conflicts={data.get('conflicts')}"
243 )
244 assert data.get("status") in ("merged", "fast_forward"), (
245 f"PHANTOM_05: merge must complete, got status={data.get('status')}"
246 )
247
248 # Verify the merge commit snapshot contains BOTH branches' changes
249 from muse.core.commits import read_commit
250 from muse.core.snapshots import read_snapshot
251
252 merge_commit_id = ref_path(root, "branch-a").read_text().strip()
253 commit_rec = read_commit(root, merge_commit_id)
254 assert commit_rec is not None
255 snap_rec = read_snapshot(root, commit_rec.snapshot_id)
256 assert snap_rec is not None
257 merged = snap_rec.manifest
258
259 assert merged.get("file_x.py") == x_v2, (
260 "PHANTOM_05: merge snapshot must contain branch-a's file_x.py change"
261 )
262 assert merged.get("file_z.py") == z_v2, (
263 "PHANTOM_05: merge snapshot must contain branch-b's file_z.py change"
264 )
File History 2 commits
sha256:981b89ffe0b877cbb076d011e5d9148ad88c255b66a4eef5cafac7f11ce26ab1 feat: Phase 1 — MergeEngine class, --on-conflict, --history… Sonnet 4.6 patch 20 hours ago
sha256:8c92016d30056bba10f40c739abdcef82334fd27185fe6d7f17bef3418f56131 test: PHANTOM_01-05 regression tests + overlay/state_merge … Sonnet 4.6 patch 1 day ago