feat(git): protected-branches enforcement + panel editor (#649)

projects.protected_branches existed end-to-end but nothing consulted it
— the panel had no editor and the git safety checks used hardcoded sets.
Now: GitService._protected_branches_for(slug) (frozenset, stripped,
fail-open to the hardcoded floor with a warning log) is unioned — never
replacing, only tightening — into rebase()'s refusal set, the shared
_delete_remote_branch_best_effort skip set (threaded through every
caller: task cleanup, PR merge/close cleanup), and sync_task_branch,
which now refuses to force-push a protected-named head (the dev-facing
sync_branch verb path the HTTP-only fix would have missed). Matching is
exact and case-sensitive; an empty list degrades to exactly the old
hardcoded behavior, pinned by union-floor regression tests (master/main
stay refused regardless of the project list).

Panel: chips editor for the field in the edit-project dialog (add via
Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to-
empty persists []) with an honest tooltip scoped to what is actually
enforced. Tests cover both the incumbent GitHub-App dialog suite and the
new Protected Branches suite in one harness.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-22 20:30:05 +02:00
committed by GitHub
co-authored by Renn F
parent 4585a248ce
commit da4d9b333d
7 changed files with 775 additions and 25 deletions
@@ -80,6 +80,194 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
dep.assert_not_awaited()
# --- projects.protected_branches UNION (2026-07-22 follow-up) -------------
# `_delete_remote_branch_best_effort` unions its hardcoded skip tuple with
# the project's own declared `protected_branches` when a project_slug is
# given. The union can only ADD protection: a missing project_slug, an
# unresolvable project, or an emptied field must reproduce the exact
# hardcoded-only behavior above.
def _project_service_returning(project: MagicMock) -> MagicMock:
svc = MagicMock()
svc.get_by_slug = AsyncMock(return_value=project)
return svc
@pytest.mark.asyncio
async def test_delete_skips_project_declared_protected_branch() -> None:
"""A custom protected branch (not in the hardcoded set) is refused when
the project declares it — the open-dependents probe is never reached,
mirroring the hardcoded-name short-circuit above."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
project = MagicMock(protected_branches=["release"])
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"), "release", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_allows_branch_not_in_projects_protected_list() -> None:
"""A branch that isn't hardcoded AND isn't in the project's declared
list is deleted normally — the union only blocks what's actually
listed, it doesn't become deny-by-default."""
svc = _service()
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
client = _fake_client()
project = MagicMock(protected_branches=["release"])
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_empty_protected_branches_matches_hardcoded_only_behavior() -> (
None
):
"""An empty (or null) protected_branches field degrades to exactly the
prior hardcoded-only behavior — clearing the list never loosens
anything, but it also never invents new protection."""
svc = _service()
_bind(svc, "_branch_has_open_dependents", AsyncMock(return_value=False))
client = _fake_client()
project = MagicMock(protected_branches=[])
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_no_project_slug_matches_hardcoded_only_behavior() -> None:
"""Omitting project_slug entirely (legacy call shape) never touches the
project service and behaves byte-for-byte like before this change."""
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") as get_project_service,
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "feature/backend/abc--cell--leaf", "tok"
)
client.delete.assert_awaited_once()
get_project_service.assert_not_called()
@pytest.mark.asyncio
async def test_delete_matches_stripped_branch_case_sensitively() -> None:
"""Stored entries are stripped of whitespace defensively, but matching
stays case-sensitive (git branch names are case-sensitive): a
differently-cased request is NOT protected by a stored ' Release '."""
svc = _service()
dep = AsyncMock(return_value=False)
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
project = MagicMock(protected_branches=[" Release "])
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
# Exact match after stripping -> refused.
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "Release", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
client2 = _fake_client()
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client2),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
# Different case -> not the same git ref -> allowed.
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "release", "tok", "acme-repo"
)
client2.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
None
):
"""A project declaring its OWN protected_branches (e.g. ["release"]) must
NOT replace the hardcoded main/master/develop skip — the union is
additive, never a substitution. master and main stay refused regardless
of what the project's list contains."""
project = MagicMock(protected_branches=["release"])
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_returning(project),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "master", "tok", "acme-repo"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
client2 = _fake_client()
with (
patch("roboco.services.git.httpx.AsyncClient", return_value=client2),
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
):
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "main", "tok", "acme-repo"
)
client2.delete.assert_not_awaited()
# --- the open-dependents probe --------------------------------------------
+246
View File
@@ -64,6 +64,9 @@ def _git_service() -> GitService:
"""Instantiate GitService without a real DB session."""
svc = GitService.__new__(GitService)
svc.log = MagicMock() # silence warning/info calls
# A placeholder — only touched (as an opaque arg to a patched
# get_project_service) by the protected_branches union tests below.
svc.session = MagicMock()
return svc
@@ -303,6 +306,249 @@ async def test_rebase_raises_validation_error_when_head_branch_is_main(
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
# ---------------------------------------------------------------------------
# rebase() — projects.protected_branches UNION (2026-07-22 follow-up)
# ---------------------------------------------------------------------------
# rebase()'s hardcoded {"master", "main"} refusal is unioned with the
# project's own declared protected_branches when project_slug is given. The
# union can only ADD refusals: no project_slug, an unresolvable project, or
# an emptied field must reproduce the exact hardcoded-only behavior above.
def _project_service_returning(project: MagicMock) -> MagicMock:
svc = MagicMock()
svc.get_by_slug = AsyncMock(return_value=project)
return svc
@pytest.mark.asyncio
async def test_rebase_raises_for_project_declared_protected_target_branch() -> None:
"""A custom protected branch (not master/main) is refused as a rebase
target when the project declares it, before any git command runs."""
svc = _git_service()
project = MagicMock(protected_branches=["release"])
with (
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
):
await svc.rebase(_WORKSPACE, "release", "acme-repo")
@pytest.mark.asyncio
async def test_rebase_allows_target_not_in_projects_protected_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A target that isn't master/main AND isn't in the project's declared
list rebases normally — the union doesn't become deny-by-default."""
run = AsyncMock(return_value=_result())
monkeypatch.setattr(GitService, "_run_git", run)
monkeypatch.setattr(
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
)
svc = _git_service()
project = MagicMock(protected_branches=["release"])
with patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
):
conflict, files = await svc.rebase(
_WORKSPACE, "feature/backend/some-task", "acme-repo"
)
assert (conflict, files) == (False, [])
@pytest.mark.asyncio
async def test_rebase_empty_protected_branches_matches_hardcoded_only_behavior(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An empty protected_branches field degrades to exactly the prior
master/main-only behavior — no extra refusal is invented."""
run = AsyncMock(return_value=_result())
monkeypatch.setattr(GitService, "_run_git", run)
monkeypatch.setattr(
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
)
svc = _git_service()
project = MagicMock(protected_branches=[])
with patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
):
conflict, files = await svc.rebase(
_WORKSPACE, "feature/backend/some-task", "acme-repo"
)
assert (conflict, files) == (False, [])
@pytest.mark.asyncio
async def test_rebase_no_project_slug_matches_hardcoded_only_behavior(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Omitting project_slug entirely (legacy call shape) never touches the
project service and behaves byte-for-byte like before this change."""
run = AsyncMock(return_value=_result())
monkeypatch.setattr(GitService, "_run_git", run)
monkeypatch.setattr(
GitService, "get_current_branch", AsyncMock(return_value="feature/x")
)
svc = _git_service()
with patch("roboco.services.git.get_project_service") as get_project_service:
conflict, files = await svc.rebase(_WORKSPACE, "feature/backend/some-task")
assert (conflict, files) == (False, [])
get_project_service.assert_not_called()
@pytest.mark.asyncio
async def test_rebase_matches_stripped_target_case_sensitively() -> None:
"""Stored entries are stripped defensively, but matching stays
case-sensitive: a differently-cased target is NOT refused by a stored
' Release '."""
project = MagicMock(protected_branches=[" Release "])
svc = _git_service()
with (
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
):
await svc.rebase(_WORKSPACE, "Release", "acme-repo")
@pytest.mark.asyncio
async def test_rebase_union_never_collapses_hardcoded_floor_to_project_list_only() -> (
None
):
"""A project declaring its OWN protected_branches (e.g. ["release"]) must
NOT replace the hardcoded master/main refusal — the union is additive,
never a substitution. Both master and main stay refused regardless of
what the project's list contains."""
project = MagicMock(protected_branches=["release"])
svc = _git_service()
with (
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
):
await svc.rebase(_WORKSPACE, "master", "acme-repo")
svc2 = _git_service()
with (
patch(
"roboco.services.git.get_project_service",
return_value=_project_service_returning(project),
),
pytest.raises(ValidationError, match="REBASE_FORBIDDEN"),
):
await svc2.rebase(_WORKSPACE, "main", "acme-repo")
# ---------------------------------------------------------------------------
# sync_task_branch() — protected HEAD branch guard (2026-07-22 follow-up)
# ---------------------------------------------------------------------------
# sync_branch force-pushes (with lease) the task's OWN branch_name, not the
# base — so the guard that matters here is on the HEAD, not the base (the
# choreographer's _sync_base_refused already guards the base and is
# untouched). A branch_name that IS master/main or one of the project's
# declared protected_branches means branch_name was mis-set; refuse before
# any workspace/rebase work.
def _sync_task(branch_name: str) -> MagicMock:
# assigned_to=None so _resolve_workspace_agent_id (no actor_agent_id
# passed) falls through to its None default instead of trying to parse
# an auto-generated MagicMock attribute as a UUID.
return MagicMock(id=uuid4(), branch_name=branch_name, assigned_to=None)
def _patch_sync_plumbing(
monkeypatch: pytest.MonkeyPatch, project: MagicMock
) -> AsyncMock:
"""Stub every collaborator sync_task_branch calls AFTER the HEAD guard,
so a test that reaches them proves the guard let a normal branch through
without actually touching a filesystem or running git. Returns the
rebase_onto_base mock so callers can assert on it."""
monkeypatch.setattr(
GitService, "_project_for_task", AsyncMock(return_value=project)
)
# _protected_branches_for (called from the new HEAD guard) resolves the
# project independently via get_project_service, not _project_for_task.
monkeypatch.setattr(
"roboco.services.git.get_project_service",
lambda _session: _project_service_returning(project),
)
monkeypatch.setattr(
GitService, "get_workspace", AsyncMock(return_value=Path("/tmp/clone"))
)
monkeypatch.setattr(
GitService, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(GitService, "_ensure_worktree_for_commit", AsyncMock())
rebase_onto_base = AsyncMock(
return_value={"status": "rebased", "unique_commits": 1}
)
monkeypatch.setattr(GitService, "rebase_onto_base", rebase_onto_base)
return rebase_onto_base
@pytest.mark.asyncio
async def test_sync_task_branch_refuses_project_declared_protected_head(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A task whose branch_name IS a project-declared protected branch is
refused before any workspace/rebase work runs."""
project = MagicMock(slug="acme-repo", protected_branches=["release"])
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
svc = _git_service()
task = _sync_task("release")
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
await svc.sync_task_branch(task, base_branch="feature/backend/parent")
rebase_onto_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_task_branch_allows_normal_head_unaffected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A normal task branch_name (not in the project's protected list) syncs
through exactly as before — the guard doesn't become deny-by-default."""
project = MagicMock(slug="acme-repo", protected_branches=["release"])
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
svc = _git_service()
task = _sync_task("feature/backend/abc12345")
result = await svc.sync_task_branch(task, base_branch="feature/backend/parent")
assert result == {"status": "rebased", "unique_commits": 1}
rebase_onto_base.assert_awaited_once()
@pytest.mark.asyncio
async def test_sync_task_branch_empty_protected_branches_matches_current_behavior(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An empty (or null) protected_branches field degrades to exactly the
prior master/main-only behavior — a normal branch still syncs fine."""
project = MagicMock(slug="acme-repo", protected_branches=[])
rebase_onto_base = _patch_sync_plumbing(monkeypatch, project)
svc = _git_service()
task = _sync_task("feature/backend/abc12345")
result = await svc.sync_task_branch(task, base_branch="feature/backend/parent")
assert result == {"status": "rebased", "unique_commits": 1}
rebase_onto_base.assert_awaited_once()
# ---------------------------------------------------------------------------
# pull() safety-gate tests
# ---------------------------------------------------------------------------