mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(git): resolve diff HEAD ref per-workspace so QA/doc/PM see real diffs (#161 facet)
Smoke-14: QA's claim_review evidence had pr_diff_summary="" and files_changed=[] on a PR with a real README change. Root cause: diff() and list_changed_files() diffed against the bare local <branch_name>. That ref only exists in the clone where the dev ran `git checkout -b` at claim. QA / documenter / PM inspect from their OWN clones, where a bare <branch_name> resolves refs/heads then refs/remotes/<name> but NEVER refs/remotes/origin/<name> — so `git diff base...<branch>` had an unresolvable HEAD and silently returned an empty diff (run with check=False). #161 previously fixed the BASE side (cell-PM parent never pushed → fall back to default branch). This is the symmetric HEAD-side facet. open_pr pushes the leaf branch, so origin/<branch> is the workspace-independent source of truth. New _resolve_head_ref fetches the branch and prefers the local branch (dev's own clone, unchanged behaviour), falling back to origin/<branch> (QA/doc/PM clones), then the bare name so the command stays well-formed. diff() and list_changed_files() route through it; explicit base (incremental dev path, base=HEAD~1) is preserved.
This commit is contained in:
+43
-12
@@ -2183,6 +2183,29 @@ class GitService(BaseService):
|
||||
return f"origin/{parent}"
|
||||
return await self._default_branch_ref(workspace)
|
||||
|
||||
async def _resolve_head_ref(self, workspace: Any, branch_name: str) -> str:
|
||||
"""Ref for the branch tip that actually resolves in `workspace`.
|
||||
|
||||
Task #161 (facet): the local ``<branch_name>`` ref only exists in
|
||||
the clone where the dev ran ``git checkout -b`` at claim. QA /
|
||||
documenter / PM inspect from their OWN clones, which never had
|
||||
that local branch — a bare ``<branch_name>`` resolves
|
||||
``refs/heads`` then ``refs/remotes/<name>`` but NEVER
|
||||
``refs/remotes/origin/<name>``, so ``git diff base...<branch>``
|
||||
had an unresolvable head and returned an empty diff (QA saw no
|
||||
changes on a real PR). ``open_pr`` pushes the leaf branch, so
|
||||
``origin/<branch>`` is the workspace-independent source of truth.
|
||||
Fetch it, then prefer the local branch (dev's own clone) and fall
|
||||
back to ``origin/<branch>``; last resort the bare name so the
|
||||
diff command stays well-formed.
|
||||
"""
|
||||
await self._run_git(workspace, ["fetch", "origin", branch_name], check=False)
|
||||
if await self._ref_exists(workspace, branch_name):
|
||||
return branch_name
|
||||
if await self._ref_exists(workspace, f"origin/{branch_name}"):
|
||||
return f"origin/{branch_name}"
|
||||
return branch_name
|
||||
|
||||
async def diff(
|
||||
self,
|
||||
*,
|
||||
@@ -2204,12 +2227,15 @@ class GitService(BaseService):
|
||||
workspace = await self._workspace_for_branch(
|
||||
branch_name, actor_agent_id=actor_agent_id
|
||||
)
|
||||
if base is None:
|
||||
base_ref = await self._resolve_diff_base(workspace, branch_name)
|
||||
diff_args = ["diff", f"{base_ref}...{branch_name}"]
|
||||
else:
|
||||
diff_args = ["diff", f"{base}...{branch_name}"]
|
||||
diff_result = await self._run_git(workspace, diff_args, check=False)
|
||||
head_ref = await self._resolve_head_ref(workspace, branch_name)
|
||||
base_ref = (
|
||||
base
|
||||
if base is not None
|
||||
else await self._resolve_diff_base(workspace, branch_name)
|
||||
)
|
||||
diff_result = await self._run_git(
|
||||
workspace, ["diff", f"{base_ref}...{head_ref}"], check=False
|
||||
)
|
||||
return diff_result.stdout
|
||||
|
||||
async def list_changed_files(
|
||||
@@ -2232,12 +2258,17 @@ class GitService(BaseService):
|
||||
workspace = await self._workspace_for_branch(
|
||||
branch_name, actor_agent_id=actor_agent_id
|
||||
)
|
||||
if base is None:
|
||||
base_ref = await self._resolve_diff_base(workspace, branch_name)
|
||||
args = ["diff", "--name-only", f"{base_ref}...{branch_name}"]
|
||||
else:
|
||||
args = ["diff", "--name-only", f"{base}...{branch_name}"]
|
||||
result = await self._run_git(workspace, args, check=False)
|
||||
head_ref = await self._resolve_head_ref(workspace, branch_name)
|
||||
base_ref = (
|
||||
base
|
||||
if base is not None
|
||||
else await self._resolve_diff_base(workspace, branch_name)
|
||||
)
|
||||
result = await self._run_git(
|
||||
workspace,
|
||||
["diff", "--name-only", f"{base_ref}...{head_ref}"],
|
||||
check=False,
|
||||
)
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
async def commit(
|
||||
|
||||
@@ -88,3 +88,140 @@ async def test_default_branch_ref_fallback_when_no_head() -> None:
|
||||
svc._ref_exists = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
ref = await svc._default_branch_ref(Path("/tmp/ws"))
|
||||
assert ref == "origin/master"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task #161 (facet): the diff HEAD side must resolve in the inspecting
|
||||
# clone. The local <branch> ref only exists in the dev's own clone (the
|
||||
# clone that ran `git checkout -b` at claim). QA / doc / PM diff from
|
||||
# their OWN clones, which only have origin/<branch> after a fetch. Diffing
|
||||
# against the bare local name there yields an empty diff (smoke-14: QA saw
|
||||
# no changes on a real PR). _resolve_head_ref + diff()/list_changed_files
|
||||
# must prefer the local branch, then origin/<branch>.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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."""
|
||||
svc = _git_service()
|
||||
svc._run_git = AsyncMock() # type: ignore[method-assign]
|
||||
svc._ref_exists = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
head = await svc._resolve_head_ref(Path("/tmp/ws"), _BR)
|
||||
assert head == _BR
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_head_ref_falls_back_to_origin_in_foreign_clone() -> None:
|
||||
"""QA/doc/PM clone has no local branch but origin/<branch> exists
|
||||
(open_pr pushed it) — diff must target origin/<branch>."""
|
||||
svc = _git_service()
|
||||
svc._run_git = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
async def ref_exists(_ws: Any, ref: str) -> bool:
|
||||
# local branch absent; only the remote-tracking ref resolves.
|
||||
return ref == f"origin/{_BR}"
|
||||
|
||||
svc._ref_exists = ref_exists # type: ignore[method-assign]
|
||||
head = await svc._resolve_head_ref(Path("/tmp/ws"), _BR)
|
||||
assert head == f"origin/{_BR}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_head_ref_fetches_branch_before_resolving() -> None:
|
||||
"""The branch is fetched into the workspace so origin/<branch> is
|
||||
available even on a clone that never saw it."""
|
||||
svc = _git_service()
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
calls.append(args)
|
||||
return type("R", (), {"returncode": 0, "stdout": ""})()
|
||||
|
||||
svc._run_git = fake_run # type: ignore[method-assign]
|
||||
svc._ref_exists = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
await svc._resolve_head_ref(Path("/tmp/ws"), _BR)
|
||||
assert ["fetch", "origin", _BR] in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_targets_origin_head_in_foreign_clone() -> None:
|
||||
"""Regression for smoke-14: diff() from QA's clone must compare
|
||||
base...origin/<branch>, not base...<bare-local-branch> (which is
|
||||
unresolvable there and silently produced an empty diff)."""
|
||||
svc = _git_service()
|
||||
svc._workspace_for_branch = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=Path("/tmp/qa-ws")
|
||||
)
|
||||
svc._resolve_diff_base = AsyncMock( # type: ignore[method-assign]
|
||||
return_value="origin/master"
|
||||
)
|
||||
svc._resolve_head_ref = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=f"origin/{_BR}"
|
||||
)
|
||||
captured: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
captured.append(args)
|
||||
return type("R", (), {"returncode": 0, "stdout": "diff body"})()
|
||||
|
||||
svc._run_git = fake_run # type: ignore[method-assign]
|
||||
|
||||
out = await svc.diff(branch_name=_BR)
|
||||
assert out == "diff body"
|
||||
assert captured == [["diff", f"origin/master...origin/{_BR}"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_changed_files_targets_origin_head_in_foreign_clone() -> None:
|
||||
"""Same fix on the files_changed path (#154 evidence)."""
|
||||
svc = _git_service()
|
||||
svc._workspace_for_branch = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=Path("/tmp/qa-ws")
|
||||
)
|
||||
svc._resolve_diff_base = AsyncMock( # type: ignore[method-assign]
|
||||
return_value="origin/master"
|
||||
)
|
||||
svc._resolve_head_ref = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=f"origin/{_BR}"
|
||||
)
|
||||
captured: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
captured.append(args)
|
||||
return type("R", (), {"returncode": 0, "stdout": "README.md\nsrc/app.py\n"})()
|
||||
|
||||
svc._run_git = fake_run # type: ignore[method-assign]
|
||||
|
||||
files = await svc.list_changed_files(branch_name=_BR)
|
||||
assert files == ["README.md", "src/app.py"]
|
||||
assert captured == [["diff", "--name-only", f"origin/master...origin/{_BR}"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_honours_explicit_base_with_resolved_head() -> None:
|
||||
"""The incremental dev path (base=HEAD~1) still works: explicit base
|
||||
is preserved, head still goes through _resolve_head_ref."""
|
||||
svc = _git_service()
|
||||
svc._workspace_for_branch = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=Path("/tmp/dev-ws")
|
||||
)
|
||||
svc._resolve_diff_base = AsyncMock( # type: ignore[method-assign]
|
||||
return_value="SHOULD_NOT_BE_USED"
|
||||
)
|
||||
svc._resolve_head_ref = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=_BR
|
||||
)
|
||||
captured: list[list[str]] = []
|
||||
|
||||
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
||||
captured.append(args)
|
||||
return type("R", (), {"returncode": 0, "stdout": ""})()
|
||||
|
||||
svc._run_git = fake_run # type: ignore[method-assign]
|
||||
await svc.diff(branch_name=_BR, base="HEAD~1")
|
||||
assert captured == [["diff", f"HEAD~1...{_BR}"]]
|
||||
svc._resolve_diff_base.assert_not_awaited()
|
||||
|
||||
Reference in New Issue
Block a user