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>
151 lines
6.5 KiB
Python
151 lines
6.5 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
|