Viewing files at commit 46ad54da0178Commit detailsView latest

Browse files
tests/test_merge_suite.py 279 lines · 13.9 KB · python
1
"""Instrument tests for suites/merge (classification, laws, oracle parsing, vendoring).
2
3
Hermetic: no Oak binary and no network. Git-backed tests skip when git is absent.
4
"""
5
import hashlib
6
import json
7
from pathlib import Path
8
import shutil
9
import sys
10
import tempfile
11
import unittest
12
13
ROOT = Path(__file__).resolve().parents[1]
14
sys.path.insert(0, str(ROOT / "suites" / "merge"))
15
sys.path.insert(0, str(ROOT / "scripts"))
16
17
from mergesuite import classify as cl  # noqa: E402
18
from mergesuite.gitops import (Git, git_conflicts, normalize_git_conflict_type,  # noqa: E402
19
                               parse_merge_tree_z, prepare_git_home, worktree_manifest)
20
from mergesuite.oak_adapter import parse_conflict_show, summarize_prediction  # noqa: E402
21
22
VENDOR = ROOT / "suites" / "merge" / "vendor" / "gitoxide"
23
24
25
def v(tag):
26
    return ("file", hashlib.sha256(tag.encode()).hexdigest())
27
28
29
def oak(tree, conflicts=(), clean=None):
30
    return {"tree": tree, "conflicts": {p: [] for p in conflicts},
31
            "clean": (not conflicts) if clean is None else clean, "error": None, "error_kind": None}
32
33
34
def gitres(tree, conflicts=()):
35
    return {"tree": tree, "conflicts": {p: ["content"] for p in conflicts}, "clean": not conflicts}
36
37
38
class ClassifyTests(unittest.TestCase):
39
    base = {"a": v("a0"), "b": v("b0")}
40
    ours = {"a": v("a1"), "b": v("b0")}
41
    theirs = {"a": v("a0"), "b": v("b1")}
42
    merged = {"a": v("a1"), "b": v("b1")}
43
44
    def test_equal(self):
45
        r = cl.classify(gitres(self.merged), oak(self.merged), self.base, self.ours, self.theirs)
46
        self.assertEqual(r["class"], "equal-to-git")
47
48
    def test_lost_change_is_oak_wrong_even_if_git_agrees(self):
49
        # Oak drops theirs' change to b.
50
        r = cl.classify(gitres(self.merged), oak(self.ours), self.base, self.ours, self.theirs)
51
        self.assertEqual(r["class"], "oak-wrong")
52
        self.assertIn("lost_change: b", r["reasons"][0])
53
54
    def test_lost_deletion(self):
55
        theirs = {"a": v("a0")}  # theirs deletes b
56
        r = cl.classify(gitres({"a": v("a1")}), oak({"a": v("a1"), "b": v("b0")}),
57
                        self.base, self.ours, theirs)
58
        self.assertEqual(r["class"], "oak-wrong")
59
60
    def test_fabricated_content(self):
61
        r = cl.classify(gitres(self.merged), oak({"a": v("a1"), "b": v("zzz")}),
62
                        self.base, self.ours, self.theirs)
63
        self.assertEqual(r["class"], "oak-wrong")
64
        self.assertIn("fabricated", r["reasons"][0])
65
66
    def test_false_conflict_is_differs_but_valid(self):
67
        r = cl.classify(gitres(self.merged), oak(self.merged, conflicts=["a"]),
68
                        self.base, self.ours, self.theirs)
69
        self.assertEqual(r["class"], "differs-but-valid")
70
        self.assertTrue(any("oak_conflicts_where_git_clean" in x for x in r["reasons"]))
71
72
    def test_rename_not_detected_keeps_both_sides(self):
73
        # ours renames a->c; theirs edits b. git moves; Oak path-level result matches git here,
74
        # but when git conflicts and Oak keeps everything, it is differs-but-valid.
75
        base = {"a": v("a0")}
76
        ours = {"b": v("a0")}
77
        theirs = {"c": v("a0")}
78
        r = cl.classify(gitres({"b": v("a0"), "c": v("a0")}, conflicts=["a", "b", "c"]),
79
                        oak({"b": v("a0"), "c": v("a0")}), base, ours, theirs)
80
        self.assertEqual(r["class"], "differs-but-valid")
81
        self.assertTrue(any("oak_clean_where_git_conflicts" in x for x in r["reasons"]))
82
83
    def test_expectation_violation_wins(self):
84
        r = cl.classify(gitres(self.merged), oak(self.merged), self.base, self.ours, self.theirs,
85
                        expect={"paths": {"a": v("a0")[1]}})
86
        self.assertEqual(r["class"], "oak-wrong")
87
        self.assertIn("expectation_violated", r["reasons"][0])
88
89
    def test_expect_absent_and_clean(self):
90
        r = cl.classify(gitres(self.merged), oak(self.merged), self.base, self.ours, self.theirs,
91
                        expect={"clean": False})
92
        self.assertEqual(r["class"], "oak-wrong")
93
        r = cl.classify(gitres(self.merged), oak(self.merged), self.base, self.ours, self.theirs,
94
                        expect={"paths": {"gone": None}, "clean": True})
95
        self.assertEqual(r["class"], "equal-to-git")
96
97
    def test_unsupported_and_harness_and_oak_error(self):
98
        self.assertEqual(cl.classify(None, None, {}, {}, {}, unsupported="gitlink")["class"], "oak-unsupported")
99
        self.assertEqual(cl.classify(None, None, {}, {}, {}, harness_error="x")["class"], "harness-error")
100
        self.assertEqual(cl.classify(gitres({}), {"error_kind": "harness", "error": "shim"}, {}, {}, {})["class"],
101
                         "harness-error")
102
        r = cl.classify(gitres({}), {"error_kind": "oak", "error": "boom", "clean": None}, {}, {}, {})
103
        self.assertEqual(r["class"], "oak-wrong")
104
        self.assertEqual(cl.classify(gitres({}), None, {}, {}, {})["class"], "harness-error")
105
106
    def test_no_unique_base_leaves_law_unmeasured(self):
107
        r = cl.classify(gitres(self.merged), oak(self.merged), None, self.ours, self.theirs)
108
        self.assertEqual(r["class"], "equal-to-git")
109
        self.assertIsNone(r["evidence"]["law_no_lost_change"])
110
111
    def test_text_merge_law(self):
112
        base = b"1\n2\n3\n4\n5\n6\n"
113
        o = b"1 ours\n2\n3\n4\n5\n6\n"
114
        t = b"1\n2\n3\n4\n5\n6 theirs\n"
115
        good = b"1 ours\n2\n3\n4\n5\n6 theirs\n"
116
        self.assertTrue(cl.text_preserves_sides(base, o, t, good))
117
        self.assertFalse(cl.text_preserves_sides(base, o, t, o))
118
        self.assertFalse(cl.text_preserves_sides(base, o, t, b"<<<<<<< a\n" + good))
119
        trees = ({"f": v("b")}, {"f": v("o")}, {"f": v("t")})
120
        texts = {"f": {"base": base, "ours": o, "theirs": t, "merged": good}}
121
        law = cl.no_lost_change(*trees, {"f": v("m")}, set(), texts)
122
        self.assertEqual(law["text_merged"], ["f"])
123
        texts["f"]["merged"] = o
124
        self.assertEqual(cl.no_lost_change(*trees, {"f": v("m")}, set(), texts)["lost"], ["f"])
125
126
    def test_one_side_taken_wholesale_is_lost(self):
127
        # base exec+empty; ours: mode-only change; theirs: content change. Oak keeping theirs'
128
        # entry (content AND exec bit) drops ours' mode change.
129
        base = {"w": ("exec", "empty")}
130
        ours = {"w": ("file", "empty")}
131
        theirs = {"w": ("exec", "123")}
132
        law = cl.no_lost_change(base, ours, theirs, {"w": ("exec", "123")}, set())
133
        self.assertEqual(law["lost"], ["w"])
134
        law = cl.no_lost_change(base, ours, theirs, {"w": ("file", "123")}, set(),
135
                                {"w": {"base": b"", "ours": b"", "theirs": b"1\n2\n3\n", "merged": b"1\n2\n3\n"}})
136
        self.assertEqual(law["text_merged"], ["w"])
137
138
    def test_conflicted_paths_are_exempt_from_law(self):
139
        law = cl.no_lost_change(self.base, self.ours, self.theirs, {}, {"a", "b"})
140
        self.assertEqual(law, {"lost": [], "fabricated": [], "text_merged": []})
141
142
    def test_symmetry(self):
143
        f = oak(self.merged, conflicts=["a"])
144
        r = oak({"a": v("other-side-markers"), "b": v("b1")}, conflicts=["a"])
145
        self.assertTrue(cl.symmetry(f, r)["holds"])
146
        self.assertFalse(cl.symmetry(f, oak(self.merged))["holds"])
147
        self.assertIsNone(cl.symmetry(f, {"clean": None}))
148
149
    def test_idempotence(self):
150
        self.assertIsNone(cl.idempotence_holds({}))
151
        good = {"idempotence": {"returncode": 0, "head_unchanged": True, "tree_unchanged": True}}
152
        self.assertTrue(cl.idempotence_holds(good))
153
        good["idempotence"]["tree_unchanged"] = False
154
        self.assertFalse(cl.idempotence_holds(good))
155
156
    def test_oak_conflict_type(self):
157
        base = {"a": ("file", "1"), "m": ("file", "1")}
158
        self.assertEqual(cl.oak_conflict_type("a", base, {"a": ("file", "2")}, {}), "modify/delete")
159
        self.assertEqual(cl.oak_conflict_type("n", base, {"n": ("file", "2")}, {"n": ("file", "3")}), "add/add")
160
        self.assertEqual(cl.oak_conflict_type("m", base, {"m": ("exec", "1")}, {"m": ("symlink", "1")}), "mode")
161
162
163
class OracleParsingTests(unittest.TestCase):
164
    def test_parse_merge_tree_z(self):
165
        out = (b"tree123\0"
166
               b"100644 aaa 1\ta\x00100644 bbb 2\ta\x00100644 ccc 3\ta\x00"
167
               b"\0"
168
               b"1\0a\0Auto-merging\0Auto-merging a\n\0"
169
               b"1\0a\0CONFLICT (contents)\0CONFLICT (content): Merge conflict in a\n\0")
170
        parsed = parse_merge_tree_z(out)
171
        self.assertEqual(parsed["tree_oid"], "tree123")
172
        self.assertEqual(parsed["conflict_stages"], {"a": [1, 2, 3]})
173
        self.assertEqual(git_conflicts(parsed), {"a": ["content"]})
174
175
    def test_parse_clean(self):
176
        parsed = parse_merge_tree_z(b"t\0\0")
177
        self.assertEqual(parsed["conflict_stages"], {})
178
        self.assertEqual(parsed["messages"], [])
179
180
    def test_type_vocabulary(self):
181
        self.assertIsNone(normalize_git_conflict_type("Auto-merging"))
182
        self.assertEqual(normalize_git_conflict_type("CONFLICT (modify/delete)"), "modify/delete")
183
        self.assertEqual(normalize_git_conflict_type("CONFLICT (rename/delete)"), "rename/delete")
184
        self.assertEqual(normalize_git_conflict_type("CONFLICT (directory rename split)"), "directory-rename")
185
186
    def test_conflict_show_and_prediction_parsing(self):
187
        doc = {"conflicts": [{"path": "f", "has_conflict_markers": False, "exists": True}]}
188
        self.assertEqual(parse_conflict_show(doc, {}), {"f": ["no-markers"]})
189
        self.assertEqual(parse_conflict_show(None, {"conflict_paths": ["x"]}), {"x": []})
190
        self.assertEqual(summarize_prediction(None, 6), {"available": False, "returncode": 6})
191
        pred = summarize_prediction({"prediction_available": True, "clean": False,
192
                                     "conflict_files": [{"path": "b"}, "a"]}, 0)
193
        self.assertEqual(pred["conflict_files"], ["a", "b"])
194
195
196
@unittest.skipUnless(shutil.which("git"), "git not installed")
197
class GitBackedTests(unittest.TestCase):
198
    def setUp(self):
199
        self.tmp = Path(tempfile.mkdtemp(prefix="mergesuite-test-"))
200
        self.home = self.tmp / "home"
201
        from oakbench.subjects import which_measured_git
202
        self.git_bin = which_measured_git() or shutil.which("git")
203
        prepare_git_home(self.home, self.git_bin)
204
205
    def tearDown(self):
206
        shutil.rmtree(self.tmp, ignore_errors=True)
207
208
    def test_builder_oracle_and_materialize_roundtrip(self):
209
        from mergesuite.builder import RepoBuilder, lines
210
        b = RepoBuilder(self.tmp / "r", self.git_bin, self.home)
211
        base = b.commit({"f": lines("1", "2", "3"), "x": ("exec", b"#!/bin/sh\n"), "l": ("link", "f")})
212
        a = b.commit({"f": lines("1 a", "2", "3"), "x": ("exec", b"#!/bin/sh\n"), "l": ("link", "f")}, [base])
213
        c = b.commit({"f": lines("1", "2", "3 c"), "l": ("link", "f")}, [base], message="unique")
214
        self.assertEqual(b.merge_bases(a, c), [base])
215
        merge = b.merge_tree(a, c)
216
        self.assertTrue(merge["clean"])
217
        self.assertEqual(sorted(merge["tree"]), ["f", "l"])
218
        self.assertEqual(merge["tree"]["l"][0], "symlink")
219
        out = self.tmp / "mat"
220
        out.mkdir()
221
        b.materialize(base, out)
222
        self.assertEqual(worktree_manifest(out), b.tree(base))
223
        # first parent order is preserved
224
        m = b.commit({"f": lines("z")}, [a, c])
225
        self.assertEqual(b.out("rev-parse", m + "^1"), a)
226
227
    def test_oracle_reports_conflict_types(self):
228
        from mergesuite.builder import RepoBuilder, lines
229
        b = RepoBuilder(self.tmp / "r2", self.git_bin, self.home)
230
        base = b.commit({"f": lines("1", "2"), "g": lines("g")})
231
        a = b.commit({"f": lines("1", "A")}, [base])
232
        c = b.commit({"f": lines("1", "C"), "g": lines("g2")}, [base])
233
        merge = b.merge_tree(a, c)
234
        self.assertFalse(merge["clean"])
235
        self.assertEqual(git_conflicts(merge), {"f": ["content"], "g": ["modify/delete"]})
236
237
    def test_sources_build_without_oak(self):
238
        from mergesuite import sources
239
        scen = sources.git_t64xx(self.tmp / "t64", self.git_bin, self.home)
240
        ids = [s["id"] for s in scen]
241
        self.assertIn("git-t6423-1a", ids)
242
        self.assertTrue(all(len(s["directions"]) == 2 and s["unsupported"] is None for s in scen))
243
        native = sources.native_incidents(self.tmp / "nat", self.git_bin, self.home)
244
        by_id = {s["id"]: s for s in native}
245
        cc = by_id["incident/criss-cross-tie-break-side-newer"]["directions"][0]
246
        self.assertEqual(len(cc["merge_bases"]), 2)  # two best common ancestors by construction
247
        self.assertIsNone(cc["trees"]["base"])
248
        skew = by_id["incident/clock-skew-merge-base-skewed"]["directions"][0]
249
        self.assertEqual(len(skew["merge_bases"]), 1)
250
        gen = sources.generated(self.tmp / "gen", self.git_bin, self.home, 3, 7)
251
        gen2 = sources.generated(self.tmp / "gen2", self.git_bin, self.home, 3, 7)
252
        self.assertEqual([s["notes"] for s in gen], [s["notes"] for s in gen2])  # seeded
253
254
255
class VendoringTests(unittest.TestCase):
256
    def test_vendored_scripts_are_byte_identical_to_pinned_upstream(self):
257
        meta = json.loads((VENDOR / "UPSTREAM.json").read_text())
258
        self.assertEqual(meta["license"], "MIT OR Apache-2.0")
259
        for name, info in meta["files"].items():
260
            path = VENDOR / name
261
            self.assertTrue(path.is_file(), name)
262
            if "sha256" in info:
263
                self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), info["sha256"], name)
264
        notice = (VENDOR / "NOTICE").read_text()
265
        self.assertIn(meta["commit"], notice)
266
        self.assertIn("Permission is hereby granted", (VENDOR / "LICENSE-MIT").read_text())
267
        self.assertIn("Apache License", (VENDOR / "LICENSE-APACHE").read_text())
268
269
    def test_no_git_test_suite_text_is_vendored(self):
270
        merge_root = ROOT / "suites" / "merge"
271
        for path in merge_root.rglob("*"):
272
            if path.is_file() and path.suffix in (".py", ".sh", ".md", ".json"):
273
                text = path.read_text(errors="replace")
274
                self.assertNotIn("test_expect_success", text, path)
275
                self.assertNotIn(". ./test-lib.sh", text, path)
276
277
278
if __name__ == "__main__":
279
    unittest.main()