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 _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>
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
"""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"
|