diff --git a/src/git/README.md b/src/git/README.md index 9198fb0088..8edf2124cc 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -88,6 +88,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 fcb47c80b9..afb922f0c7 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 # Default number of context lines to show in diff output DEFAULT_CONTEXT_LINES = 3 @@ -65,6 +65,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" @@ -78,6 +96,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() @@ -153,6 +172,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__) @@ -228,6 +275,11 @@ async def list_tools() -> list[Tool]: name=GitTools.INIT, description="Initialize a new Git repository", inputSchema=GitInit.model_json_schema(), + ), + Tool( + name=GitTools.BRANCH, + description="List Git branches", + inputSchema=GitBranch.model_json_schema(), ) ] @@ -357,6 +409,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