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
+13 -1
View File
@@ -243,6 +243,18 @@ async def get_git_log(
if not branch: if not branch:
branch = await git_service.get_current_branch(workspace) branch = await git_service.get_current_branch(workspace)
# This is the CALLER's own clone, which is never the branch's own
# author when inspecting another agent's task (QA/PM/documenter
# reading a dev's branch) — a local ref left over from an earlier
# inspection can be pinned stale (behind, or diverged after a
# routine rebase force-push) while origin has since moved. Resolve
# through _resolve_head_ref (fetch + prefer origin) instead of the
# bare branch name so this reads the same authoritative tip diff()/
# read_file_at_branch() do, not whatever this clone happened to
# have on disk from the last time it looked.
token = await git_service._token_for_branch(branch)
head_ref = await git_service._resolve_head_ref(workspace, branch, token=token)
# Get log with format. Don't raise if the branch doesn't exist in # Get log with format. Don't raise if the branch doesn't exist in
# this workspace yet — that's a normal race (branch created in a # this workspace yet — that's a normal race (branch created in a
# different agent's clone, not yet fetched here). Return empty. # different agent's clone, not yet fetched here). Return empty.
@@ -253,7 +265,7 @@ async def get_git_log(
log_format = "%H%x1f%h%x1f%s%x1f%an%x1f%aI" log_format = "%H%x1f%h%x1f%s%x1f%an%x1f%aI"
log_result = await git_service._run_git( log_result = await git_service._run_git(
workspace, workspace,
["log", f"--format={log_format}", f"-n{limit}", branch], ["log", f"--format={log_format}", f"-n{limit}", head_ref],
check=False, check=False,
) )
if log_result.returncode != 0: if log_result.returncode != 0:
+21 -14
View File
@@ -5904,9 +5904,24 @@ class GitService(BaseService):
had an unresolvable head and returned an empty diff (QA saw no had an unresolvable head and returned an empty diff (QA saw no
changes on a real PR). ``open_pr`` pushes the leaf branch, so changes on a real PR). ``open_pr`` pushes the leaf branch, so
``origin/<branch>`` is the workspace-independent source of truth. ``origin/<branch>`` is the workspace-independent source of truth.
Fetch it, then prefer the local branch (dev's own clone) and fall Fetch it, then prefer origin and fall back to the local branch;
back to ``origin/<branch>``; last resort the bare name so the last resort the bare name so the diff command stays well-formed.
diff command stays well-formed.
Every caller here is a READER (QA/PM/documenter/PR-gate/panel
inspecting a branch they don't own), never the branch's own author
mid-write so origin, not local, is the source of truth whenever
it carries anything the local ref lacks. ``origin_only`` counts
commits on origin the local ref doesn't have (via ``rev-list``,
mirroring ``_reset_head_or_diverged``'s write-path classification):
zero means local already contains everything origin has (ahead on
unpushed commits, or equal) and stays authoritative; non-zero means
origin has moved whether by a plain fast-forward OR by a
force-push that rewrote history (a parked local ref left over from
an earlier inspection, or the routine rebase-sync every task branch
gets) and origin wins either way. Unlike the write path's
``_origin_rewritten_locally``, a read never needs to tell a genuine
divergence apart from a rewritten one: both resolve to the same
action (serve origin), so no patch-equivalence check is needed here.
""" """
await self._run_git( await self._run_git(
workspace, ["fetch", "origin", branch_name], check=False, token=token workspace, ["fetch", "origin", branch_name], check=False, token=token
@@ -5915,18 +5930,10 @@ class GitService(BaseService):
local_exists = await self._ref_exists(workspace, branch_name) local_exists = await self._ref_exists(workspace, branch_name)
origin_exists = await self._ref_exists(workspace, origin_ref) origin_exists = await self._ref_exists(workspace, origin_ref)
if local_exists and origin_exists: if local_exists and origin_exists:
# An assembled branch advances on ORIGIN when child PRs merge on origin_only = await self._rev_list_count(
# GitHub, while the inspecting clone's local ref stays parked — a workspace, f"{branch_name}..{origin_ref}"
# diff off the stale local ref re-flags work that already landed
# (live 2026-07-02: two false pr_fails on the S6 cell PR). Prefer
# origin when the local ref is strictly behind it; a local ref
# that is ahead (unpushed) or diverged keeps priority.
behind = await self._run_git(
workspace,
["merge-base", "--is-ancestor", branch_name, origin_ref],
check=False,
) )
return origin_ref if behind.returncode == 0 else branch_name return origin_ref if origin_only > 0 else branch_name
if local_exists: if local_exists:
return branch_name return branch_name
if origin_exists: if origin_exists:
+7 -2
View File
@@ -74,10 +74,15 @@ _SUB_TASKS = [
] ]
_PLAN = "Land the refresh button via the frontend cell." _PLAN = "Land the refresh button via the frontend cell."
# set_plan sleeps this long inside the verb's own transaction. On the fix # set_plan sleeps this long inside the verb's own transaction. On the fix
# (1s server timeout) the sleep is cancelled well before this; disarmed # (armed server timeout) the sleep is cancelled well before this; disarmed
# (1000s server timeout, 3s client timeout) the client trips first. # (1000s server timeout, 3s client timeout) the client trips first.
# The armed timeout only needs to sit far below _HANG_SECONDS for the
# cancel-and-release semantics; it is also verb-2's ENTIRE execution budget
# (claim + every claim guard + set_plan + start + tracing gate), which keeps
# growing as guards land — 1s flaked on loaded CI runners while passing
# locally, so keep real headroom here.
_HANG_SECONDS = 8.0 _HANG_SECONDS = 8.0
_SERVER_TIMEOUT_SECONDS = 1.0 _SERVER_TIMEOUT_SECONDS = 3.0
_DISARMED_SERVER_TIMEOUT_SECONDS = 1000.0 _DISARMED_SERVER_TIMEOUT_SECONDS = 1000.0
# The MCP client's HTTP timeout for the disarmed reproduction — must be less # The MCP client's HTTP timeout for the disarmed reproduction — must be less
# than _HANG_SECONDS so the client trips before the sleep ends. Applied # than _HANG_SECONDS so the client trips before the sleep ends. Applied
+31
View File
@@ -266,6 +266,37 @@ async def test_log_with_branch_success(git_client: dict) -> None:
assert [c["author"] for c in commits] == ["me", "you"] assert [c["author"] for c in commits] == ["me", "you"]
@pytest.mark.asyncio
async def test_log_resolves_through_head_ref_not_bare_branch(
git_client: dict,
) -> None:
"""The route must route the requested branch through
``_resolve_head_ref`` (fetch + prefer origin) instead of handing git the
bare branch name straight off whatever this clone happens to have on
disk this clone is the CALLER's own, never the branch owner's, and a
left-over local ref from an earlier inspection can be pinned stale
(live 2026-07-24: a QA clone read a commit 5 review rounds old)."""
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value="origin/feature/x")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
svc._resolve_head_ref.assert_awaited_once_with("/tmp/ws", "feature/x", token="tok")
svc._run_git.assert_awaited_once()
logged_args = svc._run_git.await_args.args[1]
assert logged_args[-1] == "origin/feature/x"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_log_no_branch_fetches_current(git_client: dict) -> None: async def test_log_no_branch_fetches_current(git_client: dict) -> None:
log_result = MagicMock() log_result = MagicMock()
+4
View File
@@ -722,6 +722,10 @@ async def test_diff_returns_diff_stdout() -> None:
del check, token del check, token
if args[:1] == ["fetch"]: if args[:1] == ["fetch"]:
return MagicMock(stdout="", returncode=0) 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) return MagicMock(stdout="diff --git a b\n+hello\n", returncode=0)
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git)) _bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
@@ -103,9 +103,12 @@ _BR = "feature/backend/root1234--cellpm56--dev78901"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_head_ref_prefers_local_branch_in_dev_clone() -> None: 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 = _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) svc._ref_exists = AsyncMock(return_value=True)
head = await svc._resolve_head_ref(Path("/tmp/ws"), _BR) 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 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 parked pre-merge. ``diff()`` preferred the local ref, so the PR-gate
reviewer's evidence diff re-flagged work that had already landed — two 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, Every caller of this method is a READER (QA/PM/documenter/PR-gate/panel
use ``origin/<branch>``; a local ref that is ahead (unpushed commits) or inspecting a branch they don't own), never the branch's own author mid-
diverged keeps priority, and single-ref cases are unchanged. 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 from __future__ import annotations
@@ -24,7 +32,7 @@ _BRANCH = "feature/frontend/root--cell"
_ORIGIN = f"origin/{_BRANCH}" _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) svc = GitService.__new__(GitService)
calls: list[list[str]] = [] 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 _workspace: Path, args: list[str], **_kw: Any
) -> SimpleNamespace: ) -> SimpleNamespace:
calls.append(args) calls.append(args)
if args[0] == "merge-base": if args[0] == "rev-list":
return SimpleNamespace(returncode=ancestor_rc, stdout="") return SimpleNamespace(returncode=0, stdout=str(origin_only))
return SimpleNamespace(returncode=0, stdout="") return SimpleNamespace(returncode=0, stdout="")
async def _ref_exists(_workspace: Path, ref: str) -> bool: 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 @pytest.mark.asyncio
async def test_local_behind_origin_resolves_to_origin() -> None: 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) ref = await svc._resolve_head_ref(Path("/tmp"), _BRANCH)
assert ref == _ORIGIN assert ref == _ORIGIN
ancestor = next(c for c in calls if c[0] == "merge-base") rev_list = next(c for c in calls if c[0] == "rev-list")
assert ancestor == ["merge-base", "--is-ancestor", _BRANCH, _ORIGIN] assert rev_list == ["rev-list", "--count", f"{_BRANCH}..{_ORIGIN}"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_local_ahead_or_diverged_keeps_local() -> None: async def test_local_diverged_resolves_to_origin() -> None:
svc, _calls = _svc(refs={_BRANCH, _ORIGIN}, ancestor_rc=1) """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 assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _BRANCH
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_only_local_ref_unchanged() -> None: 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 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 @pytest.mark.asyncio
async def test_only_origin_ref_unchanged() -> None: 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 assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _ORIGIN