fix(git): reviewer reads must prefer origin over a diverged local ref (#690)

* fix(git): reviewer reads must prefer origin over a diverged local ref

_resolve_head_ref (GitService.diff/list_changed_files/read_file_at_branch)
kept local priority on ANY divergence from origin, real or rewritten. A
reviewer's clone parked on pre-rebase history after the branch's routine
force-push sync stayed frozen there across every subsequent review round,
while origin held every fix commit — QA repeatedly bounced work that had
already landed. Every caller here is a reader, never the branch's own
author mid-write, so origin now wins whenever it carries anything the
local ref lacks; local keeps priority only when it strictly contains
origin (unpushed commits, or equal).

The read-only git MCP surface (roboco_git_log) hit the same staleness
through a separate path: /api/git/log resolved the requested branch as a
bare name straight off whatever the caller's own clone had on disk, with
no fetch at all. It now routes through the same fixed _resolve_head_ref.

* test(e2e): give the armed flow-verb timeout real headroom

The armed value is also verb-2's entire execution budget (claim + every
claim guard + set_plan + start + tracing gate), which grows as guards
land; 1s flaked on loaded CI runners while passing locally. The
cancel-and-release semantics only need the timeout far below the hang.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-24 17:50:13 +02:00
committed by GitHub
co-authored by Renn F
parent eb0dcb6ecb
commit e97f46af6e
8 changed files with 277 additions and 34 deletions
+4
View File
@@ -722,6 +722,10 @@ async def test_diff_returns_diff_stdout() -> None:
del check, token
if args[:1] == ["fetch"]:
return MagicMock(stdout="", returncode=0)
if args[:1] == ["rev-parse"]:
return MagicMock(stdout="", returncode=0)
if args[:1] == ["rev-list"]:
return MagicMock(stdout="0", returncode=0)
return MagicMock(stdout="diff --git a b\n+hello\n", returncode=0)
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
@@ -103,9 +103,12 @@ _BR = "feature/backend/root1234--cellpm56--dev78901"
@pytest.mark.asyncio
async def test_resolve_head_ref_prefers_local_branch_in_dev_clone() -> None:
"""Dev's own clone has the local branch — use it unchanged."""
"""Dev's own clone has the local branch, ahead of/equal to origin
(origin has nothing local lacks) use it unchanged."""
svc = _git_service()
svc._run_git = AsyncMock()
svc._run_git = AsyncMock(
return_value=type("R", (), {"returncode": 0, "stdout": "0"})()
)
svc._ref_exists = AsyncMock(return_value=True)
head = await svc._resolve_head_ref(Path("/tmp/ws"), _BR)
@@ -0,0 +1,159 @@
"""Real-git regression tests for the read-path stale-local-ref fix.
Live incident (2026-07-24): a reviewer's clone (QA/PM/documenter/PR-gate,
never the branch's own author) held a local ref for the task branch that
had DIVERGED from origin after a routine rebase force-push. ``diff()`` and
``read_file_at_branch()`` both resolve their head ref through
``_resolve_head_ref``, which used to keep local priority on ANY divergence
(real or rewritten-history) QA's review evidence stayed frozen at a
stale commit for five straight review rounds while origin held every fix.
These run against a REAL bare origin + real clones (no mocked ``_run_git``)
mirroring ``test_git_rebase_reconcile.py`` only the workspace/token
resolution (``_workspace_for_branch`` / ``_token_for_branch``, both DB-
backed) is mocked so the test needs no database.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.services.git import GitService
if TYPE_CHECKING:
from pathlib import Path
_BRANCH = "feature/backend/task"
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args], cwd=repo, check=True, 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 _service() -> Any:
svc = GitService.__new__(GitService)
svc.log = MagicMock()
svc.session = MagicMock()
return svc
def _wire_for_reviewer(svc: Any, reviewer: Path) -> None:
"""Bypass the DB-backed workspace/token lookup: point every call at the
given clone, unauthenticated (local bare-repo remotes need no token)."""
svc._workspace_for_branch = AsyncMock(return_value=reviewer)
svc._token_for_branch = AsyncMock(return_value=None)
@pytest.fixture
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
"""A bare origin plus a dev clone, both carrying a pushed task branch."""
origin = tmp_path / "origin.git"
_init_bare(origin)
dev = tmp_path / "dev"
_clone(origin, dev)
_commit(dev, "README.md", "root\n")
_git(dev, "push", "origin", "master")
_git(dev, "checkout", "-b", _BRANCH)
_commit(dev, "feature.py", "v1\n")
_git(dev, "push", "origin", _BRANCH)
return origin, dev
@pytest.mark.asyncio
async def test_diverged_reviewer_clone_reads_origin(
repo_pair: tuple[Path, Path],
) -> None:
"""A reviewer clone checked out the branch during an earlier round; the
dev then rebased (force-pushed) it onto an advanced base. The reviewer's
local ref and origin now diverge by raw SHA the fix must still serve
origin's rewritten content, not the frozen local checkout."""
origin, dev = repo_pair
root_sha = _git(dev, "rev-parse", "master").stdout.strip()
reviewer = origin.parent / "reviewer"
_clone(origin, reviewer)
_git(reviewer, "checkout", _BRANCH) # local ref pinned at "v1"
# Advance master, then rebase the dev's branch onto it and force-push —
# this rewrites the branch's commit SHAs (routine force-push rebase).
_commit(dev, "base2.py", "advance master\n")
_git(dev, "push", "origin", "master")
_git(dev, "checkout", "master")
_git(dev, "pull")
_git(dev, "checkout", _BRANCH)
_git(dev, "rebase", "master")
_commit(dev, "fix.py", "the real fix\n")
_git(dev, "push", "--force", "origin", _BRANCH)
svc = _service()
_wire_for_reviewer(svc, reviewer)
# Literal root SHA as the diff base — an ancestor of both the pre- and
# post-rebase branch, so this is purely a probe of the HEAD side.
diff_out = await svc.diff(branch_name=_BRANCH, base=root_sha)
assert "fix.py" in diff_out
assert "the real fix" in diff_out
content = await svc.read_file_at_branch(branch_name=_BRANCH, path="fix.py")
assert content == "the real fix\n"
@pytest.mark.asyncio
async def test_author_ahead_reads_local(repo_pair: tuple[Path, Path]) -> None:
"""The branch's own author (committed but not yet pushed) still reads
their own local content origin has nothing local lacks."""
_origin, dev = repo_pair
_commit(dev, "unpushed.py", "not yet on origin\n")
svc = _service()
_wire_for_reviewer(svc, dev)
content = await svc.read_file_at_branch(branch_name=_BRANCH, path="unpushed.py")
assert content == "not yet on origin\n"
@pytest.mark.asyncio
async def test_absent_local_reads_origin(repo_pair: tuple[Path, Path]) -> None:
"""A clone that only fetched (never checked out) the branch — no local
ref at all still resolves cleanly to origin's content."""
origin, _dev = repo_pair
fresh = origin.parent / "fresh"
_clone(origin, fresh) # only master checked out; _BRANCH is origin-only
svc = _service()
_wire_for_reviewer(svc, fresh)
content = await svc.read_file_at_branch(branch_name=_BRANCH, path="feature.py")
assert content == "v1\n"
@@ -4,11 +4,19 @@ Live incident (2026-07-02): the S6 cell branch advanced on ORIGIN as child
PRs squash-merged on GitHub, but the assignee clone's local ref stayed
parked pre-merge. ``diff()`` preferred the local ref, so the PR-gate
reviewer's evidence diff re-flagged work that had already landed — two
false ``pr_fail`` verdicts on a clean PR.
false ``pr_fail`` verdicts on a clean PR. That fix only covered the
"local strictly behind" case; a DIVERGED local ref (parked on rewritten
history after the branch was force-pushed routine, since rebase syncs
force-push task branches) still kept local priority, so a reviewer's
evidence stayed frozen at a stale commit across every review round while
origin held the real fix.
Rule: when both refs exist and the local ref is STRICTLY BEHIND origin,
use ``origin/<branch>``; a local ref that is ahead (unpushed commits) or
diverged keeps priority, and single-ref cases are unchanged.
Every caller of this method is a READER (QA/PM/documenter/PR-gate/panel
inspecting a branch they don't own), never the branch's own author mid-
write. Rule: origin wins whenever it carries anything the local ref
lacks (behind OR diverged); local keeps priority only when it strictly
contains origin (ahead on unpushed commits, or equal). Single-ref cases
are unchanged.
"""
from __future__ import annotations
@@ -24,7 +32,7 @@ _BRANCH = "feature/frontend/root--cell"
_ORIGIN = f"origin/{_BRANCH}"
def _svc(*, refs: set[str], ancestor_rc: int) -> tuple[GitService, list[list[str]]]:
def _svc(*, refs: set[str], origin_only: int) -> tuple[GitService, list[list[str]]]:
svc = GitService.__new__(GitService)
calls: list[list[str]] = []
@@ -32,8 +40,8 @@ def _svc(*, refs: set[str], ancestor_rc: int) -> tuple[GitService, list[list[str
_workspace: Path, args: list[str], **_kw: Any
) -> SimpleNamespace:
calls.append(args)
if args[0] == "merge-base":
return SimpleNamespace(returncode=ancestor_rc, stdout="")
if args[0] == "rev-list":
return SimpleNamespace(returncode=0, stdout=str(origin_only))
return SimpleNamespace(returncode=0, stdout="")
async def _ref_exists(_workspace: Path, ref: str) -> bool:
@@ -47,27 +55,41 @@ def _svc(*, refs: set[str], ancestor_rc: int) -> tuple[GitService, list[list[str
@pytest.mark.asyncio
async def test_local_behind_origin_resolves_to_origin() -> None:
svc, calls = _svc(refs={_BRANCH, _ORIGIN}, ancestor_rc=0)
svc, calls = _svc(refs={_BRANCH, _ORIGIN}, origin_only=3)
ref = await svc._resolve_head_ref(Path("/tmp"), _BRANCH)
assert ref == _ORIGIN
ancestor = next(c for c in calls if c[0] == "merge-base")
assert ancestor == ["merge-base", "--is-ancestor", _BRANCH, _ORIGIN]
rev_list = next(c for c in calls if c[0] == "rev-list")
assert rev_list == ["rev-list", "--count", f"{_BRANCH}..{_ORIGIN}"]
@pytest.mark.asyncio
async def test_local_ahead_or_diverged_keeps_local() -> None:
svc, _calls = _svc(refs={_BRANCH, _ORIGIN}, ancestor_rc=1)
async def test_local_diverged_resolves_to_origin() -> None:
"""A force-pushed (rebased) branch: local carries the pre-rebase
history under old SHAs, origin holds the rewritten tip both sides
have commits the other lacks by raw SHA, but a reader must still see
origin, never the stale local rewrite (the bug: this used to keep
local priority on any divergence, real or rewritten)."""
svc, _calls = _svc(refs={_BRANCH, _ORIGIN}, origin_only=2)
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _ORIGIN
@pytest.mark.asyncio
async def test_local_ahead_keeps_local() -> None:
"""Committed-but-unpushed local work (origin has nothing local lacks)
is the one case local priority still serves the branch's own
author, not a reader's concern."""
svc, _calls = _svc(refs={_BRANCH, _ORIGIN}, origin_only=0)
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _BRANCH
@pytest.mark.asyncio
async def test_only_local_ref_unchanged() -> None:
svc, calls = _svc(refs={_BRANCH}, ancestor_rc=1)
svc, calls = _svc(refs={_BRANCH}, origin_only=0)
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _BRANCH
assert not any(c[0] == "merge-base" for c in calls)
assert not any(c[0] == "rev-list" for c in calls)
@pytest.mark.asyncio
async def test_only_origin_ref_unchanged() -> None:
svc, _calls = _svc(refs={_ORIGIN}, ancestor_rc=1)
svc, _calls = _svc(refs={_ORIGIN}, origin_only=0)
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _ORIGIN