| 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 | ]
|