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
@@ -612,7 +612,7 @@ function EditProjectForm({
{/* Protected Branches */}
<div className="grid gap-2">
<HelpTip label="Branches the fleet refuses to rebase onto, sync (force-push) as a task's own branch, or delete on the remote, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Environment-ladder rungs get separate protection, but only for task-branch cleanup, not a PR's source-branch cleanup after merge.">
<HelpTip label="Branches the fleet refuses to rebase onto or sync (force-push) as a task's own branch, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Every remote branch delete (task-branch cleanup, the stale-branch sweep, and a merged PR's source-branch cleanup) additionally refuses any environment-ladder rung, even one not listed here.">
<Label htmlFor="protected_branch_input">Protected Branches</Label>
</HelpTip>
{protectedBranches.length > 0 && (
+110 -27
View File
@@ -1183,21 +1183,29 @@ class GitService(BaseService):
async def _protected_branches_for(self, project_slug: str | None) -> frozenset[str]:
"""The project's own ``protected_branches``, normalized.
Consulted by every hardcoded rebase/delete safety gate as a UNION
Consulted by every hardcoded rebase/sync safety gate as a UNION
with its own literal set this can only ADD branches to what's
refused, never remove one, so a missing/unresolvable project or an
emptied field degrades to exactly the prior hardcoded-only behavior.
Branch names are matched case-sensitively (git refs are); entries are
stripped of surrounding whitespace defensively.
Fail-OPEN on a lookup error (logged): a rebase/sync refusal wrongly
blocking real work over a transient DB blip is the worse tradeoff
here unlike deletion (see :meth:`_protected_branches_for_deletion`),
a skipped rebase doesn't get a free retry at the next sweep.
Deliberately does NOT include environment-ladder rungs rebase
(``rebase``) and force-push sync (``sync_task_branch``) stay scoped to
the declared field + the master/main floor; see
:meth:`_protected_branches_for_deletion` for the deletion-only
superset that adds rungs.
"""
if not project_slug:
return frozenset()
try:
project = await get_project_service(self.session).get_by_slug(project_slug)
except Exception as e:
# Fail-open by design (additive-only protection degrading to the
# hardcoded floor is the right posture) — but log it, so a DB
# blip silently narrowing protection is at least observable.
self.log.warning(
"protected_branches lookup failed; degrading to hardcoded floor only",
project_slug=project_slug,
@@ -1208,6 +1216,64 @@ class GitService(BaseService):
return frozenset()
return frozenset(b.strip() for b in project.protected_branches if b.strip())
async def _protected_branches_for_deletion(
self, project_slug: str | None
) -> frozenset[str] | None:
"""Deletion-only superset of :meth:`_protected_branches_for`: also
unions in the project's environment-ladder rung branches (see
:mod:`roboco.models.env_branches`).
Consulted ONLY by ``_delete_remote_branch_best_effort`` the shared
remote-branch-deletion chokepoint every delete path (task-branch
cleanup on cancel, the stale-branch sweep, and the merged-PR
source-branch cleanup after ``merge_pull_request``/``pr_merge``/
``close_pull_request``) routes through so a ladder rung (e.g. an
env-sync PR's own source branch) can never be deleted regardless of
which caller triggered it. A null ``environments`` degenerates to a
single-rung ladder synthesized from ``default_branch`` (see
``effective_environments``), so a renamed trunk is protected here too,
not just the hardcoded ``main``/``master`` floor.
Returns ``None`` distinct from an empty ``frozenset`` when the
project LOOKUP ITSELF RAISED (a transient DB blip etc.): the caller
treats that as "skip this delete entirely" rather than degrading to
the hardcoded floor. Deletion fails CLOSED here, unlike
``_protected_branches_for``'s fail-OPEN rebase/sync posture, because
every sibling failure mode in this chokepoint already fails closed
(a missing token or an HTTPError from the forge both skip the
delete) and the delete is best-effort anyway a skipped one just
retries at the next sweep, whereas silently proceeding on an
unresolvable project could delete a custom-named rung (e.g.
"staging") this project actually declares, losing a
deployment-lineage branch for good.
A project that resolves to ``None`` (the row is genuinely gone, not
a lookup failure) is NOT the fail-closed case: its ladder is
meaningless once the project itself no longer exists, so this
returns the empty set proceed with the hardcoded floor only
rather than refusing forever to clean up an orphaned project's
leftover branches.
"""
if not project_slug:
return frozenset()
try:
project = await get_project_service(self.session).get_by_slug(project_slug)
except Exception as e:
self.log.warning(
"branch-delete protection lookup failed; skipping delete "
"rather than risk silently deleting an unresolvable rung",
project_slug=project_slug,
error=str(e),
)
return None
if project is None:
return frozenset()
fields = frozenset(
b.strip() for b in (project.protected_branches or []) if b.strip()
)
rungs = frozenset(rung.branch for rung in effective_environments(project))
return fields | rungs
async def _checkout_base_with_fallback(
self,
workspace: Path,
@@ -3653,19 +3719,37 @@ class GitService(BaseService):
Silently swallows errors cleanup is not critical. Skips branches that
look like project defaults (main / master / develop), any branch in
the project's own declared ``protected_branches`` (when
``project_slug`` is given a UNION with the hardcoded set, so a
missing/emptied field only ever loses the extra protection, never the
main/master/develop floor), and any branch that still has open
dependent PRs (an active integration target deleting it would
strand in-flight child work). Returns True if the delete request was
issued with no transport error, False on any skip/failure callers
that only fire-and-forget can ignore it; the branch-cleanup sweep
uses it to report counts.
the project's own declared ``protected_branches`` OR one of its
environment-ladder rungs (when ``project_slug`` is given see
:meth:`_protected_branches_for_deletion`; a UNION with the hardcoded
set, so a missing/emptied field or a null ladder only ever loses the
extra protection, never the main/master/develop floor), and any
branch that still has open dependent PRs (an active integration
target deleting it would strand in-flight child work). This is the
SHARED chokepoint every remote-delete caller routes through
(``delete_task_branch``, the stale-branch sweep, and
``_delete_pr_branch_best_effort``'s post-merge PR-source cleanup), so
rung protection here covers all of them, not just task-branch
cleanup. A project-lookup failure (as opposed to a resolved project
or a genuinely-gone one) fails CLOSED the whole delete is skipped,
not just floor-only-protected since a silent floor-only fallback
could delete a custom-named rung the lookup couldn't see. Returns
True if the delete request was issued with no transport error, False
on any skip/failure callers that only fire-and-forget can ignore
it; the branch-cleanup sweep uses it to report counts.
"""
protected = frozenset(
("main", "master", "develop", "")
) | await self._protected_branches_for(project_slug)
project_protected = await self._protected_branches_for_deletion(project_slug)
if project_protected is None:
self.log.warning(
"branch delete skipped: protected-branch lookup failed; "
"refusing rather than risk deleting an unresolvable rung",
branch=branch,
owner=repo_ref.owner,
repo=repo_ref.repo,
project_slug=project_slug,
)
return False
protected = frozenset(("main", "master", "develop", "")) | project_protected
if branch in protected:
return False
if await self._branch_has_open_dependents(repo_ref, branch, git_token):
@@ -3696,7 +3780,9 @@ class GitService(BaseService):
Silently swallows errors branch cleanup is not critical.
``project_slug``, when given, is forwarded to
:meth:`_delete_remote_branch_best_effort` so its own protected-branch
union covers this path too.
+ environment-ladder-rung union covers this path too a PR's own
source branch can be a ladder rung (e.g. an env-sync cascade PR), and
it is refused just like a task branch would be.
"""
try:
pr_resp = await self._forge.get_pr(
@@ -3720,14 +3806,13 @@ class GitService(BaseService):
branches don't accumulate on the remote. Returns whether the delete
was actually issued (see ``_delete_remote_branch_best_effort``).
This is the chokepoint every task-scoped remote-delete call routes
through, so the environment-ladder guard lives here rather than only
at each caller: ``_delete_remote_branch_best_effort``'s own
main/master/develop skip predates the env-ladder model and doesn't
know about it (it's a generic branch-delete primitive also used by
the merged-PR source-branch cleanup, which never targets a ladder
branch by construction) a task's ``branch_name`` could otherwise
coincide with a ladder rung and get deleted out from under it.
The environment-ladder guard is NOT re-checked here: it lives in the
shared ``_delete_remote_branch_best_effort`` chokepoint (via
``_protected_branches_for_deletion``), which every remote-delete
caller this one, the stale-branch sweep, and the merged-PR
source-branch cleanup routes through, so a task's ``branch_name``
that coincides with a ladder rung is refused there regardless of
which caller asked.
"""
git_token = await self._token_for_project(project_slug)
if not git_token:
@@ -3740,8 +3825,6 @@ class GitService(BaseService):
project = await project_service.get_by_slug(project_slug)
if not project or not project.git_url:
return False
if branch_name in {r.branch for r in effective_environments(project)}:
return False
repo_ref = self._parse_git_url(project.git_url)
except Exception:
return False
@@ -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 --------------------------------------------