Skip to content
Merged
9 changes: 9 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 66 additions & 2 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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__)

Expand Down Expand Up @@ -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(),
)
]

Expand Down Expand Up @@ -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}")

Expand Down
44 changes: 42 additions & 2 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
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