Files
orchestrator/tests/test_merge_gate_race.py
claude-bot 00d69d9e27
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 17s
feat(merge-gate): auto-rebase onto current main + re-test + serialise merges
Deterministic (no-LLM) sub-gate on the deploy-staging -> deploy edge that
catches a feature branch up to the CURRENT origin/main, re-tests the combined
tree, and serialises merges with a per-repo file lease — so two green parallel
branches can no longer break main (self-hosting safety for the orchestrator repo).

- src/merge_gate.py: branch_is_behind_main, auto_rebase_onto_main (push
  --force-with-lease ONLY the task branch, NEVER main), retest_branch, and a
  file merge-lease (atomic O_CREAT|O_EXCL, holder-aware release, stale reclaim).
  Strict never-raise contract; all git ops in the per-branch worktree.
- src/qg/checks.py: check_branch_mergeable composes the primitives under the
  lease; registered in QG_CHECKS. Conditional rollout (merge_gate_enabled /
  merge_gate_repos, default self-hosting only).
- src/stage_engine.py: sub-gate hook on deploy-staging (not a new stage). PASS ->
  advance; "merge-lock busy" -> DEFER (re-queue with available_at, anti-deadlock
  at max_concurrency=1, capped); conflict/red re-test -> rollback to development
  + developer retry (capped by MAX_DEVELOPER_RETRIES). Lease released on
  deploy->done / rollback / PR-merged webhook.
- src/db.py: enqueue_job(available_at_delay_s=...) for the defer (no schema change).
- src/webhooks/gitea.py: holder-aware lease release on PR-merged.
- src/config.py + .env.example: ORCH_MERGE_* settings.

Docs: README + adr-0006 (architect) already cover the design; CHANGELOG updated.
Tests: test_merge_gate.py, test_qg_merge_gate.py, test_merge_gate_race.py,
test_stage_engine.py::TestMergeGate, test_config.py, QG-registry snapshot.
Full suite: 535 passed.

Refs: ORCH-043

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:32:50 +00:00

151 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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