Root-cause fix for main erosion (phantom merge): code of ORCH-067/069 reached `done` while absent from origin/main (only their auto docs-PRs landed). - FR-1: verify_merged_to_main confirms merge ONLY by `git merge-base --is-ancestor <validated_sha> origin/main`; the OR-branch pr_already_merged is removed (a merged PR no longer confirms). Empty SHA / git error -> False. - FR-2: pr_already_merged demoted to merge_pr idempotency-guard; counts a PR only when merged & head.ref==<branch> & base.ref=="main" (explicit in-loop filter). - FR-3: merge_pr selects the open code-PR by head==<branch> AND base==main. - FR-5: new deterministic check_main_regression in _handle_merge_verify (after confirmed SHA-in-main, before done) verifies MAIN_REGRESSION_MARKERS still in origin/main; deterministic count==0 -> alert "main regressed" + HOLD (NOT done, no rollback); git error of the grep -> fail-open. Kill-switch ORCH_REGRESSION_GUARD_ENABLED; non-self -> no-op. - FR-4: root .gitattributes `CHANGELOG.md merge=union` so Unreleased edits auto-merge on rebase without conflict (branch not rolled back). Invariants unchanged (STAGE_TRANSITIONS, QG_CHECKS, deploy-status, merge-gate, image-freshness, DB schema, external HTTP API); non-self repos no-op (INV-5); never-raise (INV-1); merge only via Gitea PR-API (INV-2). Docs: CHANGELOG, .env.example (README/ADR updated by architect). Tests: tests/test_orch073_*.py (TC-01..18); existing merge-gate tests updated for the new code-PR filter. Refs: ORCH-073 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
213 lines
9.3 KiB
Python
213 lines
9.3 KiB
Python
"""ORCH-043 / TC-24: the parallel-merge race the gate exists to prevent.
|
||
|
||
Scenario (two green branches in ONE repo, the self-hosting risk, ТЗ §1):
|
||
* main is at C1 because branch A already merged.
|
||
* branch B was validated against C0 (the main it branched from) and is GREEN
|
||
there — but B has NOT seen A's change. A blind merge of B could break main
|
||
(semantic conflict): B is "green" yet stale.
|
||
|
||
The merge-gate makes this deterministic:
|
||
1. While A holds the merge-lease, B's gate sees "merge-lock busy" -> DEFER
|
||
(serialisation: no two catch-up+merge sequences interleave).
|
||
2. After A releases, B acquires the lease, rebases onto the CURRENT origin/main
|
||
(C1) and re-tests the COMBINED tree:
|
||
- re-test GREEN -> gate passes, lease HELD -> B is safe to merge; main stays green.
|
||
- re-test RED -> gate fails, lease RELEASED -> B rolls back to development;
|
||
main is NEVER touched.
|
||
origin/main's SHA is asserted unchanged throughout — the gate never pushes main.
|
||
|
||
Real local git (bare origin + clone), real file lease; only the pytest re-test is
|
||
stubbed (its real behaviour lives in test_merge_gate.py::retest_branch tests).
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
|
||
import pytest
|
||
|
||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate_race.db")
|
||
os.environ["ORCH_DB_PATH"] = _test_db
|
||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||
|
||
from src import git_worktree, merge_gate # noqa: E402
|
||
from src.qg import checks as qg # noqa: E402
|
||
from src.qg.checks import check_branch_mergeable # noqa: E402
|
||
|
||
|
||
def _git(cwd, *args):
|
||
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
|
||
|
||
|
||
@pytest.fixture
|
||
def race_repo(tmp_path, monkeypatch):
|
||
"""Bare origin at C1 (A merged) + clone + feature/B branched from C0.
|
||
|
||
Returns (repo, origin_path). feature/B rebases cleanly onto origin/main.
|
||
The gate is forced REAL for this repo via merge_gate_repos.
|
||
"""
|
||
repo = "orchestrator"
|
||
repos_dir = tmp_path / "repos"
|
||
wt_dir = tmp_path / "repos" / "_wt"
|
||
repos_dir.mkdir(parents=True)
|
||
|
||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir))
|
||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
|
||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
|
||
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
|
||
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
|
||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||
|
||
origin = tmp_path / "origin.git"
|
||
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
|
||
|
||
seed = tmp_path / "seed"
|
||
seed.mkdir()
|
||
_git(str(seed), "init", "-b", "main")
|
||
_git(str(seed), "config", "user.email", "t@t")
|
||
_git(str(seed), "config", "user.name", "t")
|
||
(seed / "README.md").write_text("base\n")
|
||
_git(str(seed), "add", ".")
|
||
_git(str(seed), "commit", "-m", "C0")
|
||
_git(str(seed), "remote", "add", "origin", str(origin))
|
||
_git(str(seed), "push", "origin", "main")
|
||
|
||
# B branches off C0, adds an isolated file (clean rebase onto C1).
|
||
_git(str(seed), "checkout", "-b", "feature/B")
|
||
(seed / "b.txt").write_text("from B\n")
|
||
_git(str(seed), "add", ".")
|
||
_git(str(seed), "commit", "-m", "feat(B): add b.txt")
|
||
_git(str(seed), "push", "origin", "feature/B")
|
||
|
||
# A merged -> main advances to C1 (touches a DIFFERENT file: no textual conflict).
|
||
_git(str(seed), "checkout", "main")
|
||
(seed / "a.txt").write_text("from A\n")
|
||
_git(str(seed), "add", ".")
|
||
_git(str(seed), "commit", "-m", "C1 (A merged)")
|
||
_git(str(seed), "push", "origin", "main")
|
||
|
||
main_clone = repos_dir / repo
|
||
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
|
||
_git(str(main_clone), "config", "user.email", "t@t")
|
||
_git(str(main_clone), "config", "user.name", "t")
|
||
return repo, origin
|
||
|
||
|
||
def _origin_main_sha(origin):
|
||
return _git(str(origin), "rev-parse", "main").stdout.strip()
|
||
|
||
|
||
def test_tc24_busy_lock_serialises_then_green_catch_up_is_safe(race_repo, monkeypatch):
|
||
"""A holds the lease -> B defers; after release B catches up + green re-test ->
|
||
safe merge (lease held), and origin/main is never pushed by the gate."""
|
||
repo, origin = race_repo
|
||
main_before = _origin_main_sha(origin)
|
||
|
||
# A is mid-merge: it holds the lease.
|
||
ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-A")
|
||
assert ok is True
|
||
|
||
# B's gate must DEFER (serialisation), touching nothing.
|
||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||
assert passed is False
|
||
assert reason == "merge-lock busy"
|
||
assert _origin_main_sha(origin) == main_before # main untouched
|
||
|
||
# A finishes and releases.
|
||
merge_gate.release_merge_lease(repo, "feature/A")
|
||
|
||
# B catches up: real rebase onto C1, GREEN re-test -> pass, lease HELD.
|
||
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green"))
|
||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||
assert passed is True
|
||
assert reason == "rebased onto main, re-test green"
|
||
# The gate rebased+pushed ONLY the task branch; origin/main is unchanged.
|
||
assert _origin_main_sha(origin) == main_before
|
||
# feature/B now contains C1 (a.txt) on origin after the force-with-lease push.
|
||
assert "a.txt" in _git(str(origin), "ls-tree", "--name-only", "feature/B").stdout
|
||
# Lease is HELD by B until the actual merge.
|
||
held = merge_gate._read_lease(merge_gate._lease_path(repo))
|
||
assert held is not None and held.get("branch") == "feature/B"
|
||
|
||
|
||
def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkeypatch):
|
||
"""B catches up but the COMBINED tree is red -> gate fails, lease released,
|
||
origin/main never touched (B will roll back to development upstream)."""
|
||
repo, origin = race_repo
|
||
main_before = _origin_main_sha(origin)
|
||
|
||
monkeypatch.setattr(
|
||
merge_gate, "retest_branch",
|
||
lambda r, b: (False, "re-test failed: ...1 failed, 9 passed"),
|
||
)
|
||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||
assert passed is False
|
||
assert reason.startswith("re-test failed after rebase:")
|
||
# main is still green / untouched.
|
||
assert _origin_main_sha(origin) == main_before
|
||
# The lease was released on failure (a later task can proceed).
|
||
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ORCH-065 / TC-17: recovery — "rebase+re-test green, merge not done, process
|
||
# died" -> reaper requeues -> the merge re-drives the STANDARD path WITHOUT a
|
||
# second expensive re-test when safe (the branch is already up-to-date). AC-10.
|
||
# ---------------------------------------------------------------------------
|
||
def test_tc17_redrive_skips_expensive_retest_when_already_caught_up(
|
||
race_repo, monkeypatch
|
||
):
|
||
repo, origin = race_repo
|
||
main_before = _origin_main_sha(origin)
|
||
|
||
# First pass: B catches up (real rebase onto C1) with a GREEN re-test. This is
|
||
# the work that completed before the process died — the lease is held, the
|
||
# branch is now caught up on origin.
|
||
retest_calls = []
|
||
|
||
def _retest(r, b):
|
||
retest_calls.append((r, b))
|
||
return True, "re-test green"
|
||
|
||
monkeypatch.setattr(merge_gate, "retest_branch", _retest)
|
||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||
assert passed is True
|
||
assert reason == "rebased onto main, re-test green"
|
||
assert len(retest_calls) == 1 # the expensive re-test ran ONCE
|
||
|
||
# The process "died" before the merge: release the lease the way the reaper /
|
||
# reconciler recovery path would (the row is requeued; the branch stays caught
|
||
# up because the rebase was already pushed).
|
||
merge_gate.release_merge_lease(repo, "feature/B")
|
||
|
||
# Re-drive (standard path) after recovery: the branch already contains
|
||
# origin/main, so branch_is_behind_main is False and the gate short-circuits to
|
||
# the up-to-date pass WITHOUT re-running the expensive rebase+re-test.
|
||
assert merge_gate.branch_is_behind_main(repo, "feature/B") is False
|
||
passed2, reason2 = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||
assert passed2 is True
|
||
assert reason2 == "branch up-to-date with main"
|
||
assert len(retest_calls) == 1 # NOT re-run on the re-drive (no double cost)
|
||
# origin/main was never pushed by the gate across the whole recovery.
|
||
assert _origin_main_sha(origin) == main_before
|
||
|
||
|
||
def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||
"""If the PR actually merged before the process died, the idempotency guard
|
||
reports it so the re-drive is a no-op (no second merge)."""
|
||
import httpx
|
||
repo, _ = race_repo
|
||
|
||
class _R:
|
||
status_code = 200
|
||
|
||
@staticmethod
|
||
def json():
|
||
# ORCH-073 FR-2: the guard counts a PR only when it carries THIS branch's
|
||
# code into main (merged & head.ref==branch & base.ref=="main").
|
||
return [{"merged": True, "head": {"ref": "feature/B"}, "base": {"ref": "main"}}]
|
||
|
||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|