Skip to content

Commit 0f1d304

Browse files
cbleckerclaude
andcommitted
feat(git): add git_current_branch, git_default_branch, and git_remote tools
Add three new tools to the Git MCP server: - git_current_branch: Returns the active branch name, or the short SHA with a detached HEAD indicator when HEAD is detached. - git_default_branch: Determines the default branch for a remote, returning in 'remote/branch' format (e.g. 'origin/main'). Uses git ls-remote --symref (parsed via regex), with fallback to rev-parse for local ref resolution, then common branch name detection. Accepts an optional remote parameter (defaults to "origin"). - git_remote: Lists all configured remotes with their fetch and push URLs (wraps git remote -v). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4503e2d commit 0f1d304

2 files changed

Lines changed: 264 additions & 1 deletion

File tree

src/git/src/mcp_server_git/server.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import re
23
from pathlib import Path
34
from typing import Sequence, Optional
45
from mcp.server import Server
@@ -93,6 +94,19 @@ class GitBranch(BaseModel):
9394
)
9495

9596

97+
class GitCurrentBranch(BaseModel):
98+
repo_path: str
99+
100+
class GitDefaultBranch(BaseModel):
101+
repo_path: str
102+
remote: str = Field(
103+
"origin",
104+
description="The remote to get the default branch for (defaults to 'origin')"
105+
)
106+
107+
class GitRemote(BaseModel):
108+
repo_path: str
109+
96110
class GitTools(str, Enum):
97111
STATUS = "git_status"
98112
DIFF_UNSTAGED = "git_diff_unstaged"
@@ -107,6 +121,9 @@ class GitTools(str, Enum):
107121
SHOW = "git_show"
108122

109123
BRANCH = "git_branch"
124+
CURRENT_BRANCH = "git_current_branch"
125+
DEFAULT_BRANCH = "git_default_branch"
126+
REMOTE = "git_remote"
110127

111128
def git_status(repo: git.Repo) -> str:
112129
return repo.git.status()
@@ -289,6 +306,44 @@ def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, no
289306

290307
return branch_info
291308

309+
def git_current_branch(repo: git.Repo) -> str:
310+
if repo.head.is_detached:
311+
return f"HEAD detached at {repo.head.commit.hexsha[:7]}"
312+
return repo.active_branch.name
313+
314+
def git_default_branch(repo: git.Repo, remote: str = "origin") -> str:
315+
if remote.startswith("-"):
316+
raise ValueError(f"Invalid remote name: {remote}")
317+
# Try git ls-remote --symref to detect remote HEAD
318+
try:
319+
output = repo.git.ls_remote("--symref", remote, "HEAD")
320+
# Output format: "ref: refs/heads/main\tHEAD\n<sha>\tHEAD"
321+
match = re.search(r"^ref: refs/heads/(\S+)\t", output, re.MULTILINE)
322+
if match:
323+
return f"{remote}/{match.group(1)}"
324+
except git.GitCommandError:
325+
pass
326+
327+
# Try local ref resolution via rev-parse (returns "origin/main" directly)
328+
try:
329+
return repo.git.rev_parse("--abbrev-ref", f"{remote}/HEAD")
330+
except git.GitCommandError:
331+
pass
332+
333+
# Fallback: check for common local branch names
334+
local_branches = [ref.name for ref in repo.branches]
335+
if "main" in local_branches:
336+
return f"{remote}/main"
337+
if "master" in local_branches:
338+
return f"{remote}/master"
339+
340+
raise ValueError(
341+
f"Could not determine the default branch for remote '{remote}'"
342+
)
343+
344+
def git_remote(repo: git.Repo) -> str:
345+
return repo.git.remote("-v")
346+
292347

293348
async def serve(repository: Path | None) -> None:
294349
logger = logging.getLogger(__name__)
@@ -437,7 +492,40 @@ async def list_tools() -> list[Tool]:
437492
idempotentHint=True,
438493
openWorldHint=False,
439494
),
440-
)
495+
),
496+
Tool(
497+
name=GitTools.CURRENT_BRANCH,
498+
description="Returns the name of the currently checked out branch, or the commit SHA if HEAD is detached",
499+
inputSchema=GitCurrentBranch.model_json_schema(),
500+
annotations=ToolAnnotations(
501+
readOnlyHint=True,
502+
destructiveHint=False,
503+
idempotentHint=True,
504+
openWorldHint=False,
505+
),
506+
),
507+
Tool(
508+
name=GitTools.DEFAULT_BRANCH,
509+
description="Returns the default branch for a remote in '<remote>/<branch>' format (e.g., 'origin/main'). Queries the remote directly, with fallback to local ref resolution and branch detection.",
510+
inputSchema=GitDefaultBranch.model_json_schema(),
511+
annotations=ToolAnnotations(
512+
readOnlyHint=True,
513+
destructiveHint=False,
514+
idempotentHint=True,
515+
openWorldHint=False,
516+
),
517+
),
518+
Tool(
519+
name=GitTools.REMOTE,
520+
description="Lists all configured remotes with their fetch and push URLs",
521+
inputSchema=GitRemote.model_json_schema(),
522+
annotations=ToolAnnotations(
523+
readOnlyHint=True,
524+
destructiveHint=False,
525+
idempotentHint=True,
526+
openWorldHint=False,
527+
),
528+
),
441529
]
442530

443531
async def list_repos() -> Sequence[str]:
@@ -579,6 +667,30 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
579667
text=result
580668
)]
581669

670+
case GitTools.CURRENT_BRANCH:
671+
result = git_current_branch(repo)
672+
return [TextContent(
673+
type="text",
674+
text=result
675+
)]
676+
677+
case GitTools.DEFAULT_BRANCH:
678+
result = git_default_branch(
679+
repo,
680+
arguments.get("remote", "origin")
681+
)
682+
return [TextContent(
683+
type="text",
684+
text=result
685+
)]
686+
687+
case GitTools.REMOTE:
688+
result = git_remote(repo)
689+
return [TextContent(
690+
type="text",
691+
text=result
692+
)]
693+
582694
case _:
583695
raise ValueError(f"Unknown tool: {name}")
584696

src/git/tests/test_server.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from mcp_server_git.server import (
66
git_checkout,
77
git_branch,
8+
git_current_branch,
9+
git_default_branch,
10+
git_remote,
811
git_add,
912
git_status,
1013
git_diff_unstaged,
@@ -482,3 +485,151 @@ def test_git_branch_rejects_contains_flag_injection(test_repository):
482485

483486
with pytest.raises(BadName):
484487
git_branch(test_repository, "local", not_contains="--exec=evil")
488+
489+
490+
def test_git_default_branch_rejects_remote_flag_injection(test_repository):
491+
"""git_default_branch should reject remote names starting with '-'."""
492+
with pytest.raises(ValueError):
493+
git_default_branch(test_repository, remote="--upload-pack=evil")
494+
495+
496+
# Tests for git_current_branch
497+
498+
def test_git_current_branch(test_repository):
499+
result = git_current_branch(test_repository)
500+
assert result == test_repository.active_branch.name
501+
502+
def test_git_current_branch_detached_head(test_repository):
503+
commit_sha = test_repository.head.commit.hexsha
504+
test_repository.git.checkout(commit_sha)
505+
result = git_current_branch(test_repository)
506+
assert "detached" in result.lower()
507+
assert commit_sha[:7] in result
508+
509+
510+
# Tests for git_default_branch
511+
512+
def test_git_default_branch_fallback_local(test_repository):
513+
"""Repo with no remote; falls back to detecting the local default branch name."""
514+
default_branch = test_repository.active_branch.name
515+
result = git_default_branch(test_repository)
516+
assert result == f"origin/{default_branch}"
517+
518+
def test_git_default_branch_with_remote(tmp_path):
519+
"""Create a bare remote repo, add it as origin, verify ls-remote detection works."""
520+
# Create a bare repo to act as the remote
521+
bare_path = tmp_path / "bare_remote.git"
522+
bare_repo = git.Repo.init(bare_path, bare=True)
523+
524+
# Create a local repo and push to the bare remote
525+
local_path = tmp_path / "local_repo"
526+
local_repo = git.Repo.init(local_path)
527+
528+
Path(local_path / "test.txt").write_text("test")
529+
local_repo.index.add(["test.txt"])
530+
local_repo.index.commit("initial commit")
531+
532+
local_repo.create_remote("origin", str(bare_path))
533+
local_repo.git.push("--set-upstream", "origin", local_repo.active_branch.name)
534+
535+
result = git_default_branch(local_repo)
536+
assert result == f"origin/{local_repo.active_branch.name}"
537+
538+
shutil.rmtree(local_path)
539+
shutil.rmtree(bare_path)
540+
541+
def test_git_default_branch_custom_remote(tmp_path):
542+
"""Add a remote with a non-'origin' name, verify the remote parameter selects it."""
543+
bare_path = tmp_path / "custom_remote.git"
544+
bare_repo = git.Repo.init(bare_path, bare=True)
545+
546+
local_path = tmp_path / "local_repo"
547+
local_repo = git.Repo.init(local_path)
548+
549+
Path(local_path / "test.txt").write_text("test")
550+
local_repo.index.add(["test.txt"])
551+
local_repo.index.commit("initial commit")
552+
553+
local_repo.create_remote("upstream", str(bare_path))
554+
local_repo.git.push("--set-upstream", "upstream", local_repo.active_branch.name)
555+
556+
result = git_default_branch(local_repo, remote="upstream")
557+
assert result == f"upstream/{local_repo.active_branch.name}"
558+
559+
shutil.rmtree(local_path)
560+
shutil.rmtree(bare_path)
561+
562+
def test_git_default_branch_undetectable(tmp_path):
563+
"""Repo with no remotes and no main/master branch; should raise ValueError."""
564+
repo_path = tmp_path / "no_default_repo"
565+
repo = git.Repo.init(repo_path)
566+
567+
# Create a commit on a non-standard branch name
568+
repo.git.checkout("-b", "develop")
569+
Path(repo_path / "test.txt").write_text("test")
570+
repo.index.add(["test.txt"])
571+
repo.index.commit("initial commit")
572+
573+
with pytest.raises(ValueError, match="Could not determine the default branch"):
574+
git_default_branch(repo)
575+
576+
shutil.rmtree(repo_path)
577+
578+
def test_git_default_branch_revparse_fallback(tmp_path):
579+
"""When ls-remote fails but local ref cache exists, rev-parse fallback should work."""
580+
# Create a bare repo to act as the remote
581+
bare_path = tmp_path / "bare_remote.git"
582+
git.Repo.init(bare_path, bare=True)
583+
584+
# Create a local repo and push to the bare remote
585+
local_path = tmp_path / "local_repo"
586+
local_repo = git.Repo.init(local_path)
587+
588+
Path(local_path / "test.txt").write_text("test")
589+
local_repo.index.add(["test.txt"])
590+
local_repo.index.commit("initial commit")
591+
592+
active_branch = local_repo.active_branch.name
593+
local_repo.create_remote("origin", str(bare_path))
594+
local_repo.git.push("--set-upstream", "origin", active_branch)
595+
596+
# Populate local ref cache for origin/HEAD
597+
local_repo.git.remote("set-head", "origin", "--auto")
598+
599+
# Replace remote URL with an invalid path so ls-remote will fail
600+
local_repo.git.remote("set-url", "origin", "/nonexistent/path")
601+
602+
result = git_default_branch(local_repo)
603+
assert result == f"origin/{active_branch}"
604+
605+
shutil.rmtree(local_path)
606+
shutil.rmtree(bare_path)
607+
608+
609+
# Tests for git_remote
610+
611+
def test_git_remote_no_remotes(test_repository):
612+
"""Repo with no remotes; verify empty output."""
613+
result = git_remote(test_repository)
614+
assert result == ""
615+
616+
def test_git_remote_with_remote(tmp_path):
617+
"""Repo with a remote configured; verify remote name and URL appear in output."""
618+
bare_path = tmp_path / "bare_remote.git"
619+
git.Repo.init(bare_path, bare=True)
620+
621+
local_path = tmp_path / "local_repo"
622+
local_repo = git.Repo.init(local_path)
623+
624+
Path(local_path / "test.txt").write_text("test")
625+
local_repo.index.add(["test.txt"])
626+
local_repo.index.commit("initial commit")
627+
628+
local_repo.create_remote("origin", str(bare_path))
629+
630+
result = git_remote(local_repo)
631+
assert "origin" in result
632+
assert str(bare_path) in result
633+
634+
shutil.rmtree(local_path)
635+
shutil.rmtree(bare_path)

0 commit comments

Comments
 (0)