mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
The shared rebase primitive (dev sync_branch verb, PM/CEO rebase path,
submit-freshen, merge-conflict resolver) opened with fetch -> checkout ->
unconditional reset --hard origin/<head_branch>. The dirty-tree gate
protects uncommitted edits only; the reset silently rewound past every
committed-but-unpushed commit — and the commit do-verb never pushes, so
mid-rework a dev routinely has exactly that. The force-with-lease push
then republished the truncated branch as authoritative (the lease
matched the freshly-fetched, never-moved origin ref).
rebase_onto_base now classifies local vs origin/<head> post-fetch:
- behind/equal: reset --hard origin as before (origin loses nothing)
- strictly ahead: reset skipped — the rebase runs from the local tip and
the lease'd push publishes the previously-doomed commits
- diverged: a patch-equivalence probe (rev-list --right-only
--cherry-pick) first rescues the self-inflicted residue of a prior
rebase whose force-push failed (treated as ahead, self-heals on
retry); only genuine two-sided divergence returns a new
{status: diverged, local_only, origin_only} — no reset, no rebase,
no push, neither side silently discarded
- an absent local ref is recovered from origin (branch + checkout,
never reset)
Callers: the sync_branch verb maps diverged to an actionable envelope
steering to i_am_blocked (stash-preserved note included); the
submit-freshen hard-rejects it like conflicts; the merge-conflict
resolver already escalates any non-rebased/superseded status to the
CEO and degrades gracefully (pinned by test, no code change).
New real-git suite (bare origin + clone, no subprocess mocking)
asserts origin-side outcomes: ahead-publishes, behind-adopts,
diverged-refuses-untouched, absent-ref recovery, superseded,
conflicts, and wedge self-heal on retry via a rejecting pre-receive
hook.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
847 lines
32 KiB
Python
847 lines
32 KiB
Python
"""Unit tests for GitService rebase conflict-state handling.
|
|
|
|
Pins the three critical control-flow branches of ``rebase_onto_base``:
|
|
|
|
1. **Success** — the underlying ``git rebase`` exits 0 → method returns a
|
|
non-conflict result dict and never calls ``git rebase --abort``.
|
|
2. **Conflict** — ``git rebase`` exits non-zero → method calls
|
|
``git diff --name-only --diff-filter=U`` to collect conflicted files,
|
|
calls ``git rebase --abort`` to restore the workspace, and returns a
|
|
conflict result dict.
|
|
3. **Resilience** — both ``git rebase`` and ``git rebase --abort`` exit
|
|
non-zero (e.g. abort fails mid-stream). The method must still return
|
|
the conflict dict without propagating an exception, because both are
|
|
invoked with ``check=False``.
|
|
|
|
All tests mock ``_run_git`` at the service-method level using
|
|
``AsyncMock`` with a ``side_effect`` list so each awaited call consumes
|
|
the next pre-configured result in order.
|
|
|
|
Also covers the ``rebase()`` safety gate added by the git-schema cleanup
|
|
task: rebasing onto or from a protected branch (master/main) is rejected
|
|
with a service-layer ``ValidationError`` before any git command runs.
|
|
|
|
Also covers:
|
|
* ``pull()`` dirty-tree and diverged-branch ``ValidationError`` gates.
|
|
* ``pull()`` success path.
|
|
* ``GitRebaseRequest.target_branch`` Pydantic field validator.
|
|
* Route-level role gate: DEVELOPER → 403, CELL_PM → 200.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
from uuid import uuid4
|
|
|
|
import pydantic
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from roboco.api.deps import get_agent_context, get_db
|
|
from roboco.api.routes.git import router as git_router
|
|
from roboco.api.schemas.git import GitRebaseRequest
|
|
from roboco.models.base import AgentRole
|
|
from roboco.models.permissions import AgentContext
|
|
from roboco.services.base import ValidationError
|
|
from roboco.services.git import GitService
|
|
|
|
_HTTP_200 = 200
|
|
_HTTP_403 = 403
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HEAD = "feature/backend/root--task"
|
|
_BASE = "feature/backend/root"
|
|
_WORKSPACE = Path("/tmp/fake-ws")
|
|
_TOKEN = "ghp_fake"
|
|
|
|
|
|
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
|
|
|
|
|
|
def _result(returncode: int = 0, stdout: str = "", stderr: str = "") -> Any:
|
|
"""Minimal subprocess result stand-in."""
|
|
r = MagicMock()
|
|
r.returncode = returncode
|
|
r.stdout = stdout
|
|
r.stderr = stderr
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — success path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_path_returns_rebased_and_does_not_call_abort(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When git rebase exits 0 the method returns a non-conflict result and
|
|
never invokes ``git rebase --abort``.
|
|
|
|
Call sequence for the success path (rebase OK, 2 unique commits):
|
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
|
[1] fetch origin
|
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
|
[3] checkout HEAD branch
|
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
|
[7] rebase origin/BASE ← exits 0
|
|
[8] rev-list --count ← returns "2"
|
|
[9] push --force-with-lease ← pushes the rebased branch
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # [0] status --porcelain → clean
|
|
_result(), # [1] fetch
|
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
|
_result(), # [3] checkout
|
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
|
_result(), # [6] reset (local not ahead → reset)
|
|
_result(), # [7] rebase ← success
|
|
_result(stdout="2\n"), # [8] rev-list origin/BASE..HEAD
|
|
_result(), # [9] push
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "rebased", "unique_commits": 2}
|
|
|
|
# Verify abort was never called
|
|
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
|
assert abort_call not in run.call_args_list, (
|
|
"git rebase --abort must NOT be called on a clean rebase"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — conflict path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conflict_path_calls_diff_then_abort_and_returns_conflict_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When git rebase exits non-zero the method:
|
|
|
|
* calls ``git diff --name-only --diff-filter=U`` to identify conflicted files,
|
|
* calls ``git rebase --abort`` to restore the workspace,
|
|
* returns ``{"status": "conflicts", "files": [<conflicted files>]}``.
|
|
|
|
Call sequence:
|
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
|
[1] fetch origin
|
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
|
[3] checkout HEAD branch
|
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
|
[7] rebase origin/BASE ← exits 1 (conflict)
|
|
[8] diff --name-only ← lists conflicted files
|
|
[9] rebase --abort ← exits 0
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # [0] status --porcelain → clean
|
|
_result(), # [1] fetch
|
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
|
_result(), # [3] checkout
|
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
|
_result(), # [6] reset (local not ahead → reset)
|
|
_result(returncode=1), # [7] rebase ← conflict
|
|
_result(stdout="src/a.py\nsrc/b.py\n"), # [8] diff
|
|
_result(), # [9] rebase --abort
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
|
|
|
|
# Verify the diff call was made with the correct flags
|
|
diff_call = call(
|
|
_WORKSPACE,
|
|
["diff", "--name-only", "--diff-filter=U"],
|
|
check=False,
|
|
)
|
|
assert diff_call in run.call_args_list, (
|
|
"git diff --name-only --diff-filter=U must be called to collect conflicted"
|
|
" files"
|
|
)
|
|
|
|
# Verify abort was called
|
|
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
|
assert abort_call in run.call_args_list, (
|
|
"git rebase --abort must be called to restore the workspace after a conflict"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — resilience: both rebase and abort exit non-zero
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_exception(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When ``git rebase`` exits non-zero AND ``git rebase --abort`` also
|
|
exits non-zero, the method must still return a conflict result dict
|
|
without raising an exception.
|
|
|
|
Both are called with ``check=False`` so a non-zero exit code from
|
|
either command produces a result object (not a raised exception).
|
|
|
|
Call sequence:
|
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
|
[1] fetch origin
|
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
|
[3] checkout HEAD branch
|
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
|
[7] rebase origin/BASE ← exits 1 (conflict)
|
|
[8] diff --name-only ← lists conflicted files
|
|
[9] rebase --abort ← exits 1 (abort also fails)
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # [0] status --porcelain → clean
|
|
_result(), # [1] fetch
|
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
|
_result(), # [3] checkout
|
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
|
_result(), # [6] reset (local not ahead → reset)
|
|
_result(returncode=1), # [7] rebase ← conflict
|
|
_result(stdout="src/conflict.py\n"), # [8] diff
|
|
_result(returncode=1), # [9] rebase --abort ← also fails
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
|
|
# Must not raise even though both rebase and abort return non-zero
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4 — diverged: both sides carry unique commits, neither is touched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diverged_path_returns_diverged_and_touches_nothing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When local HEAD and origin/HEAD each carry commits the other lacks
|
|
AND the patch-equivalence probe finds a real origin-only commit (no
|
|
local match), the method refuses immediately — no reset, no rebase, no
|
|
push.
|
|
|
|
Call sequence:
|
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
|
[1] fetch origin
|
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
|
[3] checkout HEAD branch
|
|
[4] rev-list --count origin/H..HEAD ← local_only=2
|
|
[5] rev-list --count HEAD..origin/H ← origin_only=1
|
|
[6] rev-list --count --right-only --cherry-pick HEAD...origin/H
|
|
← 1 origin-only commit survives patch-equivalence → DIVERGED
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # [0] status --porcelain → clean
|
|
_result(), # [1] fetch
|
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
|
_result(), # [3] checkout
|
|
_result(stdout="2\n"), # [4] rev-list origin/H..HEAD → local_only=2
|
|
_result(stdout="1\n"), # [5] rev-list HEAD..origin/H → origin_only=1
|
|
_result(stdout="1\n"), # [6] cherry-pick probe → 1 genuinely unmatched
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "diverged", "local_only": 2, "origin_only": 1}
|
|
# Nothing past the classification ran: no reset, no rebase, no push.
|
|
subcommands = {c.args[1][0] for c in run.call_args_list}
|
|
assert subcommands.isdisjoint({"reset", "rebase", "push"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Safety gate tests for rebase() — protected-branch guard
|
|
# ---------------------------------------------------------------------------
|
|
# These test the service-layer ``rebase()`` method (the workspace-scoped API
|
|
# endpoint helper), NOT ``rebase_onto_base()`` (the internal gateway helper).
|
|
# The guard runs BEFORE any git command, so no ``_run_git`` mock is needed
|
|
# for target-branch cases; the head-branch case requires a stubbed
|
|
# ``get_current_branch``.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_target_is_master() -> None:
|
|
"""rebase() must raise ValidationError for target_branch='master'."""
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "master")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_target_is_main() -> None:
|
|
"""rebase() must raise ValidationError for target_branch='main'."""
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "main")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_head_branch_is_master(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""rebase() must raise ValidationError when HEAD is 'master'.
|
|
|
|
The target-branch check passes (we pass a safe target), but the
|
|
head-branch guard fires when get_current_branch returns 'master'.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"get_current_branch",
|
|
AsyncMock(return_value="master"),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_head_branch_is_main(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""rebase() must raise ValidationError when HEAD is 'main'."""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"get_current_branch",
|
|
AsyncMock(return_value="main"),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_raises_validation_error_on_dirty_tree(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() raises ValidationError(DIRTY_WORKSPACE) when the tree is dirty.
|
|
|
|
The pre-flight ``git status --porcelain`` returns modified files, so pull
|
|
must reject immediately before any network call.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(return_value=_result(stdout=" M dirty.py\n")),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="DIRTY_WORKSPACE"):
|
|
await svc.pull(_WORKSPACE)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_raises_validation_error_on_diverged_branch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() raises ValidationError(DIVERGED_BRANCH) when --ff-only fails.
|
|
|
|
The pre-flight status is clean, but ``git pull --ff-only`` exits non-zero
|
|
with a "not possible to fast-forward" message because the branch has
|
|
diverged from origin.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # status --porcelain → clean
|
|
_result( # pull --ff-only → diverged
|
|
returncode=1,
|
|
stderr="fatal: Not possible to fast-forward, aborting.",
|
|
),
|
|
]
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="DIVERGED_BRANCH"):
|
|
await svc.pull(_WORKSPACE)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_success_returns_post_pull_status(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() returns the post-pull status on a clean, fast-forwardable branch.
|
|
|
|
The pre-flight ``git status --porcelain`` is clean and ``git pull --ff-only``
|
|
succeeds, so pull() returns the post-pull ``get_status`` tuple.
|
|
"""
|
|
_post_pull: tuple[str, bool, list[str], list[str], list[str], int, int] = (
|
|
"feature/backend/task",
|
|
False,
|
|
[],
|
|
[],
|
|
[],
|
|
0,
|
|
0,
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(side_effect=[_result(stdout=""), _result(returncode=0)]),
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
|
)
|
|
monkeypatch.setattr(GitService, "get_status", AsyncMock(return_value=_post_pull))
|
|
|
|
svc = _git_service()
|
|
result = await svc.pull(_WORKSPACE)
|
|
|
|
assert result == _post_pull
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GitRebaseRequest.target_branch field validator tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rebase_request_target_branch_dash_prefix_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch that starts with '-'.
|
|
|
|
Branch names beginning with '-' are not valid git ref names and look
|
|
like CLI flags, so the schema validator rejects them with a clear error.
|
|
"""
|
|
with pytest.raises(pydantic.ValidationError, match="INVALID_TARGET_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="-bad-branch",
|
|
)
|
|
|
|
|
|
def test_rebase_request_target_branch_protected_name_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch 'main' (a protected branch name)."""
|
|
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="main",
|
|
)
|
|
|
|
|
|
def test_rebase_request_target_branch_master_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch 'master' (a protected branch name)."""
|
|
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="master",
|
|
)
|
|
|
|
|
|
def test_rebase_request_valid_target_branch_accepted() -> None:
|
|
"""GitRebaseRequest accepts a valid, non-protected target_branch."""
|
|
req = GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="feature/backend/some-task",
|
|
)
|
|
assert req.target_branch == "feature/backend/some-task"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route-level tests: role gate on POST /rebase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _mock_db_generator() -> Any:
|
|
"""Async generator yielding a MagicMock as the database session.
|
|
|
|
FastAPI's original ``get_db`` is an async generator (uses ``yield``).
|
|
The override must also be a generator (or at least async) so FastAPI
|
|
handles the dependency lifecycle correctly.
|
|
"""
|
|
yield MagicMock()
|
|
|
|
|
|
def _build_git_app(agent_context: AgentContext) -> FastAPI:
|
|
"""Minimal FastAPI app with the git router and overridden agent context."""
|
|
app = FastAPI()
|
|
app.include_router(git_router, prefix="/git")
|
|
app.dependency_overrides[get_agent_context] = lambda: agent_context
|
|
app.dependency_overrides[get_db] = _mock_db_generator
|
|
return app
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_endpoint_developer_gets_403() -> None:
|
|
"""POST /git/rebase returns HTTP 403 for a DEVELOPER-role agent.
|
|
|
|
The role gate fires before any service call, so no git service mock
|
|
is needed.
|
|
"""
|
|
agent = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER)
|
|
app = _build_git_app(agent)
|
|
transport = ASGITransport(app=app)
|
|
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/git/rebase",
|
|
json={
|
|
"project_slug": "roboco",
|
|
"target_branch": "feature/backend/some-task",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == _HTTP_403
|
|
detail = response.json()["detail"]
|
|
assert "REBASE_ROLE_RESTRICTED" in detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_endpoint_pm_gets_200() -> None:
|
|
"""POST /git/rebase returns HTTP 200 for a CELL_PM-role agent.
|
|
|
|
The role gate passes; no task_id is supplied so the ownership check
|
|
is skipped; project resolution and the git service are patched.
|
|
"""
|
|
agent = AgentContext(agent_id=uuid4(), role=AgentRole.CELL_PM)
|
|
app = _build_git_app(agent)
|
|
|
|
# Mock project service → returns a project with slug "roboco"
|
|
mock_project = MagicMock()
|
|
mock_project.slug = "roboco"
|
|
mock_project_svc = MagicMock()
|
|
mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project)
|
|
|
|
# Mock git service → workspace + rebase succeed without conflict
|
|
mock_git_svc = MagicMock()
|
|
mock_git_svc.get_workspace = AsyncMock(return_value=Path("/tmp/fake-ws"))
|
|
mock_git_svc.rebase = AsyncMock(return_value=(False, []))
|
|
|
|
transport = ASGITransport(app=app)
|
|
|
|
with (
|
|
patch(
|
|
"roboco.api.routes.git.get_project_service", return_value=mock_project_svc
|
|
),
|
|
patch("roboco.api.routes.git.get_git_service", return_value=mock_git_svc),
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/git/rebase",
|
|
json={
|
|
"project_slug": "roboco",
|
|
"target_branch": "feature/backend/some-task",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == _HTTP_200
|
|
body = response.json()
|
|
assert body["project_slug"] == "roboco"
|
|
assert body["conflict"] is False
|
|
assert body["conflicted_files"] == []
|