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:
@@ -2704,8 +2704,10 @@ class Choreographer:
|
|||||||
branch onto its base is safe; ``sync_task_branch`` pushes only the
|
branch onto its base is safe; ``sync_task_branch`` pushes only the
|
||||||
HEAD branch (master/main are never written). Fail-open on probe/sync
|
HEAD branch (master/main are never written). Fail-open on probe/sync
|
||||||
errors — the PR/merge layer keeps its own behind checks — but a rebase
|
errors — the PR/merge layer keeps its own behind checks — but a rebase
|
||||||
CONFLICT is a hard reject naming the files, so the PM routes a
|
CONFLICT is a hard reject naming the files, and a DIVERGED branch (the
|
||||||
conflict-resolution revision instead of re-submitting blind.
|
PM's own clone and origin each carry unique commits) is likewise a
|
||||||
|
hard reject, so the PM routes a conflict-resolution revision instead
|
||||||
|
of re-submitting blind.
|
||||||
"""
|
"""
|
||||||
if not getattr(t, "branch_name", None) or not base_branch:
|
if not getattr(t, "branch_name", None) or not base_branch:
|
||||||
return None
|
return None
|
||||||
@@ -2723,7 +2725,34 @@ class Choreographer:
|
|||||||
"assembled_freshen_sync_failed", task_id=str(t.id), error=str(exc)
|
"assembled_freshen_sync_failed", task_id=str(t.id), error=str(exc)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if result.get("status") == "conflicts":
|
reject = self._freshen_rejection_for(
|
||||||
|
result, behind=behind, base_branch=base_branch, verb=verb
|
||||||
|
)
|
||||||
|
if reject is not None:
|
||||||
|
return reject
|
||||||
|
logger.info(
|
||||||
|
"assembled_branch_freshened",
|
||||||
|
task_id=str(t.id),
|
||||||
|
verb=verb,
|
||||||
|
base_branch=base_branch,
|
||||||
|
behind=behind,
|
||||||
|
status=result.get("status"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _freshen_rejection_for(
|
||||||
|
result: dict[str, Any], *, behind: int, base_branch: str, verb: str
|
||||||
|
) -> Envelope | None:
|
||||||
|
"""Hard-reject shapes for ``_freshen_assembled_branch``'s sync result.
|
||||||
|
|
||||||
|
Conflicts and divergence both mean the auto-sync could not safely
|
||||||
|
reconcile the branch on its own — extracted so the caller's
|
||||||
|
return-statement count stays under the PLR0911 budget (mirrors
|
||||||
|
``_sync_branch_preflight_rejection``).
|
||||||
|
"""
|
||||||
|
status = result.get("status")
|
||||||
|
if status == "conflicts":
|
||||||
files = ", ".join(result.get("files") or []) or "unknown files"
|
files = ", ".join(result.get("files") or []) or "unknown files"
|
||||||
return Envelope.invalid_state(
|
return Envelope.invalid_state(
|
||||||
message=(
|
message=(
|
||||||
@@ -2740,13 +2769,22 @@ class Choreographer:
|
|||||||
),
|
),
|
||||||
context_briefing={},
|
context_briefing={},
|
||||||
)
|
)
|
||||||
logger.info(
|
if status == "diverged":
|
||||||
"assembled_branch_freshened",
|
return Envelope.invalid_state(
|
||||||
task_id=str(t.id),
|
message=(
|
||||||
verb=verb,
|
f"{verb} refused: the assembled branch has DIVERGED from "
|
||||||
base_branch=base_branch,
|
f"its origin copy ({result.get('local_only', '?')} "
|
||||||
behind=behind,
|
f"local-only commit(s), {result.get('origin_only', '?')} "
|
||||||
status=result.get("status"),
|
"origin-only commit(s)) — this workspace and origin each "
|
||||||
|
"carry work the other lacks"
|
||||||
|
),
|
||||||
|
remediate=(
|
||||||
|
"a human must reconcile the two histories by hand (fetch, "
|
||||||
|
"inspect both tips, merge or rebase deliberately) before "
|
||||||
|
"re-submitting — auto-sync refuses to guess which side to "
|
||||||
|
"keep"
|
||||||
|
),
|
||||||
|
context_briefing={},
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -4614,11 +4652,28 @@ class Choreographer:
|
|||||||
def _sync_branch_next_hint(t: Any, result: dict[str, Any]) -> str:
|
def _sync_branch_next_hint(t: Any, result: dict[str, Any]) -> str:
|
||||||
"""Compute the ``next`` hint for a completed ``sync_branch`` run.
|
"""Compute the ``next`` hint for a completed ``sync_branch`` run.
|
||||||
|
|
||||||
Three shapes: a rebase conflict (files listed, stash noted if one was
|
Four shapes: a genuine divergence (neither side touched, a human
|
||||||
taken), a clean rebase whose stash pop then conflicted (stash
|
must reconcile), a rebase conflict (files listed, stash noted if one
|
||||||
|
was taken), a clean rebase whose stash pop then conflicted (stash
|
||||||
preserved, never dropped), or the plain spec default.
|
preserved, never dropped), or the plain spec default.
|
||||||
"""
|
"""
|
||||||
status = str(result.get("status", "unknown"))
|
status = str(result.get("status", "unknown"))
|
||||||
|
if status == "diverged":
|
||||||
|
hint = (
|
||||||
|
"sync_branch refused: your branch and its origin copy have "
|
||||||
|
f"DIVERGED ({result.get('local_only', '?')} commit(s) only "
|
||||||
|
f"in your workspace, {result.get('origin_only', '?')} only "
|
||||||
|
"on origin) — neither side was touched. escalate via "
|
||||||
|
"i_am_blocked(reason='...') so a human can reconcile the two "
|
||||||
|
"histories by hand"
|
||||||
|
)
|
||||||
|
if result.get("stash_pop_conflict"):
|
||||||
|
hint += (
|
||||||
|
" — your stashed changes were also popped back into a "
|
||||||
|
"conflict; the stash is preserved (not dropped), resolve "
|
||||||
|
"it by hand once the divergence is reconciled"
|
||||||
|
)
|
||||||
|
return hint
|
||||||
if status == "conflicts":
|
if status == "conflicts":
|
||||||
hint = (
|
hint = (
|
||||||
f"sync_branch hit conflicts on {result.get('files', [])};"
|
f"sync_branch hit conflicts on {result.get('files', [])};"
|
||||||
|
|||||||
+136
-17
@@ -5180,6 +5180,18 @@ class GitService(BaseService):
|
|||||||
can now merge cleanly.
|
can now merge cleanly.
|
||||||
- ``{"status": "conflicts", "files": [...]}`` — the rebase hit
|
- ``{"status": "conflicts", "files": [...]}`` — the rebase hit
|
||||||
conflicts and was aborted; a developer must resolve by hand.
|
conflicts and was aborted; a developer must resolve by hand.
|
||||||
|
- ``{"status": "diverged", "local_only": int, "origin_only": int}``
|
||||||
|
— the local ``head_branch`` and ``origin/<head_branch>`` each
|
||||||
|
carry commits the other lacks with no patch-equivalent on the
|
||||||
|
other side. Most often this is the residue of a PRIOR call to
|
||||||
|
this same method that rebased locally but whose force-push then
|
||||||
|
failed (network blip, a flow-verb timeout kill, a container
|
||||||
|
reap between rebase and push) — that case is recognized by
|
||||||
|
patch-equivalence and self-heals as "ahead" instead (see
|
||||||
|
:meth:`_reset_head_or_diverged`). What's left is a genuine
|
||||||
|
divergence, e.g. the task bounced to a different agent's clone
|
||||||
|
that pushed meanwhile. Refused outright: neither side is
|
||||||
|
touched and nothing is pushed — a human must reconcile.
|
||||||
Any of the above may carry ``"stash_pop_conflict": True`` when
|
Any of the above may carry ``"stash_pop_conflict": True`` when
|
||||||
``stash`` popped into a conflict (see below).
|
``stash`` popped into a conflict (see below).
|
||||||
|
|
||||||
@@ -5188,32 +5200,47 @@ class GitService(BaseService):
|
|||||||
legitimate when it is the head's true merge target; the choreographer
|
legitimate when it is the head's true merge target; the choreographer
|
||||||
refuses only a mis-resolved one.
|
refuses only a mis-resolved one.
|
||||||
|
|
||||||
Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so the
|
Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so
|
||||||
``git reset --hard`` below can't discard uncommitted agent edits —
|
uncommitted agent edits are never discarded — UNLESS ``stash=True``,
|
||||||
UNLESS ``stash=True``, in which case the dirty worktree (tracked +
|
in which case the dirty worktree (tracked + untracked, ``-u``) is
|
||||||
untracked, ``-u``) is stashed first and popped back after the rebase
|
stashed first and popped back after the rebase instead of refusing
|
||||||
instead of refusing outright (the dev-facing dead end this closes:
|
outright (the dev-facing dead end this closes: DIRTY_WORKSPACE had no
|
||||||
DIRTY_WORKSPACE had no in-gate remedy other than a raw ``git`` the
|
in-gate remedy other than a raw ``git`` the agent is denied). A pop
|
||||||
agent is denied). A pop conflict is never auto-resolved — the stash
|
conflict is never auto-resolved — the stash is left in place (never
|
||||||
is left in place (never dropped) and the result gets
|
dropped) and the result gets ``stash_pop_conflict: True`` so the
|
||||||
``stash_pop_conflict: True`` so the caller returns an actionable
|
caller returns an actionable envelope; the agent's uncommitted work
|
||||||
envelope; the agent's uncommitted work is never lost.
|
is never lost.
|
||||||
|
|
||||||
|
Beyond uncommitted edits, a COMMITTED local tip is never discarded
|
||||||
|
either. The ``commit`` do-verb never pushes, so mid-rework a dev
|
||||||
|
routinely has committed-but-unpushed commits on ``head_branch`` — the
|
||||||
|
old unconditional ``reset --hard origin/<head_branch>`` right after
|
||||||
|
checkout silently rewound past them before the force-with-lease push
|
||||||
|
republished the truncated branch as authoritative. This now
|
||||||
|
classifies local vs ``origin/<head_branch>`` (post-fetch) first: an
|
||||||
|
absent local ref is recovered from origin (checkout, never reset —
|
||||||
|
nothing local to discard); local behind-or-equal resets to origin as
|
||||||
|
before (origin has nothing to lose); local strictly ahead skips the
|
||||||
|
reset and rebases from the local tip instead (a superset the push
|
||||||
|
below publishes); a genuine divergence refuses via ``"diverged"``
|
||||||
|
rather than guessing which side to keep.
|
||||||
"""
|
"""
|
||||||
stashed = await self._stash_if_dirty(workspace, stash=stash)
|
stashed = await self._stash_if_dirty(workspace, stash=stash)
|
||||||
|
|
||||||
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
|
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
|
||||||
|
await self._ensure_local_head_ref(workspace, head_branch)
|
||||||
await self._run_git(workspace, ["checkout", head_branch])
|
await self._run_git(workspace, ["checkout", head_branch])
|
||||||
await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"])
|
diverged = await self._reset_head_or_diverged(workspace, head_branch)
|
||||||
|
if diverged is not None:
|
||||||
|
if stashed:
|
||||||
|
await self._pop_stash_into(workspace, diverged)
|
||||||
|
return diverged
|
||||||
rebase = await self._run_git(
|
rebase = await self._run_git(
|
||||||
workspace, ["rebase", f"origin/{base_branch}"], check=False
|
workspace, ["rebase", f"origin/{base_branch}"], check=False
|
||||||
)
|
)
|
||||||
if rebase.returncode != 0:
|
if rebase.returncode != 0:
|
||||||
return await self._abort_rebase_conflict(workspace, stashed=stashed)
|
return await self._abort_rebase_conflict(workspace, stashed=stashed)
|
||||||
count = await self._run_git(
|
unique = await self._rev_list_count(workspace, f"origin/{base_branch}..HEAD")
|
||||||
workspace,
|
|
||||||
["rev-list", "--count", f"origin/{base_branch}..HEAD"],
|
|
||||||
)
|
|
||||||
unique = int(count.stdout.strip() or "0")
|
|
||||||
if unique == 0:
|
if unique == 0:
|
||||||
result: dict[str, Any] = {"status": "superseded"}
|
result: dict[str, Any] = {"status": "superseded"}
|
||||||
else:
|
else:
|
||||||
@@ -5227,6 +5254,97 @@ class GitService(BaseService):
|
|||||||
await self._pop_stash_into(workspace, result)
|
await self._pop_stash_into(workspace, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _rev_list_count(self, workspace: Path, range_spec: str) -> int:
|
||||||
|
"""``git rev-list --count <range_spec>`` as an int (empty stdout → 0)."""
|
||||||
|
result = await self._run_git(workspace, ["rev-list", "--count", range_spec])
|
||||||
|
return int(result.stdout.strip() or "0")
|
||||||
|
|
||||||
|
async def _ensure_local_head_ref(self, workspace: Path, head_branch: str) -> None:
|
||||||
|
"""Recover an absent local ``head_branch`` ref from origin before checkout.
|
||||||
|
|
||||||
|
Mirrors :meth:`_assert_on_task_branch`'s recovery: worktree flows
|
||||||
|
normally guarantee the local ref already exists, but a caller running
|
||||||
|
against a bare clone root (or a re-provisioned workspace) may only
|
||||||
|
have the branch on ``origin`` (this runs post-fetch) — create the
|
||||||
|
local ref (never reset one that already exists) so the unconditional
|
||||||
|
checkout right after this never fails on a missing branch.
|
||||||
|
"""
|
||||||
|
exists = await self._run_git(
|
||||||
|
workspace,
|
||||||
|
["rev-parse", "--verify", "--quiet", f"refs/heads/{head_branch}"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if exists.returncode != 0:
|
||||||
|
await self._run_git(
|
||||||
|
workspace, ["branch", head_branch, f"origin/{head_branch}"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _reset_head_or_diverged(
|
||||||
|
self, workspace: Path, head_branch: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Classify checked-out ``head_branch`` against ``origin/<head_branch>``.
|
||||||
|
|
||||||
|
Resets local to origin when local carries nothing origin lacks
|
||||||
|
(behind or equal — origin is authoritative, today's behavior).
|
||||||
|
Leaves the local tip untouched when it's strictly ahead
|
||||||
|
(committed-but-unpushed work — a superset of origin the
|
||||||
|
force-with-lease push below will publish along with the rebase).
|
||||||
|
|
||||||
|
BOTH sides carrying unique commits by raw SHA isn't proof of a
|
||||||
|
genuine divergence: a prior run of this same method can rebase
|
||||||
|
locally and then have its force-push fail after, leaving local and
|
||||||
|
origin both non-empty forever on retry even though origin's tip is
|
||||||
|
just local's old history under new SHAs. :meth:`_origin_rewritten_locally`
|
||||||
|
tells the two apart by patch-equivalence and this self-heals as
|
||||||
|
"ahead" instead. Only a real two-sided divergence — e.g. the task
|
||||||
|
bounced to a different agent's clone that pushed meanwhile — still
|
||||||
|
returns a ``{"status": "diverged", ...}`` dict; neither side is
|
||||||
|
silently discarded.
|
||||||
|
"""
|
||||||
|
local_only = await self._rev_list_count(
|
||||||
|
workspace, f"origin/{head_branch}..HEAD"
|
||||||
|
)
|
||||||
|
origin_only = await self._rev_list_count(
|
||||||
|
workspace, f"HEAD..origin/{head_branch}"
|
||||||
|
)
|
||||||
|
if local_only > 0 and origin_only > 0:
|
||||||
|
if await self._origin_rewritten_locally(workspace, head_branch):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": "diverged",
|
||||||
|
"local_only": local_only,
|
||||||
|
"origin_only": origin_only,
|
||||||
|
}
|
||||||
|
if local_only == 0:
|
||||||
|
await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _origin_rewritten_locally(
|
||||||
|
self, workspace: Path, head_branch: str
|
||||||
|
) -> bool:
|
||||||
|
"""True when every origin-only commit has a patch-equivalent local one.
|
||||||
|
|
||||||
|
Rescues the self-inflicted wedge from a rebase whose force-push
|
||||||
|
failed afterwards: local already carries origin's commits under new
|
||||||
|
SHAs, so a raw SHA rev-list count sees both sides positive forever.
|
||||||
|
``rev-list --cherry-pick --right-only`` drops any origin-only commit
|
||||||
|
whose patch (context-adjusted patch-id, same mechanism as
|
||||||
|
:meth:`unmerged_child_commits`'s ``git cherry``) matches one of
|
||||||
|
local's exclusive commits; whatever survives is truly exclusive to
|
||||||
|
origin, so a non-zero count still refuses as a real divergence.
|
||||||
|
"""
|
||||||
|
result = await self._run_git(
|
||||||
|
workspace,
|
||||||
|
[
|
||||||
|
"rev-list",
|
||||||
|
"--count",
|
||||||
|
"--right-only",
|
||||||
|
"--cherry-pick",
|
||||||
|
f"HEAD...origin/{head_branch}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return int(result.stdout.strip() or "0") == 0
|
||||||
|
|
||||||
async def _stash_if_dirty(self, workspace: Path, *, stash: bool) -> bool:
|
async def _stash_if_dirty(self, workspace: Path, *, stash: bool) -> bool:
|
||||||
"""Clean-tree gate for :meth:`rebase_onto_base`.
|
"""Clean-tree gate for :meth:`rebase_onto_base`.
|
||||||
|
|
||||||
@@ -5348,7 +5466,8 @@ class GitService(BaseService):
|
|||||||
rebase through the dev ``sync_branch`` verb instead of the CEO/PM-only
|
rebase through the dev ``sync_branch`` verb instead of the CEO/PM-only
|
||||||
``/rebase`` HTTP route. Mirrors ``rebase_pr_for_task``'s workspace/token
|
``/rebase`` HTTP route. Mirrors ``rebase_pr_for_task``'s workspace/token
|
||||||
resolution and delegates to :meth:`rebase_onto_base`, returning the same
|
resolution and delegates to :meth:`rebase_onto_base`, returning the same
|
||||||
classification dict (``rebased`` / ``superseded`` / ``conflicts``).
|
classification dict (``rebased`` / ``superseded`` / ``conflicts`` /
|
||||||
|
``diverged``).
|
||||||
|
|
||||||
``stash`` forwards to :meth:`rebase_onto_base` — auto-stash a dirty
|
``stash`` forwards to :meth:`rebase_onto_base` — auto-stash a dirty
|
||||||
worktree instead of refusing DIRTY_WORKSPACE.
|
worktree instead of refusing DIRTY_WORKSPACE.
|
||||||
|
|||||||
@@ -85,6 +85,27 @@ async def test_freshen_conflicts_reject_with_files() -> None:
|
|||||||
assert "stats.json" in body["message"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_freshen_fails_open_on_probe_error() -> None:
|
async def test_freshen_fails_open_on_probe_error() -> None:
|
||||||
git = AsyncMock()
|
git = AsyncMock()
|
||||||
|
|||||||
@@ -148,6 +148,38 @@ async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
|
|||||||
assert env.error is None
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_unknown_rebase_outcome_escalates_rather_than_completing(
|
async def test_unknown_rebase_outcome_escalates_rather_than_completing(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -131,6 +131,73 @@ async def test_sync_branch_conflicts_aborts_and_steers_to_resolve() -> None:
|
|||||||
assert "sync_branch again" 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
|
@pytest.mark.asyncio
|
||||||
async def test_sync_branch_not_found_for_unknown_task() -> None:
|
async def test_sync_branch_not_found_for_unknown_task() -> None:
|
||||||
aid = uuid4()
|
aid = uuid4()
|
||||||
|
|||||||
@@ -1463,7 +1463,10 @@ async def test_rebase_onto_base_stash_true_auto_stashes_and_pops() -> None:
|
|||||||
if args[:2] == ["status", "--porcelain"]:
|
if args[:2] == ["status", "--porcelain"]:
|
||||||
res.stdout = " M dirty.py\n"
|
res.stdout = " M dirty.py\n"
|
||||||
elif args[:2] == ["rev-list", "--count"]:
|
elif args[:2] == ["rev-list", "--count"]:
|
||||||
res.stdout = "1"
|
# Only the post-rebase unique-vs-base count is non-zero; the
|
||||||
|
# pre-rebase local-vs-origin(HEAD) classification reads as
|
||||||
|
# "nothing unique on either side" (behind/equal, not diverged).
|
||||||
|
res.stdout = "1" if args[2] == "origin/master..HEAD" else "0"
|
||||||
else:
|
else:
|
||||||
res.stdout = ""
|
res.stdout = ""
|
||||||
return res
|
return res
|
||||||
@@ -1499,7 +1502,10 @@ async def test_rebase_onto_base_stash_pop_conflict_preserves_stash() -> None:
|
|||||||
if args[:2] == ["status", "--porcelain"]:
|
if args[:2] == ["status", "--porcelain"]:
|
||||||
res.stdout = " M dirty.py\n"
|
res.stdout = " M dirty.py\n"
|
||||||
elif args[:2] == ["rev-list", "--count"]:
|
elif args[:2] == ["rev-list", "--count"]:
|
||||||
res.stdout = "1"
|
# Only the post-rebase unique-vs-base count is non-zero; the
|
||||||
|
# pre-rebase local-vs-origin(HEAD) classification reads as
|
||||||
|
# "nothing unique on either side" (behind/equal, not diverged).
|
||||||
|
res.stdout = "1" if args[2] == "origin/master..HEAD" else "0"
|
||||||
elif args == ["stash", "pop"]:
|
elif args == ["stash", "pop"]:
|
||||||
res.returncode = 1 # pop conflicted — stash is NOT dropped by git
|
res.returncode = 1 # pop conflicted — stash is NOT dropped by git
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -94,21 +94,27 @@ async def test_success_path_returns_rebased_and_does_not_call_abort(
|
|||||||
Call sequence for the success path (rebase OK, 2 unique commits):
|
Call sequence for the success path (rebase OK, 2 unique commits):
|
||||||
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
||||||
[1] fetch origin
|
[1] fetch origin
|
||||||
[2] checkout HEAD branch
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
||||||
[3] reset --hard origin/HEAD
|
[3] checkout HEAD branch
|
||||||
[4] rebase origin/BASE ← exits 0
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
||||||
[5] rev-list --count ← returns "2"
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
||||||
[6] push --force-with-lease ← pushes the rebased branch
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
||||||
|
[7] rebase origin/BASE ← exits 0
|
||||||
|
[8] rev-list --count ← returns "2"
|
||||||
|
[9] push --force-with-lease ← pushes the rebased branch
|
||||||
"""
|
"""
|
||||||
run = AsyncMock(
|
run = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
_result(stdout=""), # [0] status --porcelain → clean
|
_result(stdout=""), # [0] status --porcelain → clean
|
||||||
_result(), # [1] fetch
|
_result(), # [1] fetch
|
||||||
_result(), # [2] checkout
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
||||||
_result(), # [3] reset
|
_result(), # [3] checkout
|
||||||
_result(), # [4] rebase ← success
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
||||||
_result(stdout="2\n"), # [5] rev-list
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
||||||
_result(), # [6] push
|
_result(), # [6] reset (local not ahead → reset)
|
||||||
|
_result(), # [7] rebase ← success
|
||||||
|
_result(stdout="2\n"), # [8] rev-list origin/BASE..HEAD
|
||||||
|
_result(), # [9] push
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(GitService, "_run_git", run)
|
monkeypatch.setattr(GitService, "_run_git", run)
|
||||||
@@ -148,21 +154,27 @@ async def test_conflict_path_calls_diff_then_abort_and_returns_conflict_files(
|
|||||||
Call sequence:
|
Call sequence:
|
||||||
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
||||||
[1] fetch origin
|
[1] fetch origin
|
||||||
[2] checkout HEAD branch
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
||||||
[3] reset --hard origin/HEAD
|
[3] checkout HEAD branch
|
||||||
[4] rebase origin/BASE ← exits 1 (conflict)
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
||||||
[5] diff --name-only ← lists conflicted files
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
||||||
[6] rebase --abort ← exits 0
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
||||||
|
[7] rebase origin/BASE ← exits 1 (conflict)
|
||||||
|
[8] diff --name-only ← lists conflicted files
|
||||||
|
[9] rebase --abort ← exits 0
|
||||||
"""
|
"""
|
||||||
run = AsyncMock(
|
run = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
_result(stdout=""), # [0] status --porcelain → clean
|
_result(stdout=""), # [0] status --porcelain → clean
|
||||||
_result(), # [1] fetch
|
_result(), # [1] fetch
|
||||||
_result(), # [2] checkout
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
||||||
_result(), # [3] reset
|
_result(), # [3] checkout
|
||||||
_result(returncode=1), # [4] rebase ← conflict
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
||||||
_result(stdout="src/a.py\nsrc/b.py\n"), # [5] diff
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
||||||
_result(), # [6] rebase --abort
|
_result(), # [6] reset (local not ahead → reset)
|
||||||
|
_result(returncode=1), # [7] rebase ← conflict
|
||||||
|
_result(stdout="src/a.py\nsrc/b.py\n"), # [8] diff
|
||||||
|
_result(), # [9] rebase --abort
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(GitService, "_run_git", run)
|
monkeypatch.setattr(GitService, "_run_git", run)
|
||||||
@@ -214,21 +226,27 @@ async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_ex
|
|||||||
Call sequence:
|
Call sequence:
|
||||||
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
||||||
[1] fetch origin
|
[1] fetch origin
|
||||||
[2] checkout HEAD branch
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
||||||
[3] reset --hard origin/HEAD
|
[3] checkout HEAD branch
|
||||||
[4] rebase origin/BASE ← exits 1 (conflict)
|
[4] rev-list --count origin/H..HEAD ← local_only=0
|
||||||
[5] diff --name-only ← lists conflicted files
|
[5] rev-list --count HEAD..origin/H ← origin_only=0 (not diverged)
|
||||||
[6] rebase --abort ← exits 1 (abort also fails)
|
[6] reset --hard origin/HEAD ← local has nothing unique
|
||||||
|
[7] rebase origin/BASE ← exits 1 (conflict)
|
||||||
|
[8] diff --name-only ← lists conflicted files
|
||||||
|
[9] rebase --abort ← exits 1 (abort also fails)
|
||||||
"""
|
"""
|
||||||
run = AsyncMock(
|
run = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
_result(stdout=""), # [0] status --porcelain → clean
|
_result(stdout=""), # [0] status --porcelain → clean
|
||||||
_result(), # [1] fetch
|
_result(), # [1] fetch
|
||||||
_result(), # [2] checkout
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
||||||
_result(), # [3] reset
|
_result(), # [3] checkout
|
||||||
_result(returncode=1), # [4] rebase ← conflict
|
_result(stdout="0\n"), # [4] rev-list origin/H..HEAD → local_only=0
|
||||||
_result(stdout="src/conflict.py\n"), # [5] diff
|
_result(stdout="0\n"), # [5] rev-list HEAD..origin/H → origin_only=0
|
||||||
_result(returncode=1), # [6] rebase --abort ← also fails
|
_result(), # [6] reset (local not ahead → reset)
|
||||||
|
_result(returncode=1), # [7] rebase ← conflict
|
||||||
|
_result(stdout="src/conflict.py\n"), # [8] diff
|
||||||
|
_result(returncode=1), # [9] rebase --abort ← also fails
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(GitService, "_run_git", run)
|
monkeypatch.setattr(GitService, "_run_git", run)
|
||||||
@@ -246,6 +264,57 @@ async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_ex
|
|||||||
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
|
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 4 — diverged: both sides carry unique commits, neither is touched
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diverged_path_returns_diverged_and_touches_nothing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""When local HEAD and origin/HEAD each carry commits the other lacks
|
||||||
|
AND the patch-equivalence probe finds a real origin-only commit (no
|
||||||
|
local match), the method refuses immediately — no reset, no rebase, no
|
||||||
|
push.
|
||||||
|
|
||||||
|
Call sequence:
|
||||||
|
[0] status --porcelain ← clean (the H8 dirty-tree gate)
|
||||||
|
[1] fetch origin
|
||||||
|
[2] rev-parse --verify --quiet ← local HEAD ref exists
|
||||||
|
[3] checkout HEAD branch
|
||||||
|
[4] rev-list --count origin/H..HEAD ← local_only=2
|
||||||
|
[5] rev-list --count HEAD..origin/H ← origin_only=1
|
||||||
|
[6] rev-list --count --right-only --cherry-pick HEAD...origin/H
|
||||||
|
← 1 origin-only commit survives patch-equivalence → DIVERGED
|
||||||
|
"""
|
||||||
|
run = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_result(stdout=""), # [0] status --porcelain → clean
|
||||||
|
_result(), # [1] fetch
|
||||||
|
_result(returncode=0), # [2] rev-parse --verify (local ref exists)
|
||||||
|
_result(), # [3] checkout
|
||||||
|
_result(stdout="2\n"), # [4] rev-list origin/H..HEAD → local_only=2
|
||||||
|
_result(stdout="1\n"), # [5] rev-list HEAD..origin/H → origin_only=1
|
||||||
|
_result(stdout="1\n"), # [6] cherry-pick probe → 1 genuinely unmatched
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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": "diverged", "local_only": 2, "origin_only": 1}
|
||||||
|
# Nothing past the classification ran: no reset, no rebase, no push.
|
||||||
|
subcommands = {c.args[1][0] for c in run.call_args_list}
|
||||||
|
assert subcommands.isdisjoint({"reset", "rebase", "push"})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Safety gate tests for rebase() — protected-branch guard
|
# Safety gate tests for rebase() — protected-branch guard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Real-git regression tests for the committed-work-preserving reconciliation
|
||||||
|
in ``rebase_onto_base``.
|
||||||
|
|
||||||
|
The historical bug (live, struck repeatedly): the preamble ran an
|
||||||
|
unconditional ``git reset --hard origin/<head_branch>`` right after checkout,
|
||||||
|
which rewound the local branch to the last-PUSHED tip — silently discarding
|
||||||
|
every committed-but-unpushed commit, since the ``commit`` do-verb never
|
||||||
|
pushes. The subsequent ``push --force-with-lease`` then succeeded (the lease
|
||||||
|
matches the freshly-fetched origin ref, which never moved) and republished
|
||||||
|
the truncated branch as authoritative.
|
||||||
|
|
||||||
|
These run against a REAL bare origin + real clones (no mocked ``_run_git``)
|
||||||
|
— the bug lived exactly in real-git ref semantics, and a mock can't
|
||||||
|
reproduce a fast-forward/divergence classification bug like this one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.exceptions import GitCommandError
|
||||||
|
from roboco.services.git import GitService
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_HEAD = "feature/backend/task"
|
||||||
|
_BASE = "master"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], cwd=repo, check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_ok(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Non-raising variant for existence probes."""
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], cwd=repo, check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_bare(path: Path) -> None:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "--bare", "--initial-branch=master", str(path)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(repo: Path) -> None:
|
||||||
|
_git(repo, "config", "user.email", "t@example.com")
|
||||||
|
_git(repo, "config", "user.name", "T")
|
||||||
|
_git(repo, "config", "commit.gpgsign", "false")
|
||||||
|
|
||||||
|
|
||||||
|
def _clone(origin: Path, dest: Path) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", str(origin), str(dest)], check=True, capture_output=True
|
||||||
|
)
|
||||||
|
_configure(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(repo: Path, name: str, content: str) -> None:
|
||||||
|
(repo / name).write_text(content)
|
||||||
|
_git(repo, "add", name)
|
||||||
|
_git(repo, "commit", "-m", f"add {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_subjects(repo: Path, ref: str) -> list[str]:
|
||||||
|
out = _git(repo, "log", "--format=%s", ref)
|
||||||
|
return out.stdout.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _rev(repo: Path, ref: str) -> str:
|
||||||
|
return _git(repo, "rev-parse", ref).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _service() -> GitService:
|
||||||
|
svc = GitService.__new__(GitService)
|
||||||
|
svc.log = MagicMock()
|
||||||
|
svc.session = MagicMock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
"""A bare origin plus a working clone, both carrying a pushed task branch
|
||||||
|
(``master`` with one root commit, ``_HEAD`` branched off it with one more)."""
|
||||||
|
origin = tmp_path / "origin.git"
|
||||||
|
_init_bare(origin)
|
||||||
|
seed = tmp_path / "seed"
|
||||||
|
_clone(origin, seed)
|
||||||
|
_commit(seed, "README.md", "root\n")
|
||||||
|
_git(seed, "push", "origin", "master")
|
||||||
|
_git(seed, "checkout", "-b", _HEAD)
|
||||||
|
_commit(seed, "feature.py", "v1\n")
|
||||||
|
_git(seed, "push", "origin", _HEAD)
|
||||||
|
|
||||||
|
work = tmp_path / "work"
|
||||||
|
_clone(origin, work)
|
||||||
|
_git(work, "checkout", _HEAD)
|
||||||
|
return origin, work
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_ahead_survives_sync_and_reaches_origin(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(a) Committed-but-unpushed local work is a superset of origin — the
|
||||||
|
reset is skipped and the rebase + force-push PUBLISHES it instead of
|
||||||
|
discarding it (the historical data-loss bug)."""
|
||||||
|
origin, work = repo_pair
|
||||||
|
_commit(work, "fix.py", "unpushed fix\n") # committed locally, never pushed
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "rebased"
|
||||||
|
assert "add fix.py" in _log_subjects(origin, _HEAD)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_behind_adopts_origin_unchanged(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(b) Local has nothing origin lacks (a sibling pushed while this clone
|
||||||
|
sat idle) — resets to origin exactly as before the fix."""
|
||||||
|
origin, work = repo_pair
|
||||||
|
other = origin.parent / "other"
|
||||||
|
_clone(origin, other)
|
||||||
|
_git(other, "checkout", _HEAD)
|
||||||
|
_commit(other, "sibling.py", "from another clone\n")
|
||||||
|
_git(other, "push", "origin", _HEAD)
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "rebased"
|
||||||
|
assert "add sibling.py" in _log_subjects(work, _HEAD)
|
||||||
|
assert _rev(work, _HEAD) == _rev(work, f"origin/{_HEAD}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diverged_refuses_and_touches_neither_side(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(c) Both local and origin carry unique commits — refuse outright;
|
||||||
|
neither branch nor origin is touched, nothing is pushed."""
|
||||||
|
origin, work = repo_pair
|
||||||
|
other = origin.parent / "other"
|
||||||
|
_clone(origin, other)
|
||||||
|
_git(other, "checkout", _HEAD)
|
||||||
|
_commit(other, "sibling.py", "from another clone\n")
|
||||||
|
_git(other, "push", "origin", _HEAD)
|
||||||
|
|
||||||
|
_commit(work, "local.py", "local-only work\n") # unpushed local commit
|
||||||
|
|
||||||
|
local_before = _rev(work, _HEAD)
|
||||||
|
origin_before = _rev(origin, _HEAD)
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"status": "diverged", "local_only": 1, "origin_only": 1}
|
||||||
|
assert _rev(work, _HEAD) == local_before
|
||||||
|
assert _rev(origin, _HEAD) == origin_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_ref_absent_recovers_from_origin(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""A workspace whose local ref for ``head_branch`` doesn't exist yet
|
||||||
|
(only fetched/tracked from origin, e.g. a bare clone-root caller) is
|
||||||
|
recovered — checkout, never reset, since there's nothing local to lose."""
|
||||||
|
origin, _work = repo_pair
|
||||||
|
fresh = origin.parent / "fresh"
|
||||||
|
_clone(origin, fresh) # only master is checked out; _HEAD is origin-only
|
||||||
|
|
||||||
|
assert _git_ok(fresh, "rev-parse", "--verify", "--quiet", _HEAD).returncode != 0
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
fresh, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "rebased"
|
||||||
|
assert _git_ok(fresh, "rev-parse", "--verify", "--quiet", _HEAD).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_superseded_still_returns_superseded(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(d) A head whose work is already in base is still 'superseded' —
|
||||||
|
byte-for-byte unchanged by the reconciliation step."""
|
||||||
|
origin, work = repo_pair
|
||||||
|
|
||||||
|
# Fast-forward base past the branch's own tip so the rebase leaves it
|
||||||
|
# with zero unique commits over base.
|
||||||
|
other = origin.parent / "other-base"
|
||||||
|
_clone(origin, other)
|
||||||
|
_git(other, "merge", "--no-ff", "-m", "merge feature", f"origin/{_HEAD}")
|
||||||
|
_git(other, "push", "origin", "master")
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
assert result == {"status": "superseded"}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_rejecting_hook(bare: Path) -> None:
|
||||||
|
"""Make every push to ``bare`` fail, simulating a push-side failure
|
||||||
|
(network blip, flow-verb timeout kill, container reap) after a rebase
|
||||||
|
already succeeded locally."""
|
||||||
|
hook = bare / "hooks" / "pre-receive"
|
||||||
|
hook.write_text("#!/bin/sh\nexit 1\n")
|
||||||
|
hook.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_rejecting_hook(bare: Path) -> None:
|
||||||
|
(bare / "hooks" / "pre-receive").unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_self_inflicted_wedge_self_heals_on_retry(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(e) A prior rebase that succeeded locally but whose force-push then
|
||||||
|
failed leaves local=rebased-history, origin=old-history — both rev-list
|
||||||
|
counts positive by raw SHA. The patch-equivalence probe recognizes
|
||||||
|
origin's tip as fully rewritten into local's exclusive commits, so the
|
||||||
|
retry self-heals as 'rebased' instead of refusing as 'diverged' forever."""
|
||||||
|
origin, work = repo_pair
|
||||||
|
|
||||||
|
# Advance master so the rebase actually rewrites HEAD's commits' SHAs.
|
||||||
|
other = origin.parent / "other-base"
|
||||||
|
_clone(origin, other)
|
||||||
|
_commit(other, "base2.py", "advance master\n")
|
||||||
|
_git(other, "push", "origin", "master")
|
||||||
|
|
||||||
|
_commit(work, "fix.py", "unpushed fix\n") # local commit to be rebased
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
_install_rejecting_hook(origin)
|
||||||
|
with pytest.raises(GitCommandError):
|
||||||
|
await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
_remove_rejecting_hook(origin)
|
||||||
|
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
work, head_branch=_HEAD, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "rebased"
|
||||||
|
assert "add fix.py" in _log_subjects(origin, _HEAD)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conflicts_still_refuses_and_reports_files(
|
||||||
|
repo_pair: tuple[Path, Path],
|
||||||
|
) -> None:
|
||||||
|
"""(d) A genuine rebase conflict is still reported exactly as before —
|
||||||
|
aborted, files listed, nothing pushed."""
|
||||||
|
origin, _work = repo_pair
|
||||||
|
|
||||||
|
# Branch off the ORIGINAL master and edit README.md.
|
||||||
|
conflict_head = "feature/backend/conflict"
|
||||||
|
conflicting = origin.parent / "conflicting"
|
||||||
|
_clone(origin, conflicting)
|
||||||
|
_git(conflicting, "checkout", "-b", conflict_head)
|
||||||
|
(conflicting / "README.md").write_text("branch change\n")
|
||||||
|
_git(conflicting, "add", "README.md")
|
||||||
|
_git(conflicting, "commit", "-m", "branch edits README")
|
||||||
|
_git(conflicting, "push", "origin", conflict_head)
|
||||||
|
|
||||||
|
# Move master with a COMPETING edit to the same line, so rebasing the
|
||||||
|
# branch onto the new master tip collides.
|
||||||
|
other = origin.parent / "other-base"
|
||||||
|
_clone(origin, other)
|
||||||
|
(other / "README.md").write_text("master change\n")
|
||||||
|
_git(other, "add", "README.md")
|
||||||
|
_git(other, "commit", "-m", "master edits README")
|
||||||
|
_git(other, "push", "origin", "master")
|
||||||
|
|
||||||
|
svc = _service()
|
||||||
|
result = await svc.rebase_onto_base(
|
||||||
|
conflicting, head_branch=conflict_head, base_branch=_BASE, git_token=""
|
||||||
|
)
|
||||||
|
assert result["status"] == "conflicts"
|
||||||
|
assert result["files"] == ["README.md"]
|
||||||
@@ -64,7 +64,14 @@ async def test_rebase_rebased_force_pushes_when_unique_commits() -> None:
|
|||||||
pushed.append(args)
|
pushed.append(args)
|
||||||
return _result()
|
return _result()
|
||||||
if args[:2] == ["rev-list", "--count"]:
|
if args[:2] == ["rev-list", "--count"]:
|
||||||
|
# Only the post-rebase unique-vs-base count is non-zero; the
|
||||||
|
# pre-rebase local-vs-origin(HEAD) classification must read as
|
||||||
|
# "nothing unique on either side" or this would misclassify as
|
||||||
|
# diverged before the rebase ever runs.
|
||||||
|
range_spec = next(iter(args[2:]), "")
|
||||||
|
if range_spec == f"origin/{_BASE}..HEAD":
|
||||||
return _result(stdout="3\n")
|
return _result(stdout="3\n")
|
||||||
|
return _result(stdout="0\n")
|
||||||
return _result()
|
return _result()
|
||||||
|
|
||||||
with patch.object(svc, "_run_git", new=fake_run):
|
with patch.object(svc, "_run_git", new=fake_run):
|
||||||
|
|||||||
Reference in New Issue
Block a user