From 9173399ce8183ed8bced337761f7334ea470a113 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 12 Jun 2025 21:56:59 -0400 Subject: [PATCH] Add tests for new git branch functionality --- pyproject.toml | 1 + tests/__init__.py | 0 tests/test_git_diff.py | 410 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 54 ++++++ 4 files changed, 465 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_git_diff.py diff --git a/pyproject.toml b/pyproject.toml index c1fb5ea..7bc42e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.13" dependencies = [ "gitpython>=3.1.44", + "pytest>=8.4.0", "rich>=14.0.0", ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_git_diff.py b/tests/test_git_diff.py new file mode 100644 index 0000000..330655f --- /dev/null +++ b/tests/test_git_diff.py @@ -0,0 +1,410 @@ +""" +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" diff --git a/uv.lock b/uv.lock index 9b3e323..604439a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.13" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -26,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -47,6 +65,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -56,18 +92,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pytest" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797 }, +] + [[package]] name = "reviewllama" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "gitpython" }, + { name = "pytest" }, { name = "rich" }, ] [package.metadata] requires-dist = [ { name = "gitpython", specifier = ">=3.1.44" }, + { name = "pytest", specifier = ">=8.4.0" }, { name = "rich", specifier = ">=14.0.0" }, ]