Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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