mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(tasks): merge cross-lineage dependency content at branch cut The dependency gate enforced timing but never content: a dependent's fresh branch could miss a same-repo dependency's merged work when that merge landed outside the branch's own ancestor chain (cross-cell edges under one root, same-repo batch cross-root edges). After a successful branch cut, each dependency's real merge target (resolve_parent_branch) is fetched and, unless already an ancestor, merged into the new branch; conflicts abort cleanly (branch stays at its cut point, warning + an accumulating task marker note) and never fail the claim. Cross-repo dependencies are skipped — no shared history. Zero git work for the no-deps common case; resumes never re-enter (branch creation only). * chore(foundation): regenerate lifecycle artifacts; reflow inherited prose * [lineage] mypy-clean mock idioms in the lineage orchestration tests --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
186 lines
6.7 KiB
Python
186 lines
6.7 KiB
Python
"""``GitService.merge_dependency_lineage`` — the cross-subtree/cross-cell
|
|
dependency-lineage gap.
|
|
|
|
The claim-time dependency gate (``TaskService._claim_blocked_by_dependencies``)
|
|
only enforces TIMING — a dependency must be terminal before the dependent task
|
|
can claim — it never checks whether the dependency's MERGED content is
|
|
reachable from the dependent's freshly cut branch. A cross-subtree/cross-cell
|
|
edge (e.g. a UX cell task merged into the UX cell branch, depended on by a
|
|
sibling frontend cell task cut from the frontend cell branch) can complete on
|
|
a branch the dependent never descends from.
|
|
|
|
This backfills it at branch-cut time: fetch the dependency's merge-target
|
|
branch, no-op if it is already an ancestor of the fresh branch (the common,
|
|
transitively-safe case), otherwise merge it in. On a real conflict the merge
|
|
is aborted and the branch is left exactly at its cut point — a content
|
|
assist, never a gate, so it always returns a status rather than raising.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.git import GitService
|
|
|
|
_WORKSPACE = Path("/tmp/fake-ws")
|
|
_TASK_ID = uuid4()
|
|
_WORKTREE = _WORKSPACE / ".worktrees" / str(_TASK_ID)[:8]
|
|
_BRANCH = "feature/frontend/root--fe-cell"
|
|
_SOURCE = "feature/ux_ui/root--ux-cell"
|
|
_ORIGIN_SOURCE = f"origin/{_SOURCE}"
|
|
|
|
|
|
def _git_service() -> GitService:
|
|
svc = GitService.__new__(GitService)
|
|
svc.log = MagicMock()
|
|
return svc
|
|
|
|
|
|
def _result(returncode: int = 0, stdout: str = "") -> Any:
|
|
r = MagicMock()
|
|
r.returncode = returncode
|
|
r.stdout = stdout
|
|
return r
|
|
|
|
|
|
def _drive(
|
|
svc: GitService, responses: dict[tuple[str, ...], Any]
|
|
) -> list[tuple[Path, list[str]]]:
|
|
"""Stub _run_git to answer by args-prefix; _ensure_worktree_for_commit +
|
|
_token_for_project are no-ops so only merge mechanics are under test."""
|
|
calls: list[tuple[Path, list[str]]] = []
|
|
|
|
async def _run(workspace: Path, args: list[str], **_kw: object) -> Any:
|
|
calls.append((Path(workspace), list(args)))
|
|
for prefix, resp in responses.items():
|
|
if tuple(args[: len(prefix)]) == prefix:
|
|
return resp
|
|
return _result()
|
|
|
|
object.__setattr__(svc, "_run_git", _run)
|
|
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
|
object.__setattr__(svc, "_ensure_worktree_for_commit", AsyncMock())
|
|
return calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_ancestor_is_a_no_op() -> None:
|
|
"""source_branch already reachable from branch_name: no merge, no push —
|
|
the common, transitively-safe case (same-parent siblings, root waves)."""
|
|
svc = _git_service()
|
|
calls = _drive(
|
|
svc,
|
|
{
|
|
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
|
("merge-base", "--is-ancestor"): _result(returncode=0),
|
|
},
|
|
)
|
|
|
|
result = await svc.merge_dependency_lineage(
|
|
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
|
)
|
|
|
|
assert result == {"status": "already_ancestor"}
|
|
assert not [c for c in calls if c[1][:1] == ["merge"]], (
|
|
"an already-ancestor source must never be merged"
|
|
)
|
|
assert not [c for c in calls if c[1][:1] == ["push"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_ref_short_circuits_before_touching_worktree() -> None:
|
|
"""source_branch not on origin: no worktree touched, no merge attempted."""
|
|
svc = _git_service()
|
|
calls = _drive(svc, {("rev-parse", "--verify", "--quiet"): _result(returncode=1)})
|
|
|
|
result = await svc.merge_dependency_lineage(
|
|
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
|
)
|
|
|
|
assert result == {"status": "missing_ref"}
|
|
ensure = object.__getattribute__(svc, "_ensure_worktree_for_commit")
|
|
ensure.assert_not_awaited()
|
|
assert not [c for c in calls if c[1][:1] == ["merge-base"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outside_lineage_merges_and_pushes_from_the_worktree() -> None:
|
|
"""source_branch NOT an ancestor: merged into the worktree and pushed —
|
|
the actual gap fix (cross-cell dependency content backfilled)."""
|
|
svc = _git_service()
|
|
calls = _drive(
|
|
svc,
|
|
{
|
|
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
|
("merge-base", "--is-ancestor"): _result(returncode=1),
|
|
("merge", "--no-edit"): _result(returncode=0),
|
|
("push", "origin"): _result(returncode=0),
|
|
},
|
|
)
|
|
|
|
result = await svc.merge_dependency_lineage(
|
|
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
|
)
|
|
|
|
assert result == {"status": "merged"}
|
|
merge_calls = [c for c in calls if c[1][:1] == ["merge"]]
|
|
push_calls = [c for c in calls if c[1][:1] == ["push"]]
|
|
assert merge_calls and all(ws == _WORKTREE for ws, _ in merge_calls), (
|
|
"the merge must run in the task's worktree, not the shared clone"
|
|
)
|
|
assert push_calls and push_calls[0][1] == ["push", "origin", _BRANCH]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_conflict_aborts_and_reports_files() -> None:
|
|
"""A real conflict: abort, leave the branch at its cut point, never push."""
|
|
svc = _git_service()
|
|
calls = _drive(
|
|
svc,
|
|
{
|
|
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
|
("merge-base", "--is-ancestor"): _result(returncode=1),
|
|
("merge", "--no-edit"): _result(returncode=1),
|
|
("diff", "--name-only", "--diff-filter=U"): _result(
|
|
stdout="src/a.py\nsrc/b.py\n"
|
|
),
|
|
("merge", "--abort"): _result(returncode=0),
|
|
},
|
|
)
|
|
|
|
result = await svc.merge_dependency_lineage(
|
|
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
|
)
|
|
|
|
assert result == {"status": "conflict", "files": ["src/a.py", "src/b.py"]}
|
|
assert not [c for c in calls if c[1][:1] == ["push"]], (
|
|
"a conflicted merge must never push"
|
|
)
|
|
abort_calls = [c for c in calls if c[1] == ["merge", "--abort"]]
|
|
assert abort_calls, "a failed merge must be aborted, restoring the branch"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_succeeds_but_push_fails_is_reported_distinctly() -> None:
|
|
"""A clean local merge that can't reach origin is not silently "merged"."""
|
|
svc = _git_service()
|
|
_drive(
|
|
svc,
|
|
{
|
|
("rev-parse", "--verify", "--quiet"): _result(returncode=0),
|
|
("merge-base", "--is-ancestor"): _result(returncode=1),
|
|
("merge", "--no-edit"): _result(returncode=0),
|
|
("push", "origin"): _result(returncode=1),
|
|
},
|
|
)
|
|
|
|
result = await svc.merge_dependency_lineage(
|
|
_WORKSPACE, _TASK_ID, _BRANCH, _SOURCE, project_slug="roboco-api"
|
|
)
|
|
|
|
assert result == {"status": "merged_push_failed"}
|