forked from cardosofelipe/fast-next-template
Implements the Git Operations MCP server (Issue #58) providing: Core features: - GitPython wrapper for local repository operations (clone, commit, push, pull, diff, log) - Branch management (create, delete, list, checkout) - Workspace isolation per project with file-based locking - Gitea provider for remote PR operations MCP Tools (17 registered): - clone_repository, git_status, create_branch, list_branches - checkout, commit, push, pull, diff, log - create_pull_request, get_pull_request, list_pull_requests - merge_pull_request, get_workspace, lock_workspace, unlock_workspace Technical details: - FastMCP + FastAPI with JSON-RPC 2.0 protocol - pydantic-settings for configuration (env prefix: GIT_OPS_) - Comprehensive error hierarchy with structured codes - 131 tests passing with 67% coverage - Async operations via ThreadPoolExecutor Closes: #105, #106, #107, #108, #109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""
|
|
Tests for the git_wrapper module.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from exceptions import (
|
|
BranchExistsError,
|
|
BranchNotFoundError,
|
|
CheckoutError,
|
|
CommitError,
|
|
GitError,
|
|
)
|
|
from git_wrapper import GitWrapper
|
|
from models import FileChangeType
|
|
|
|
|
|
class TestGitWrapperInit:
|
|
"""Tests for GitWrapper initialization."""
|
|
|
|
def test_init_with_valid_path(self, temp_workspace, test_settings):
|
|
"""Test initialization with a valid path."""
|
|
wrapper = GitWrapper(temp_workspace, test_settings)
|
|
assert wrapper.workspace_path == temp_workspace
|
|
assert wrapper.settings == test_settings
|
|
|
|
def test_repo_property_raises_on_non_git(self, temp_workspace, test_settings):
|
|
"""Test that accessing repo on non-git dir raises error."""
|
|
wrapper = GitWrapper(temp_workspace, test_settings)
|
|
with pytest.raises(GitError, match="Not a git repository"):
|
|
_ = wrapper.repo
|
|
|
|
def test_repo_property_works_on_git_dir(self, git_repo, test_settings):
|
|
"""Test that repo property works for git directory."""
|
|
wrapper = GitWrapper(Path(git_repo.working_dir), test_settings)
|
|
assert wrapper.repo is not None
|
|
assert wrapper.repo.head is not None
|
|
|
|
|
|
class TestGitWrapperStatus:
|
|
"""Tests for git status operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_clean_repo(self, git_wrapper_with_repo):
|
|
"""Test status on a clean repository."""
|
|
result = await git_wrapper_with_repo.status()
|
|
|
|
assert result.branch == "main"
|
|
assert result.is_clean is True
|
|
assert len(result.staged) == 0
|
|
assert len(result.unstaged) == 0
|
|
assert len(result.untracked) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_with_untracked(self, git_wrapper_with_repo, git_repo):
|
|
"""Test status with untracked files."""
|
|
# Create untracked file
|
|
untracked_file = Path(git_repo.working_dir) / "untracked.txt"
|
|
untracked_file.write_text("untracked content")
|
|
|
|
result = await git_wrapper_with_repo.status()
|
|
|
|
assert result.is_clean is False
|
|
assert "untracked.txt" in result.untracked
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_with_modified(self, git_wrapper_with_repo, git_repo):
|
|
"""Test status with modified files."""
|
|
# Modify existing file
|
|
readme = Path(git_repo.working_dir) / "README.md"
|
|
readme.write_text("# Modified content\n")
|
|
|
|
result = await git_wrapper_with_repo.status()
|
|
|
|
assert result.is_clean is False
|
|
assert len(result.unstaged) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_with_staged(self, git_wrapper_with_repo, git_repo):
|
|
"""Test status with staged changes."""
|
|
# Create and stage a file
|
|
new_file = Path(git_repo.working_dir) / "staged.txt"
|
|
new_file.write_text("staged content")
|
|
git_repo.index.add(["staged.txt"])
|
|
|
|
result = await git_wrapper_with_repo.status()
|
|
|
|
assert result.is_clean is False
|
|
assert len(result.staged) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_exclude_untracked(self, git_wrapper_with_repo, git_repo):
|
|
"""Test status without untracked files."""
|
|
untracked_file = Path(git_repo.working_dir) / "untracked.txt"
|
|
untracked_file.write_text("untracked")
|
|
|
|
result = await git_wrapper_with_repo.status(include_untracked=False)
|
|
|
|
assert len(result.untracked) == 0
|
|
|
|
|
|
class TestGitWrapperBranch:
|
|
"""Tests for branch operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch(self, git_wrapper_with_repo):
|
|
"""Test creating a new branch."""
|
|
result = await git_wrapper_with_repo.create_branch("feature-test")
|
|
|
|
assert result.success is True
|
|
assert result.branch == "feature-test"
|
|
assert result.is_current is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_without_checkout(self, git_wrapper_with_repo):
|
|
"""Test creating branch without checkout."""
|
|
result = await git_wrapper_with_repo.create_branch("feature-no-checkout", checkout=False)
|
|
|
|
assert result.success is True
|
|
assert result.branch == "feature-no-checkout"
|
|
assert result.is_current is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_exists_error(self, git_wrapper_with_repo):
|
|
"""Test error when branch already exists."""
|
|
await git_wrapper_with_repo.create_branch("existing-branch", checkout=False)
|
|
|
|
with pytest.raises(BranchExistsError):
|
|
await git_wrapper_with_repo.create_branch("existing-branch")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_branch(self, git_wrapper_with_repo):
|
|
"""Test deleting a branch."""
|
|
# Create branch first
|
|
await git_wrapper_with_repo.create_branch("to-delete", checkout=False)
|
|
|
|
# Delete it
|
|
result = await git_wrapper_with_repo.delete_branch("to-delete")
|
|
|
|
assert result.success is True
|
|
assert result.branch == "to-delete"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_branch_not_found(self, git_wrapper_with_repo):
|
|
"""Test error when deleting non-existent branch."""
|
|
with pytest.raises(BranchNotFoundError):
|
|
await git_wrapper_with_repo.delete_branch("nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_current_branch_error(self, git_wrapper_with_repo):
|
|
"""Test error when deleting current branch."""
|
|
with pytest.raises(GitError, match="Cannot delete current branch"):
|
|
await git_wrapper_with_repo.delete_branch("main")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_branches(self, git_wrapper_with_repo):
|
|
"""Test listing branches."""
|
|
# Create some branches
|
|
await git_wrapper_with_repo.create_branch("branch-a", checkout=False)
|
|
await git_wrapper_with_repo.create_branch("branch-b", checkout=False)
|
|
|
|
result = await git_wrapper_with_repo.list_branches()
|
|
|
|
assert result.current_branch == "main"
|
|
branch_names = [b["name"] for b in result.local_branches]
|
|
assert "main" in branch_names
|
|
assert "branch-a" in branch_names
|
|
assert "branch-b" in branch_names
|
|
|
|
|
|
class TestGitWrapperCheckout:
|
|
"""Tests for checkout operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_existing_branch(self, git_wrapper_with_repo):
|
|
"""Test checkout of existing branch."""
|
|
# Create branch first
|
|
await git_wrapper_with_repo.create_branch("test-branch", checkout=False)
|
|
|
|
result = await git_wrapper_with_repo.checkout("test-branch")
|
|
|
|
assert result.success is True
|
|
assert result.ref == "test-branch"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_create_new(self, git_wrapper_with_repo):
|
|
"""Test checkout with branch creation."""
|
|
result = await git_wrapper_with_repo.checkout("new-branch", create_branch=True)
|
|
|
|
assert result.success is True
|
|
assert result.ref == "new-branch"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_nonexistent_error(self, git_wrapper_with_repo):
|
|
"""Test error when checking out non-existent ref."""
|
|
with pytest.raises(CheckoutError):
|
|
await git_wrapper_with_repo.checkout("nonexistent-branch")
|
|
|
|
|
|
class TestGitWrapperCommit:
|
|
"""Tests for commit operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_staged_changes(self, git_wrapper_with_repo, git_repo):
|
|
"""Test committing staged changes."""
|
|
# Create and stage a file
|
|
new_file = Path(git_repo.working_dir) / "newfile.txt"
|
|
new_file.write_text("new content")
|
|
git_repo.index.add(["newfile.txt"])
|
|
|
|
result = await git_wrapper_with_repo.commit("Add new file")
|
|
|
|
assert result.success is True
|
|
assert result.message == "Add new file"
|
|
assert result.files_changed == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_all_changes(self, git_wrapper_with_repo, git_repo):
|
|
"""Test committing all changes (auto-stage)."""
|
|
# Create a file without staging
|
|
new_file = Path(git_repo.working_dir) / "unstaged.txt"
|
|
new_file.write_text("content")
|
|
|
|
result = await git_wrapper_with_repo.commit("Commit unstaged")
|
|
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_nothing_to_commit(self, git_wrapper_with_repo):
|
|
"""Test error when nothing to commit."""
|
|
with pytest.raises(CommitError, match="Nothing to commit"):
|
|
await git_wrapper_with_repo.commit("Empty commit")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_with_author(self, git_wrapper_with_repo, git_repo):
|
|
"""Test commit with custom author."""
|
|
new_file = Path(git_repo.working_dir) / "authored.txt"
|
|
new_file.write_text("authored content")
|
|
|
|
result = await git_wrapper_with_repo.commit(
|
|
"Custom author commit",
|
|
author_name="Custom Author",
|
|
author_email="custom@test.com",
|
|
)
|
|
|
|
assert result.success is True
|
|
|
|
|
|
class TestGitWrapperDiff:
|
|
"""Tests for diff operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_no_changes(self, git_wrapper_with_repo):
|
|
"""Test diff with no changes."""
|
|
result = await git_wrapper_with_repo.diff()
|
|
|
|
assert result.files_changed == 0
|
|
assert result.total_additions == 0
|
|
assert result.total_deletions == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_with_changes(self, git_wrapper_with_repo, git_repo):
|
|
"""Test diff with modified files."""
|
|
# Modify a file
|
|
readme = Path(git_repo.working_dir) / "README.md"
|
|
readme.write_text("# Modified\nNew line\n")
|
|
|
|
result = await git_wrapper_with_repo.diff()
|
|
|
|
assert result.files_changed > 0
|
|
|
|
|
|
class TestGitWrapperLog:
|
|
"""Tests for log operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_basic(self, git_wrapper_with_repo):
|
|
"""Test basic log."""
|
|
result = await git_wrapper_with_repo.log()
|
|
|
|
assert result.total_commits > 0
|
|
assert len(result.commits) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_with_limit(self, git_wrapper_with_repo, git_repo):
|
|
"""Test log with limit."""
|
|
# Create more commits
|
|
for i in range(5):
|
|
file_path = Path(git_repo.working_dir) / f"file{i}.txt"
|
|
file_path.write_text(f"content {i}")
|
|
git_repo.index.add([f"file{i}.txt"])
|
|
git_repo.index.commit(f"Commit {i}")
|
|
|
|
result = await git_wrapper_with_repo.log(limit=3)
|
|
|
|
assert len(result.commits) == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_commit_info(self, git_wrapper_with_repo):
|
|
"""Test that log returns proper commit info."""
|
|
result = await git_wrapper_with_repo.log(limit=1)
|
|
|
|
commit = result.commits[0]
|
|
assert "sha" in commit
|
|
assert "message" in commit
|
|
assert "author_name" in commit
|
|
assert "author_email" in commit
|
|
|
|
|
|
class TestGitWrapperUtilities:
|
|
"""Tests for utility methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_valid_ref_true(self, git_wrapper_with_repo):
|
|
"""Test valid ref detection."""
|
|
is_valid = await git_wrapper_with_repo.is_valid_ref("main")
|
|
assert is_valid is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_valid_ref_false(self, git_wrapper_with_repo):
|
|
"""Test invalid ref detection."""
|
|
is_valid = await git_wrapper_with_repo.is_valid_ref("nonexistent")
|
|
assert is_valid is False
|
|
|
|
def test_diff_to_change_type(self, git_wrapper_with_repo):
|
|
"""Test change type conversion."""
|
|
wrapper = git_wrapper_with_repo
|
|
|
|
assert wrapper._diff_to_change_type("A") == FileChangeType.ADDED
|
|
assert wrapper._diff_to_change_type("M") == FileChangeType.MODIFIED
|
|
assert wrapper._diff_to_change_type("D") == FileChangeType.DELETED
|
|
assert wrapper._diff_to_change_type("R") == FileChangeType.RENAMED
|
|
|
|
|
|
class TestGitWrapperStage:
|
|
"""Tests for staging operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stage_specific_files(self, git_wrapper_with_repo, git_repo):
|
|
"""Test staging specific files."""
|
|
# Create files
|
|
file1 = Path(git_repo.working_dir) / "file1.txt"
|
|
file2 = Path(git_repo.working_dir) / "file2.txt"
|
|
file1.write_text("content 1")
|
|
file2.write_text("content 2")
|
|
|
|
count = await git_wrapper_with_repo.stage(["file1.txt"])
|
|
|
|
assert count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stage_all(self, git_wrapper_with_repo, git_repo):
|
|
"""Test staging all files."""
|
|
file1 = Path(git_repo.working_dir) / "all1.txt"
|
|
file2 = Path(git_repo.working_dir) / "all2.txt"
|
|
file1.write_text("content 1")
|
|
file2.write_text("content 2")
|
|
|
|
count = await git_wrapper_with_repo.stage()
|
|
|
|
assert count >= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unstage_files(self, git_wrapper_with_repo, git_repo):
|
|
"""Test unstaging files."""
|
|
# Create and stage file
|
|
file1 = Path(git_repo.working_dir) / "unstage.txt"
|
|
file1.write_text("to unstage")
|
|
git_repo.index.add(["unstage.txt"])
|
|
|
|
count = await git_wrapper_with_repo.unstage()
|
|
|
|
assert count >= 1
|
|
|
|
|
|
class TestGitWrapperReset:
|
|
"""Tests for reset operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_soft(self, git_wrapper_with_repo, git_repo):
|
|
"""Test soft reset."""
|
|
# Create a commit to reset
|
|
file1 = Path(git_repo.working_dir) / "reset_soft.txt"
|
|
file1.write_text("content")
|
|
git_repo.index.add(["reset_soft.txt"])
|
|
git_repo.index.commit("Commit to reset")
|
|
|
|
result = await git_wrapper_with_repo.reset("HEAD~1", mode="soft")
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_mixed(self, git_wrapper_with_repo, git_repo):
|
|
"""Test mixed reset (default)."""
|
|
file1 = Path(git_repo.working_dir) / "reset_mixed.txt"
|
|
file1.write_text("content")
|
|
git_repo.index.add(["reset_mixed.txt"])
|
|
git_repo.index.commit("Commit to reset")
|
|
|
|
result = await git_wrapper_with_repo.reset("HEAD~1", mode="mixed")
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_invalid_mode(self, git_wrapper_with_repo):
|
|
"""Test error on invalid reset mode."""
|
|
with pytest.raises(GitError, match="Invalid reset mode"):
|
|
await git_wrapper_with_repo.reset("HEAD", mode="invalid")
|
|
|
|
|
|
class TestGitWrapperStash:
|
|
"""Tests for stash operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stash_changes(self, git_wrapper_with_repo, git_repo):
|
|
"""Test stashing changes."""
|
|
# Make changes
|
|
readme = Path(git_repo.working_dir) / "README.md"
|
|
readme.write_text("Modified for stash")
|
|
|
|
result = await git_wrapper_with_repo.stash("Test stash")
|
|
|
|
# Result should be stash ref or None if nothing to stash
|
|
# (depends on whether changes were already staged)
|
|
assert result is None or result.startswith("stash@")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stash_nothing(self, git_wrapper_with_repo):
|
|
"""Test stash with no changes."""
|
|
result = await git_wrapper_with_repo.stash()
|
|
|
|
assert result is None
|