feat(git): env-ladder rung protection at the shared remote-delete chokepoint (#651)

Rung protection lived only in delete_task_branch; the post-merge PR-
source cleanup (and the stale-branch sweep's shared primitive) could
still delete a branch that IS a ladder rung. _protected_branches_for_
deletion(slug) — field ∪ rung names, null-ladder shim included — now
feeds _delete_remote_branch_best_effort, so every remote deletion path
is covered; delete_task_branch's local rung check is removed as exactly
subsumed (verified byte-identical comparison semantics). Bonus closed
gap: a renamed trunk (default_branch 'trunk', null ladder) is now
delete-protected, which the hardcoded main/master floor never covered.

Per adversarial review, the deletion lookup fails CLOSED: a raised
project lookup skips the delete with a warning (a skipped best-effort
delete just retries next sweep — free safety), while a genuinely-gone
project proceeds with the hardcoded floor (its ladder is meaningless).
The rebase/sync resolver stays fail-open — a refused rebase on a DB
blip would wrongly block work, a different tradeoff, now documented.
Panel tooltip updated to the new truth. 29 tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-23 00:05:33 +02:00
committed by GitHub
co-authored by Renn F
parent 3806317aa7
commit fa459998b4
4 changed files with 320 additions and 36 deletions
@@ -336,13 +336,17 @@ async def test_unknown_project_returns_zeroed_result(
@pytest.mark.asyncio
async def test_delete_task_branch_refuses_env_ladder_rung_directly(
cleanup_setup: dict[str, Any],
cleanup_setup: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""The chokepoint guard, not just the sweep's candidate filter: even a
direct ``delete_task_branch`` call (e.g. the cancel-path caller in
task.py) must refuse a branch that is an environment-ladder rung — the
generic ``_delete_remote_branch_best_effort`` primitive's own
main/master/develop skip predates the ladder model and doesn't know it."""
task.py) must refuse a branch that is an environment-ladder rung. This
protection now lives entirely inside the shared
``_delete_remote_branch_best_effort`` chokepoint (via
``_protected_branches_for_deletion``) rather than as a local check in
``delete_task_branch`` — so this test exercises the REAL chokepoint
(the fixture's ``AsyncMock`` stub for it is bypassed) and proves the
rung short-circuits before any network probe."""
project = cleanup_setup["project"]
project.environments = [
{"name": "head", "branch": "develop"},
@@ -350,8 +354,12 @@ async def test_delete_task_branch_refuses_env_ladder_rung_directly(
]
await cleanup_setup["db"].flush()
ok = await cleanup_setup["svc"].delete_task_branch(project.slug, "develop")
svc = GitService(cleanup_setup["db"])
monkeypatch.setattr(svc, "_token_for_project", AsyncMock(return_value="tok"))
dependents = AsyncMock(return_value=False)
monkeypatch.setattr(svc, "_branch_has_open_dependents", dependents)
ok = await svc.delete_task_branch(project.slug, "develop")
assert ok is False
remote_delete = cleanup_setup["svc"]._delete_remote_branch_best_effort
remote_delete.assert_not_awaited()
dependents.assert_not_awaited()
@@ -88,7 +88,7 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
# hardcoded-only behavior above.
def _project_service_returning(project: MagicMock) -> MagicMock:
def _project_service_returning(project: MagicMock | None) -> MagicMock:
svc = MagicMock()
svc.get_by_slug = AsyncMock(return_value=project)
return svc
@@ -227,6 +227,134 @@ async def test_delete_matches_stripped_branch_case_sensitively() -> None:
client2.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_refuses_env_ladder_rung_not_in_declared_list() -> None:
"""The environment-ladder rung union (2026-07-22 follow-up, #649 gap
closure): a branch that ISN'T in the project's declared
``protected_branches`` but IS one of its ladder rungs is still refused —
``_protected_branches_for_deletion`` unions rungs in on top of the
declared field, and this shared chokepoint is where every remote-delete
caller (task-branch cleanup, the stale-branch sweep, and the merged-PR
source-branch cleanup) ends up."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
project = MagicMock(
protected_branches=["release"],
environments=[
{"name": "head", "branch": "develop"},
{"name": "prod", "branch": "master"},
],
)
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "develop", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_refuses_null_ladder_default_branch() -> None:
"""A project with a renamed trunk and no declared ladder (``environments``
null) synthesizes a single-rung ladder from ``default_branch`` — so
'trunk' is protected here even though it matches neither the hardcoded
main/master/develop floor nor anything in ``protected_branches``."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
project = MagicMock(
protected_branches=[], environments=None, default_branch="trunk"
)
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "trunk", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_allows_branch_that_is_neither_field_rung_nor_floor() -> None:
"""A project declaring BOTH protected_branches and a real ladder still
deletes a branch that is in none of the three protected sets — the
rung union doesn't become deny-by-default any more than the field
union does."""
svc = _service()
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
client = _fake_client()
project = MagicMock(
protected_branches=["release"],
environments=[
{"name": "head", "branch": "develop"},
{"name": "prod", "branch": "master"},
],
)
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"),
"feature/backend/abc--cell--leaf",
"tok",
"acme-repo",
)
client.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_pr_branch_refuses_rung_source_via_merge_cleanup_path() -> None:
"""The post-merge PR-source-branch cleanup (``_delete_pr_branch_best_effort``
← ``merge_pull_request``/``pr_merge``/``close_pull_request``) is the gap
#649 left open: it reaches the same shared ``_delete_remote_branch_best_effort``
chokepoint, so a PR whose OWN source branch is a ladder rung (e.g. an
env-sync cascade PR) is refused too, not just a task-branch delete."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
pr_resp = MagicMock(is_success=True)
pr_resp.json.return_value = {"head": {"ref": "release/env-sync"}}
client.get = AsyncMock(return_value=pr_resp)
project = MagicMock(
protected_branches=[],
environments=[
{"name": "head", "branch": "develop"},
{"name": "prod", "branch": "release/env-sync"},
],
)
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
await svc._delete_pr_branch_best_effort(
RepoRef("acme", "repo"), 7, "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
None
@@ -268,6 +396,71 @@ async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only
client2.delete.assert_not_awaited()
# --- fail-CLOSED on a lookup failure (2026-07-22 adversarial-review follow-up)
# `_protected_branches_for_deletion` distinguishes "lookup raised" (skip the
# delete entirely — None) from "project genuinely gone" (proceed with the
# hardcoded floor — empty frozenset). Every sibling failure mode in this
# chokepoint already fails closed (missing token, HTTPError), and a skipped
# delete is free — it just retries at the next sweep — so silently degrading
# to floor-only on an unresolvable project (which could delete a
# custom-named rung like "staging") is the wrong tradeoff here, unlike
# _protected_branches_for's fail-open rebase/sync posture.
def _project_service_raising(exc: Exception) -> MagicMock:
svc = MagicMock()
svc.get_by_slug = AsyncMock(side_effect=exc)
return svc
@pytest.mark.asyncio
async def test_delete_skips_entirely_when_project_lookup_raises() -> None:
"""A transient DB blip during the lookup skips the WHOLE delete (fail
CLOSED) rather than degrading to floor-only protection — a custom rung
name like "staging" would otherwise slip through undetected."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_raising(RuntimeError("db blip")),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "staging", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_proceeds_with_floor_when_project_genuinely_gone() -> None:
"""A resolved-to-``None`` project (the row itself no longer exists) is
NOT a lookup failure — its ladder is meaningless once the project is
gone, so cleaning up its now-orphaned branches proceeds with the
hardcoded floor only, rather than being refused forever."""
svc = _service()
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
client = _fake_client()
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(None),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"),
"feature/backend/abc--cell--leaf",
"tok",
"acme-repo",
)
client.delete.assert_awaited_once()
# --- the open-dependents probe --------------------------------------------