| 1 | """Outcome classification and merge laws. Pure functions; no I/O.
|
| 2 |
|
| 3 | Classes (every case gets exactly one; nothing passes silently):
|
| 4 |
|
| 5 | - equal-to-git: same clean/conflicted status, same conflicted path set, and
|
| 6 | identical content+mode for every non-conflicted path.
|
| 7 | - differs-but-valid: differs from git, but no change from either side is lost
|
| 8 | or fabricated at any non-conflicted path (e.g. Oak conflicts where git's
|
| 9 | rename detection resolved cleanly; git conflicts where Oak's path-level merge
|
| 10 | kept both sides). Reasons say which.
|
| 11 | - oak-wrong: a non-conflicted path loses a side's change, carries content no
|
| 12 | input or valid text merge explains, a scenario's stated expectation (e.g.
|
| 13 | "revert stays reverted") is violated, or Oak errors on a valid input.
|
| 14 | - oak-unsupported: the input cannot be represented through Oak's public
|
| 15 | surface (gitlinks, multiple git merge bases or unrelated histories for the
|
| 16 | fork+squash construction), decided before Oak runs.
|
| 17 | - harness-error: the instrument failed (fixture build, oracle, shim).
|
| 18 | """
|
| 19 | from __future__ import annotations
|
| 20 |
|
| 21 | import difflib
|
| 22 | from typing import Dict, Iterable, List, Optional, Set, Tuple
|
| 23 |
|
| 24 | CLASSES = ("equal-to-git", "differs-but-valid", "oak-wrong", "oak-unsupported", "harness-error")
|
| 25 | MARKERS = (b"<<<<<<< ", b"=======", b">>>>>>> ")
|
| 26 |
|
| 27 | Value = Tuple[str, str]
|
| 28 | Tree = Dict[str, Value]
|
| 29 |
|
| 30 |
|
| 31 | def split_tree(tree: Tree, conflicted: Iterable[str]) -> Tree:
|
| 32 | conflicted = set(conflicted)
|
| 33 | return {p: v for p, v in tree.items() if p not in conflicted}
|
| 34 |
|
| 35 |
|
| 36 | def oak_conflict_type(path: str, base: Tree, ours: Tree, theirs: Tree) -> str:
|
| 37 | """Oak's per-path conflict kind, derived from the three inputs (Oak merges path-by-path)."""
|
| 38 | b, o, t = base.get(path), ours.get(path), theirs.get(path)
|
| 39 | if o is None or t is None:
|
| 40 | return "modify/delete" if b is not None else "file/directory"
|
| 41 | if b is None:
|
| 42 | return "add/add"
|
| 43 | if o[1] == t[1] and o[0] != t[0]:
|
| 44 | return "mode"
|
| 45 | return "content"
|
| 46 |
|
| 47 |
|
| 48 | def text_preserves_sides(base: bytes, ours: bytes, theirs: bytes, merged: bytes) -> bool:
|
| 49 | """Heuristic line-level check: every block a side inserted/replaced appears in the merge,
|
| 50 | and the merge carries no conflict markers. Deletions are not checked (lines repeat)."""
|
| 51 | if any(m in merged for m in MARKERS) and not any(m in x for x in (base, ours, theirs) for m in MARKERS):
|
| 52 | return False
|
| 53 | b, m = base.splitlines(), merged.splitlines()
|
| 54 | for side in (ours.splitlines(), theirs.splitlines()):
|
| 55 | for tag, _i1, _i2, j1, j2 in difflib.SequenceMatcher(None, b, side, autojunk=False).get_opcodes():
|
| 56 | if tag in ("insert", "replace") and not _contains(m, side[j1:j2]):
|
| 57 | return False
|
| 58 | return True
|
| 59 |
|
| 60 |
|
| 61 | def _contains(haystack: List[bytes], needle: List[bytes]) -> bool:
|
| 62 | if not needle:
|
| 63 | return True
|
| 64 | n = len(needle)
|
| 65 | return any(haystack[i:i + n] == needle for i in range(len(haystack) - n + 1))
|
| 66 |
|
| 67 |
|
| 68 | def _three_way_kind(b: Optional[Value], o: Optional[Value], t: Optional[Value]) -> Optional[str]:
|
| 69 | """Expected entry kind (file/exec/symlink) of a both-sides-changed path, or None when
|
| 70 | the two sides changed the kind incompatibly (a mode conflict)."""
|
| 71 | bk, ok, tk = (b and b[0]), (o and o[0]), (t and t[0])
|
| 72 | if ok == tk:
|
| 73 | return ok
|
| 74 | if ok == bk:
|
| 75 | return tk
|
| 76 | if tk == bk:
|
| 77 | return ok
|
| 78 | return None
|
| 79 |
|
| 80 |
|
| 81 | def lacks_final_newline(ev: dict) -> bool:
|
| 82 | """True when a non-empty merge input does not end in a newline (the fb-537 trigger)."""
|
| 83 | return any(x and not x.endswith(b"\n") for x in (ev.get("base"), ev.get("ours"), ev.get("theirs")))
|
| 84 |
|
| 85 |
|
| 86 | def has_new_markers(ev: dict) -> bool:
|
| 87 | inputs = (ev.get("base") or b"", ev.get("ours") or b"", ev.get("theirs") or b"")
|
| 88 | return any(m in ev.get("merged", b"") and not any(m in x for x in inputs) for m in MARKERS)
|
| 89 |
|
| 90 |
|
| 91 | def no_lost_change(base: Tree, ours: Tree, theirs: Tree, result: Tree, conflicted: Set[str],
|
| 92 | texts: Optional[Dict[str, dict]] = None) -> Dict[str, List[str]]:
|
| 93 | """Path-level law. Returns {"lost", "mode_lost", "markers", "markers_no_final_newline",
|
| 94 | "markers_symlink", "fabricated",
|
| 95 | "text_merged"}.
|
| 96 |
|
| 97 | For every non-conflicted path: a change made by exactly one side must be
|
| 98 | present; an identical change on both sides must be present; a divergent
|
| 99 | change on both sides must have been text-merged preserving both sides'
|
| 100 | content AND the 3-way-merged entry kind/mode (checked with
|
| 101 | `texts[path] = {base, ours, theirs, merged}` bytes). Merged bytes carrying
|
| 102 | conflict markers that no input had are reported as `markers`."""
|
| 103 | lost, mode_lost, markers, markers_nofinal, markers_symlink, fabricated, merged_ok = [], [], [], [], [], [], []
|
| 104 | texts = texts or {}
|
| 105 | for path in sorted(set(base) | set(ours) | set(theirs) | set(result)):
|
| 106 | if path in conflicted:
|
| 107 | continue
|
| 108 | b, o, t, r = base.get(path), ours.get(path), theirs.get(path), result.get(path)
|
| 109 | if o == t:
|
| 110 | want = o
|
| 111 | elif o == b:
|
| 112 | want = t
|
| 113 | elif t == b:
|
| 114 | want = o
|
| 115 | else:
|
| 116 | ev = texts.get(path)
|
| 117 | kind = _three_way_kind(b, o, t)
|
| 118 | if r is not None and r in (o, t):
|
| 119 | # One side taken wholesale: the other side's change is dropped. When the dropped
|
| 120 | # side changed only the kind/mode (same content as base), call it a mode loss.
|
| 121 | other = t if r == o else o
|
| 122 | same_content = o is not None and t is not None and o[1] == t[1]
|
| 123 | mode_only = other is not None and (same_content or (
|
| 124 | b is not None and other[1] == b[1] and other[0] != b[0]))
|
| 125 | (mode_lost if mode_only else lost).append(path)
|
| 126 | elif r is None:
|
| 127 | lost.append(path)
|
| 128 | elif o is not None and t is not None and o[1] == t[1] and r[1] == o[1]:
|
| 129 | # Same content both sides, kinds differ: only the mode merge is at stake.
|
| 130 | (merged_ok if kind is not None and r[0] == kind else mode_lost).append(path)
|
| 131 | elif ev is None:
|
| 132 | fabricated.append(path)
|
| 133 | elif has_new_markers(ev):
|
| 134 | symlinky = "symlink" in (o and o[0], t and t[0], r[0])
|
| 135 | if symlinky:
|
| 136 | markers_symlink.append(path)
|
| 137 | else:
|
| 138 | (markers_nofinal if lacks_final_newline(ev) else markers).append(path)
|
| 139 | elif kind is None or r[0] != kind:
|
| 140 | mode_lost.append(path) # content merged, but a side's mode/kind change dropped
|
| 141 | elif text_preserves_sides(ev.get("base") or b"", ev["ours"], ev["theirs"], ev["merged"]):
|
| 142 | merged_ok.append(path)
|
| 143 | else:
|
| 144 | lost.append(path)
|
| 145 | continue
|
| 146 | if r != want:
|
| 147 | # Absent, or another input's value (a side's change was dropped) -> lost;
|
| 148 | # a value no input has -> fabricated.
|
| 149 | (lost if r is None or r in (b, o, t) else fabricated).append(path)
|
| 150 | return {"lost": lost, "mode_lost": mode_lost, "markers": markers, "markers_no_final_newline": markers_nofinal,
|
| 151 | "markers_symlink": markers_symlink,
|
| 152 | "fabricated": fabricated,
|
| 153 | "text_merged": merged_ok}
|
| 154 |
|
| 155 |
|
| 156 | def validate_class(value: str) -> str:
|
| 157 | if value not in CLASSES:
|
| 158 | raise ValueError(f"unknown merge-suite class {value!r}; expected one of {CLASSES}")
|
| 159 | return value
|
| 160 |
|
| 161 |
|
| 162 | def _classify(git_result: Optional[dict], oak_result: Optional[dict], base: Optional[Tree], ours: Tree,
|
| 163 | theirs: Tree, *, unsupported: Optional[str] = None, harness_error: Optional[str] = None,
|
| 164 | expect: Optional[dict] = None, texts: Optional[Dict[str, dict]] = None) -> dict:
|
| 165 | """Classify one directional case. git_result: {clean, tree, conflicts{path: [types]}}.
|
| 166 | oak_result: adapter result ({clean, tree, conflicts, error, error_kind})."""
|
| 167 | if harness_error:
|
| 168 | return {"class": "harness-error", "reasons": [harness_error]}
|
| 169 | if unsupported:
|
| 170 | return {"class": "oak-unsupported", "reasons": [unsupported]}
|
| 171 | if oak_result is None or git_result is None:
|
| 172 | return {"class": "harness-error", "reasons": ["missing result"]}
|
| 173 | if oak_result.get("error_kind") == "harness":
|
| 174 | return {"class": "harness-error", "reasons": [oak_result.get("error") or "harness"]}
|
| 175 | if oak_result.get("error_kind") == "oak-setup":
|
| 176 | # A scripted setup step (import, push, staged pull) failed before the measured merge.
|
| 177 | # That is not a merge verdict and must never be absorbed by a known merge bug.
|
| 178 | return {"class": "harness-error", "reasons": ["setup_failed: " + str(oak_result.get("error"))[:300]]}
|
| 179 | if oak_result.get("error_kind") == "oak" or oak_result.get("clean") is None:
|
| 180 | reasons = ["oak_error: " + str(oak_result.get("error"))[:300]]
|
| 181 | pf = oak_result.get("post_failure") or {}
|
| 182 | if pf.get("hazard_head_moved_worktree_stale"):
|
| 183 | reasons.append("failed_pull_moved_head_worktree_stale: head %s -> %s, %d worktree path(s) "
|
| 184 | "differ from HEAD, re-pull says %s" % (
|
| 185 | str(pf.get("head_before"))[:12], str(pf.get("head_after"))[:12],
|
| 186 | pf.get("worktree_changes_vs_head", 0), pf.get("repull_parent_sync")))
|
| 187 | if pf.get("hazard_worktree_dirty_head_unmoved"):
|
| 188 | reasons.append("failed_pull_left_worktree_dirty: HEAD unchanged, %d worktree path(s) differ "
|
| 189 | "from HEAD (e.g. %s)" % (pf.get("worktree_changes_vs_head", 0),
|
| 190 | ",".join((pf.get("worktree_changes_sample") or [])[:3])))
|
| 191 | return {"class": "oak-wrong", "reasons": reasons}
|
| 192 |
|
| 193 | oak_tree: Tree = oak_result["tree"]
|
| 194 | oak_conf: Set[str] = set(oak_result.get("conflicts") or {})
|
| 195 | git_conf: Set[str] = set(git_result.get("conflicts") or {})
|
| 196 | reasons: List[str] = []
|
| 197 | # base None = no unique merge base (criss-cross): the path-level law is undefined
|
| 198 | # there and stays unmeasured (null); expectations and the git oracle still apply.
|
| 199 | law = no_lost_change(base, ours, theirs, oak_tree, oak_conf, texts) if base is not None else None
|
| 200 | evidence = {"law_no_lost_change": law}
|
| 201 | base = base or {}
|
| 202 |
|
| 203 | if expect:
|
| 204 | violations = check_expectations(expect, oak_result, oak_tree, oak_conf)
|
| 205 | if violations:
|
| 206 | label = f"[{expect['label']}]" if expect.get("label") else ""
|
| 207 | return {"class": "oak-wrong", "reasons": [f"expectation_violated{label}: " + "; ".join(violations)],
|
| 208 | "evidence": evidence}
|
| 209 | law = law or {"lost": [], "mode_lost": [], "markers": [], "markers_no_final_newline": [],
|
| 210 | "markers_symlink": [], "fabricated": [], "text_merged": []}
|
| 211 | if law["markers_no_final_newline"]:
|
| 212 | reasons.append("committed_conflict_markers_no_final_newline: "
|
| 213 | + ",".join(law["markers_no_final_newline"][:10]))
|
| 214 | if law["markers_symlink"]:
|
| 215 | reasons.append("committed_conflict_markers_symlink: " + ",".join(law["markers_symlink"][:10]))
|
| 216 | if law["markers"]:
|
| 217 | reasons.append("committed_conflict_markers: " + ",".join(law["markers"][:10]))
|
| 218 | if law["lost"]:
|
| 219 | reasons.append("lost_change: " + ",".join(law["lost"][:10]))
|
| 220 | if law["mode_lost"]:
|
| 221 | reasons.append("lost_mode_change: " + ",".join(law["mode_lost"][:10]))
|
| 222 | if law["fabricated"]:
|
| 223 | reasons.append("fabricated_content: " + ",".join(law["fabricated"][:10]))
|
| 224 | if reasons:
|
| 225 | return {"class": "oak-wrong", "reasons": reasons, "evidence": evidence}
|
| 226 |
|
| 227 | git_clean_part = split_tree(git_result["tree"], git_conf)
|
| 228 | oak_clean_part = split_tree(oak_tree, oak_conf)
|
| 229 | if (bool(oak_result["clean"]) == bool(git_result["clean"]) and oak_conf == git_conf
|
| 230 | and oak_clean_part == git_clean_part):
|
| 231 | type_diff = conflict_type_differences(git_result.get("conflicts") or {}, oak_conf, base, ours, theirs)
|
| 232 | out = {"class": "equal-to-git", "reasons": [], "evidence": evidence}
|
| 233 | if type_diff:
|
| 234 | out["notes"] = ["conflict_type_differs: " + ", ".join(type_diff[:10])]
|
| 235 | return out
|
| 236 |
|
| 237 | false_conf = sorted(oak_conf - git_conf)
|
| 238 | missed = sorted(git_conf - oak_conf)
|
| 239 | if false_conf:
|
| 240 | reasons.append("oak_conflicts_where_git_clean: " + ",".join(false_conf[:10]))
|
| 241 | if missed:
|
| 242 | reasons.append("oak_clean_where_git_conflicts: " + ",".join(missed[:10]))
|
| 243 | shared = set(git_clean_part) & set(oak_clean_part)
|
| 244 | only_git = sorted(p for p in set(git_clean_part) - set(oak_clean_part) if p not in oak_conf)
|
| 245 | only_oak = sorted(p for p in set(oak_clean_part) - set(git_clean_part) if p not in git_conf)
|
| 246 | changed = sorted(p for p in shared if git_clean_part[p] != oak_clean_part[p])
|
| 247 | if only_git or only_oak or changed:
|
| 248 | reasons.append("tree_differs(all side changes preserved): git_only=%s oak_only=%s content=%s"
|
| 249 | % (only_git[:6], only_oak[:6], changed[:6]))
|
| 250 | if law["text_merged"] and changed:
|
| 251 | reasons.append("text_merge_algorithm_differs: " + ",".join(sorted(set(changed) & set(law["text_merged"]))[:6]))
|
| 252 | if not reasons:
|
| 253 | reasons.append("status_differs")
|
| 254 | evidence.update({"false_conflict_paths": false_conf, "missed_conflict_paths": missed})
|
| 255 | return {"class": "differs-but-valid", "reasons": reasons, "evidence": evidence}
|
| 256 |
|
| 257 |
|
| 258 | def conflict_type_differences(git_conflicts: Dict[str, List[str]], oak_conf: Set[str],
|
| 259 | base: Tree, ours: Tree, theirs: Tree) -> List[str]:
|
| 260 | out = []
|
| 261 | for path in sorted(oak_conf & set(git_conflicts)):
|
| 262 | oak_kind = oak_conflict_type(path, base, ours, theirs)
|
| 263 | git_kinds = git_conflicts[path]
|
| 264 | if git_kinds and oak_kind not in git_kinds and not (oak_kind == "content" and "binary" in git_kinds):
|
| 265 | out.append(f"{path}: git={'/'.join(git_kinds)} oak={oak_kind}")
|
| 266 | return out
|
| 267 |
|
| 268 |
|
| 269 | def check_expectations(expect: dict, oak_result: dict, tree: Tree, conflicts: Set[str]) -> List[str]:
|
| 270 | """expect = {"clean": bool?, "paths": {path: sha256 | None (absent)}, "conflicts": [paths]?}."""
|
| 271 | out = []
|
| 272 | if "clean" in expect and bool(oak_result.get("clean")) != expect["clean"]:
|
| 273 | out.append(f"clean={oak_result.get('clean')} expected {expect['clean']}")
|
| 274 | for path, digest in (expect.get("paths") or {}).items():
|
| 275 | got = tree.get(path)
|
| 276 | if digest is None and got is not None and path not in conflicts:
|
| 277 | out.append(f"{path} present, expected absent")
|
| 278 | elif digest is not None and (got is None or got[1] != digest):
|
| 279 | out.append(f"{path} content {'absent' if got is None else got[1][:12]} expected {digest[:12]}")
|
| 280 | if "conflicts" in expect and sorted(conflicts) != sorted(expect["conflicts"]):
|
| 281 | out.append(f"conflicts={sorted(conflicts)} expected {sorted(expect['conflicts'])}")
|
| 282 | return out
|
| 283 |
|
| 284 |
|
| 285 | def symmetry(forward: dict, reverse: dict) -> Optional[dict]:
|
| 286 | """Merge-symmetry law over two directional Oak results (None if either is unusable).
|
| 287 |
|
| 288 | merge(A,B) ≅ merge(B,A): same clean flag, same conflicted path set and the
|
| 289 | same non-conflicted content. Conflicted files are excluded (side labels and
|
| 290 | marker order legitimately differ)."""
|
| 291 | if not forward or not reverse or forward.get("clean") is None or reverse.get("clean") is None:
|
| 292 | return None
|
| 293 | fc, rc = set(forward.get("conflicts") or {}), set(reverse.get("conflicts") or {})
|
| 294 | ft, rt = split_tree(forward["tree"], fc), split_tree(reverse["tree"], rc)
|
| 295 | diffs = sorted(p for p in set(ft) | set(rt) if ft.get(p) != rt.get(p) and p not in fc | rc)
|
| 296 | ok = forward["clean"] == reverse["clean"] and fc == rc and not diffs
|
| 297 | return {"holds": ok, "conflict_sets_equal": fc == rc, "clean_equal": forward["clean"] == reverse["clean"],
|
| 298 | "content_diff_paths": diffs[:20]}
|
| 299 |
|
| 300 |
|
| 301 | def idempotence_holds(oak_result: dict) -> Optional[bool]:
|
| 302 | idem = oak_result.get("idempotence") if oak_result else None
|
| 303 | if not idem:
|
| 304 | return None
|
| 305 | return (idem.get("returncode") == 0 and idem.get("head_unchanged") is True
|
| 306 | and idem.get("tree_unchanged") is True)
|
| 307 |
|
| 308 |
|
| 309 | def classify(*args, **kwargs) -> dict:
|
| 310 | """Classify one directional case (see _classify); the class is always validated."""
|
| 311 | verdict = _classify(*args, **kwargs)
|
| 312 | validate_class(verdict["class"])
|
| 313 | return verdict
|