diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index 0f2710f279..df3089bab4 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -68,6 +68,63 @@ son plancher, ce qui est le comportement voulu -- la tete qui va etre mergee n'a jamais ete observee 2 h par la CI avant le rebase. +#16149 -- le rafraichissement de base ne re-arme plus le plancher qu'il franchit +-------------------------------------------------------------------------------- + +`gh pr update-branch` est le seul remede a un rouge perime (un `gh run rerun` +rejoue la base gelee d'origine), mais il cree un commit de fusion qui +rafraichit la date de committer : le remede re-armait les 120 min qu'il sert +a franchir -- une taxe de 2 h par reparation, sur un commit sans aucun +contenu d'auteur (son delta appartient a `main`, deja gate par ses propres +gardes). Le plancher se mesure desormais sur le DERNIER COMMIT QUI MODIFIE +LE COTE PR. Un rebase, lui, reecrit les commits d'auteur : single-parent, il +reste mesure -- le comportement voulu ci-dessus. Une fusion dont le second +parent n'est PAS sur la base (l'auteur incorpore sa propre sous-branche) +introduit du contenu d'auteur : elle reste mesuree aussi. Une filiation +illisible ne vaut pas reconnaissance de rafraichissement : la fusion se +mesure alors elle-meme (comportement d'avant #16149 -- plus strict, jamais +plus lache). + +CR ai-01 2026-09-16 -- la forme des parents ne prouve pas l'absence de contenu +----------------------------------------------------------------------------- + +Contre-exemple exact-tree (review CHANGES_REQUESTED sur la tete b722ae246f) : +un `git merge main` MANUEL avec resolution substantielle de conflit a +exactement la forme de parents d'un update-branch (second parent ancetre de +la base) tout en ecrivant du contenu d'auteur frais dans l'arbre. Remonter +sur la seule forme des parents exemptait a tort cette resolution : le +plancher etait mesure sur le vieux premier parent alors que la tete portait +du contenu jamais observe par la CI. + +L'exemption exige desormais une PREUVE d'absence de contenu d'auteur : +l'arbre du commit de fusion doit etre IDENTIQUE a l'auto-merge de ses deux +parents, verifie par `git merge-tree --write-tree ` (Git >= 2.38) +apres fetch borne des parents manquants. Un auto-merge impossible (conflits +-- sortie non nulle de merge-tree) prouve qu'un VRAI merge ne peut exister +qu'avec une resolution d'auteur : jamais exemptee. Une preuve indisponible +(git absent, fetch muet, sortie illisible) ne vaut PAS exemption : la fusion +se mesure elle-meme -- fail-closed, exactement comme une filiation +illisible. Le prix assume : un update-branch serveur dont la preuve echoue +pour raison d'infrastructure re-arme le plancher (2 h) ; c'est le trade-off +d'une frontiere de securite -- on ne franchit jamais sur une absence de +preuve. + +CR ai-01 2026-09-16 19:10Z -- la preuve doit etre ATTEIGNABLE dans le gate +--------------------------------------------------------------------------- + +`pr-gate.yml` tourne sur un checkout `actions/checkout@v4` SANS fetch-depth +(shallow depth 1) et sparse. Dans cette topologie, chaque parent est ramene +en `--depth=1` : les deux parents n'ont aucun historique commun, `merge-base` +echoue, et `git merge-tree --write-tree` refuse de calculer ("unrelated +histories") -- la preuve d'equivalence etait INATTENABLE et meme un +update-branch serveur LEGITIME se re-armait 120 min. Le repair : avant +merge-tree, on approfondit le checkout par palliers bornes +(`git fetch --deepen=N`, cap `_DEEPEN_MAX_DEPTH`) jusqu'a rendre le +merge-base calculable. Au-dela du cap, ou dans un depot non shallow (des +historiques veritablement sans relation), la preuve reste indisponible -> +fail-closed : l'exemption ne franchit jamais sur une absence de preuve +(inchange). + Derogation ---------- @@ -190,11 +247,14 @@ def _gh_json(path: str) -> object: raise DwellError("reponse non-JSON de gh api {}".format(path)) from exc -def head_committed_at(repo: str, sha: str, fetch=_gh_json) -> datetime: - """Date de COMMITTER de `sha`. Leve `DwellError` si elle est illisible.""" +def _commit_payload(repo: str, sha: str, fetch=_gh_json) -> dict: payload = fetch("repos/{}/commits/{}".format(repo, sha)) if not isinstance(payload, dict): raise DwellError("payload commit inattendu pour {}".format(sha[:12])) + return payload + + +def _committer_date(payload: dict, sha: str) -> datetime: committer = ((payload.get("commit") or {}).get("committer") or {}) date = committer.get("date") if not date: @@ -202,16 +262,214 @@ def head_committed_at(repo: str, sha: str, fetch=_gh_json) -> datetime: return parse_iso8601(date) -def is_waived(repo: str, pr_number: int, fetch=_gh_json) -> bool: - """La PR porte-t-elle `merge-dwell-waived` ? +def head_committed_at(repo: str, sha: str, fetch=_gh_json) -> datetime: + """Date de COMMITTER de `sha`. Leve `DwellError` si elle est illisible.""" + return _committer_date(_commit_payload(repo, sha, fetch), sha) + + +def _second_parent_is_base_ancestor( + repo: str, parent_sha: str, base_sha: str, fetch=_gh_json +) -> bool: + """#16149 : le second parent du commit de fusion est-il un ancetre de la + base ? C'est la signature d'un rafraichissement de base (`gh pr + update-branch` comme `git merge main` manuel). L'egalite directe evite + l'appel compare ; sinon `compare/{base}...{parent}` rend "behind" quand + parent est un ancetre de base. Une lecture illisible ne vaut PAS + reconnaissance : False, la fusion se mesure alors elle-meme (comportement + d'avant #16149 -- plus strict, jamais plus lache).""" + if parent_sha == base_sha: + return True + try: + cmp = fetch( + "repos/{}/compare/{}...{}".format(repo, base_sha, parent_sha) + ) + except DwellError: + return False + if not isinstance(cmp, dict): + return False + return cmp.get("status") in ("behind", "identical") + + +def _default_run_git(args): + """Execute `git ` dans le depot courant (le gate tourne a la racine + du checkout). Renvoie (returncode, stdout) ; les erreurs d'execution + remontent a l'appelant, qui les traite en preuve indisponible.""" + completed = subprocess.run( + ["git"] + list(args), + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=False, + ) + return completed.returncode, completed.stdout - Un echec de lecture n'est PAS une derogation : il remonte en `DwellError` - et le gate refuse (rule 1). Lire « pas de label » d'une API muette est - exactement le zero propre que le harnais interdit de croire. - """ + +def _ensure_commit_present(sha, run_git): + """Best effort : ramener localement un parent absent avant merge-tree. + Le checkout du gate porte le cote PR ; la base, elle, peut manquer. Un + echec ici n'est PAS fatal : merge-tree echouera ensuite et la fusion se + mesurera elle-meme (fail-closed, cf. CR 2026-09-16). L'approfondissement + qui rendra le merge-base calculable est gere par `_deepen_until_merge_base`.""" + try: + rc, _ = run_git(["cat-file", "-e", sha + "^{commit}"]) + if rc == 0: + return + run_git(["fetch", "--depth=1", "--quiet", "origin", sha]) + except (OSError, subprocess.SubprocessError): + pass # best effort : merge-tree echouera, la fusion se mesurera + + +#: Bornes de l'approfondissement shallow (CR ai-01 2026-09-16 19:10Z). Le +#: checkout du gate est shallow (actions/checkout@v4 sans fetch-depth, depth +#: 1) : deux parents ramenes en --depth=1 n'ont aucun historique commun, et +#: merge-tree refuse alors de calculer -- le cas legitime update-branch se +#: re-armait. On approfondit par palliers croissants jusqu'au merge-base ; +#: au-dela de la borne, la preuve reste indisponible -> fail-closed. +_DEEPEN_START_DEPTH = 64 +_DEEPEN_FACTOR = 2 +_DEEPEN_MAX_DEPTH = 1024 + + +def _merge_base_available(first, second, run_git): + """Le merge-base des deux parents est-il calculable localement ? + Une execution defaillante vaut indisponible (False), jamais acquise.""" + try: + rc, _ = run_git(["merge-base", first, second]) + return rc == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def _is_shallow_repository(run_git): + """Le checkout courant est-il shallow ? `git fetch --deepen` y est + refuse : un merge-base introuvable dans un depot COMPLET est un + veritable unrelated-histories, qui reste fail-closed sans aucun fetch.""" + try: + rc, out = run_git(["rev-parse", "--is-shallow-repository"]) + return rc == 0 and (out or "").strip() == "true" + except (OSError, subprocess.SubprocessError): + return False + + +def _deepen_until_merge_base(first, second, run_git): + """Best effort borne : rendre le merge-base de deux parents calculable. + + `git fetch --deepen=N` approfondit TOUTES les frontieres shallow du + checkout (y compris les parents ramenes par sha en --depth=1) ; le + merge-base est re-teste apres chaque pallier. Echec ou cap atteint : + False -- merge-tree echouera ensuite et la fusion se mesurera elle-meme + (fail-closed, cf. CR 2026-09-16). Ne leve jamais.""" + if not _is_shallow_repository(run_git): + return False + depth = _DEEPEN_START_DEPTH + while depth <= _DEEPEN_MAX_DEPTH: + try: + rc, _ = run_git( + ["fetch", "--deepen={}".format(depth), "--quiet", "origin"] + ) + if rc == 0 and _merge_base_available(first, second, run_git): + return True + except (OSError, subprocess.SubprocessError): + return False + depth *= _DEEPEN_FACTOR + return False + + +def _auto_merge_tree(first, second, run_git): + """Tree OID de l'auto-merge de deux parents, ou None si non calculable. + + `git merge-tree --write-tree` (Git >= 2.38) ecrit l'arbre du merge + automatique SANS toucher a l'index ni au worktree, et sort non nul en + cas de conflits -- un merge reel n'existe alors qu'avec une resolution + d'auteur : jamais content-free. Dans le checkout shallow du gate, les + parents sont d'abord approfondis par palliers bornes jusqu'a rendre le + merge-base calculable (CR ai-01 2026-09-16 19:10Z). Toute execution + defaillante (git absent, OSError, sortie vide) vaut preuve indisponible : + None, fail-closed.""" + try: + if not _merge_base_available(first, second, run_git): + _deepen_until_merge_base(first, second, run_git) + rc, out = run_git(["merge-tree", "--write-tree", first, second]) + except (OSError, subprocess.SubprocessError): + return None + if rc != 0: + return None + for line in (out or "").splitlines(): + oid = line.strip() + if oid: + return oid + return None + + +def _tree_sha(payload): + commit = payload.get("commit") or {} + tree = commit.get("tree") or {} + return tree.get("sha") if isinstance(tree, dict) else None + + +#: Borne de la remontee first-parent : au-dela, l'etat est pathologique (une +#: PR accumule rarement 50 rafraichissements de base non rebases) et on +#: refuse plutot que de mesurer silencieusement un commit arbitraire. +_MAX_WALK = 50 + + +def last_authoritative_committed_at( + repo: str, + sha: str, + base_sha: str, + fetch=_gh_json, + run_git=None, +) -> datetime: + """#16149 : date de COMMITTER du dernier commit qui modifie le cote PR. + + Remonte la chaine first-parent au-dela des fusions de rafraichissement + de base PROUVEES content-free : deux parents, le SECOND ancetre de la + base, ET l'arbre du commit identique a l'auto-merge des parents (CR + ai-01 2026-09-16 : la forme des parents seule n'exempte pas une + resolution manuelle de conflit). Le rebase, la fusion d'une sous-branche + propre, une resolution d'auteur et toute fusion dont la preuve + d'equivalence est indisponible restent mesurees -- fail-closed.""" + if run_git is None: + run_git = _default_run_git + current = sha + for _ in range(_MAX_WALK): + payload = _commit_payload(repo, current, fetch) + parents = payload.get("parents") or [] + if len(parents) == 2: + second = (parents[1] or {}).get("sha") or "" + if second and _second_parent_is_base_ancestor( + repo, second, base_sha, fetch + ): + first = (parents[0] or {}).get("sha") + if not first: + raise DwellError( + "fusion sans premier parent sur {}".format(current[:12]) + ) + # CR 2026-09-16 : preuve d'absence de contenu d'auteur. + # L'arbre du merge doit etre exactement l'auto-merge ; sinon + # (resolution substantif, conflits, preuve muette) la fusion + # se mesure elle-meme. + _ensure_commit_present(first, run_git) + _ensure_commit_present(second, run_git) + auto_tree = _auto_merge_tree(first, second, run_git) + merge_tree = _tree_sha(payload) + if auto_tree and merge_tree and auto_tree == merge_tree: + current = first + continue + return _committer_date(payload, current) + return _committer_date(payload, current) + raise DwellError( + "chaine first-parent de plus de {} fusions de base depuis {} " + "-- etat pathologique, refus".format(_MAX_WALK, sha[:12]) + ) + + +def _pr_payload(repo: str, pr_number: int, fetch=_gh_json) -> dict: payload = fetch("repos/{}/pulls/{}".format(repo, pr_number)) if not isinstance(payload, dict): raise DwellError("payload PR inattendu pour #{}".format(pr_number)) + return payload + + +def _labels_carry_waiver(payload: dict) -> bool: names = { (label or {}).get("name", "") for label in (payload.get("labels") or []) @@ -220,6 +478,16 @@ def is_waived(repo: str, pr_number: int, fetch=_gh_json) -> bool: return WAIVER_LABEL in names +def is_waived(repo: str, pr_number: int, fetch=_gh_json) -> bool: + """La PR porte-t-elle `merge-dwell-waived` ? + + Un echec de lecture n'est PAS une derogation : il remonte en `DwellError` + et le gate refuse (rule 1). Lire « pas de label » d'une API muette est + exactement le zero propre que le harnais interdit de croire. + """ + return _labels_carry_waiver(_pr_payload(repo, pr_number, fetch)) + + def check( repo: str, sha: str, @@ -227,6 +495,7 @@ def check( dwell_min: float = DEFAULT_DWELL_MIN, now: "datetime | None" = None, fetch=_gh_json, + run_git=None, ) -> tuple[bool, str]: """Verdict reseau complet. Renvoie `(ok, message)`. @@ -238,8 +507,14 @@ def check( """ if dwell_min <= 0 or pr_number is None: return True, "dwell non applicable (hors contexte de PR ou desactive)" - waived = is_waived(repo, pr_number, fetch=fetch) - committed = head_committed_at(repo, sha, fetch=fetch) + pr = _pr_payload(repo, pr_number, fetch=fetch) + waived = _labels_carry_waiver(pr) + base_sha = ((pr.get("base") or {}).get("sha") or "") + if not base_sha: + raise DwellError("pas de base.sha sur la PR #{}".format(pr_number)) + committed = last_authoritative_committed_at( + repo, sha, base_sha, fetch=fetch, run_git=run_git + ) ok, _remaining, message = evaluate( committed, now or datetime.now(timezone.utc), dwell_min, waived ) diff --git a/scripts/tests/test_merge_dwell.py b/scripts/tests/test_merge_dwell.py index 5beaf55a19..16d54a9bbc 100644 --- a/scripts/tests/test_merge_dwell.py +++ b/scripts/tests/test_merge_dwell.py @@ -18,6 +18,9 @@ Run: python -m pytest scripts/tests/test_merge_dwell.py """ +import os +import shutil +import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path @@ -151,7 +154,7 @@ def boom(path): # le reseau ne doit meme pas etre touche def test_check_bout_en_bout_refuse_une_tete_jeune(): def fetch(path): if path.startswith("repos/o/r/pulls/"): - return {"labels": []} + return {"labels": [], "base": {"sha": "ba5e0000"}} return {"commit": {"committer": {"date": "2026-09-07T11:55:00Z"}}} ok, msg = merge_dwell.check("o/r", "abc", 42, 120.0, now=NOW, fetch=fetch) @@ -162,7 +165,7 @@ def fetch(path): def test_check_bout_en_bout_accepte_une_tete_agee(): def fetch(path): if path.startswith("repos/o/r/pulls/"): - return {"labels": []} + return {"labels": [], "base": {"sha": "ba5e0000"}} return {"commit": {"committer": {"date": "2026-09-07T09:00:00Z"}}} ok, msg = merge_dwell.check("o/r", "abc", 42, 120.0, now=NOW, fetch=fetch) @@ -170,6 +173,271 @@ def fetch(path): assert "dwell ecoule" in msg +# --- 5. #16149 -- le rafraichissement de base ne re-arme pas le plancher ---- + +def _commit(sha, date, parents, tree=None): + payload = { + "commit": {"committer": {"date": date}}, + "parents": [{"sha": p} for p in parents], + } + if tree is not None: + payload["commit"]["tree"] = {"sha": tree} + return payload + + +def _git_proving(auto_tree): + """Fake run_git dont merge-tree PROUVE l'equivalence : l'auto-merge des + parents donne exactement `auto_tree` (les objets sont presents, donc le + merge-base est calculable).""" + def run_git(args): + if args[:2] == ["cat-file", "-e"]: + return 0, "" + if args[0] == "fetch": + return 0, "" + if args[0] == "merge-base": + return 0, "b0" + if args[:2] == ["merge-tree", "--write-tree"]: + return 0, auto_tree + "\n" + raise AssertionError("git inattendu: " + " ".join(args)) + return run_git + + +def _git_conflicting(): + """Fake run_git dont merge-tree rend non nul : l'auto-merge CONFLIT -- un + merge reel n'existe qu'avec une resolution d'auteur.""" + def run_git(args): + if args[:2] == ["cat-file", "-e"]: + return 0, "" + if args[0] == "fetch": + return 0, "" + if args[0] == "merge-base": + return 0, "b0" + if args[:2] == ["merge-tree", "--write-tree"]: + return 1, "" + raise AssertionError("git inattendu: " + " ".join(args)) + return run_git + + +def _git_absent(): + """Fake run_git dont l'execution meme echoue (git introuvable) : preuve + indisponible -> fail-closed, jamais d'exemption.""" + def run_git(args): + raise OSError("git introuvable") + return run_git + + +def test_16149_update_branch_ne_re_armed_pas_le_plancher(): + """Critere d'acceptation de l'issue : commit d'auteur T-180 min, puis + fusion de rafraichissement de base T-1 min (second parent = la base + elle-meme, arbre PROUVE identique a l'auto-merge) -- le plancher se + mesure sur l'auteur, donc ecoule.""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit( + "m3rg3", "2026-09-07T11:59:00Z", ["auc0", "ba5e"], tree="7ee0" + ) + if path == "repos/o/r/commits/auc0": + return _commit("auc0", "2026-09-07T09:00:00Z", ["r00t"]) + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, fetch=fetch, + run_git=_git_proving("7ee0"), + ) + assert ok is True + assert "2026-09-07T09:00:00Z" in msg, ( + "le plancher doit se mesurer sur le commit d'auteur, pas la fusion" + ) + + +def test_16149_second_parent_via_compare_behind(): + """Le second parent ancetre DE LOIN de la base (pas d'egalite directe) : + l'appel compare rend "behind" et la fusion est franchie aussi.""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit( + "m3rg3", "2026-09-07T11:59:00Z", ["auc0", "0ld"], tree="7ee0" + ) + if path == "repos/o/r/commits/auc0": + return _commit("auc0", "2026-09-07T08:00:00Z", ["r00t"]) + if path == "repos/o/r/compare/ba5e...0ld": + return {"status": "behind", "behind_by": 3} + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, fetch=fetch, + run_git=_git_proving("7ee0"), + ) + assert ok is True + assert "2026-09-07T08:00:00Z" in msg + + +def test_16149_fusion_de_sous_branche_auteur_re_arme(): + """Controle FN : une fusion dont le second parent n'est PAS un ancetre + de la base (l'auteur incorpore sa propre sous-branche) introduit du + contenu d'auteur -- le plancher se re-arme sur elle.""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit("m3rg3", "2026-09-07T11:59:00Z", ["auc0", "feat"]) + if path == "repos/o/r/compare/ba5e...feat": + return {"status": "diverged"} + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check("o/r", "m3rg3", 42, 120.0, now=NOW, fetch=fetch) + assert ok is False + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_16149_filiation_illisible_reste_stricte(): + """Controle FN : un compare muet ne vaut pas reconnaissance de + rafraichissement -- la fusion se mesure elle-meme (comportement + d'avant #16149, plus strict jamais plus lache).""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit("m3rg3", "2026-09-07T11:59:00Z", ["auc0", "0ld"]) + if path.startswith("repos/o/r/compare/"): + raise merge_dwell.DwellError("compare muet") + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check("o/r", "m3rg3", 42, 120.0, now=NOW, fetch=fetch) + assert ok is False + + +def test_16149_deux_fusions_empilees_sont_toutes_deux_franchies(): + """Deux update-branch successifs (chacun PROUVE content-free) : la + remontee franchit les deux et mesure le commit d'auteur d'origine.""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit( + "m3rg3", "2026-09-07T11:59:00Z", ["m3rg2", "ba5e"], tree="7ee0" + ) + if path == "repos/o/r/commits/m3rg2": + return _commit( + "m3rg2", "2026-09-07T10:59:00Z", ["auc0", "0ld"], tree="7ee0" + ) + if path == "repos/o/r/commits/auc0": + return _commit("auc0", "2026-09-07T07:00:00Z", ["r00t"]) + if path == "repos/o/r/compare/ba5e...0ld": + return {"status": "behind", "behind_by": 2} + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, fetch=fetch, + run_git=_git_proving("7ee0"), + ) + assert ok is True + assert "2026-09-07T07:00:00Z" in msg + + +def test_16149_payload_pr_sans_base_leve(): + """Un payload PR sans base.sha est un etat illisible : DwellError (rule 1 + -- on refuse, on ne mesure pas sur une supposition).""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": []} + raise AssertionError("chemin inattendu: " + path) + + with pytest.raises(merge_dwell.DwellError): + merge_dwell.check("o/r", "abc", 42, 120.0, now=NOW, fetch=fetch) + + +# --- CR ai-01 2026-09-16 : la forme des parents ne prouve pas l'absence de +# contenu d'auteur ; l'exemption exige une equivalence d'arbre PROUVEE ---- + + +def _cr_payloads(tree): + """Le contre-exemple exact de la review : head merge frais a 11:59, + parents [author-old, base], auteur ancien a 08:00 -- NOW = 12:00, donc + mesurer l'auteur rend le plancher ecoule (240 min), mesurer la fusion + le re-arme (1 min).""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": "ba5e"}} + if path == "repos/o/r/commits/m3rg3": + return _commit( + "m3rg3", "2026-09-07T11:59:00Z", ["auc0", "ba5e"], tree=tree + ) + if path == "repos/o/r/commits/auc0": + return _commit("auc0", "2026-09-07T08:00:00Z", ["r00t"]) + raise AssertionError("chemin inattendu: " + path) + return fetch + + +def test_cr_20260916_resolution_manuelle_substantive_re_arme(): + """LE faux negatif de la review : merge manuel de la base porteur d'une + resolution de conflit substantielle -- l'arbre du commit DIFFERE de + l'auto-merge (res0 != auto). La fusion reste authoritative : le plancher + se re-arme sur 11:59, l'ok=True d'avant le repair est impossible.""" + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="res0"), run_git=_git_proving("a010"), + ) + assert ok is False, ( + "une resolution d'auteur substantive dans un merge de base doit " + "re-armer le plancher -- c'est le bypass exact de la review" + ) + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_cr_20260916_auto_merge_conflitant_re_arme(): + """merge-tree rend non nul : l'auto-merge CONFLIT, donc tout merge reel + de ces parents ne peut exister qu'avec une resolution d'auteur -- meme + si l'arbre du commit etait par hasard celui d'un des parents, aucune + equivalence n'est prouvable : la fusion se mesure.""" + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="a010"), run_git=_git_conflicting(), + ) + assert ok is False + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_cr_20260916_preuve_indisponible_fail_closed(): + """git introuvable : la preuve d'equivalence est indisponible, PAS + acquise. Fail-closed -- la fusion se mesure elle-meme (le trade-off + assume de la CR : jamais d'exemption sur une absence de preuve).""" + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="a010"), run_git=_git_absent(), + ) + assert ok is False + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_cr_20260916_payload_sans_tree_fail_closed(): + """Le payload API ne porte pas commit.tree.sha : la comparaison + d'equivalence est incomplete -- pas d'exemption sur une moitie de + preuve.""" + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree=None), run_git=_git_proving("a010"), + ) + assert ok is False + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_cr_20260916_vrai_update_branch_equivalent_passe_toujours(): + """Controle FP : un update-branch REEL (arbre du commit == auto-merge) + franchit toujours la remontee -- le repair ne retablit pas la taxe de + 2 h pour le cas legitime #16149.""" + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="a010"), run_git=_git_proving("a010"), + ) + assert ok is True + assert "2026-09-07T08:00:00Z" in msg + + def test_le_verdict_ne_dit_jamais_d_attendre(): """#15726 : le message d'un plancher non ecoule ne doit pas fabriquer de l'attente. @@ -191,3 +459,223 @@ def test_le_verdict_ne_dit_jamais_d_attendre(): # Et ce qui doit y etre : la lane continue, et peut rejouer elle-meme. assert "NE PAS ATTENDRE" in msg assert "rerun" in msg + + +# --- CR ai-01 2026-09-16 19:10Z : la preuve doit etre ATTEIGNABLE dans le +# checkout shallow du gate (integration depot reel, chemin de production +# `_default_run_git`) ------------------------------------------------------ + + +def _git_version_supported(): + """`git merge-tree --write-tree` demande Git >= 2.38 (CR 2026-09-16).""" + try: + out = subprocess.run( + ["git", "--version"], capture_output=True, text=True + ).stdout + except OSError: + return False + parts = out.strip().split() + if len(parts) < 3 or parts[0] != "git": + return False + try: + major, minor = (int(x) for x in parts[2].split(".")[:2]) + except ValueError: + return False + return (major, minor) >= (2, 38) + + +def _git(cwd, *args, env=None): + e = { + "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "test@local", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "test@local", + } + if env: + e.update(env) + return subprocess.run( + ["git", *args], cwd=str(cwd), env={**os.environ, **e}, + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + + +def _git_ok(result, what): + assert result.returncode == 0, "{} a echoue:\n{}\n{}".format( + what, result.stdout, result.stderr + ) + + +def _build_gate_topology(tmp_path, substantive=False): + """Mini-depot REEL simulant la topologie du gate (`pr-gate.yml` : + `actions/checkout@v4` sans fetch-depth -> checkout shallow depth 1). Le + clone gate ne porte que la tete de PR ; les parents du merge de base en + sont absents. Renvoie (chemin du clone, info) avec les sha/arbres REELS. + + `substantive=False` : update-branch legitime (arbre == auto-merge). + `substantive=True` : merge --no-commit + contenu d'auteur ajoute (arbre + != auto-merge) -- la resolution manuelle substantive de la CR.""" + origin = tmp_path / "origin" + uri = origin.as_uri() + _git_ok(_git(tmp_path, "init", "--bare", "-b", "main", str(origin)), + "init origin") + work = tmp_path / "work" + _git_ok(_git(tmp_path, "clone", uri, str(work)), "clone work") + _git(work, "config", "user.name", "test") + _git(work, "config", "user.email", "test@local") + _git_ok(_git(work, "commit", "--allow-empty", "-m", "b0"), "b0") + root_sha = _git(work, "rev-parse", "HEAD").stdout.strip() + for i in range(40): + (work / "g{:02d}".format(i)).write_text("g", encoding="utf-8") + _git(work, "add", "-A") + _git_ok(_git(work, "commit", "-m", "base{}".format(i)), + "base{}".format(i)) + base_tip = _git(work, "rev-parse", "main").stdout.strip() + + author_env = {"GIT_AUTHOR_DATE": "2026-09-07T08:00:00Z", + "GIT_COMMITTER_DATE": "2026-09-07T08:00:00Z"} + _git_ok(_git(work, "checkout", "-b", "pr", root_sha), "branch pr") + (work / "a").write_text("a1", encoding="utf-8") + _git(work, "add", "-A") + _git_ok(_git(work, "commit", "-m", "a1", env=author_env), "a1") + (work / "b").write_text("b1", encoding="utf-8") + _git(work, "add", "-A") + _git_ok(_git(work, "commit", "-m", "a2", env=author_env), "a2") + author_tip = _git(work, "rev-parse", "HEAD").stdout.strip() + + merge_env = {"GIT_AUTHOR_DATE": "2026-09-07T11:59:00Z", + "GIT_COMMITTER_DATE": "2026-09-07T11:59:00Z"} + if substantive: + _git_ok(_git(work, "merge", "--no-commit", "main"), "merge no-commit") + with (work / "a").open("a", encoding="utf-8") as fh: + fh.write("\nresolution substantive d'auteur") + _git(work, "add", "-A") + _git_ok(_git(work, "commit", "-m", "merge resolution", env=merge_env), + "commit resolution") + else: + _git_ok(_git(work, "merge", "-m", "update-branch merge", "main", + env=merge_env), "merge update-branch") + merge_sha = _git(work, "rev-parse", "HEAD").stdout.strip() + merge_tree = _git(work, "rev-parse", merge_sha + "^{tree}").stdout.strip() + + _git_ok(_git(work, "push", "origin", "main"), "push main") + _git_ok(_git(work, "push", "origin", "pr"), "push pr") + + gate = tmp_path / "gate" + _git_ok(_git(tmp_path, "clone", "--depth=1", "--branch", "pr", + uri, str(gate)), "clone gate shallow") + return gate, { + "merge_sha": merge_sha, "merge_tree": merge_tree, + "merge_date": "2026-09-07T11:59:00Z", "author_tip": author_tip, + "author_date": "2026-09-07T08:00:00Z", "base_tip": base_tip, + "root_sha": root_sha, + } + + +def _shallow_fetch_payloads(info): + """Payloads API coherents avec le depot REEL construit : les sha et + arbres cites sont ceux du repo ; seules les lectures gh api sont fakes.""" + def fetch(path): + if path == "repos/o/r/pulls/42": + return {"labels": [], "base": {"sha": info["base_tip"]}} + if path == "repos/o/r/commits/" + info["merge_sha"]: + return _commit(info["merge_sha"], info["merge_date"], + [info["author_tip"], info["base_tip"]], + tree=info["merge_tree"]) + if path == "repos/o/r/commits/" + info["author_tip"]: + return _commit(info["author_tip"], info["author_date"], + [info["root_sha"]]) + raise AssertionError("chemin inattendu: " + path) + return fetch + + +@pytest.mark.skipif(not _git_version_supported(), + reason="git >= 2.38 requis (merge-tree --write-tree)") +def test_cr_20260916_update_branch_legitime_franchit_le_checkout_shallow( + tmp_path, monkeypatch +): + """LE defaut de la CR 19:10Z : dans le checkout shallow du gate + (actions/checkout depth 1), les parents ramenes en --depth=1 n'ont aucun + historique commun -> merge-base indisponible, merge-tree refusait de + calculer -> la preuve d'equivalence etait inatteignable et meme le + update-branch LEGITIME se re-armait 120 min. Avec l'approfondissement + borne, l'exemption est prouvable et la remontee mesure le commit + d'auteur (T-240 min) -- plancher ecoule.""" + gate, info = _build_gate_topology(tmp_path, substantive=False) + monkeypatch.chdir(gate) + ok, msg = merge_dwell.check( + "o/r", info["merge_sha"], 42, 120.0, now=NOW, + fetch=_shallow_fetch_payloads(info), + run_git=merge_dwell._default_run_git, + ) + assert ok is True, msg + assert info["author_date"] in msg, ( + "le plancher doit se mesurer sur le commit d'auteur, pas la fusion" + ) + + +@pytest.mark.skipif(not _git_version_supported(), + reason="git >= 2.38 requis (merge-tree --write-tree)") +def test_cr_20260916_resolution_substantive_re_arme_le_checkout_shallow( + tmp_path, monkeypatch +): + """Dans la MEME topologie shallow, un merge de base porteur de contenu + d'auteur (arbre != auto-merge) reste autoritatif : le plancher se re-arme + sur la fusion (T-1 min).""" + gate, info = _build_gate_topology(tmp_path, substantive=True) + monkeypatch.chdir(gate) + ok, msg = merge_dwell.check( + "o/r", info["merge_sha"], 42, 120.0, now=NOW, + fetch=_shallow_fetch_payloads(info), + run_git=merge_dwell._default_run_git, + ) + assert ok is False, msg + assert msg.startswith("tete du " + info["merge_date"]) + + +def test_cr_20260916_approfondissement_indisponible_fail_closed(): + """Le deepen ne repond plus (reseau) : la preuve reste indisponible, la + fusion se mesure elle-meme -- l'approfondissement ne transforme jamais + un echec en exemption.""" + def run_git(args): + if args[:2] == ["cat-file", "-e"]: + return 0, "" + if args[0] == "merge-base": + return 1, "" + if args[:2] == ["rev-parse", "--is-shallow-repository"]: + return 0, "true\n" + if args[0] == "fetch": # deepen : plus rien ne repond + return 128, "" + if args[:2] == ["merge-tree", "--write-tree"]: + return 128, "fatal: refusing to merge unrelated histories" + raise AssertionError("git inattendu: " + " ".join(args)) + + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="a010"), run_git=run_git, + ) + assert ok is False + assert msg.startswith("tete du 2026-09-07T11:59:00Z") + + +def test_cr_20260916_repo_complet_sans_merge_base_reste_fail_closed(): + """Depot COMPLET (non shallow) sans merge-base = historiques veritablement + sans relation : --deepen y est refuse par git et on ne tente AUCUN fetch + -- la fusion se mesure elle-meme.""" + calls = [] + + def run_git(args): + calls.append(args[0]) + if args[:2] == ["cat-file", "-e"]: + return 0, "" + if args[0] == "merge-base": + return 1, "" + if args[:2] == ["rev-parse", "--is-shallow-repository"]: + return 0, "false\n" + if args[:2] == ["merge-tree", "--write-tree"]: + return 128, "fatal: refusing to merge unrelated histories" + raise AssertionError("git inattendu: " + " ".join(args)) + + ok, msg = merge_dwell.check( + "o/r", "m3rg3", 42, 120.0, now=NOW, + fetch=_cr_payloads(tree="a010"), run_git=run_git, + ) + assert ok is False + assert "fetch" not in calls, "un depot complet sans merge-base ne fetch pas"