202 lines
9.1 KiB
Python
202 lines
9.1 KiB
Python
# #region Test.Git.Merge.Coverage [C:3] [TYPE Module] [SEMANTICS test, git, merge, coverage, edge]
|
|
# @BRIEF Additional edge coverage for GitServiceMergeMixin — error-specific paths in _build_unfinished_merge_payload, resolve_merge_conflicts root, promote_direct_merge fallbacks.
|
|
# @RELATION BINDS_TO -> [GitServiceMergeMixin]
|
|
|
|
import contextlib
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from git.exc import GitCommandError
|
|
|
|
|
|
@pytest.fixture
|
|
def mixin():
|
|
from src.services.git._merge import GitServiceMergeMixin
|
|
m = GitServiceMergeMixin()
|
|
m._locked = lambda x: contextlib.nullcontext()
|
|
return m
|
|
|
|
|
|
def _make_ref(name: str):
|
|
"""Create a simple mock object with .name returning the given string."""
|
|
r = MagicMock()
|
|
r.name = name
|
|
return r
|
|
|
|
|
|
class TestBuildUnfinishedMergePayload:
|
|
"""Cover edge-error handlers in _build_unfinished_merge_payload."""
|
|
|
|
def test_merge_head_read_error(self, mixin):
|
|
"""merge_head_path.read_text fails → merge_head = '<unreadable>'."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
repo.active_branch.name = "main"
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("pathlib.Path.read_text", side_effect=[OSError("perm denied"), "merge msg"]):
|
|
result = mixin._build_unfinished_merge_payload(repo)
|
|
assert result["merge_head"] == "<unreadable>"
|
|
|
|
def test_merge_msg_read_error(self, mixin):
|
|
"""MERGE_MSG read fails → merge_message_preview = '<unreadable>'."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
repo.active_branch.name = "main"
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("pathlib.Path.read_text", side_effect=["abc123", OSError("no msg")]):
|
|
result = mixin._build_unfinished_merge_payload(repo)
|
|
assert result["merge_message_preview"] == "<unreadable>"
|
|
|
|
def test_merge_msg_file_missing(self, mixin):
|
|
"""MERGE_MSG file does not exist → merge_message_preview = empty."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
repo.active_branch.name = "main"
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
def exists_side(path):
|
|
return "MERGE_MSG" not in path
|
|
with patch("os.path.exists", side_effect=exists_side), \
|
|
patch("pathlib.Path.read_text", return_value="abc123"):
|
|
result = mixin._build_unfinished_merge_payload(repo)
|
|
assert result["merge_message_preview"] == ""
|
|
|
|
def test_active_branch_error(self, mixin):
|
|
"""active_branch.name raises → current_branch = 'detached_or_unknown'."""
|
|
repo = MagicMock()
|
|
repo.git_dir = "/tmp/repo/.git"
|
|
repo.working_tree_dir = "/tmp/repo"
|
|
type(repo.active_branch).name = PropertyMock(side_effect=Exception("detached"))
|
|
repo.index.unmerged_blobs.return_value = {}
|
|
with patch("os.path.exists", return_value=True), \
|
|
patch("pathlib.Path.read_text", return_value="abc123"):
|
|
result = mixin._build_unfinished_merge_payload(repo)
|
|
assert result["current_branch"] == "detached_or_unknown"
|
|
|
|
|
|
class TestResolveMergeConflicts:
|
|
"""Cover missing root error path."""
|
|
|
|
def test_empty_working_tree_dir_raises(self, mixin):
|
|
"""Empty string working_tree_dir → repo_root becomes CWD, but abspath returns ''."""
|
|
repo = MagicMock()
|
|
repo.working_tree_dir = ""
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
|
|
patch("os.path.abspath", return_value=""):
|
|
with pytest.raises(HTTPException, match="unavailable"):
|
|
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
|
|
|
|
|
|
class TestPromoteDirectMerge:
|
|
"""Cover promote error-recovery paths."""
|
|
|
|
def test_original_branch_exception(self, mixin):
|
|
"""active_branch.name raises → original_branch = None."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev"), _make_ref("main")]
|
|
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
type(repo.active_branch).name = PropertyMock(side_effect=[Exception("no branch"), "dev"])
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
result = mixin.promote_direct_merge(1, "dev", "main")
|
|
assert result["status"] == "merged"
|
|
|
|
def test_source_branch_from_origin_ref(self, mixin):
|
|
"""Source from origin/ref → checkout -b from origin."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("main")]
|
|
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
result = mixin.promote_direct_merge(1, "dev", "main")
|
|
assert result["status"] == "merged"
|
|
repo.git.checkout.assert_any_call("-b", "dev", "origin/dev")
|
|
|
|
def test_target_branch_from_origin_ref(self, mixin):
|
|
"""Target from origin/ref → checkout -b from origin."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev")]
|
|
repo.refs = [_make_ref("origin/dev"), _make_ref("origin/staging")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
result = mixin.promote_direct_merge(1, "dev", "staging")
|
|
assert result["status"] == "merged"
|
|
|
|
def test_pull_target_failure_logged(self, mixin):
|
|
"""origin.pull(target) fails → logged, merge proceeds."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev"), _make_ref("main")]
|
|
repo.refs = [_make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
result = mixin.promote_direct_merge(1, "dev", "main")
|
|
assert result["status"] == "merged"
|
|
|
|
def test_merge_conflict_raises_409(self, mixin):
|
|
"""CONFLICT → HTTP 409."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev"), _make_ref("main")]
|
|
repo.refs = [_make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
repo.git.merge.side_effect = Exception("CONFLICT in file.txt")
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
with pytest.raises(HTTPException, match="Merge conflict"):
|
|
mixin.promote_direct_merge(1, "dev", "main")
|
|
|
|
def test_merge_generic_error_raises_500(self, mixin):
|
|
"""Generic error → HTTP 500."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev"), _make_ref("main")]
|
|
repo.refs = [_make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
repo.git.merge.side_effect = Exception("some other error")
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
with pytest.raises(HTTPException, match="Direct promote failed"):
|
|
mixin.promote_direct_merge(1, "dev", "main")
|
|
|
|
def test_restore_original_branch_failure(self, mixin):
|
|
"""Restore fails in finally → caught."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev"), _make_ref("main")]
|
|
repo.refs = [_make_ref("origin/main")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
repo.git.checkout.side_effect = [None, None, GeneratorExit("checkout fail")]
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
result = mixin.promote_direct_merge(1, "dev", "main")
|
|
assert result["status"] == "merged"
|
|
|
|
def test_target_branch_not_found(self, mixin):
|
|
"""Target not found → HTTP 404."""
|
|
repo = MagicMock()
|
|
repo.heads = [_make_ref("dev")]
|
|
repo.refs = [_make_ref("origin/dev")]
|
|
origin = MagicMock()
|
|
repo.remote.return_value = origin
|
|
type(repo.active_branch).name = PropertyMock(return_value="dev")
|
|
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
|
|
with pytest.raises(HTTPException, match="not found"):
|
|
mixin.promote_direct_merge(1, "dev", "nonexistent")
|
|
# #endregion Test.Git.Merge.Coverage
|