Viewing files at commit 46ad54da0178Commit detailsView latest

suites/merge/mergesuite/sources.py 452 lines · 25.9 KB · python
1
"""Scenario sources: vendored gitoxide baselines, suite-authored scenarios, generated histories.
2
3
A scenario is a dict:
4
  id, version, origin, upstream{id, license, commit?}, repo (git repo Path),
5
  directions: [{name, plan (see oak_adapter.run_pull_sync), oracle{ours, theirs},
6
                trees{base, ours, theirs}, expect?, intent_note?}],
7
  unsupported: reason | None, notes
8
"""
9
from __future__ import annotations
10
11
import os
12
from pathlib import Path
13
import random
14
import subprocess
15
from typing import Dict, List, Optional
16
17
from .builder import RepoBuilder, lines
18
from .gitops import Git, git_env, sha256
19
20
VENDOR = Path(__file__).resolve().parents[1] / "vendor" / "gitoxide"
21
GITOXIDE_COMMIT = "acf906f8fcdb8e8d143570737786543dc5a204a0"
22
GITOXIDE_LICENSE = "MIT OR Apache-2.0"
23
GIT_TESTS_REF = "git v2.50.1 (d82adb61ba2fd11d8f2587fca1b6bd7925ce4044) t/ comment prose; re-implemented, GPL text not copied"
24
25
26
# --------------------------------------------------------------------------- helpers
27
28
def three_way(git: Git, ours: str, theirs: str) -> dict:
29
    """Directional plan for a plain 3-way case: main=theirs, branch=fork(base)+ours tree."""
30
    bases = git.merge_bases(ours, theirs)
31
    base = bases[0] if len(bases) == 1 else None
32
    return {
33
        "plan": {"main": theirs, "fork": base, "steps": [{"op": "commit", "tree": ours}]},
34
        "oracle": {"ours": ours, "theirs": theirs},
35
        "trees": {"base": base, "ours": ours, "theirs": theirs},
36
        "merge_bases": bases,
37
    }
38
39
40
def unsupported_reason(git: Git, direction: dict) -> Optional[str]:
41
    bases = direction["merge_bases"]
42
    if not bases:
43
        return "unrelated histories: Oak branches always fork from a main commit"
44
    if len(bases) > 1:
45
        return ("multiple git merge bases: the ours side would need merge commits, which the "
46
                "fork+checkpoint construction cannot import (criss-cross is covered by staged native scenarios)")
47
    for key in ("base", "ours", "theirs"):
48
        mode_lines = git.out("ls-tree", "-r", "--full-tree", direction["trees"][key])
49
        if any(line.startswith("160000 ") for line in mode_lines.splitlines()):
50
            return "gitlink (submodule) entries: Oak's git import drops gitlinks"
51
    return None
52
53
54
def symmetric(scenario_id: str, origin: str, upstream: dict, git: Git, a: str, b: str,
55
              names=("A-B", "B-A"), notes: str = "", expect: Optional[dict] = None) -> dict:
56
    directions = []
57
    for name, (ours, theirs) in zip(names, ((a, b), (b, a))):
58
        d = three_way(git, ours, theirs)
59
        d["name"] = name
60
        if expect:
61
            d["expect"] = expect
62
        directions.append(d)
63
    reason = unsupported_reason(git, directions[0])
64
    return {"id": scenario_id, "version": 1, "origin": origin, "upstream": upstream,
65
            "repo": git.repo, "directions": directions, "unsupported": reason, "notes": notes}
66
67
68
def _run_vendored(script: str, workdir: Path, git_bin: str, home: Path) -> Path:
69
    out = workdir / "gitoxide" / script.replace(".sh", "")
70
    marker = out / ".mergesuite-complete"
71
    if marker.exists():
72
        return out
73
    out.mkdir(parents=True, exist_ok=True)
74
    env = git_env(home)
75
    for k in ("GIT_AUTHOR_DATE", "GIT_COMMITTER_DATE"):
76
        env.pop(k)  # the scripts `tick` their own dates
77
    env["PATH"] = str(Path(git_bin).parent) + os.pathsep + env.get("PATH", "")
78
    p = subprocess.run(["bash", str(VENDOR / script)], cwd=out, env=env, capture_output=True, timeout=900)
79
    (out / "script.log").write_bytes(p.stdout[-20000:] + p.stderr[-20000:])
80
    if p.returncode:
81
        raise RuntimeError(f"vendored {script} failed rc={p.returncode}; see {out / 'script.log'}")
82
    marker.write_text("ok\n")
83
    return out
84
85
86
# --------------------------------------------------------------------------- gitoxide
87
88
def gitoxide_tree(workdir: Path, git_bin: str, home: Path) -> List[dict]:
89
    root = _run_vendored("tree-baseline.sh", workdir, git_bin, home)
90
    seen, scenarios = set(), []
91
    for line in (root / "baseline.cases").read_text().splitlines():
92
        parts = line.split(" ")
93
        directory, style, _oc, ours_ref, _tc, theirs_ref, merge_info = parts[:7]
94
        if style != "merge":
95
            continue  # diff3 duplicates differ only in marker text; Oak has one marker style
96
        name = merge_info.replace(".merge-info", "").replace("-reversed", "")
97
        key = (directory, name)
98
        if key in seen:
99
            continue
100
        seen.add(key)
101
        git = Git(root / directory, git_bin, home)
102
        a, b = git.rev(ours_ref), git.rev(theirs_ref)
103
        deviation = " ".join(parts[8:]).strip()
104
        scenarios.append(symmetric(
105
            f"gix-tree/{directory}/{name}", "gitoxide-tree-baseline",
106
            {"id": f"gix-merge tree-baseline.sh {directory} {name}", "license": GITOXIDE_LICENSE,
107
             "commit": GITOXIDE_COMMIT}, git, a, b,
108
            names=(f"{ours_ref}-{theirs_ref}", f"{theirs_ref}-{ours_ref}"),
109
            notes=("gitoxide deviation note: " + deviation) if deviation else ""))
110
    return scenarios
111
112
113
def gitoxide_cartesian(workdir: Path, git_bin: str, home: Path, include_submodule: bool = True) -> List[dict]:
114
    root = _run_vendored("tree-cartesian-baseline.sh", workdir, git_bin, home)
115
    scenarios = []
116
    for matrix in ("matrix", "matrix-submodule") if include_submodule else ("matrix",):
117
        git = Git(root / matrix, git_bin, home)
118
        for line in (root / matrix / "cases.tsv").read_text().splitlines():
119
            left, right, a, b = line.split("\t")[:4]
120
            scenarios.append(symmetric(
121
                f"gix-cartesian/{matrix}/{left}--{right}", "gitoxide-cartesian",
122
                {"id": f"gix-merge tree-cartesian-baseline.sh {matrix} {left}--{right}",
123
                 "license": GITOXIDE_LICENSE, "commit": GITOXIDE_COMMIT}, git, a, b))
124
    return scenarios
125
126
127
def gitoxide_text(workdir: Path, git_bin: str, home: Path) -> List[dict]:
128
    """Each blob triple becomes a one-file tree merge (default marker style only)."""
129
    root = _run_vendored("text-baseline.sh", workdir, git_bin, home)
130
    repo_dir = workdir / "gitoxide" / "text-trees"
131
    scenarios = []
132
    builder = None
133
    for line in (root / "baseline.cases").read_text().splitlines():
134
        parts = line.split(" ")
135
        ours_blob, base_blob, theirs_blob, output = parts[:4]
136
        if not output.endswith("/merge.merged") or len(parts) > 4:
137
            continue
138
        case = output[:-len("/merge.merged")]
139
        if builder is None:
140
            builder = RepoBuilder(repo_dir, git_bin, home)
141
        data = {k: (root / p).read_bytes() for k, p in
142
                (("base", base_blob), ("ours", ours_blob), ("theirs", theirs_blob))}
143
        base = builder.commit({"file": data["base"]}, message=f"{case} base")
144
        a = builder.commit({"file": data["ours"]}, [base], message=f"{case} ours")
145
        b = builder.commit({"file": data["theirs"]}, [base], message=f"{case} theirs")
146
        scenarios.append(symmetric(
147
            f"gix-text/{case}", "gitoxide-text-baseline",
148
            {"id": f"gix-merge text-baseline.sh {case} merge", "license": GITOXIDE_LICENSE,
149
             "commit": GITOXIDE_COMMIT}, builder, a, b, names=("ours-theirs", "theirs-ours")))
150
    return scenarios
151
152
153
# --------------------------------------------------------------------------- git t64xx (re-implemented)
154
155
def _c(name: str, variant: str = "") -> bytes:
156
    """Distinct 6-line content per logical file (similar enough for rename detection)."""
157
    body = [f"{name} line {i}" for i in range(1, 7)]
158
    if variant == "top":
159
        body[0] += " (edited top)"
160
    elif variant == "bottom":
161
        body[-1] += " (edited bottom)"
162
    elif variant == "alt":
163
        body = [f"{name} alternative {i}" for i in range(1, 7)]
164
    return lines(*body)
165
166
167
def _tree(spec: Dict[str, str]) -> Dict[str, bytes]:
168
    """{path: logical-name[:variant]} -> {path: content}."""
169
    out = {}
170
    for path, logical in spec.items():
171
        name, _, variant = logical.partition(":")
172
        out[path] = _c(name, variant)
173
    return out
174
175
176
def _d(prefix: str, names: str, **variants) -> Dict[str, str]:
177
    """_d("z", "b c d") -> {"z/b": "b", ...}; variants map name->logical override."""
178
    return {f"{prefix}/{n}": variants.get(n, n) for n in names.split()}
179
180
181
def _u(*dicts) -> Dict[str, str]:
182
    out = {}
183
    for d in dicts:
184
        out.update(d)
185
    return out
186
187
188
# (id, O, A, B). Shapes re-expressed from the testcase comment prose; content is ours.
189
T64XX = [
190
    ("git-t6423-1a", _d("z", "b c"), _d("y", "b c"), _u(_d("z", "b c d"), {"z/e/f": "f"})),
191
    ("git-t6423-1b", _u(_d("z", "b c"), _d("y", "d")), _u(_d("z", "b c e"), _d("y", "d")), _d("y", "b c d")),
192
    ("git-t6423-1c", _u(_d("z", "b c"), _d("x", "d")), _u(_d("y", "b c"), _d("x", "d")), _d("z", "b c d")),
193
    ("git-t6423-1f", _d("z", "b c d e f"), _d("z", "b c d e f g"), _u(_d("y", "b c"), _d("x", "d e f"))),
194
    ("git-t6423-2a", _d("z", "b c"), {"y/b": "b", "w/c": "c"}, _d("z", "b c d")),
195
    ("git-t6423-3a", _d("z", "b c d"), _d("z", "b c d"), _u(_d("y", "b c"), _d("x", "d"))),
196
    ("git-t6423-4a", _d("z", "b c d e"), _u(_d("y", "b c d"), _d("z", "e")), _d("z", "b c d e f")),
197
    ("git-t6423-5a", _u(_d("z", "b c"), _d("y", "d")),
198
     _u(_d("z", "b c f"), {"z/e": "e1"}, _d("y", "d"), {"y/e": "e2"}), _d("y", "b c d")),
199
    ("git-t6423-6a", _d("z", "b c d"), _d("z", "b"), _u(_d("y", "b c"), _d("z", "d"))),
200
    ("git-t6423-6c", _d("z", "b c"), _d("z", "b c"), _u(_d("y", "b c"), _d("z", "d"))),
201
    ("git-t6423-6d", _u(_d("z", "b c"), _d("x", "d")), _u(_d("z", "b c"), _d("x", "d")), _u(_d("y", "b c"), _d("z", "d"))),
202
    ("git-t6423-7a", _d("z", "b c"), _d("y", "b c"), {"w/b": "b", "x/c": "c", "z/d": "d"}),
203
    ("git-t6423-8c", _d("z", "b c d"), _d("y", "b c"), _d("z", "b c d e", d="d:bottom")),
204
    ("git-t6423-9a", _u(_d("z", "b c"), _d("z/d", "e f g")), _u(_d("y", "b c"), _d("x/w", "e f g")),
205
     _u(_d("z", "b c i"), _d("z/d", "e f g h"))),
206
    ("git-t6423-9b", _u(_d("z", "b c"), _d("x", "d")), _u(_d("y", "b c"), _d("x", "d", d="d:top")),
207
     _d("z", "b c d", d="d:bottom")),
208
    ("git-t6423-12d", {"a/b/subdir/foo": "foo"}, {"subdir/foo": "foo"}, {"a/b/subdir/foo": "foo", "a/b/bar": "bar"}),
209
    ("git-t6423-12i", {"source/subdir/foo": "foo", "source/bar": "bar", "source/baz": "baz"},
210
     {"source/foo": "foo", "source/bar": "bar", "source/baz": "baz"},
211
     {"source/subdir/foo": "foo", "source/subdir/bar": "bar", "source/baz": "baz:bottom"}),
212
]
213
214
215
def git_t64xx(workdir: Path, git_bin: str, home: Path) -> List[dict]:
216
    scenarios = []
217
    for sid, o, a, b in T64XX:
218
        builder = RepoBuilder(workdir / "t64xx" / sid, git_bin, home)
219
        base = builder.commit(_tree(o), message=f"{sid} O")
220
        ca = builder.commit(_tree(a), [base], message=f"{sid} A")
221
        cb = builder.commit(_tree(b), [base], message=f"{sid} B")
222
        scenarios.append(symmetric(sid, "git-t64xx-reimplemented",
223
                                   {"id": sid, "license": "scenario idea only (source GPL-2.0-only)",
224
                                    "commit": GIT_TESTS_REF}, builder, ca, cb))
225
    # t6422 corner cases (break detection / binary rename), content authored here.
226
    five = lines("1", "2", "3", "4", "5")
227
    extra = [
228
        ("git-t6422-break-detection-1", {"a": five, "b": lines("foo")},
229
         {"c": five, "a": lines("Completely different content"), "b": lines("foo")},
230
         {"a": five + lines("6"), "b": lines("foo")}),
231
        ("git-t6422-break-detection-2", {"a": five, "b": lines("foo")},
232
         {"c": five + lines("7"), "a": lines("Completely different content"), "b": lines("foo")},
233
         {"a": lines("Completely different content"), "b": lines("foo")}),
234
        ("git-t6422-break-detection-3", {"a": five}, {"b": five}, {"b": five, "a": lines("foobar")}),
235
        ("git-t6422-rename-rename-1to2-binary", {"a": bytes(range(256)) * 4},
236
         {"b": bytes(range(256)) * 4}, {"c": bytes(range(256)) * 4}),
237
    ]
238
    for sid, o, a, b in extra:
239
        builder = RepoBuilder(workdir / "t64xx" / sid, git_bin, home)
240
        base = builder.commit(o, message=f"{sid} O")
241
        ca = builder.commit(a, [base], message=f"{sid} A")
242
        cb = builder.commit(b, [base], message=f"{sid} B")
243
        scenarios.append(symmetric(sid, "git-t64xx-reimplemented",
244
                                   {"id": sid, "license": "scenario idea only (source GPL-2.0-only)",
245
                                    "commit": GIT_TESTS_REF}, builder, ca, cb))
246
    return scenarios
247
248
249
# --------------------------------------------------------------------------- native incident shapes
250
251
F0 = lines("l1", "l2", "l3", "l4", "l5")
252
FX = lines("l1", "l2 X", "l3", "l4", "l5")
253
FK = lines("l1", "l2 K", "l3", "l4", "l5")
254
255
256
def _staged(sid: str, builder: RepoBuilder, plan: dict, oracle_ours: str, oracle_theirs: str,
257
            expect: dict, upstream: dict, notes: str, intent_note: str = "") -> dict:
258
    bases = builder.merge_bases(oracle_ours, oracle_theirs)
259
    direction = {"name": "branch-into-main-sync", "plan": plan,
260
                 "oracle": {"ours": oracle_ours, "theirs": oracle_theirs},
261
                 "trees": {"base": bases[0] if len(bases) == 1 else None, "ours": oracle_ours,
262
                           "theirs": oracle_theirs},
263
                 "merge_bases": bases, "expect": expect}
264
    if intent_note:
265
        direction["intent_note"] = intent_note
266
    return {"id": sid, "version": 1, "origin": "native-incident", "upstream": upstream,
267
            "repo": builder.repo, "directions": [direction], "unsupported": None, "notes": notes,
268
            "staged": True}
269
270
271
def native_incidents(workdir: Path, git_bin: str, home: Path) -> List[dict]:
272
    out = []
273
    seed = {"id": "oak cli/src/commands/merge/merge_base_fixtures.rs squash_revert (5d2ee0fb)",
274
            "license": "suite-authored"}
275
    repro = {"id": "worker-mrmrs-20260926T092526-repro-server-mergebase REPRO.md", "license": "suite-authored"}
276
277
    # N1: stale base C vs correct X; ours clone holds only R-C-X locally (partial graph),
278
    # the rest of main (M1..M9, Z1, H) is hydrated during pull.
279
    b = RepoBuilder(workdir / "native" / "stale-base", git_bin, home)
280
    files = {"f.txt": F0, "r.txt": lines("r")}
281
    r = b.commit(files, message="R")
282
    files = dict(files, **{"c.txt": lines("c")})
283
    c = b.commit(files, [r], message="C")
284
    x = b.commit(dict(files, **{"f.txt": FX}), [c], message="X")
285
    main_files, prev = dict(files, **{"f.txt": FX}), x
286
    for i in range(1, 10):
287
        main_files = dict(main_files, **{f"m{i}.txt": lines(f"m{i}")})
288
        if i == 5:
289
            main_files["f.txt"] = F0  # M5 reverts X
290
        prev = b.commit(main_files, [prev], message=f"M{i}")
291
    z1 = b.commit(dict(files, **{"z.txt": lines("z1", "z2", "z3")}), [c], message="Z1")
292
    h = b.commit(dict(main_files, **{"z.txt": lines("z1", "z2", "z3")}), [prev, z1], message="H")
293
    a1 = b.commit(dict(files, **{"f.txt": FX, "a.txt": lines("a")}), [x], message="a1")
294
    out.append(_staged("incident/stale-base-squash-revert", b,
295
                       {"main": h, "fork": x, "steps": [{"op": "commit", "tree": a1}]}, a1, h,
296
                       {"clean": True, "paths": {"f.txt": sha256(F0), "a.txt": sha256(lines("a"))}},
297
                       seed, "True base X; stale base C would resurrect X (reverted by M5)."))
298
    # N1b: fork from the side commit Z1 (reachable only through H's merge parent).
299
    h2 = b.commit(dict(main_files, **{"z.txt": lines("z1", "z2", "z3 main")}), [h], message="H2")
300
    oz = b.commit(dict(files, **{"z.txt": lines("z1 ours", "z2", "z3")}), [z1], message="oz")
301
    out.append(_staged("incident/merge-base-via-merge-parent", b,
302
                       {"main": h2, "fork": z1, "steps": [{"op": "commit", "tree": oz}]}, oz, h2,
303
                       {"clean": True, "paths": {"z.txt": sha256(lines("z1 ours", "z2", "z3 main")),
304
                                                 "f.txt": sha256(F0)}},
305
                       seed, "Base is Z1 (reached via H.merge_parent); a stale base (C) makes z.txt add/add."))
306
307
    # N2: revert after a CLI parent sync (lineage recorded by the sync commit).
308
    b = RepoBuilder(workdir / "native" / "revert-after-sync", git_bin, home)
309
    c0 = b.commit({"f.txt": F0}, message="C0")
310
    f1 = b.commit({"f.txt": F0, "feat.txt": lines("feat")}, [c0], message="F1")
311
    m1 = b.commit({"f.txt": FX}, [c0], message="M1 adds X")
312
    m2 = b.commit({"f.txt": F0}, [m1], message="M2 reverts X")
313
    s = b.commit({"f.txt": FX, "feat.txt": lines("feat")}, [f1, m1], message="S sync")
314
    out.append(_staged("incident/revert-after-client-sync", b,
315
                       {"main": m2, "fork": c0, "steps": [{"op": "commit", "tree": f1},
316
                                                          {"op": "pull", "stage": m1}]}, s, m2,
317
                       {"clean": True, "paths": {"f.txt": sha256(F0), "feat.txt": sha256(lines("feat"))}},
318
                       repro, "Client pull-sync must record merge_parent=M1 so M2's revert survives."))
319
    # N2b: lineage-less sync (manual copy of main's change; the server /sync shape).
320
    s0 = b.commit({"f.txt": FX, "feat.txt": lines("feat")}, [f1], message="S0 lineage-less")
321
    out.append(_staged("incident/revert-after-lineage-less-sync", b,
322
                       {"main": m2, "fork": c0, "steps": [{"op": "commit", "tree": f1},
323
                                                          {"op": "commit", "tree": s0}]}, s0, m2,
324
                       {"clean": True}, repro,
325
                       "History without the sync's merge parent: git and Oak both resurrect X.",
326
                       intent_note=("Intent is 'revert stays reverted', but once lineage is lost no merge "
327
                                    "can recover it; hosted POST /sync producing this shape is phase-2 "
328
                                    "(REPRO.md Suspect 1).")))
329
330
    # N3: criss-cross with two best common ancestors {F, Mk}; tie-break must not resurrect K.
331
    for variant, f_when, mk_when in (("side-newer", 300, 120), ("side-older", 60, 300)):
332
        b = RepoBuilder(workdir / "native" / f"criss-cross-{variant}", git_bin, home)
333
        base = b.commit({"f.txt": F0}, message="C", when=0)
334
        mk = b.commit({"f.txt": FK}, [base], message="Mk adds K", when=mk_when)
335
        fside = b.commit({"f.txt": F0, "side.txt": lines("side")}, [base], message="F side", when=f_when)
336
        hmerge = b.commit({"f.txt": FK, "side.txt": lines("side")}, [mk, fside], message="H merges F", when=400)
337
        rev = b.commit({"f.txt": F0, "side.txt": lines("side")}, [hmerge], message="R reverts K", when=500)
338
        o1 = b.commit({"f.txt": F0, "side.txt": lines("side"), "ours.txt": lines("o")}, [fside], message="o1", when=450)
339
        s1 = b.commit({"f.txt": FK, "side.txt": lines("side"), "ours.txt": lines("o")}, [o1, mk], message="S1 sync", when=460)
340
        out.append(_staged(f"incident/criss-cross-tie-break-{variant}", b,
341
                           {"main": rev, "fork": fside, "steps": [{"op": "commit", "tree": o1},
342
                                                                  {"op": "pull", "stage": mk}]}, s1, rev,
343
                           {"clean": True, "paths": {"f.txt": sha256(F0), "ours.txt": sha256(lines("o"))}},
344
                           repro, "Best common ancestors {F, Mk}; choosing F alone resurrects K."))
345
346
    # N4: clock skew (REPRO.md s2): Y is the best CA but 24h older than X.
347
    for variant, y_when in (("skewed", -86400), ("monotone-control", 30)):
348
        b = RepoBuilder(workdir / "native" / f"clock-skew-{variant}", git_bin, home)
349
        x = b.commit({"f.txt": F0}, message="X", when=0)
350
        y = b.commit({"f.txt": FX}, [x], message="Y", when=y_when)
351
        h1 = b.commit({"f.txt": F0, "h.txt": lines("h")}, [x], message="H1", when=3600)
352
        mh = b.commit({"f.txt": FX, "h.txt": lines("h")}, [y, h1], message="M_h", when=3 * 3600)
353
        rr = b.commit({"f.txt": F0, "h.txt": lines("h")}, [mh], message="R reverts Y", when=5 * 3600)
354
        w = b.commit({"f.txt": F0, "feat.txt": lines("feat")}, [x], message="W", when=2 * 3600)
355
        a = b.commit({"f.txt": FX, "feat.txt": lines("feat")}, [w, y], message="A sync", when=4 * 3600)
356
        out.append(_staged(f"incident/clock-skew-merge-base-{variant}", b,
357
                           {"main": rr, "fork": x, "steps": [{"op": "commit", "tree": w},
358
                                                             {"op": "pull", "stage": y}]}, a, rr,
359
                           {"clean": True, "paths": {"f.txt": sha256(F0), "feat.txt": sha256(lines("feat"))}},
360
                           repro, "Best CA Y has an older timestamp than X; a time-ordered walk picks X."))
361
362
    # t6416 criss-cross + modify/delete resolved differently (staged with a scripted resolution).
363
    b = RepoBuilder(workdir / "native" / "t6416-criss-cross-modify-delete", git_bin, home)
364
    ca = b.commit({"file": lines("A"), "keep": lines("k")}, message="A")
365
    cb = b.commit({"file": lines("B"), "keep": lines("k")}, [ca], message="B")
366
    cc = b.commit({"keep": lines("k")}, [ca], message="C")
367
    cd = b.commit({"file": lines("B"), "keep": lines("k")}, [cc, cb], message="D keeps B")
368
    o1 = b.commit({"file": lines("B"), "keep": lines("k"), "ours.txt": lines("o")}, [cb], message="o1")
369
    ce = b.commit({"keep": lines("k"), "ours.txt": lines("o")}, [o1, cc], message="E deletes")
370
    out.append(_staged("git-t6416-criss-cross-modify-delete", b,
371
                       {"main": cd, "fork": cb, "steps": [{"op": "commit", "tree": o1},
372
                                                          {"op": "pull", "stage": cc, "resolve": {"file": None}}]},
373
                       ce, cd, {"clean": False},
374
                       {"id": "git-t6416 'criss-cross + modify/delete resolved differently'",
375
                        "license": "scenario idea only (source GPL-2.0-only)", "commit": GIT_TESTS_REF},
376
                       "Two histories resolved the same modify/delete differently; git reports a conflict."))
377
    return out
378
379
380
# --------------------------------------------------------------------------- generated (merge laws)
381
382
def generated(workdir: Path, git_bin: str, home: Path, count: int, seed: int) -> List[dict]:
383
    rng = random.Random(seed)
384
    out = []
385
    for n in range(count):
386
        builder = RepoBuilder(workdir / "generated" / f"g{seed}-{n:03d}", git_bin, home)
387
        base_files = {f"src/f{i}.txt": lines(*[f"f{i} line {k}" for k in range(12)]) for i in range(4)}
388
        base = builder.commit(base_files, message="base")
389
        region = {"A": range(0, 5), "B": range(7, 12)}
390
        sides = {}
391
        ops_log = {}
392
        shared_edit = rng.random() < 0.15
393
        for side in ("A", "B"):
394
            files = dict(base_files)
395
            ops = []
396
            for _ in range(rng.randint(1, 3)):
397
                op = rng.choice(["edit", "edit", "overlap", "delete", "add", "chmod", "rename"])
398
                target = rng.choice(sorted(p for p in files if p.startswith("src/")) or ["src/new.txt"])
399
                editable = target in files and not isinstance(files[target], tuple) \
400
                    and len(files[target].splitlines()) == 12
401
                if op in ("edit", "overlap") and editable:
402
                    body = files[target].decode().splitlines()
403
                    idx = 5 if op == "overlap" else rng.choice(list(region[side]))
404
                    body[idx] = body[idx] + f" [{side}{n}]"
405
                    files[target] = lines(*body)
406
                elif op == "delete" and target in files:
407
                    del files[target]
408
                elif op == "add":
409
                    files[f"src/{side.lower()}{n}-{len(ops)}.txt"] = lines(f"new {side} {n} {len(ops)}")
410
                elif op == "chmod" and target in files and not isinstance(files[target], tuple):
411
                    files[target] = ("exec", files[target])
412
                elif op == "rename" and target in files:
413
                    files[target.replace("src/", "moved/")] = files.pop(target)
414
                else:
415
                    continue
416
                ops.append(f"{op}:{target}")
417
            if shared_edit:
418
                files["shared.txt"] = lines("same change on both sides")
419
                ops.append("shared-add")
420
            sides[side] = builder.commit(files, [base], message=f"side {side}")
421
            ops_log[side] = ops
422
        out.append(symmetric(f"generated/s{seed}-{n:03d}", "generated-law",
423
                             {"id": f"seeded generator seed={seed} n={n}", "license": "suite-authored"},
424
                             builder, sides["A"], sides["B"], notes=f"ops={ops_log}"))
425
    return out
426
427
428
PHASE2_SPECS = [
429
    {"id": "phase2/concurrent-merges-one-target-M0a",
430
     "needs": "hosted merge endpoint (oakspace POST /branches/:b/merge)",
431
     "spec": "N branches each add a distinct file; concurrent merges to main. Every 200-acknowledged merge "
432
             "must be present in main's content and first-parent lineage; losers get a retryable refusal.",
433
     "source": "REPRO.md M0a"},
434
    {"id": "phase2/push-during-merge-M0b", "needs": "hosted merge endpoint",
435
     "spec": "Branch head a1 resolved; owner pushes a2 before finalize. Merge must include a2 or be refused; "
436
             "never close the branch with merge_parent=a2 while main lacks a2's change.",
437
     "source": "REPRO.md M0b"},
438
    {"id": "phase2/server-sync-lineage-less-S1", "needs": "hosted POST /branches/:b/sync",
439
     "spec": "Web 'Update' sync must record merge_parent=main head; after main reverts the synced change, "
440
             "merging the branch keeps the revert (see incident/revert-after-lineage-less-sync for the "
441
             "client-side consequence).", "source": "REPRO.md Suspect 1"},
442
    {"id": "phase2/criss-cross-client-vs-server-tie-break", "needs": "hosted merge + client prediction",
443
     "spec": "For incident/criss-cross-tie-break-* shapes, server merge result and client "
444
             "`oak merge --dry-run --json` prediction must agree and keep the revert.",
445
     "source": "REPRO.md 'Not executed: tie-break among multiple best CAs (M1)'"},
446
    {"id": "phase2/server-clock-skew-merge-base-S2", "needs": "hosted merge endpoint",
447
     "spec": "Server find_merge_base on the incident/clock-skew-merge-base-skewed shape must pick Y.",
448
     "source": "REPRO.md Suspect 2"},
449
    {"id": "phase2/dirty-tree-across-moved-branch-fb530", "needs": "client+server: branch head moved remotely",
450
     "spec": "Uncommitted edits survive `oak pull` when the branch head moved on the server; no silent discard.",
451
     "source": "PROGRAM.md coordination fb-530"},
452
]