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:
Renzo F
2026-07-24 14:34:25 +02:00
committed by GitHub
co-authored by Renn F
parent 3516d925fe
commit 71f5426e40
9 changed files with 744 additions and 63 deletions
@@ -148,6 +148,38 @@ async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
assert env.error is None
@pytest.mark.asyncio
async def test_diverged_rebase_outcome_escalates_rather_than_completing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A diverged rebase (local and origin each carry unique commits) must
escalate like any other non-rebased/non-superseded outcome — never
silently complete or re-merge a branch that could still be missing one
side's work."""
git = AsyncMock()
git.rebase_pr_for_task = AsyncMock(
return_value={"status": "diverged", "local_only": 1, "origin_only": 2}
)
git.close_pull_request = AsyncMock()
git.pr_merge = AsyncMock()
task = AsyncMock()
task.admin_set_status = AsyncMock()
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
choreo = _choreo(task, git, monkeypatch)
t = MagicMock(
pr_number=161, project_id=uuid4(), parent_task_id=None, team="backend"
)
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
)
task.admin_set_status.assert_awaited_once()
task.cell_pm_complete.assert_not_awaited()
git.close_pull_request.assert_not_awaited()
git.pr_merge.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_rebase_outcome_escalates_rather_than_completing(
monkeypatch: pytest.MonkeyPatch,