Skip to content

Commit 5e4c9f2

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 5e4c9f2

2 files changed

Lines changed: 238 additions & 1 deletion

File tree

src/git/src/mcp_server_git/server.py

Lines changed: 93 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,42 @@ 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+
# Try git ls-remote --symref to detect remote HEAD
316+
try:
317+
output = repo.git.ls_remote("--symref", remote, "HEAD")
318+
# Output format: "ref: refs/heads/main\tHEAD\n<sha>\tHEAD"
319+
match = re.search(r"^ref: refs/heads/(\S+)\t", output, re.MULTILINE)
320+
if match:
321+
return f"{remote}/{match.group(1)}"
322+
except git.GitCommandError:
323+
pass
324+
325+
# Try local ref resolution via rev-parse (returns "origin/main" directly)
326+
try:
327+
return repo.git.rev_parse("--abbrev-ref", f"{remote}/HEAD")
328+
except git.GitCommandError:
329+
pass
330+
331+
# Fallback: check for common local branch names
332+
local_branches = [ref.name for ref in repo.branches]
333+
if "main" in local_branches:
334+
return f"{remote}/main"
335+
if "master" in local_branches:
336+
return f"{remote}/master"
337+
338+
raise ValueError(
339+
f"Could not determine the default branch for remote '{remote}'"
340+
)
341+
342+
def git_remote(repo: git.Repo) -> str:
343+
return repo.git.remote("-v")
344+
292345

293346
async def serve(repository: Path | None) -> None:
294347
logger = logging.getLogger(__name__)
@@ -437,7 +490,22 @@ async def list_tools() -> list[Tool]:
437490
idempotentHint=True,
438491
openWorldHint=False,
439492
),
440-
)
493+
),
494+
Tool(
495+
name=GitTools.CURRENT_BRANCH,
496+
description="Returns the name of the currently checked out branch, or the commit SHA if HEAD is detached",
497+
inputSchema=GitCurrentBranch.model_json_schema(),
498+
),
499+
Tool(
500+
name=GitTools.DEFAULT_BRANCH,
501+
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.",
502+
inputSchema=GitDefaultBranch.model_json_schema(),
503+
),
504+
Tool(
505+
name=GitTools.REMOTE,
506+
description="Lists all configured remotes with their fetch and push URLs",
507+
inputSchema=GitRemote.model_json_schema(),
508+
),
441509
]
442510

443511
async def list_repos() -> Sequence[str]:
@@ -579,6 +647,30 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
579647
text=result
580648
)]
581649

650+
case GitTools.CURRENT_BRANCH:
651+
result = git_current_branch(repo)
652+
return [TextContent(
653+
type="text",
654+
text=result
655+
)]
656+
657+
case GitTools.DEFAULT_BRANCH:
658+
result = git_default_branch(
659+
repo,
660+
arguments.get("remote", "origin")
661+
)
662+
return [TextContent(
663+
type="text",
664+
text=result
665+
)]
666+
667+
case GitTools.REMOTE:
668+
result = git_remote(repo)
669+
return [TextContent(
670+
type="text",
671+
text=result
672+
)]
673+
582674
case _:
583675
raise ValueError(f"Unknown tool: {name}")
584676

src/git/tests/test_server.py

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

0 commit comments

Comments
 (0)