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