# #region Test.Git.Merge [C:3] [TYPE Module] [SEMANTICS test,git,merge,conflict,resolution] # @BRIEF Tests for GitServiceMergeMixin — merge status, conflict listing, resolution, abort, continue, direct promote. # @RELATION BINDS_TO -> [GitServiceMergeMixin] # @TEST_EDGE: no_merge_in_progress -> has_unfinished_merge=false # @TEST_EDGE: unfinished_merge -> MERGE_HEAD present, payload built # @TEST_EDGE: conflict_list -> unmerged blobs parsed with mine/theirs # @TEST_EDGE: resolve_mine -> checkout --ours called # @TEST_EDGE: resolve_theirs -> checkout --theirs called # @TEST_EDGE: resolve_manual -> file written with content # @TEST_EDGE: resolve_invalid_strategy -> HTTPException 400 # @TEST_EDGE: resolve_empty_filepath -> HTTPException 400 # @TEST_EDGE: abort_no_merge -> returns no_merge_in_progress # @TEST_EDGE: continue_with_conflicts -> HTTPException 409 # @TEST_EDGE: promote_same_branch -> HTTPException 400 # @TEST_EDGE: promote_missing_branch -> HTTPException 404 import contextlib import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import pytest from unittest.mock import AsyncMock, MagicMock, patch, mock_open from fastapi import HTTPException from src.services.git._merge import GitServiceMergeMixin class TestableGitMerge(GitServiceMergeMixin): """Concrete test class providing _locked stub. get_repo must be set per-test.""" def __init__(self, mock_repo=None): self._mock_repo = mock_repo or MagicMock() def _locked(self, dashboard_id): @contextlib.contextmanager def _lock(): yield return _lock() # ── _read_blob_text ── class TestReadBlobText: """_read_blob_text — blob content decoding.""" # #region test_blob_none [C:2] [TYPE Function] def test_blob_none(self): """None blob returns empty string.""" svc = TestableGitMerge() assert svc._read_blob_text(None) == "" # #endregion test_blob_none # #region test_blob_valid [C:2] [TYPE Function] def test_blob_valid(self): """Valid blob decoded as UTF-8.""" svc = TestableGitMerge() blob = MagicMock() blob.data_stream.read.return_value = b"hello world" assert svc._read_blob_text(blob) == "hello world" # #endregion test_blob_valid # #region test_blob_decode_error [C:2] [TYPE Function] def test_blob_decode_error(self): """Decode exception returns empty string.""" svc = TestableGitMerge() blob = MagicMock() blob.data_stream.read.side_effect = Exception("binary data") assert svc._read_blob_text(blob) == "" # #endregion test_blob_decode_error # ── _get_unmerged_file_paths ── class TestGetUnmergedFilePaths: """_get_unmerged_file_paths — list conflicted files.""" # #region test_unmerged_files [C:2] [TYPE Function] def test_unmerged_files(self): """Returns sorted list of conflicted file paths.""" svc = TestableGitMerge() repo = MagicMock() repo.index.unmerged_blobs.return_value = {"b.txt": [], "a.txt": []} result = svc._get_unmerged_file_paths(repo) assert result == ["a.txt", "b.txt"] # #endregion test_unmerged_files # #region test_unmerged_exception [C:2] [TYPE Function] def test_unmerged_exception(self): """Exception returns empty list.""" svc = TestableGitMerge() repo = MagicMock() repo.index.unmerged_blobs.side_effect = Exception("index error") assert svc._get_unmerged_file_paths(repo) == [] # #endregion test_unmerged_exception # ── get_merge_status ── class TestGetMergeStatus: """get_merge_status — unfinished merge detection.""" # #region test_no_merge_in_progress [C:2] [TYPE Function] @pytest.mark.asyncio async def test_no_merge_in_progress(self): """No MERGE_HEAD → has_unfinished_merge=false.""" repo = MagicMock() repo.git_dir = "/tmp/fake_repo/.git" repo.active_branch.name = "dev" repo.working_tree_dir = "/tmp/fake_repo" svc = TestableGitMerge(repo) svc.get_repo = AsyncMock(return_value=repo) with patch("os.path.exists", return_value=False): result = await svc.get_merge_status(1) assert result["has_unfinished_merge"] is False assert result["current_branch"] == "dev" assert result["conflicts_count"] == 0 # #endregion test_no_merge_in_progress # #region test_merge_in_progress [C:2] [TYPE Function] @pytest.mark.asyncio async def test_merge_in_progress(self): """MERGE_HEAD exists → has_unfinished_merge=true.""" repo = MagicMock() repo.git_dir = "/tmp/fake_repo/.git" repo.active_branch.name = "dev" repo.working_tree_dir = "/tmp/fake_repo" repo.index.unmerged_blobs.return_value = {"conflict.txt": [(2, MagicMock()), (3, MagicMock())]} svc = TestableGitMerge(repo) svc.get_repo = AsyncMock(return_value=repo) with patch("os.path.exists", return_value=True), \ patch("pathlib.Path.read_text", return_value="abc123def456"): result = await svc.get_merge_status(1) assert result["has_unfinished_merge"] is True assert result["merge_head"] == "abc123def456" assert result["conflicts_count"] >= 0 # #endregion test_merge_in_progress # ── get_merge_conflicts ── class TestGetMergeConflicts: """get_merge_conflicts — list conflict details.""" # #region test_conflicts_listed [C:2] [TYPE Function] @pytest.mark.asyncio async def test_conflicts_listed(self): """Unmerged blobs parsed into mine/theirs content.""" repo = MagicMock() mine_blob = MagicMock() mine_blob.data_stream.read.return_value = b"mine content" theirs_blob = MagicMock() theirs_blob.data_stream.read.return_value = b"theirs content" repo.index.unmerged_blobs.return_value = { "file.txt": [(2, mine_blob), (3, theirs_blob)], } svc = TestableGitMerge(repo) svc.get_repo = AsyncMock(return_value=repo) result = await svc.get_merge_conflicts(1) assert len(result) == 1 assert result[0]["file_path"] == "file.txt" assert result[0]["mine"] == "mine content" assert result[0]["theirs"] == "theirs content" # #endregion test_conflicts_listed # #region test_no_conflicts [C:2] [TYPE Function] @pytest.mark.asyncio async def test_no_conflicts(self): """No unmerged blobs → empty list.""" repo = MagicMock() repo.index.unmerged_blobs.return_value = {} svc = TestableGitMerge(repo) svc.get_repo = AsyncMock(return_value=repo) result = await svc.get_merge_conflicts(1) assert result == [] # #endregion test_no_conflicts # ── resolve_merge_conflicts ── class TestResolveMergeConflicts: """resolve_merge_conflicts — strategy-based resolution.""" # #region test_resolve_mine [C:2] [TYPE Function] def test_resolve_mine(self): """Strategy 'mine' → checkout --ours.""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.resolve_merge_conflicts(1, [{"file_path": "a.txt", "resolution": "mine"}]) repo.git.checkout.assert_called_with("--ours", "--", "a.txt") repo.git.add.assert_called_with("a.txt") assert result == ["a.txt"] # #endregion test_resolve_mine # #region test_resolve_theirs [C:2] [TYPE Function] def test_resolve_theirs(self): """Strategy 'theirs' → checkout --theirs.""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.resolve_merge_conflicts(1, [{"file_path": "b.txt", "resolution": "theirs"}]) repo.git.checkout.assert_called_with("--theirs", "--", "b.txt") assert result == ["b.txt"] # #endregion test_resolve_theirs # #region test_resolve_manual [C:2] [TYPE Function] def test_resolve_manual(self): """Strategy 'manual' → writes content to file.""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with patch("builtins.open", mock_open()) as mf, \ patch("os.makedirs"), \ patch("os.path.abspath", side_effect=lambda p: p if p.startswith("/") else f"/tmp/repo/{p}"): result = svc.resolve_merge_conflicts(1, [ {"file_path": "c.txt", "resolution": "manual", "content": "resolved text"}, ]) assert result == ["c.txt"] repo.git.add.assert_called_with("c.txt") # #endregion test_resolve_manual # #region test_resolve_invalid_strategy [C:2] [TYPE Function] def test_resolve_invalid_strategy(self): """Unknown strategy → HTTPException 400.""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="Unsupported"): svc.resolve_merge_conflicts(1, [{"file_path": "x.txt", "resolution": "invalid"}]) # #endregion test_resolve_invalid_strategy # #region test_resolve_empty_filepath [C:2] [TYPE Function] def test_resolve_empty_filepath(self): """Missing file_path → HTTPException 400.""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="file_path is required"): svc.resolve_merge_conflicts(1, [{"file_path": "", "resolution": "mine"}]) # #endregion test_resolve_empty_filepath # #region test_resolve_empty_resolutions [C:2] [TYPE Function] def test_resolve_empty_resolutions(self): """Empty resolutions list → returns [].""" repo = MagicMock() repo.working_tree_dir = "/tmp/repo" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) assert svc.resolve_merge_conflicts(1, []) == [] assert svc.resolve_merge_conflicts(1, None) == [] # #endregion test_resolve_empty_resolutions # ── abort_merge ── class TestAbortMerge: """abort_merge — cancel ongoing merge.""" # #region test_abort_success [C:2] [TYPE Function] def test_abort_success(self): """Successful abort → status='aborted'.""" repo = MagicMock() svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.abort_merge(1) repo.git.merge.assert_called_with("--abort") assert result["status"] == "aborted" # #endregion test_abort_success # #region test_abort_no_merge [C:2] [TYPE Function] def test_abort_no_merge(self): """No merge in progress → status='no_merge_in_progress'.""" from git.exc import GitCommandError repo = MagicMock() repo.git.merge.side_effect = GitCommandError("merge", "fatal: There is no merge to abort") svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.abort_merge(1) assert result["status"] == "no_merge_in_progress" # #endregion test_abort_no_merge # #region test_abort_conflict [C:2] [TYPE Function] def test_abort_conflict(self): """Other GitCommandError → HTTPException 409.""" from git.exc import GitCommandError repo = MagicMock() repo.git.merge.side_effect = GitCommandError("merge", "fatal: something else") svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="Cannot abort"): svc.abort_merge(1) # #endregion test_abort_conflict # ── continue_merge ── class TestContinueMerge: """continue_merge — finalize merge after resolution.""" # #region test_continue_with_message [C:2] [TYPE Function] def test_continue_with_message(self): """Commit with custom message.""" repo = MagicMock() repo.index.unmerged_blobs.return_value = {} repo.head.commit.hexsha = "abc123" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.continue_merge(1, message="resolve conflicts") repo.git.commit.assert_called_with("-m", "resolve conflicts") assert result["status"] == "committed" assert result["commit_hash"] == "abc123" # #endregion test_continue_with_message # #region test_continue_no_message [C:2] [TYPE Function] def test_continue_no_message(self): """No message → --no-edit.""" repo = MagicMock() repo.index.unmerged_blobs.return_value = {} repo.head.commit.hexsha = "def456" svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.continue_merge(1) repo.git.commit.assert_called_with("--no-edit") assert result["status"] == "committed" # #endregion test_continue_no_message # #region test_continue_unresolved_conflicts [C:2] [TYPE Function] def test_continue_unresolved_conflicts(self): """Unmerged files remain → HTTPException 409.""" repo = MagicMock() repo.index.unmerged_blobs.return_value = {"file.txt": [(2, MagicMock())]} svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException) as exc_info: svc.continue_merge(1) assert exc_info.value.status_code == 409 # #endregion test_continue_unresolved_conflicts # #region test_continue_nothing_to_commit [C:2] [TYPE Function] def test_continue_nothing_to_commit(self): """Nothing to commit → status='already_clean'.""" from git.exc import GitCommandError repo = MagicMock() repo.index.unmerged_blobs.return_value = {} repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit") svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.continue_merge(1) assert result["status"] == "already_clean" # #endregion test_continue_nothing_to_commit # ── promote_direct_merge ── class TestPromoteDirectMerge: """promote_direct_merge — direct branch-to-branch merge.""" # #region test_promote_same_branch [C:2] [TYPE Function] def test_promote_same_branch(self): """Same from/to → HTTPException 400.""" repo = MagicMock() svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="must be different"): svc.promote_direct_merge(1, "dev", "dev") # #endregion test_promote_same_branch # #region test_promote_empty_branch [C:2] [TYPE Function] def test_promote_empty_branch(self): """Empty branch name → HTTPException 400.""" repo = MagicMock() svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="required"): svc.promote_direct_merge(1, "", "main") # #endregion test_promote_empty_branch # #region test_promote_success [C:2] [TYPE Function] def test_promote_success(self): """Successful merge → status='merged'.""" repo = MagicMock() repo.active_branch.name = "dev" repo.heads = [MagicMock(name="dev"), MagicMock(name="main")] repo.heads[0].name = "dev" repo.heads[1].name = "main" repo.refs = [MagicMock()] repo.refs[0].name = "origin/main" origin = MagicMock() repo.remote.return_value = origin svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) result = svc.promote_direct_merge(1, "dev", "main") assert result["status"] == "merged" assert result["from_branch"] == "dev" assert result["to_branch"] == "main" origin.fetch.assert_called_once() repo.git.merge.assert_called_once() origin.push.assert_called_once() repo.git.checkout.assert_called() # #endregion test_promote_success # #region test_promote_no_origin [C:2] [TYPE Function] def test_promote_no_origin(self): """No origin remote → HTTPException 400.""" repo = MagicMock() repo.active_branch.name = "dev" repo.remote.side_effect = ValueError("no remote") svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="origin"): svc.promote_direct_merge(1, "dev", "main") # #endregion test_promote_no_origin # #region test_promote_source_not_found [C:2] [TYPE Function] def test_promote_source_not_found(self): """Source branch not in heads or refs → HTTPException 404.""" repo = MagicMock() repo.active_branch.name = "dev" repo.heads = [] repo.refs = [] origin = MagicMock() repo.remote.return_value = origin svc = TestableGitMerge(repo) svc.get_repo = MagicMock(return_value=repo) with pytest.raises(HTTPException, match="not found"): svc.promote_direct_merge(1, "nonexistent", "main") # #endregion test_promote_source_not_found # #endregion Test.Git.Merge