""" Unit tests for git analysis functionality using pytest. """ from pathlib import Path from unittest.mock import Mock, PropertyMock, patch import pytest from git.exc import GitCommandError, InvalidGitRepositoryError from reviewllama.git_diff import (GitAnalysis, GitDiff, analyze_git_repository, branch_exists, create_git_diff, determine_change_type, extract_file_content, filter_reviewable_diffs, find_git_repository, get_base_branch, get_current_branch_name, get_diff_between_branches, process_diff_items) # Fixtures @pytest.fixture def mock_repo(): """Create a mock git repository.""" repo = Mock() repo.active_branch.name = "feature-branch" repo.head.commit.hexsha = "abc123def456" return repo @pytest.fixture def mock_diff_item(): """Create a mock git diff item.""" diff = Mock() diff.a_path = "src/main.py" diff.b_path = "src/main.py" diff.new_file = False diff.deleted_file = False diff.a_blob = Mock() diff.b_blob = Mock() diff.a_blob.data_stream.read.return_value = b"old content" diff.b_blob.data_stream.read.return_value = b"new content" diff.__str__ = Mock(return_value="diff content") return diff @pytest.fixture def sample_git_diffs(): """Create sample GitDiff objects for testing.""" return [ GitDiff("src/main.py", "old", "new", "diff", "modified"), GitDiff("test.js", "old", "new", "diff", "added"), GitDiff("README.md", "old", "new", "diff", "modified"), GitDiff("image.png", "old", "new", "diff", "added"), ] # Tests for find_git_repository @patch('reviewllama.git_diff.Repo') def test_find_git_repository_success(mock_repo_class): """Test successful git repository detection.""" mock_repo_instance = Mock() mock_repo_class.return_value = mock_repo_instance path = Path("/project") result = find_git_repository(path) mock_repo_class.assert_called_once_with(path, search_parent_directories=True) assert result == mock_repo_instance @patch('reviewllama.git_diff.Repo') def test_find_git_repository_not_found(mock_repo_class): """Test git repository not found.""" mock_repo_class.side_effect = InvalidGitRepositoryError() path = Path("/not-a-repo") with pytest.raises(ValueError, match="No git repository found"): find_git_repository(path) # Tests for get_current_branch_name def test_get_current_branch_name_active_branch(mock_repo): """Test getting current branch name from active branch.""" result = get_current_branch_name(mock_repo) assert result == "feature-branch" def test_get_current_branch_name_detached_head(mock_repo): """Test getting current branch name in detached HEAD state.""" type(mock_repo.active_branch).name = PropertyMock(side_effect=TypeError()) result = get_current_branch_name(mock_repo) assert result == "abc123de" # First 8 chars of hexsha # Tests for branch_exists def test_branch_exists_true(mock_repo): """Test branch exists returns True when branch found.""" mock_repo.commit.return_value = Mock() result = branch_exists(mock_repo, "main") assert result is True mock_repo.commit.assert_called_once_with("main") def test_branch_exists_false(mock_repo): """Test branch exists returns False when branch not found.""" mock_repo.commit.side_effect = Exception("Branch not found") result = branch_exists(mock_repo, "nonexistent") assert result is False # Tests for get_base_branch @patch('reviewllama.git_diff.branch_exists') def test_get_base_branch_requested_exists(mock_branch_exists, mock_repo): """Test get_base_branch returns requested branch when it exists.""" mock_branch_exists.return_value = True result = get_base_branch(mock_repo, "develop") assert result == "develop" mock_branch_exists.assert_called_once_with(mock_repo, "develop") @patch('reviewllama.git_diff.branch_exists') def test_get_base_branch_fallback_to_master(mock_branch_exists, mock_repo): """Test get_base_branch falls back to master when requested not found.""" mock_branch_exists.side_effect = [False, True] # requested=False, master=True result = get_base_branch(mock_repo, "nonexistent") assert result == "master" @patch('reviewllama.git_diff.branch_exists') def test_get_base_branch_fallback_to_main(mock_branch_exists, mock_repo): """Test get_base_branch falls back to main when master not found.""" mock_branch_exists.side_effect = [False, False, True] # requested=False, master=False, main=True result = get_base_branch(mock_repo, "nonexistent") assert result == "main" @patch('reviewllama.git_diff.branch_exists') def test_get_base_branch_no_fallback_found(mock_branch_exists, mock_repo): """Test get_base_branch raises error when no fallback found.""" mock_branch_exists.return_value = False with pytest.raises(ValueError, match="Base branch 'nonexistent' not found"): get_base_branch(mock_repo, "nonexistent") # Tests for get_diff_between_branches def test_get_diff_between_branches_success(mock_repo): """Test successful diff between branches.""" base_commit = Mock() current_commit = Mock() mock_diff = Mock() mock_repo.commit.side_effect = [base_commit, current_commit] base_commit.diff.return_value = mock_diff result = get_diff_between_branches(mock_repo, "master", "feature") assert result == mock_diff base_commit.diff.assert_called_once_with(current_commit) def test_get_diff_between_branches_git_error(mock_repo): """Test diff between branches with git error.""" mock_repo.commit.side_effect = GitCommandError("git", "error") with pytest.raises(ValueError, match="Failed to get diff"): get_diff_between_branches(mock_repo, "master", "feature") # Tests for determine_change_type def test_determine_change_type_added(): """Test determine_change_type for added file.""" diff = Mock() diff.new_file = True diff.deleted_file = False result = determine_change_type(diff) assert result == "added" def test_determine_change_type_deleted(): """Test determine_change_type for deleted file.""" diff = Mock() diff.new_file = False diff.deleted_file = True result = determine_change_type(diff) assert result == "deleted" def test_determine_change_type_modified(): """Test determine_change_type for modified file.""" diff = Mock() diff.new_file = False diff.deleted_file = False result = determine_change_type(diff) assert result == "modified" # Tests for extract_file_content def test_extract_file_content_old(mock_diff_item): """Test extracting old file content.""" result = extract_file_content(mock_diff_item, is_old=True) assert result == "old content" mock_diff_item.a_blob.data_stream.read.assert_called_once() def test_extract_file_content_new(mock_diff_item): """Test extracting new file content.""" result = extract_file_content(mock_diff_item, is_old=False) assert result == "new content" mock_diff_item.b_blob.data_stream.read.assert_called_once() def test_extract_file_content_none_blob(): """Test extracting content when blob is None.""" diff = Mock() diff.a_blob = None result = extract_file_content(diff, is_old=True) assert result == "" def test_extract_file_content_decode_error(): """Test extracting content with decode error.""" diff = Mock() diff.a_blob.data_stream.read.side_effect = UnicodeDecodeError("utf-8", b"", 0, 1, "error") result = extract_file_content(diff, is_old=True) assert result == "" # Tests for create_git_diff def test_create_git_diff(mock_diff_item): """Test creating GitDiff from git diff item.""" result = create_git_diff(mock_diff_item) assert isinstance(result, GitDiff) assert result.file_path == "src/main.py" assert result.old_content == "old content" assert result.new_content == "new content" assert result.diff_text == "diff content" assert result.change_type == "modified" def test_create_git_diff_unknown_path(): """Test creating GitDiff with unknown file path.""" diff = Mock() diff.a_path = None diff.b_path = None diff.new_file = False diff.deleted_file = False diff.a_blob = None diff.b_blob = None diff.__str__ = Mock(return_value="diff") result = create_git_diff(diff) assert result.file_path == "unknown" # Tests for process_diff_items def test_process_diff_items(): """Test processing multiple diff items.""" diff1 = Mock() diff1.a_path = "file1.py" diff1.b_path = "file1.py" diff1.new_file = False diff1.deleted_file = False diff1.a_blob = None diff1.b_blob = None diff1.__str__ = Mock(return_value="diff1") diff2 = Mock() diff2.a_path = "file2.py" diff2.b_path = "file2.py" diff2.new_file = True diff2.deleted_file = False diff2.a_blob = None diff2.b_blob = None diff2.__str__ = Mock(return_value="diff2") diff_index = [diff1, diff2] result = process_diff_items(diff_index) assert len(result) == 2 assert all(isinstance(diff, GitDiff) for diff in result) assert result[0].file_path == "file1.py" assert result[1].file_path == "file2.py" # Tests for filter_reviewable_diffs def test_filter_reviewable_diffs(sample_git_diffs): """Test filtering reviewable diffs.""" result = filter_reviewable_diffs(sample_git_diffs) # Should include .py and .js files, exclude .md and .png assert len(result) == 2 file_paths = [diff.file_path for diff in result] assert "src/main.py" in file_paths assert "test.js" in file_paths assert "README.md" not in file_paths assert "image.png" not in file_paths def test_filter_reviewable_diffs_empty(): """Test filtering reviewable diffs with empty input.""" result = filter_reviewable_diffs([]) assert result == [] # Tests for analyze_git_repository @patch('reviewllama.git_diff.find_git_repository') @patch('reviewllama.git_diff.get_current_branch_name') @patch('reviewllama.git_diff.get_base_branch') @patch('reviewllama.git_diff.get_diff_between_branches') @patch('reviewllama.git_diff.process_diff_items') @patch('reviewllama.git_diff.filter_reviewable_diffs') def test_analyze_git_repository_success( mock_filter, mock_process, mock_get_diff, mock_get_base, mock_get_current, mock_find_repo, sample_git_diffs ): """Test successful git repository analysis.""" # Setup mocks mock_repo = Mock() mock_find_repo.return_value = mock_repo mock_get_current.return_value = "feature-branch" mock_get_base.return_value = "master" mock_get_diff.return_value = Mock() mock_process.return_value = sample_git_diffs mock_filter.return_value = sample_git_diffs[:2] # Filter to 2 items path = Path("/project") result = analyze_git_repository(path, "main") # Verify result assert isinstance(result, GitAnalysis) assert result.repository_path == path assert result.current_branch == "feature-branch" assert result.base_branch == "master" assert len(result.diffs) == 2 assert result.total_files_changed == 2 # Verify function calls mock_find_repo.assert_called_once_with(path) mock_get_current.assert_called_once_with(mock_repo) mock_get_base.assert_called_once_with(mock_repo, "main") mock_get_diff.assert_called_once_with(mock_repo, "master", "feature-branch") @patch('reviewllama.git_diff.find_git_repository') def test_analyze_git_repository_not_found(mock_find_repo): """Test git repository analysis when repo not found.""" mock_find_repo.side_effect = ValueError("No git repository found") path = Path("/not-a-repo") with pytest.raises(ValueError, match="No git repository found"): analyze_git_repository(path, "") # Integration test def test_git_diff_dataclass(): """Test GitDiff dataclass creation and immutability.""" diff = GitDiff( file_path="test.py", old_content="old", new_content="new", diff_text="diff", change_type="modified" ) assert diff.file_path == "test.py" assert diff.old_content == "old" assert diff.new_content == "new" assert diff.diff_text == "diff" assert diff.change_type == "modified" # Test immutability with pytest.raises(AttributeError): diff.file_path = "changed.py" def test_git_diff_dataclass(): """Test GitAnalysis dataclass creation and immutability.""" diffs = [GitDiff("test.py", "old", "new", "diff", "modified")] analysis = GitAnalysis( repository_path=Path("/project"), current_branch="feature", base_branch="master", diffs=diffs, total_files_changed=1 ) assert analysis.repository_path == Path("/project") assert analysis.current_branch == "feature" assert analysis.base_branch == "master" assert len(analysis.diffs) == 1 assert analysis.total_files_changed == 1 # Test immutability with pytest.raises(AttributeError): analysis.current_branch = "changed"