mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -131,6 +131,73 @@ async def test_sync_branch_conflicts_aborts_and_steers_to_resolve() -> None:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user