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