Files
roboco/tests/unit/services/test_git_rebase.py
T
818f2ac7a6 [21e195cd] Panel-wide UI standardization and usability pass (#194)
* [4c179e3a] Add git pull, fetch, and rebase backend endpoints (#190)

* [f966f772] feat(git): add pull, fetch, and rebase endpoints with integration tests (#185)

- Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response schemas
- Add GitService.pull(), fetch(), and rebase() methods using _network_git_timeout()
- Add POST /api/git/pull, /api/git/fetch, /api/git/rebase route handlers
- Rebase detects conflicts via git diff --name-only --diff-filter=U and aborts cleanly
- Integration tests cover success path and GitCommandError→500 for all three endpoints
- Rebase conflict test verifies conflict=True with populated conflicted_files list

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [26e2b7af] test(git): add AsyncMock unit tests for rebase_onto_base conflict-state handling (#186)

New test_git_rebase.py covers three branches of rebase_onto_base:
- success path: rebase exits 0, returns rebased status, abort never called
- conflict path: non-zero exit → diff → abort → returns conflict+files
- resilience: both rebase and abort exit non-zero, still returns conflict dict without exception

All tests use AsyncMock with side_effect sequences to mock _run_git at the service-method level.

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

* [551b1dbf] Panel-wide frontend UI standardization and page fixes (#193)

* [1ec787b2] feat(panel): design-system sweep — full-width layouts, scrollbar fix, Secretary button, component audit (#188)

- Settings page: remove max-w-3xl, wrap cards in grid-cols-1 lg:grid-cols-2 two-column layout
- AI Providers page: remove max-w-5xl so AIRoutingCard fills available width
- Journals AgentList: replace ScrollArea with overflow-y-auto div to eliminate nested scrollbar
- Secretary chat input: add items-stretch to flex row so Send/Start button matches Textarea height
- Component audit: replace all raw <button>/<input>/hand-rolled badge spans outside components/ui/ with canonical Button, Checkbox, Badge variants across 15 files:
  - ai-routing-card.tsx: ModeButton → Button, checkbox → Checkbox, badge spans → Badge
  - self-hosted-section.tsx: eye-toggle → Button ghost icon-sm, badge spans → Badge
  - journals/agent-item.tsx, communications/channel-item.tsx → Button ghost
  - kb-search-bar.tsx, kb-filters.tsx → Button ghost
  - kb-category-nav.tsx, git-log-panel.tsx → Button ghost
  - communications/page.tsx (channel + group lists) → Button ghost
  - projects/project-table.tsx, products/product-table.tsx → Button link
  - git-branch-panel.tsx (local + remote lists) → Button ghost
  - tasks/dependency-selector.tsx: Button ghost + Checkbox for visual indicator
  - tasks/task-table.tsx: sortable header + expand toggle → Button ghost
  - business/goals-tab.tsx: hidden button → Button

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [435b37b4] feat(metrics,notifications): URL-persisted tab state, semantic chart colors, humanized counts (#187)

- Notifications page: replace useState with useSearchParams/useRouter for
  ?tab= URL parameter (all/unread/pending, default: unread); Suspense wrapper
  with skeleton fallback for SSR compatibility.

- Metrics page: split into Performance tab (Velocity + Task Status + Agent
  Status + Team Health) and Token Usage tab (TokenUsageCostsSection) with
  ?tab= URL parameter (default: performance); Suspense wrapper; Refresh button
  moved inside PerformanceTabContent; humanizeCount() helper applies K/M
  suffixes to all MetricCard numeric values >= 1000.

- Chart components (usage-time-series, agent-usage, team-usage, model-donut):
  replace var(--chart-N) CSS vars with explicit semantic hex colors —
  #3b82f6 blue for informational, #f59e0b amber for warning/pending,
  #22c55e green for success/healthy, #ef4444 red for error/blocked,
  #a855f7 purple for supplemental.

pnpm lint and pnpm typecheck pass with zero new errors.

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [ccd256f4] Kanban mobile viewport: 375px layout, column navigation, 44px touch targets (#191)

* [ccd256f4] feat(kanban): mobile 375px layout with column navigator and 44px touch targets

- KanbanBoard: add activeColumnIndex state + mobile prev/next column
  navigator (lg:hidden); existing horizontal-scroll layout hidden on
  mobile (hidden lg:flex). Desktop DnD behavior unchanged.
- KanbanColumn: add optional className prop (cn-based) so mobile view
  can pass w-full/sm:w-full to fill the viewport.
- KanbanCard: bump all action buttons to min-h-11 (44px) touch targets
  (Assign, Pass, Fail, Move-forward).

* [ccd256f4] fix(kanban): change breakpoint from lg to sm for mobile/desktop layout switch

AC3 requires >=640px viewport to show multi-column layout (sm: breakpoint).
Previous impl used lg: (1024px), leaving 640-1023px in single-column mode.

Change:
- Mobile navigator div: lg:hidden → sm:hidden
- Desktop multi-column div: hidden lg:flex → hidden sm:flex

At <640px: single-column with prev/next navigator (375px mobile use case).
At >=640px: full horizontal-scroll multi-column layout (per AC3).
DnD behavior and all other layout unchanged.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [23f02af4] Agents page On-Demand section + Board composition; Overview Quick Actions visibility + Team Health Intake/Secretary (#189)

* [23f02af4] feat(agents,overview): On-Demand section, Board composition fix, Intake/Secretary in Quick Actions + Team Health

- agent-definitions.ts: remove AgentRole.MAIN_PM from getBoardAgents
  (Main PM has its own dedicated section; including it there was redundant).
  Add getOnDemandAgents() that catches agents not in any standard team
  (board/main_pm/backend/frontend/ux_ui/marketing) and not a standard cell
  role — surfaces prompter/intake agents that the API may return.

- agents/page.tsx: import getOnDemandAgents; add a conditional
  'On-Demand Agents' AgentGrid section (only rendered when the API returns
  at least one matching agent, e.g. the Intake interviewer).

- quick-actions-bar.tsx: add 'Task Intake' button (→/prompter, Sparkles
  icon) and 'Secretary' button (→/business?tab=secretary, Bot icon)
  alongside existing quick actions so operators can reach on-demand agents
  from the Overview in one click.

- team-health-cards.tsx: add OnDemandAgentCard sub-component (link card
  with On-Demand badge) and render static cards for 'Task Intake' and
  'Secretary' appended after the API-driven TeamHealthCard list, giving
  them equal visual presence in the Team Health section.

pnpm lint and pnpm typecheck pass with zero new errors.

* [23f02af4] fix(agents,overview): QA revision — enum entries, QuickActions placement, On-Demand title, Board PR_REVIEWER

AC3: types/index.ts AgentRole enum adds PR_REVIEWER, PROMPTER, SECRETARY.
     agent-selector.tsx ROLE_LABELS exhaustive Record updated accordingly.

AC4: command-center.tsx QuickActionsBar moved to after Team Health section,
     before CEO Approval Queue and data-heavy grid rows — visible without
     scrolling on a 900px viewport.

AC1: agents/page.tsx On-Demand AgentGrid title fixed to 'On-Demand'
     (was 'On-Demand Agents' in prior commit).

AC2: agent-definitions.ts getBoardAgents adds explicit PR_REVIEWER inclusion
     and uses inclusion-based getOnDemandAgents (PROMPTER|SECRETARY roles).

AC5: team-health-cards.tsx static OnDemandAgentCard implementation refined
     with correct fallback rendering when no API team data.

AC6: pnpm lint and pnpm typecheck (src only) pass with zero new errors.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [b1c59206] Git page: Pull, Fetch, Rebase buttons wired to backend; Rebase destructive confirmation dialog (#192)

* [b1c59206] feat(git): add Pull, Fetch, Rebase operations to Git page with destructive confirmation dialog for Rebase

- Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response types
- Add gitApi.pull(), gitApi.fetch(), gitApi.rebase() with mock stubs for /git/pull, /git/fetch, /git/rebase
- Add useGitPull, useGitFetch, useGitRebase mutation hooks with cache invalidation; exported via useGitOperations
- Add Pull (Download icon), Fetch (RefreshCcw icon), Rebase (GitGraph icon) buttons to GitActionsPanel
- Rebase button triggers AlertDialog with destructive confirmation before calling API
- Wire handlePull, handleFetch, handleRebase handlers in git-browser.tsx with toast feedback

* [b1c59206] fix(git): add destructive styling and branch name to Rebase AlertDialog

- Add className='border-destructive bg-destructive/5' to AlertDialogContent
  so the dialog container has the required red-tinted styling (AC3)
- Update AlertDialogDescription to interpolate status?.current_branch so
  the dialog body explicitly names the branch being rebased (AC3)

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [3f305ed9] Frontend: Fix git control contract, complete Secretary restyling, and apply polish (CEO revision) (#199)

* [72de8a65] fix(git): correct Pull/Fetch/Rebase types, API mocks, request fields, and toast handlers (#197)

- types/git.ts: GitPullResponse and GitFetchResponse now have current_branch,
  has_changes, staged_files, unstaged_files, untracked_files, ahead, behind
  (matching backend GitStatusResponse); removed nonexistent commits_received/
  refs_updated/remote fields
- types/git.ts: GitRebaseRequest now uses target_branch: string (not onto?: string);
  GitRebaseResponse now has conflict: boolean and conflicted_files: string[]
  (removed branch/onto/commits_rebased); task_id made optional on all three
  request types
- lib/api/git.ts: Updated mock returns for pull/fetch/rebase to match new types
- git-actions-panel.tsx: onRebase prop now (targetBranch: string) => void;
  Rebase AlertDialog now contains an Input for target_branch; AlertDialogAction
  disabled when targetBranch empty and passes the value to onRebase
- git-browser.tsx: handlePull and handleFetch toast references result.current_branch;
  handleRebase accepts targetBranch, sends target_branch in payload, toasts
  result.conflict and result.conflicted_files; no 'manual' task_id for any
  pull/fetch/rebase operation

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [be6a17fc] feat(ui): design-system polish — chart tokens, KB aria-label, Kanban touch targets (#196)

- kb-search-bar.tsx: add aria-label="Clear search" to the clear (X) button
- model-usage-donut.tsx: replace hex CHART_COLORS with var(--chart-1)…var(--chart-5)
- usage-time-series-chart.tsx: replace hex stopColor/stroke with var(--chart-1)/var(--chart-2)
- agent-usage-chart.tsx: Bar fill hex → var(--chart-1)
- team-usage-chart.tsx: Bar fill hex → var(--chart-1)
- kanban-card.tsx: min-h-11 → max-sm:min-h-11 (44px touch target mobile-only, 3 buttons)
- secretary-tab.tsx: already compliant (Button + design-system tokens), no change needed

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [d62036bd] Backend: Fix git endpoint schemas, add safety gates, and unit tests (CEO revision) (#200)

* [d0593fe3] feat(git): remove agent_id from schemas and add service-layer safety gates (#195)

- Remove agent_id field from all 9 git request schemas (GitCreateBranchRequest, GitCheckoutRequest, GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest, GitPullRequest, GitFetchRequest, GitRebaseRequest); agent identity comes from JWT auth context
- Make task_id Optional[UUID]=None in GitPullRequest, GitFetchRequest, GitRebaseRequest
- Add field_validator to GitRebaseRequest rejecting target_branch starting with '-' or equal to 'master'/'main'
- Add lightweight PullRequest, FetchRequest, RebaseRequest schemas for gateway layer
- Add dirty-workspace check to GitService.pull() (raises ValidationError if porcelain output)
- Switch GitService.pull() to --ff-only; raises ValidationError with diverged-branch message on non-zero exit
- Add master/main guard to GitService.rebase() for both head_branch and target_branch
- Update callers: routes/tasks.py (2x), services/task.py, tests/unit/services/test_git.py (2x)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [a2f96961] Add role-gated rebase endpoint and unit tests (test_git_rebase.py) (#198)

* [a2f96961] feat(git): add role-gated rebase endpoint and unit tests

Add role-gate to POST /rebase restricting access to DEVELOPER and
CELL_PM roles; add master/main protected-branch guard to
GitService.rebase() before any git subprocess runs; add 4 unit tests
in tests/unit/services/test_git_rebase.py covering both
target-branch and head-branch REBASE_FORBIDDEN cases

* [a2f96961] fix(git): invert rebase role gate, add ownership check, schema validator, and missing tests

- _REBASE_ALLOWED_ROLES changed from {DEVELOPER, CELL_PM} to {CEO, CELL_PM, MAIN_PM}
  so developers correctly receive 403 per AC1/AC2
- rebase_branch() now verifies task ownership for non-CEO PM callers: if task_id
  is provided and the task is not assigned to the calling agent, returns 403/404
- GitRebaseRequest.target_branch gets a @field_validator rejecting '-' prefix
  names and protected branch names (main, master, develop)
- GitService.pull() gains pre-flight safety gates: raises ValidationError
  DIRTY_TREE when staged/unstaged changes exist, DIVERGED_BRANCH when
  ahead > 0 and behind > 0
- test_git_rebase.py adds 9 new tests: pull() dirty-tree ValidationError,
  pull() diverged-branch ValidationError, pull() success path, schema
  validator for '-' prefix and protected names, and route-level tests
  confirming HTTP 403 for DEVELOPER and HTTP 200 for CELL_PM on POST /rebase

* [a2f96961] fix(tests): add type annotations for tuple variables in test_git_rebase.py

mypy needs explicit tuple type annotations when assigning bare tuples
to variables used as mock side_effect return values — fixes var-annotated
error caught by the server-side quality gate

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [94015c6d] Frontend R3: Fix legacy git taskId coercion + rebase placeholder + phantom fields (#204)

* [401ddb40] fix(git): remove phantom fields from GitPullRequest/GitFetchRequest and make task_id optional in write request interfaces; use taskId || undefined in git-browser.tsx handlers to avoid 422 errors when no task context is active (#201)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [cca8d0c0] fix(git): fix rebase placeholder and surface backend error in toast (#202)

git-actions-panel.tsx: change rebase target_branch Input placeholder
from "e.g. main or origin/main" to "Remote ref (e.g. origin/HEAD)" so
no default branch name (main/master/develop) is suggested.

git-browser.tsx: import getErrorMessage from @/lib/api/client and use
it in handleRebase catch block instead of the hardcoded string "Failed
to rebase". getErrorMessage extracts the real detail from
AxiosError.response.data.detail and falls back to a non-empty generic
message, satisfying both the detail-surfacing and fallback criteria.

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [1ea0fbcb] Backend R3: Relax legacy git schemas + fix integration tests (#206)

* [219c539b] Make task_id Optional in git request schemas and update service methods (#205)

* [219c539b] feat(git): make task_id Optional in git schemas and add None-guards in service methods

- GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest now have task_id: UUID | None = None
- commit_for_task, push_for_task, create_pr_for_task, merge_pr_for_task skip ownership/state checks when task_id is None and proceed to the git operation
- Added 16 unit tests in tests/unit/api/routes/test_git_optional_task_id.py covering schema validation and HTTP endpoint responses
- Added 4 integration tests in tests/integration/test_git_routes.py for no-422 behaviour
- All quality gates pass: ruff format, ruff check, mypy, pytest

* [219c539b] fix(tests): remove unused type-ignore comments, redundant cast, and invalid agent_id kwarg in git_optional_task_id unit tests

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [de95ce94] test(git): fix 3 rebase integration tests to use non-protected target_branch (#203)

- Add pm_git_client fixture (CELL_PM role) needed for the role-gated rebase endpoint
- Change target_branch from 'main' to 'develop' in test_rebase_success, test_rebase_conflict, and test_rebase_git_command_error
- Remove task_id from request bodies (optional field; random UUIDs trigger 404)
- Switch all 3 rebase tests to use pm_git_client instead of git_client

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

* chore: ruff format test_agent_image_registry.py (unblock quality gate)

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-17 06:36:28 +02:00

526 lines
18 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
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] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE ← exits 0
[4] rev-list --count ← returns "2"
[5] push --force-with-lease ← pushes the rebased branch
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(), # [3] rebase ← success
_result(stdout="2\n"), # [4] rev-list
_result(), # [5] 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] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE ← exits 1 (conflict)
[4] diff --name-only ← lists conflicted files
[5] rebase --abort ← exits 0
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(returncode=1), # [3] rebase ← conflict
_result(stdout="src/a.py\nsrc/b.py\n"), # [4] diff
_result(), # [5] 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] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE ← exits 1 (conflict)
[4] diff --name-only ← lists conflicted files
[5] rebase --abort ← exits 1 (abort also fails)
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(returncode=1), # [3] rebase ← conflict
_result(stdout="src/conflict.py\n"), # [4] diff
_result(returncode=1), # [5] 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"]}
# ---------------------------------------------------------------------------
# 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")
# ---------------------------------------------------------------------------
# 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"] == []