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>
568 lines
19 KiB
Python
568 lines
19 KiB
Python
"""sync_branch rebases the caller's task branch onto its base THROUGH the gate.
|
|
|
|
Multi-level sequencing Phase B1. Raw shell git is denied to agents
|
|
(``Bash(git:*)`` base deny), so a developer whose branch fell behind its base
|
|
had no gate-level rebase — only the CEO/PM-only ``/rebase`` HTTP route.
|
|
``sync_branch`` is the dev verb that wraps the rebase through the gate
|
|
(traced + evidenced), so the "everything goes through the gates" invariant
|
|
holds. These tests pin the handler:
|
|
|
|
- happy path: git.sync_task_branch runs, evidence carries the rebase result,
|
|
heartbeat fires
|
|
- conflicts: rebase aborted, next_hint points the dev at resolve-by-hand
|
|
- not_found: unknown task id
|
|
- not_authorized: only the current claimant can sync (ownership gate)
|
|
- no branch: branchless / not-yet-claimed task → invalid_state, steer to
|
|
i_will_work_on
|
|
- protected base: master/main refused only when MIS-resolved — a branch-bearing
|
|
parent exists (base should have been that branch) or the parent row is
|
|
missing/corrupt. A standalone (parentless) task and a child of a branchless
|
|
coordination parent legitimately rebase onto master (it IS their merge
|
|
target; the push only ever hits the task branch). ``-``-refs always refused.
|
|
- git failure: sync_task_branch raises → invalid_state, steer to i_am_blocked
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.base import ValidationError
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
def _make_deps(**overrides: object) -> ChoreographerDeps:
|
|
base: dict[str, object] = {
|
|
"task": AsyncMock(),
|
|
"work_session": AsyncMock(),
|
|
"git": AsyncMock(),
|
|
"a2a": AsyncMock(),
|
|
"journal": AsyncMock(),
|
|
"audit": AsyncMock(),
|
|
"evidence_repo": AsyncMock(),
|
|
}
|
|
base.update(overrides)
|
|
repo = base["evidence_repo"]
|
|
assert isinstance(repo, AsyncMock)
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
return ChoreographerDeps(**base)
|
|
|
|
|
|
_BRANCH = "feature/backend/abc12345"
|
|
_BASE = "feature/backend/parent12345"
|
|
|
|
|
|
def _task(*, tid: object, aid: object, branch: str | None = _BRANCH) -> MagicMock:
|
|
return MagicMock(
|
|
id=tid,
|
|
status="in_progress",
|
|
assigned_to=aid,
|
|
branch_name=branch,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_rebases_and_returns_evidence() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "rebased",
|
|
"commits_rebased": 3,
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
git_svc.sync_task_branch.assert_awaited_once_with(
|
|
t, base_branch=_BASE, actor_agent_id=aid, stash=False
|
|
)
|
|
assert env.error is None
|
|
assert env.evidence is not None
|
|
assert env.evidence["base_branch"] == _BASE
|
|
assert env.evidence["head_branch"] == _BRANCH
|
|
assert env.evidence["rebase"]["status"] == "rebased"
|
|
task_svc.heartbeat.assert_awaited_once_with(tid)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_conflicts_aborts_and_steers_to_resolve() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "conflicts",
|
|
"files": ["src/a.py", "src/b.py"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error is None
|
|
assert env.next is not None
|
|
assert "resolve by hand" in env.next
|
|
assert "sync_branch again" in env.next
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_diverged_steers_to_i_am_blocked() -> None:
|
|
"""A diverged branch is not an error — it's a clean ok-envelope hint to
|
|
escalate, mirroring how a conflict is surfaced (git-only op, no DB
|
|
transition either way)."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "diverged",
|
|
"local_only": 2,
|
|
"origin_only": 1,
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error is None
|
|
assert env.next is not None
|
|
assert "DIVERGED" in env.next
|
|
assert "i_am_blocked" in env.next
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_diverged_with_stash_pop_conflict_notes_preserved_stash() -> (
|
|
None
|
|
):
|
|
"""A diverged result can ALSO carry a stash-pop conflict (the stash was
|
|
taken, the rebase never touched either side, but popping it back still
|
|
collided) — the hint must not drop that warning."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "diverged",
|
|
"local_only": 2,
|
|
"origin_only": 1,
|
|
"stash_pop_conflict": True,
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid, stash=True)
|
|
|
|
assert env.error is None
|
|
assert env.next is not None
|
|
assert "DIVERGED" in env.next
|
|
assert "preserved" in env.next.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_not_found_for_unknown_task() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = None
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "not_found"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_rejects_when_not_claimant() -> None:
|
|
aid = uuid4()
|
|
other = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=other)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
# PRECONDITION_OWNERSHIP rejects a non-owner as not_authorized.
|
|
assert env.error == "not_authorized"
|
|
git_svc.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_no_branch_steers_to_i_will_work_on() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
# branch_name=None — task not yet claimed / branchless coordination root.
|
|
t = _task(tid=tid, aid=aid, branch=None)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "i_will_work_on" in (env.remediate or "")
|
|
git_svc.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_refuses_master_base_with_branch_bearing_parent() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
t.parent_task_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
# Both the task fetch and the parent fetch resolve to a branch-bearing row:
|
|
# the base should have been the parent's branch, so master is mis-resolved.
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value="master"),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "protected" in (env.message or "")
|
|
git_svc.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_allows_master_base_for_standalone_task() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
# Parentless standalone task (video / ci-watch / dep-update): master IS
|
|
# the merge target, so the rebase must go through.
|
|
t.parent_task_id = None
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {"status": "rebased", "commits_rebased": 2}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value="master"),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error is None
|
|
git_svc.sync_task_branch.assert_awaited_once_with(
|
|
t, base_branch="master", actor_agent_id=aid, stash=False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_allows_master_base_for_branchless_parent_child() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
t.parent_task_id = uuid4()
|
|
branchless_parent = MagicMock(branch_name=None)
|
|
task_svc = AsyncMock()
|
|
# First get: the task itself; second get: the branchless coordination
|
|
# parent — its child was cut from master, so master is the true base.
|
|
task_svc.get.side_effect = [t, branchless_parent]
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {"status": "rebased", "commits_rebased": 1}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value="master"),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error is None
|
|
git_svc.sync_task_branch.assert_awaited_once_with(
|
|
t, base_branch="master", actor_agent_id=aid, stash=False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_refuses_master_base_when_parent_row_missing() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
t.parent_task_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.side_effect = [t, None]
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value="master"),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
git_svc.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_refuses_injection_ref_unconditionally() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
t.parent_task_id = None
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value="-evil-ref"),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
git_svc.sync_task_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_git_failure_steers_to_i_am_blocked() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.side_effect = RuntimeError("network down")
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "i_am_blocked" in (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_protected_head_refusal_steers_to_i_am_blocked() -> None:
|
|
"""GitService.sync_task_branch's protected-HEAD-branch guard (2026-07-22
|
|
follow-up — a mis-set branch_name matching master/main or a project's
|
|
declared protected_branches) surfaces through the same generic
|
|
invalid_state/i_am_blocked path as any other git failure — the
|
|
choreographer doesn't need to special-case it, only propagate it."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.side_effect = ValidationError(
|
|
f"REBASE_FORBIDDEN: task branch_name '{_BRANCH}' is a protected branch"
|
|
)
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "REBASE_FORBIDDEN" in (env.message or "")
|
|
assert "i_am_blocked" in (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_passes_stash_flag_through() -> None:
|
|
"""stash=True on the verb forwards to GitService.sync_task_branch."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 1}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid, stash=True)
|
|
|
|
git_svc.sync_task_branch.assert_awaited_once_with(
|
|
t, base_branch=_BASE, actor_agent_id=aid, stash=True
|
|
)
|
|
assert env.error is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_dirty_workspace_failure_steers_to_stash_or_commit() -> None:
|
|
"""A DIRTY_WORKSPACE failure gets a specific, actionable remediate — not
|
|
the generic i_am_blocked escalation."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.side_effect = RuntimeError(
|
|
"DIRTY_WORKSPACE: Cannot rebase with uncommitted changes."
|
|
)
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "invalid_state"
|
|
assert "stash=True" in (env.remediate or "")
|
|
assert "commit(" in (env.remediate or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_conflicts_with_stash_preserved_notes_it_in_next() -> None:
|
|
"""A conflict with stash_preserved=True tells the dev their stash is safe."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "conflicts",
|
|
"files": ["src/a.py"],
|
|
"stash_preserved": True,
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid, stash=True)
|
|
|
|
assert env.error is None
|
|
assert "stash" in (env.next or "").lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_stash_pop_conflict_notes_preserved_stash() -> None:
|
|
"""A clean rebase whose stash pop conflicted must not read as a plain
|
|
success — the dev still has manual work to finish."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = _task(tid=tid, aid=aid)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
|
|
git_svc = AsyncMock()
|
|
git_svc.sync_task_branch.return_value = {
|
|
"status": "rebased",
|
|
"unique_commits": 2,
|
|
"stash_pop_conflict": True,
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
with patch(
|
|
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
|
|
new=AsyncMock(return_value=_BASE),
|
|
):
|
|
env = await c.sync_branch(aid, tid, stash=True)
|
|
|
|
assert env.error is None
|
|
assert "conflict" in (env.next or "").lower()
|
|
assert "preserved" in (env.next or "").lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_branch_rejection_writes_audit_row() -> None:
|
|
"""Every rejection envelope must call audit.log_event (Task 6 contract)."""
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = None
|
|
audit_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, audit=audit_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.sync_branch(aid, tid)
|
|
|
|
assert env.error == "not_found"
|
|
audit_svc.log_event.assert_awaited_once()
|
|
kwargs = audit_svc.log_event.await_args.kwargs
|
|
assert kwargs["event_type"] == "gateway.rejected"
|
|
assert kwargs["details"]["verb"] == "sync_branch"
|