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:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user