Skip to content

Commit 0f9f95a

Browse files
authored
Merge pull request #1943 from JavieHush/main
Add Git Branch tool to mcp-server-git
2 parents 531c1fe + 1f95231 commit 0f9f95a

File tree

3 files changed

+117
-4
lines changed

3 files changed

+117
-4
lines changed

src/git/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ Please note that mcp-server-git is currently in early development. The functiona
8888
- `repo_path` (string): Path to directory to initialize git repo
8989
- Returns: Confirmation of repository initialization
9090

91+
13. `git_branch`
92+
- List Git branches
93+
- Inputs:
94+
- `repo_path` (string): Path to the Git repository.
95+
- `branch_type` (string): Whether to list local branches ('local'), remote branches ('remote') or all branches('all').
96+
- `contains` (string, optional): The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified
97+
- `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
98+
- Returns: List of branches
99+
91100
## Installation
92101

93102
### Using uv (recommended)

src/git/src/mcp_server_git/server.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from pathlib import Path
3-
from typing import Sequence
3+
from typing import Sequence, Optional
44
from mcp.server import Server
55
from mcp.server.session import ServerSession
66
from mcp.server.stdio import stdio_server
@@ -13,7 +13,7 @@
1313
)
1414
from enum import Enum
1515
import git
16-
from pydantic import BaseModel
16+
from pydantic import BaseModel, Field
1717

1818
# Default number of context lines to show in diff output
1919
DEFAULT_CONTEXT_LINES = 3
@@ -65,6 +65,24 @@ class GitShow(BaseModel):
6565
class GitInit(BaseModel):
6666
repo_path: str
6767

68+
class GitBranch(BaseModel):
69+
repo_path: str = Field(
70+
...,
71+
description="The path to the Git repository.",
72+
)
73+
branch_type: str = Field(
74+
...,
75+
description="Whether to list local branches ('local'), remote branches ('remote') or all branches('all').",
76+
)
77+
contains: Optional[str] = Field(
78+
None,
79+
description="The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified",
80+
)
81+
not_contains: Optional[str] = Field(
82+
None,
83+
description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified",
84+
)
85+
6886
class GitTools(str, Enum):
6987
STATUS = "git_status"
7088
DIFF_UNSTAGED = "git_diff_unstaged"
@@ -78,6 +96,7 @@ class GitTools(str, Enum):
7896
CHECKOUT = "git_checkout"
7997
SHOW = "git_show"
8098
INIT = "git_init"
99+
BRANCH = "git_branch"
81100

82101
def git_status(repo: git.Repo) -> str:
83102
return repo.git.status()
@@ -153,6 +172,34 @@ def git_show(repo: git.Repo, revision: str) -> str:
153172
output.append(d.diff.decode('utf-8'))
154173
return "".join(output)
155174

175+
def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str:
176+
match contains:
177+
case None:
178+
contains_sha = (None,)
179+
case _:
180+
contains_sha = ("--contains", contains)
181+
182+
match not_contains:
183+
case None:
184+
not_contains_sha = (None,)
185+
case _:
186+
not_contains_sha = ("--no-contains", not_contains)
187+
188+
match branch_type:
189+
case 'local':
190+
b_type = None
191+
case 'remote':
192+
b_type = "-r"
193+
case 'all':
194+
b_type = "-a"
195+
case _:
196+
return f"Invalid branch type: {branch_type}"
197+
198+
# None value will be auto deleted by GitPython
199+
branch_info = repo.git.branch(b_type, *contains_sha, *not_contains_sha)
200+
201+
return branch_info
202+
156203
async def serve(repository: Path | None) -> None:
157204
logger = logging.getLogger(__name__)
158205

@@ -228,6 +275,11 @@ async def list_tools() -> list[Tool]:
228275
name=GitTools.INIT,
229276
description="Initialize a new Git repository",
230277
inputSchema=GitInit.model_json_schema(),
278+
),
279+
Tool(
280+
name=GitTools.BRANCH,
281+
description="List Git branches",
282+
inputSchema=GitBranch.model_json_schema(),
231283
)
232284
]
233285

@@ -357,6 +409,18 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
357409
text=result
358410
)]
359411

412+
case GitTools.BRANCH:
413+
result = git_branch(
414+
repo,
415+
arguments.get("branch_type", 'local'),
416+
arguments.get("contains", None),
417+
arguments.get("not_contains", None),
418+
)
419+
return [TextContent(
420+
type="text",
421+
text=result
422+
)]
423+
360424
case _:
361425
raise ValueError(f"Unknown tool: {name}")
362426

src/git/tests/test_server.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
from pathlib import Path
33
import git
4-
from mcp_server_git.server import git_checkout
4+
from mcp_server_git.server import git_checkout, git_branch
55
import shutil
66

77
@pytest.fixture
@@ -27,4 +27,44 @@ def test_git_checkout_existing_branch(test_repository):
2727
def test_git_checkout_nonexistent_branch(test_repository):
2828

2929
with pytest.raises(git.GitCommandError):
30-
git_checkout(test_repository, "nonexistent-branch")
30+
git_checkout(test_repository, "nonexistent-branch")
31+
32+
def test_git_branch_local(test_repository):
33+
test_repository.git.branch("new-branch-local")
34+
result = git_branch(test_repository, "local")
35+
assert "new-branch-local" in result
36+
37+
def test_git_branch_remote(test_repository):
38+
# GitPython does not easily support creating remote branches without a remote.
39+
# This test will check the behavior when 'remote' is specified without actual remotes.
40+
result = git_branch(test_repository, "remote")
41+
assert "" == result.strip() # Should be empty if no remote branches
42+
43+
def test_git_branch_all(test_repository):
44+
test_repository.git.branch("new-branch-all")
45+
result = git_branch(test_repository, "all")
46+
assert "new-branch-all" in result
47+
48+
def test_git_branch_contains(test_repository):
49+
# Create a new branch and commit to it
50+
test_repository.git.checkout("-b", "feature-branch")
51+
Path(test_repository.working_dir / Path("feature.txt")).write_text("feature content")
52+
test_repository.index.add(["feature.txt"])
53+
commit = test_repository.index.commit("feature commit")
54+
test_repository.git.checkout("master")
55+
56+
result = git_branch(test_repository, "local", contains=commit.hexsha)
57+
assert "feature-branch" in result
58+
assert "master" not in result
59+
60+
def test_git_branch_not_contains(test_repository):
61+
# Create a new branch and commit to it
62+
test_repository.git.checkout("-b", "another-feature-branch")
63+
Path(test_repository.working_dir / Path("another_feature.txt")).write_text("another feature content")
64+
test_repository.index.add(["another_feature.txt"])
65+
commit = test_repository.index.commit("another feature commit")
66+
test_repository.git.checkout("master")
67+
68+
result = git_branch(test_repository, "local", not_contains=commit.hexsha)
69+
assert "another-feature-branch" not in result
70+
assert "master" in result

0 commit comments

Comments
 (0)