mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user