Viewing files at commit 19b295c14414Commit detailsView latest

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