gabriel / muse public
test_stress_merge_correctness.py python
409 lines 16.7 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
1 """Adversarial stress tests for the three-way merge engine.
2
3 Covers:
4 - apply_merge edge cases: both sides delete the same file, theirs-only delete,
5 ours-only add, both add the same file with identical hash (clean).
6 - detect_conflicts: full combinatorial (empty sets, symmetric, one-sided).
7 - diff_snapshots: many files added / removed / modified.
8 - diff_snapshots then detect_conflicts → apply_merge pipeline correctness.
9 - Large manifest diffs (500 paths).
10 - MergeState round-trip with and without optional fields.
11 - Corrupt MERGE_STATE.json is silently ignored (returns None).
12 - apply_resolution raises FileNotFoundError for absent object.
13 """
14
15 import json
16 import pathlib
17 import secrets
18 import datetime
19
20 import pytest
21
22 from muse.core._types import Manifest, fake_id, blob_id
23 from muse.core.merge_engine import (
24 MergeState,
25 apply_merge,
26 apply_resolution,
27 clear_merge_state,
28 detect_conflicts,
29 diff_snapshots,
30 read_merge_state,
31 write_merge_state,
32 )
33 from muse.core.object_store import write_object
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _h(label: str) -> str:
42 return fake_id(label)
43
44
45 @pytest.fixture
46 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
47 muse = tmp_path / ".muse"
48 muse.mkdir()
49 (muse / "objects").mkdir()
50 return tmp_path
51
52
53 # ===========================================================================
54 # diff_snapshots — exhaustive
55 # ===========================================================================
56
57
58 class TestDiffSnapshotsExhaustive:
59 def test_identical_manifests_no_diff(self) -> None:
60 m = {f"file-{i}.mid": _h(f"content-{i}") for i in range(100)}
61 assert diff_snapshots(m, m) == set()
62
63 def test_all_files_added(self) -> None:
64 added = {f"new-{i}.mid": _h(f"new-{i}") for i in range(50)}
65 result = diff_snapshots({}, added)
66 assert result == set(added.keys())
67
68 def test_all_files_removed(self) -> None:
69 original = {f"old-{i}.mid": _h(f"old-{i}") for i in range(50)}
70 result = diff_snapshots(original, {})
71 assert result == set(original.keys())
72
73 def test_all_files_modified(self) -> None:
74 base = {f"f{i}.mid": _h(f"v1-{i}") for i in range(50)}
75 target = {f"f{i}.mid": _h(f"v2-{i}") for i in range(50)}
76 result = diff_snapshots(base, target)
77 assert result == set(base.keys())
78
79 def test_mixed_add_remove_modify(self) -> None:
80 base = {"keep.mid": _h("keep"), "remove.mid": _h("remove"), "modify.mid": _h("old")}
81 target = {"keep.mid": _h("keep"), "add.mid": _h("new"), "modify.mid": _h("new")}
82 result = diff_snapshots(base, target)
83 assert result == {"remove.mid", "add.mid", "modify.mid"}
84 assert "keep.mid" not in result
85
86 def test_500_file_manifest_correct_diff(self) -> None:
87 base = {f"path/to/file-{i:04d}.mid": _h(f"v1-{i}") for i in range(500)}
88 target = dict(base)
89 # Modify 100, add 50, remove 50.
90 modified = set()
91 for i in range(0, 100):
92 key = f"path/to/file-{i:04d}.mid"
93 target[key] = _h(f"v2-{i}")
94 modified.add(key)
95 added = set()
96 for i in range(500, 550):
97 key = f"path/to/new-{i}.mid"
98 target[key] = _h(f"new-{i}")
99 added.add(key)
100 removed = set()
101 for i in range(450, 500):
102 key = f"path/to/file-{i:04d}.mid"
103 del target[key]
104 removed.add(key)
105 result = diff_snapshots(base, target)
106 assert result == modified | added | removed
107
108 def test_symmetric_diff_not_required(self) -> None:
109 """diff_snapshots is not symmetric: order matters."""
110 a = {"f.mid": _h("hash-a")}
111 b = {"f.mid": _h("hash-b")}
112 assert diff_snapshots(a, b) == {"f.mid"}
113 assert diff_snapshots(b, a) == {"f.mid"}
114
115
116 # ===========================================================================
117 # detect_conflicts — exhaustive
118 # ===========================================================================
119
120
121 class TestDetectConflictsExhaustive:
122 def test_empty_both_sides(self) -> None:
123 assert detect_conflicts(set(), set(), {}, {}) == set()
124
125 def test_empty_ours(self) -> None:
126 theirs_m = {"a.mid": "h1", "b.mid": "h2"}
127 assert detect_conflicts(set(), {"a.mid", "b.mid"}, {}, theirs_m) == set()
128
129 def test_empty_theirs(self) -> None:
130 ours_m = {"a.mid": "h1", "b.mid": "h2"}
131 assert detect_conflicts({"a.mid", "b.mid"}, set(), ours_m, {}) == set()
132
133 def test_full_overlap_divergent(self) -> None:
134 """All paths changed by both sides with DIFFERENT content — all conflict."""
135 paths = {f"f{i}.mid" for i in range(50)}
136 ours_m = {p: f"ours-{p}" for p in paths}
137 theirs_m = {p: f"theirs-{p}" for p in paths}
138 assert detect_conflicts(paths, paths, ours_m, theirs_m) == paths
139
140 def test_full_overlap_convergent(self) -> None:
141 """All paths changed by both sides to the SAME content — zero conflicts."""
142 paths = {f"f{i}.mid" for i in range(50)}
143 shared_m = {p: f"shared-{p}" for p in paths}
144 assert detect_conflicts(paths, paths, shared_m, dict(shared_m)) == set()
145
146 def test_no_overlap(self) -> None:
147 ours = {f"ours-{i}.mid" for i in range(25)}
148 theirs = {f"theirs-{i}.mid" for i in range(25)}
149 ours_m = {p: "h" for p in ours}
150 theirs_m = {p: "h" for p in theirs}
151 assert detect_conflicts(ours, theirs, ours_m, theirs_m) == set()
152
153 def test_partial_overlap_divergent(self) -> None:
154 ours = {"shared.mid", "only-ours.mid"}
155 theirs = {"shared.mid", "only-theirs.mid"}
156 ours_m = {"shared.mid": "h_ours", "only-ours.mid": "h_ou"}
157 theirs_m = {"shared.mid": "h_theirs", "only-theirs.mid": "h_th"}
158 assert detect_conflicts(ours, theirs, ours_m, theirs_m) == {"shared.mid"}
159
160 def test_commutativity(self) -> None:
161 a_paths = {f"f{i}" for i in range(30)}
162 b_paths = {f"f{i}" for i in range(20, 50)}
163 a_m = {p: f"a-{p}" for p in a_paths}
164 b_m = {p: f"b-{p}" for p in b_paths}
165 assert detect_conflicts(a_paths, b_paths, a_m, b_m) == detect_conflicts(b_paths, a_paths, b_m, a_m)
166
167 def test_both_delete_convergent(self) -> None:
168 """Both sides deleted the same file — convergent, no conflict."""
169 assert detect_conflicts({"gone.mid"}, {"gone.mid"}, {}, {}) == set()
170
171 def test_same_add_convergent(self) -> None:
172 """Both sides independently added the same file with identical content."""
173 ours_m = {"new.mid": "hash42"}
174 theirs_m = {"new.mid": "hash42"}
175 assert detect_conflicts({"new.mid"}, {"new.mid"}, ours_m, theirs_m) == set()
176
177 def test_delete_vs_modify_divergent(self) -> None:
178 """One side deleted, other modified — genuinely divergent."""
179 assert detect_conflicts({"a.mid"}, {"a.mid"}, {}, {"a.mid": "h_new"}) == {"a.mid"}
180
181
182 # ===========================================================================
183 # apply_merge — exhaustive
184 # ===========================================================================
185
186
187 class TestApplyMergeExhaustive:
188 def test_both_sides_delete_same_file_not_conflicting(self) -> None:
189 """Both sides delete the same file — no conflict, file absent in merged."""
190 base = {"shared.mid": _h("shared")}
191 ours = {}
192 theirs = {}
193 ours_changed = {"shared.mid"}
194 theirs_changed = {"shared.mid"}
195 # No conflict paths specified (caller decided it's not a conflict).
196 result = apply_merge(base, ours, theirs, ours_changed, theirs_changed, set())
197 assert "shared.mid" not in result
198
199 def test_only_theirs_adds_file(self) -> None:
200 base: Manifest = {}
201 ours: Manifest = {}
202 theirs = {"new.mid": _h("new")}
203 result = apply_merge(base, ours, theirs, set(), {"new.mid"}, set())
204 assert result["new.mid"] == _h("new")
205
206 def test_only_ours_adds_file(self) -> None:
207 base: Manifest = {}
208 theirs: Manifest = {}
209 ours = {"new.mid": _h("ours-new")}
210 result = apply_merge(base, ours, theirs, {"new.mid"}, set(), set())
211 assert result["new.mid"] == _h("ours-new")
212
213 def test_both_add_same_file_same_hash_no_conflict(self) -> None:
214 """Both sides independently add the same file with the same content hash — no conflict."""
215 base: Manifest = {}
216 h = _h("identical-content")
217 ours = {"new.mid": h}
218 theirs = {"new.mid": h}
219 # Caller detects: same hash = no conflict.
220 result = apply_merge(base, ours, theirs, {"new.mid"}, {"new.mid"}, set())
221 assert result["new.mid"] == h
222
223 def test_conflict_path_falls_back_to_base(self) -> None:
224 base = {"conflict.mid": _h("base")}
225 ours = {"conflict.mid": _h("ours")}
226 theirs = {"conflict.mid": _h("theirs")}
227 result = apply_merge(
228 base, ours, theirs,
229 {"conflict.mid"}, {"conflict.mid"}, {"conflict.mid"}
230 )
231 # Conflict paths are excluded → base value is kept.
232 assert result["conflict.mid"] == _h("base")
233
234 def test_theirs_deletion_removes_from_merged(self) -> None:
235 base = {"f.mid": _h("f"), "g.mid": _h("g")}
236 ours = {"f.mid": _h("f"), "g.mid": _h("g")}
237 theirs = {"f.mid": _h("f")} # g.mid deleted on theirs
238 result = apply_merge(base, ours, theirs, set(), {"g.mid"}, set())
239 assert "g.mid" not in result
240
241 def test_unrelated_changes_both_preserved(self) -> None:
242 base = {"a.mid": _h("a0"), "b.mid": _h("b0"), "c.mid": _h("c0")}
243 ours = {"a.mid": _h("a1"), "b.mid": _h("b0"), "c.mid": _h("c0")}
244 theirs = {"a.mid": _h("a0"), "b.mid": _h("b1"), "c.mid": _h("c0")}
245 result = apply_merge(
246 base, ours, theirs, {"a.mid"}, {"b.mid"}, set()
247 )
248 assert result["a.mid"] == _h("a1")
249 assert result["b.mid"] == _h("b1")
250 assert result["c.mid"] == _h("c0")
251
252 def test_large_manifest_clean_merge(self) -> None:
253 """200 files: 100 changed by ours, 100 changed by theirs, no overlap."""
254 base = {f"f{i:03d}.mid": _h(f"v0-{i}") for i in range(200)}
255 ours = dict(base)
256 theirs = dict(base)
257 ours_changed = set()
258 theirs_changed = set()
259 for i in range(100):
260 ours[f"f{i:03d}.mid"] = _h(f"v-ours-{i}")
261 ours_changed.add(f"f{i:03d}.mid")
262 for i in range(100, 200):
263 theirs[f"f{i:03d}.mid"] = _h(f"v-theirs-{i}")
264 theirs_changed.add(f"f{i:03d}.mid")
265 result = apply_merge(base, ours, theirs, ours_changed, theirs_changed, set())
266 for i in range(100):
267 assert result[f"f{i:03d}.mid"] == _h(f"v-ours-{i}")
268 for i in range(100, 200):
269 assert result[f"f{i:03d}.mid"] == _h(f"v-theirs-{i}")
270
271 def test_pipeline_diff_detect_merge(self) -> None:
272 """End-to-end: run diff → detect → apply and verify correctness.
273
274 Scenario:
275 base = {conflict.mid, ours-only.mid, theirs-only.mid, untouched.mid}
276 ours: modifies conflict.mid, deletes ours-only.mid (only ours touches it)
277 theirs: modifies conflict.mid, deletes theirs-only.mid (only theirs touches it)
278
279 Expected results:
280 conflict.mid: bilateral conflict → stays at base value
281 ours-only.mid: deleted only by ours → deleted in merged
282 theirs-only.mid: deleted only by theirs → deleted in merged
283 untouched.mid: neither side changed → stays at base
284 """
285 base = {
286 "conflict.mid": _h("c0"),
287 "ours-only.mid": _h("o0"),
288 "theirs-only.mid": _h("t0"),
289 "untouched.mid": _h("u0"),
290 }
291 # ours: modifies conflict.mid, deletes ours-only.mid, leaves theirs-only and untouched
292 ours = {
293 "conflict.mid": _h("c-ours"),
294 "theirs-only.mid": _h("t0"),
295 "untouched.mid": _h("u0"),
296 }
297 # theirs: modifies conflict.mid, deletes theirs-only.mid, leaves ours-only and untouched
298 theirs = {
299 "conflict.mid": _h("c-theirs"),
300 "ours-only.mid": _h("o0"),
301 "untouched.mid": _h("u0"),
302 }
303
304 ours_changed = diff_snapshots(base, ours)
305 theirs_changed = diff_snapshots(base, theirs)
306 conflicts = detect_conflicts(ours_changed, theirs_changed, ours, theirs)
307
308 result = apply_merge(base, ours, theirs, ours_changed, theirs_changed, conflicts)
309
310 # conflict.mid: both sides changed to DIFFERENT hashes → stays at base.
311 assert result["conflict.mid"] == _h("c0")
312 # ours-only.mid: deleted by ours only → absent in merged.
313 assert "ours-only.mid" not in result
314 # theirs-only.mid: deleted by theirs only → absent in merged.
315 assert "theirs-only.mid" not in result
316 # untouched.mid: neither side touched → stays at base.
317 assert result["untouched.mid"] == _h("u0")
318
319
320 # ===========================================================================
321 # MergeState I/O — adversarial
322 # ===========================================================================
323
324
325 class TestMergeStateIOAdversarial:
326 def test_conflict_paths_sorted_on_write(self, repo: pathlib.Path) -> None:
327 write_merge_state(
328 repo, base_commit="b", ours_commit="o", theirs_commit="t",
329 conflict_paths=["z.mid", "a.mid", "m.mid"],
330 )
331 state = read_merge_state(repo)
332 assert state is not None
333 assert state.conflict_paths == ["a.mid", "m.mid", "z.mid"]
334
335 def test_optional_other_branch_absent(self, repo: pathlib.Path) -> None:
336 write_merge_state(
337 repo, base_commit="b", ours_commit="o", theirs_commit="t",
338 conflict_paths=[],
339 )
340 state = read_merge_state(repo)
341 assert state is not None
342 assert state.other_branch is None
343
344 def test_corrupt_json_returns_none(self, repo: pathlib.Path) -> None:
345 path = repo / ".muse" / "MERGE_STATE.json"
346 path.write_text("{not valid json")
347 assert read_merge_state(repo) is None
348
349 def test_empty_json_returns_none_gracefully(self, repo: pathlib.Path) -> None:
350 path = repo / ".muse" / "MERGE_STATE.json"
351 path.write_text("")
352 assert read_merge_state(repo) is None
353
354 def test_missing_file_returns_none(self, repo: pathlib.Path) -> None:
355 assert read_merge_state(repo) is None
356
357 def test_clear_idempotent(self, repo: pathlib.Path) -> None:
358 # Clearing when no state file exists should not raise.
359 clear_merge_state(repo)
360 clear_merge_state(repo)
361
362 def test_write_overwrite_previous(self, repo: pathlib.Path) -> None:
363 b2 = fake_id("base2")
364 o2 = fake_id("ours2")
365 t2 = fake_id("theirs2")
366 write_merge_state(repo, base_commit=fake_id("base1"), ours_commit=fake_id("ours1"), theirs_commit=fake_id("theirs1"), conflict_paths=["a.mid"])
367 write_merge_state(repo, base_commit=b2, ours_commit=o2, theirs_commit=t2, conflict_paths=["b.mid"])
368 state = read_merge_state(repo)
369 assert state is not None
370 assert state.base_commit == b2
371 assert state.conflict_paths == ["b.mid"]
372
373 def test_100_conflict_paths_round_trip(self, repo: pathlib.Path) -> None:
374 paths = [f"track-{i:03d}.mid" for i in range(100)]
375 write_merge_state(repo, base_commit=fake_id("base"), ours_commit=fake_id("ours"), theirs_commit=fake_id("theirs"), conflict_paths=paths)
376 state = read_merge_state(repo)
377 assert state is not None
378 assert state.conflict_paths == sorted(paths)
379
380 def test_merge_state_is_frozen_dataclass(self) -> None:
381 ms = MergeState(conflict_paths=["a.mid"], base_commit="b")
382 with pytest.raises((AttributeError, TypeError)):
383 ms.__setattr__("base_commit", "new")
384
385
386 # ===========================================================================
387 # apply_resolution
388 # ===========================================================================
389
390
391 class TestApplyResolution:
392 def test_resolution_restores_correct_content(self, repo: pathlib.Path) -> None:
393 data = b"resolved content"
394 oid = blob_id(data)
395 write_object(repo, oid, data)
396 apply_resolution(repo, "beat.mid", oid)
397 restored = (repo / "beat.mid").read_bytes()
398 assert restored == data
399
400 def test_resolution_creates_nested_dirs(self, repo: pathlib.Path) -> None:
401 data = b"nested file"
402 oid = blob_id(data)
403 write_object(repo, oid, data)
404 apply_resolution(repo, "sub/dir/beat.mid", oid)
405 assert (repo / "sub" / "dir" / "beat.mid").read_bytes() == data
406
407 def test_resolution_missing_object_raises(self, repo: pathlib.Path) -> None:
408 with pytest.raises(FileNotFoundError):
409 apply_resolution(repo, "beat.mid", fake_id("missing-object"))
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago