Files
e97f46af6e fix(git): reviewer reads must prefer origin over a diverged local ref (#690)
* fix(git): reviewer reads must prefer origin over a diverged local ref

_resolve_head_ref (GitService.diff/list_changed_files/read_file_at_branch)
kept local priority on ANY divergence from origin, real or rewritten. A
reviewer's clone parked on pre-rebase history after the branch's routine
force-push sync stayed frozen there across every subsequent review round,
while origin held every fix commit — QA repeatedly bounced work that had
already landed. Every caller here is a reader, never the branch's own
author mid-write, so origin now wins whenever it carries anything the
local ref lacks; local keeps priority only when it strictly contains
origin (unpushed commits, or equal).

The read-only git MCP surface (roboco_git_log) hit the same staleness
through a separate path: /api/git/log resolved the requested branch as a
bare name straight off whatever the caller's own clone had on disk, with
no fetch at all. It now routes through the same fixed _resolve_head_ref.

* test(e2e): give the armed flow-verb timeout real headroom

The armed value is also verb-2's entire execution budget (claim + every
claim guard + set_plan + start + tracing gate), which grows as guards
land; 1s flaked on loaded CI runners while passing locally. The
cancel-and-release semantics only need the timeout far below the hang.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:50:13 +02:00

1131 lines
41 KiB
Python

"""Git API route coverage."""
from __future__ import annotations
import uuid
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
import pytest_asyncio
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 _translate_error
from roboco.api.routes.git import router as git_router
from roboco.db.tables import AgentTable, ProjectTable
from roboco.exceptions import GitCommandError, GitTimeoutError
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.permissions import AgentContext
from roboco.services.base import (
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def git_client(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="GitProj",
slug=f"git-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(git_router, prefix="/api/git")
async def _override_db() -> AsyncGenerator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=cast("uuid.UUID", agent.id),
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "agent": agent, "project": project, "db": db_session}
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def pm_git_client(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Like git_client but with CELL_PM role — required for the rebase endpoint."""
agent = AgentTable(
id=uuid4(),
name="PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="GitProj",
slug=f"git-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(git_router, prefix="/api/git")
async def _override_db() -> AsyncGenerator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=cast("uuid.UUID", agent.id),
role=AgentRole.CELL_PM,
team=Team.BACKEND,
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "agent": agent, "project": project, "db": db_session}
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"}
# ---------------------------------------------------------------------------
# project resolution
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_project_not_found(git_client: dict) -> None:
response = await git_client["client"].get(
"/api/git/status?project_slug=ghost-project", headers=_HDR
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# get_git_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_success(git_client: dict) -> None:
workspace = "/tmp/ws"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value=workspace)
svc.get_status = AsyncMock(return_value=("main", False, [], [], [], 0, 0))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_status_validation_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=ValidationError("bad"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_status_unauthorized(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=UnauthorizedError("nope"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_status_not_found(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=NotFoundError("missing"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_status_git_timeout_directly() -> None:
"""Exercise _translate_error's GitTimeoutError branch directly.
The route uses `except ServiceError as e` from services.base, but
GitTimeoutError extends roboco.exceptions.ServiceError (different
class), so it never enters _translate_error in practice. We invoke
the helper directly to cover the branch.
"""
err = GitTimeoutError("git status", 10)
http_exc = _translate_error(err)
assert http_exc.status_code == HTTPStatus.GATEWAY_TIMEOUT
@pytest.mark.asyncio
async def test_status_git_command_error_directly() -> None:
"""Direct invocation of _translate_error's GitCommandError branch."""
err = GitCommandError("git status", "stderr")
http_exc = _translate_error(err)
assert http_exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# log
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_log_with_branch_success(git_client: dict) -> None:
# Fields are \x1f-delimited (not "|"): the second commit's subject contains
# a literal "|" (e.g. "curl|sh") which used to shift the split and land the
# author+date in one field, 500-ing on datetime.fromisoformat.
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = (
"abc123\x1fabc\x1ffix bug\x1fme\x1f2026-01-01T00:00:00Z\n"
"def456\x1fdef\x1fclose curl|sh RCE\x1fyou\x1f2026-01-01T00:00:01Z\n"
)
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
commits = response.json()["commits"]
assert [c["message"] for c in commits] == ["fix bug", "close curl|sh RCE"]
assert [c["author"] for c in commits] == ["me", "you"]
@pytest.mark.asyncio
async def test_log_resolves_through_head_ref_not_bare_branch(
git_client: dict,
) -> None:
"""The route must route the requested branch through
``_resolve_head_ref`` (fetch + prefer origin) instead of handing git the
bare branch name straight off whatever this clone happens to have on
disk — this clone is the CALLER's own, never the branch owner's, and a
left-over local ref from an earlier inspection can be pinned stale
(live 2026-07-24: a QA clone read a commit 5 review rounds old)."""
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value="origin/feature/x")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
svc._resolve_head_ref.assert_awaited_once_with("/tmp/ws", "feature/x", token="tok")
svc._run_git.assert_awaited_once()
logged_args = svc._run_git.await_args.args[1]
assert logged_args[-1] == "origin/feature/x"
@pytest.mark.asyncio
async def test_log_no_branch_fetches_current(git_client: dict) -> None:
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.get_current_branch = AsyncMock(return_value="main")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_log_unknown_branch_returns_empty(git_client: dict) -> None:
log_result = MagicMock()
log_result.returncode = 1
log_result.stderr = "no such branch"
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=ghost",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["commits"] == []
@pytest.mark.asyncio
async def test_log_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=NotFoundError("no ws"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_branches_local_only(git_client: dict) -> None:
branch_result = MagicMock()
branch_result.stdout = "refs/heads/main|abc123\nrefs/heads/feature/x|def456\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.get_current_branch = AsyncMock(return_value="main")
svc._run_git = AsyncMock(return_value=branch_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/branches?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["main"]["is_remote"] is False
assert names["feature/x"]["is_remote"] is False
# include_remote=False (default) never prunes.
svc.prune_remote_best_effort.assert_not_awaited()
@pytest.mark.asyncio
async def test_branches_with_remote(git_client: dict) -> None:
"""Regression: `%(refname)` renders a remote-tracking ref as
`refs/remotes/origin/<branch>` (real git never emits the old stub's
`remotes/origin/<branch>` shape) — it must classify as remote with the
`refs/remotes/origin/` prefix stripped down to the bare branch name, and
the symbolic `origin/HEAD` ref must be dropped, not surfaced as a fake
branch named "HEAD"."""
branch_result = MagicMock()
branch_result.stdout = (
"refs/heads/main|abc123\n"
"refs/remotes/origin/feature/y|def456\n"
"refs/remotes/origin/HEAD|abc123\n"
)
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.get_current_branch = AsyncMock(return_value="main")
svc._run_git = AsyncMock(return_value=branch_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/branches?project_slug={git_client['project'].slug}"
"&include_remote=true",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["feature/y"]["is_remote"] is True
assert "origin/feature/y" not in names
assert "HEAD" not in names
svc.prune_remote_best_effort.assert_awaited_once_with("/tmp/ws")
@pytest.mark.asyncio
async def test_branches_skips_empty_lines(git_client: dict) -> None:
"""Line 246: empty line in branch output triggers continue."""
branch_result = MagicMock()
# Embed an empty line between two branches.
branch_result.stdout = "refs/heads/main|abc\n\nrefs/heads/feature/x|def\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.get_current_branch = AsyncMock(return_value="main")
svc._run_git = AsyncMock(return_value=branch_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/branches?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_branches_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=NotFoundError("no ws"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/branches?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# diff
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_diff_basic(git_client: dict) -> None:
diff_res = MagicMock(stdout="some diff")
stat_res = MagicMock(stdout="a.py | 2\nb.py | 4\n2 files changed\n")
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._run_git = AsyncMock(side_effect=[diff_res, stat_res])
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/diff?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_diff_staged_with_file(git_client: dict) -> None:
diff_res = MagicMock(stdout="")
stat_res = MagicMock(stdout="")
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._run_git = AsyncMock(side_effect=[diff_res, stat_res])
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/diff?project_slug={git_client['project'].slug}"
"&staged=true&file_path=a.py",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_diff_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=NotFoundError("no ws"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/diff?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(return_value=("abc123", "msg", 1, 5, 2))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/commit",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
"message": "fix some thing",
"commit_type": "fix",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_commit_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(side_effect=ValidationError("bad"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/commit",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
"message": "fix some thing",
"commit_type": "fix",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# push
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_push_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.push_for_task = AsyncMock(return_value=("feature/x", 2))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/push",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_push_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.push_for_task = AsyncMock(side_effect=NotFoundError("missing"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/push",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# branch/create
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_branch_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_branch_for_task = AsyncMock(
return_value=("feature/backend/X", "main")
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branch/create",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"branch_type": "feature",
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_create_branch_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_branch_for_task = AsyncMock(side_effect=UnauthorizedError("no perm"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branch/create",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"branch_type": "feature",
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# checkout
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_checkout_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.checkout_branch_for_agent = AsyncMock(return_value=None)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/checkout",
json={
"project_slug": git_client["project"].slug,
"branch": "feature/x",
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_checkout_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.checkout_branch_for_agent = AsyncMock(side_effect=UnauthorizedError("nope"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/checkout",
json={
"project_slug": git_client["project"].slug,
"branch": "master",
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# pr/create
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_pr_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(
return_value=(42, "https://github.com/x/y/pull/42", "T", "feat", "main")
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/create",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_create_pr_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(side_effect=ValidationError("bad"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/create",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# ---------------------------------------------------------------------------
# pr/merge
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_merge_pr_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(return_value=("main", "abc"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/merge",
json={
"project_slug": git_client["project"].slug,
"pr_number": 42,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_merge_pr_service_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(side_effect=NotFoundError("no PR"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/merge",
json={
"project_slug": git_client["project"].slug,
"pr_number": 42,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# UUID resolution path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_with_uuid(git_client: dict) -> None:
"""Pass a UUID string instead of slug — should look up by UUID."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.get_status = AsyncMock(return_value=("main", False, [], [], [], 0, 0))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].id}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Generic ServiceError -> default 500
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_generic_service_error(git_client: dict) -> None:
"""Any other ServiceError becomes 500."""
class CustomError(ServiceError):
pass
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(side_effect=CustomError("err"))
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/status?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# pull
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pull_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.pull = AsyncMock(return_value=("main", False, [], [], [], 0, 0))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pull",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["current_branch"] == "main"
assert "ahead" in data
assert "behind" in data
assert "has_changes" in data
assert "staged_files" in data
assert "unstaged_files" in data
assert "untracked_files" in data
@pytest.mark.asyncio
async def test_pull_git_command_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.pull = AsyncMock(side_effect=GitCommandError("pull", "network error"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pull",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# fetch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
_ahead = 2
_behind = 1
svc.fetch = AsyncMock(
return_value=("feature/x", True, ["a.py"], [], [], _ahead, _behind)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/fetch",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["current_branch"] == "feature/x"
assert data["ahead"] == _ahead
assert data["behind"] == _behind
assert data["has_changes"] is True
assert "staged_files" in data
assert "unstaged_files" in data
assert "untracked_files" in data
@pytest.mark.asyncio
async def test_fetch_git_command_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.fetch = AsyncMock(side_effect=GitCommandError("fetch", "network error"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/fetch",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# rebase
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rebase_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(return_value=(False, []))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["conflict"] is False
assert data["conflicted_files"] == []
@pytest.mark.asyncio
async def test_rebase_conflict(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(return_value=(True, ["src/foo.py", "src/bar.py"]))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["conflict"] is True
assert data["conflicted_files"] == ["src/foo.py", "src/bar.py"]
@pytest.mark.asyncio
async def test_rebase_git_command_error(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(side_effect=GitCommandError("rebase", "fatal error"))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# task_id Optional — no 422 when task_id is omitted
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_without_task_id_no_422(git_client: dict) -> None:
"""POST /commit without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(
return_value=("abc123", "feat: add thing", 1, 5, 2)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/commit",
json={
"project_slug": git_client["project"].slug,
"agent_id": str(uuid4()),
"message": "add a new thing",
"commit_type": "feat",
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_push_without_task_id_no_422(git_client: dict) -> None:
"""POST /push without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.push_for_task = AsyncMock(return_value=("feature/x", 3))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/push",
json={
"project_slug": git_client["project"].slug,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_create_pr_without_task_id_no_422(git_client: dict) -> None:
"""POST /pr/create without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(
return_value=(
7,
"https://github.com/x/y/pull/7",
"feat: add thing",
"feat/x",
"main",
)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/create",
json={
"project_slug": git_client["project"].slug,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
"""POST /pr/merge without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(return_value=("main", "deadbeef"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/merge",
json={
"project_slug": git_client["project"].slug,
"pr_number": 99,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# branches/cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cleanup_branches_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(return_value=(3, 2, 1, 0, False, None))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert (
data["remote_deleted"],
data["local_deleted"],
data["skipped"],
data["errors"],
data["truncated"],
) == (3, 2, 1, 0, False)
svc.cleanup_stale_branches.assert_awaited_once_with(
pm_git_client["project"].slug, after_task_id=None
)
@pytest.mark.asyncio
async def test_cleanup_branches_reports_truncation(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(
return_value=(200, 190, 0, 0, True, "0" * 32)
)
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["truncated"] is True
@pytest.mark.asyncio
async def test_cleanup_branches_developer_gets_403(git_client: dict) -> None:
"""git_client carries a DEVELOPER-role agent — same role gate as /rebase."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "BRANCH_CLEANUP_ROLE_RESTRICTED" in response.json()["detail"]
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_cleanup_branches_project_not_found(pm_git_client: dict) -> None:
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": "does-not-exist"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# Re-export to keep import alive (TC reorders imports)
_ = SimpleNamespace