Files
roboco/tests/unit/gateway/test_assembled_branch_freshen.py
T
71f5426e40 fix(git): never discard committed local work in rebase_onto_base (#683)
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>
2026-07-24 14:34:25 +02:00

148 lines
4.8 KiB
Python

"""Behind-base auto-sync for the assembled PM submits (B2).
The needs_revision ↔ awaiting_pr_review ping-pong (live, 2026-07-02): a cell /
root revision re-submitted a head whose BASE had moved (sibling cells merged),
so the gate re-failed the same missing-work finding every cycle. Leaf devs
have the ``_behind_base_gate`` + ``sync_branch``; the assembled submits had no
freshness check at all. ``_freshen_assembled_branch`` closes that: at
submit_up / submit_root time every child is terminal, so rebasing the
assembled branch onto its base is safe — conflicts become a clean rejection
naming the files instead of a blind re-review.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
return ChoreographerDeps(**base)
def _cell_task() -> MagicMock:
return MagicMock(
id=uuid4(),
branch_name="feature/frontend/root--cell",
team="frontend",
)
@pytest.mark.asyncio
async def test_freshen_noop_when_up_to_date() -> None:
git = AsyncMock()
git.is_behind_base.return_value = (0, 3)
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is None
git.sync_task_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_freshen_rebases_when_behind_and_proceeds() -> None:
git = AsyncMock()
git.is_behind_base.return_value = (2, 3)
git.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 3}
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is None
git.sync_task_branch.assert_awaited_once()
@pytest.mark.asyncio
async def test_freshen_conflicts_reject_with_files() -> None:
git = AsyncMock()
git.is_behind_base.return_value = (2, 3)
git.sync_task_branch.return_value = {
"status": "conflicts",
"files": ["frontend/src/lib/stats.json"],
}
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "stats.json" in body["message"]
@pytest.mark.asyncio
async def test_freshen_diverged_rejects() -> None:
"""A diverged branch is refused just like a conflict — never guessed at."""
git = AsyncMock()
git.is_behind_base.return_value = (2, 3)
git.sync_task_branch.return_value = {
"status": "diverged",
"local_only": 1,
"origin_only": 2,
}
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "DIVERGED" in body["message"]
assert "reconcile" in body["remediate"]
@pytest.mark.asyncio
async def test_freshen_fails_open_on_probe_error() -> None:
git = AsyncMock()
git.is_behind_base.side_effect = RuntimeError("network sad")
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is None
@pytest.mark.asyncio
async def test_freshen_fails_open_on_sync_error() -> None:
git = AsyncMock()
git.is_behind_base.return_value = (1, 1)
git.sync_task_branch.side_effect = RuntimeError("rebase runner sad")
c = Choreographer(_make_deps(git=git))
env = await c._freshen_assembled_branch(
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
)
assert env is None
@pytest.mark.asyncio
async def test_freshen_skips_branchless_and_missing_base() -> None:
git = AsyncMock()
c = Choreographer(_make_deps(git=git))
branchless = MagicMock(id=uuid4(), branch_name=None, team="frontend")
assert (
await c._freshen_assembled_branch(branchless, base_branch="x", verb="submit_up")
is None
)
assert (
await c._freshen_assembled_branch(
_cell_task(), base_branch="", verb="submit_up"
)
is None
)
git.is_behind_base.assert_not_awaited()