From e4d856214bdd72a19d5c4564481c0157ccd5db7d Mon Sep 17 00:00:00 2001 From: JavieHush Date: Wed, 28 May 2025 16:18:42 +0800 Subject: [PATCH] feat: Add git branch functionality and unit tests This commit introduces the `git branch` tool to the MCP Git server, allowing users to list branches with various filtering options. Changes include: - Implemented `git_branch` function in `src/git/src/mcp_server_git/server.py` to support listing local, remote, and all branches, as well as filtering by `contains` and `not_contains` SHA values. - Added comprehensive unit tests for the `git branch` functionality in `src/git/tests/test_server.py`, covering different branch types and commit filtering scenarios. - Updated `src/git/README.md`. --- src/git/README.md | 9 ++++ src/git/src/mcp_server_git/server.py | 68 +++++++++++++++++++++++++++- src/git/tests/test_server.py | 44 +++++++++++++++++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index 6aaf81ac65..2a1268aeb9 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -85,6 +85,15 @@ Please note that mcp-server-git is currently in early development. The functiona - `repo_path` (string): Path to directory to initialize git repo - Returns: Confirmation of repository initialization +13. `git_branch` + - List Git branches + - Inputs: + - `repo_path` (string): Path to the Git repository. + - `branch_type` (string): Whether to list local branches ('local'), remote branches ('remote') or all branches('all'). + - `contains` (string, optional): The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified + - `not_contains` (string, optional): The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified + - Returns: List of branches + ## Installation ### Using uv (recommended) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index c6a346cfef..dae72a07f3 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Sequence +from typing import Sequence, Optional from mcp.server import Server from mcp.server.session import ServerSession from mcp.server.stdio import stdio_server @@ -13,7 +13,7 @@ ) from enum import Enum import git -from pydantic import BaseModel +from pydantic import BaseModel, Field class GitStatus(BaseModel): repo_path: str @@ -59,6 +59,24 @@ class GitShow(BaseModel): class GitInit(BaseModel): repo_path: str +class GitBranch(BaseModel): + repo_path: str = Field( + ..., + description="The path to the Git repository.", + ) + branch_type: str = Field( + ..., + description="Whether to list local branches ('local'), remote branches ('remote') or all branches('all').", + ) + contains: Optional[str] = Field( + None, + description="The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified", + ) + not_contains: Optional[str] = Field( + None, + description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified", + ) + class GitTools(str, Enum): STATUS = "git_status" DIFF_UNSTAGED = "git_diff_unstaged" @@ -72,6 +90,7 @@ class GitTools(str, Enum): CHECKOUT = "git_checkout" SHOW = "git_show" INIT = "git_init" + BRANCH = "git_branch" def git_status(repo: git.Repo) -> str: return repo.git.status() @@ -147,6 +166,34 @@ def git_show(repo: git.Repo, revision: str) -> str: output.append(d.diff.decode('utf-8')) return "".join(output) +def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str: + match contains: + case None: + contains_sha = (None,) + case _: + contains_sha = ("--contains", contains) + + match not_contains: + case None: + not_contains_sha = (None,) + case _: + not_contains_sha = ("--no-contains", not_contains) + + match branch_type: + case 'local': + b_type = None + case 'remote': + b_type = "-r" + case 'all': + b_type = "-a" + case _: + return f"Invalid branch type: {branch_type}" + + # None value will be auto deleted by GitPython + branch_info = repo.git.branch(b_type, *contains_sha, *not_contains_sha) + + return branch_info + async def serve(repository: Path | None) -> None: logger = logging.getLogger(__name__) @@ -222,6 +269,11 @@ async def list_tools() -> list[Tool]: name=GitTools.INIT, description="Initialize a new Git repository", inputSchema=GitInit.schema(), + ), + Tool( + name=GitTools.BRANCH, + description="List Git branches", + inputSchema=GitBranch.schema(), ) ] @@ -351,6 +403,18 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: text=result )] + case GitTools.BRANCH: + result = git_branch( + repo, + arguments.get("branch_type", 'local'), + arguments.get("contains", None), + arguments.get("not_contains", None), + ) + return [TextContent( + type="text", + text=result + )] + case _: raise ValueError(f"Unknown tool: {name}") diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 0ec4d52c1f..911a90cfbb 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -1,7 +1,7 @@ import pytest from pathlib import Path import git -from mcp_server_git.server import git_checkout +from mcp_server_git.server import git_checkout, git_branch import shutil @pytest.fixture @@ -27,4 +27,44 @@ def test_git_checkout_existing_branch(test_repository): def test_git_checkout_nonexistent_branch(test_repository): with pytest.raises(git.GitCommandError): - git_checkout(test_repository, "nonexistent-branch") \ No newline at end of file + git_checkout(test_repository, "nonexistent-branch") + +def test_git_branch_local(test_repository): + test_repository.git.branch("new-branch-local") + result = git_branch(test_repository, "local") + assert "new-branch-local" in result + +def test_git_branch_remote(test_repository): + # GitPython does not easily support creating remote branches without a remote. + # This test will check the behavior when 'remote' is specified without actual remotes. + result = git_branch(test_repository, "remote") + assert "" == result.strip() # Should be empty if no remote branches + +def test_git_branch_all(test_repository): + test_repository.git.branch("new-branch-all") + result = git_branch(test_repository, "all") + assert "new-branch-all" in result + +def test_git_branch_contains(test_repository): + # Create a new branch and commit to it + test_repository.git.checkout("-b", "feature-branch") + Path(test_repository.working_dir / Path("feature.txt")).write_text("feature content") + test_repository.index.add(["feature.txt"]) + commit = test_repository.index.commit("feature commit") + test_repository.git.checkout("master") + + result = git_branch(test_repository, "local", contains=commit.hexsha) + assert "feature-branch" in result + assert "master" not in result + +def test_git_branch_not_contains(test_repository): + # Create a new branch and commit to it + test_repository.git.checkout("-b", "another-feature-branch") + Path(test_repository.working_dir / Path("another_feature.txt")).write_text("another feature content") + test_repository.index.add(["another_feature.txt"]) + commit = test_repository.index.commit("another feature commit") + test_repository.git.checkout("master") + + result = git_branch(test_repository, "local", not_contains=commit.hexsha) + assert "another-feature-branch" not in result + assert "master" in result