Skip to content

Commit 2f69028

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 72efc42 commit 2f69028

2 files changed

Lines changed: 238 additions & 2 deletions

File tree

src/git/src/mcp_server_git/server.py

Lines changed: 93 additions & 2 deletions
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
@@ -92,6 +93,19 @@ class GitBranch(BaseModel):
9293
)
9394

9495

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

108122
BRANCH = "git_branch"
123+
CURRENT_BRANCH = "git_current_branch"
124+
DEFAULT_BRANCH = "git_default_branch"
125+
REMOTE = "git_remote"
109126

110127
def git_status(repo: git.Repo) -> str:
111128
return repo.git.status()
@@ -268,6 +285,42 @@ def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, no
268285

269286
return branch_info
270287

288+
def git_current_branch(repo: git.Repo) -> str:
289+
if repo.head.is_detached:
290+
return f"HEAD detached at {repo.head.commit.hexsha[:7]}"
291+
return repo.active_branch.name
292+
293+
def git_default_branch(repo: git.Repo, remote: str = "origin") -> str:
294+
# Try git ls-remote --symref to detect remote HEAD
295+
try:
296+
output = repo.git.ls_remote("--symref", remote, "HEAD")
297+
# Output format: "ref: refs/heads/main\tHEAD\n<sha>\tHEAD"
298+
match = re.search(r"^ref: refs/heads/(\S+)\t", output, re.MULTILINE)
299+
if match:
300+
return f"{remote}/{match.group(1)}"
301+
except git.GitCommandError:
302+
pass
303+
304+
# Try local ref resolution via rev-parse (returns "origin/main" directly)
305+
try:
306+
return repo.git.rev_parse("--abbrev-ref", f"{remote}/HEAD")
307+
except git.GitCommandError:
308+
pass
309+
310+
# Fallback: check for common local branch names
311+
local_branches = [ref.name for ref in repo.branches]
312+
if "main" in local_branches:
313+
return f"{remote}/main"
314+
if "master" in local_branches:
315+
return f"{remote}/master"
316+
317+
raise ValueError(
318+
f"Could not determine the default branch for remote '{remote}'"
319+
)
320+
321+
def git_remote(repo: git.Repo) -> str:
322+
return repo.git.remote("-v")
323+
271324

272325
async def serve(repository: Path | None) -> None:
273326
logger = logging.getLogger(__name__)
@@ -345,8 +398,22 @@ async def list_tools() -> list[Tool]:
345398
name=GitTools.BRANCH,
346399
description="List Git branches",
347400
inputSchema=GitBranch.model_json_schema(),
348-
349-
)
401+
),
402+
Tool(
403+
name=GitTools.CURRENT_BRANCH,
404+
description="Returns the name of the currently checked out branch, or the commit SHA if HEAD is detached",
405+
inputSchema=GitCurrentBranch.model_json_schema(),
406+
),
407+
Tool(
408+
name=GitTools.DEFAULT_BRANCH,
409+
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.",
410+
inputSchema=GitDefaultBranch.model_json_schema(),
411+
),
412+
Tool(
413+
name=GitTools.REMOTE,
414+
description="Lists all configured remotes with their fetch and push URLs",
415+
inputSchema=GitRemote.model_json_schema(),
416+
),
350417
]
351418

352419
async def list_repos() -> Sequence[str]:
@@ -488,6 +555,30 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
488555
text=result
489556
)]
490557

558+
case GitTools.CURRENT_BRANCH:
559+
result = git_current_branch(repo)
560+
return [TextContent(
561+
type="text",
562+
text=result
563+
)]
564+
565+
case GitTools.DEFAULT_BRANCH:
566+
result = git_default_branch(
567+
repo,
568+
arguments.get("remote", "origin")
569+
)
570+
return [TextContent(
571+
type="text",
572+
text=result
573+
)]
574+
575+
case GitTools.REMOTE:
576+
result = git_remote(repo)
577+
return [TextContent(
578+
type="text",
579+
text=result
580+
)]
581+
491582
case _:
492583
raise ValueError(f"Unknown tool: {name}")
493584

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,
@@ -423,3 +426,145 @@ def test_git_checkout_rejects_malicious_refs(test_repository):
423426

424427
# Cleanup
425428
malicious_ref_path.unlink()
429+
430+
431+
# Tests for git_current_branch
432+
433+
def test_git_current_branch(test_repository):
434+
result = git_current_branch(test_repository)
435+
assert result == test_repository.active_branch.name
436+
437+
def test_git_current_branch_detached_head(test_repository):
438+
commit_sha = test_repository.head.commit.hexsha
439+
test_repository.git.checkout(commit_sha)
440+
result = git_current_branch(test_repository)
441+
assert "detached" in result.lower()
442+
assert commit_sha[:7] in result
443+
444+
445+
# Tests for git_default_branch
446+
447+
def test_git_default_branch_fallback_local(test_repository):
448+
"""Repo with no remote; falls back to detecting the local default branch name."""
449+
default_branch = test_repository.active_branch.name
450+
result = git_default_branch(test_repository)
451+
assert result == f"origin/{default_branch}"
452+
453+
def test_git_default_branch_with_remote(tmp_path):
454+
"""Create a bare remote repo, add it as origin, verify ls-remote detection works."""
455+
# Create a bare repo to act as the remote
456+
bare_path = tmp_path / "bare_remote.git"
457+
bare_repo = git.Repo.init(bare_path, bare=True)
458+
459+
# Create a local repo and push to the bare remote
460+
local_path = tmp_path / "local_repo"
461+
local_repo = git.Repo.init(local_path)
462+
463+
Path(local_path / "test.txt").write_text("test")
464+
local_repo.index.add(["test.txt"])
465+
local_repo.index.commit("initial commit")
466+
467+
local_repo.create_remote("origin", str(bare_path))
468+
local_repo.git.push("--set-upstream", "origin", local_repo.active_branch.name)
469+
470+
result = git_default_branch(local_repo)
471+
assert result == f"origin/{local_repo.active_branch.name}"
472+
473+
shutil.rmtree(local_path)
474+
shutil.rmtree(bare_path)
475+
476+
def test_git_default_branch_custom_remote(tmp_path):
477+
"""Add a remote with a non-'origin' name, verify the remote parameter selects it."""
478+
bare_path = tmp_path / "custom_remote.git"
479+
bare_repo = git.Repo.init(bare_path, bare=True)
480+
481+
local_path = tmp_path / "local_repo"
482+
local_repo = git.Repo.init(local_path)
483+
484+
Path(local_path / "test.txt").write_text("test")
485+
local_repo.index.add(["test.txt"])
486+
local_repo.index.commit("initial commit")
487+
488+
local_repo.create_remote("upstream", str(bare_path))
489+
local_repo.git.push("--set-upstream", "upstream", local_repo.active_branch.name)
490+
491+
result = git_default_branch(local_repo, remote="upstream")
492+
assert result == f"upstream/{local_repo.active_branch.name}"
493+
494+
shutil.rmtree(local_path)
495+
shutil.rmtree(bare_path)
496+
497+
def test_git_default_branch_undetectable(tmp_path):
498+
"""Repo with no remotes and no main/master branch; should raise ValueError."""
499+
repo_path = tmp_path / "no_default_repo"
500+
repo = git.Repo.init(repo_path)
501+
502+
# Create a commit on a non-standard branch name
503+
repo.git.checkout("-b", "develop")
504+
Path(repo_path / "test.txt").write_text("test")
505+
repo.index.add(["test.txt"])
506+
repo.index.commit("initial commit")
507+
508+
with pytest.raises(ValueError, match="Could not determine the default branch"):
509+
git_default_branch(repo)
510+
511+
shutil.rmtree(repo_path)
512+
513+
def test_git_default_branch_revparse_fallback(tmp_path):
514+
"""When ls-remote fails but local ref cache exists, rev-parse fallback should work."""
515+
# Create a bare repo to act as the remote
516+
bare_path = tmp_path / "bare_remote.git"
517+
git.Repo.init(bare_path, bare=True)
518+
519+
# Create a local repo and push to the bare remote
520+
local_path = tmp_path / "local_repo"
521+
local_repo = git.Repo.init(local_path)
522+
523+
Path(local_path / "test.txt").write_text("test")
524+
local_repo.index.add(["test.txt"])
525+
local_repo.index.commit("initial commit")
526+
527+
active_branch = local_repo.active_branch.name
528+
local_repo.create_remote("origin", str(bare_path))
529+
local_repo.git.push("--set-upstream", "origin", active_branch)
530+
531+
# Populate local ref cache for origin/HEAD
532+
local_repo.git.remote("set-head", "origin", "--auto")
533+
534+
# Replace remote URL with an invalid path so ls-remote will fail
535+
local_repo.git.remote("set-url", "origin", "/nonexistent/path")
536+
537+
result = git_default_branch(local_repo)
538+
assert result == f"origin/{active_branch}"
539+
540+
shutil.rmtree(local_path)
541+
shutil.rmtree(bare_path)
542+
543+
544+
# Tests for git_remote
545+
546+
def test_git_remote_no_remotes(test_repository):
547+
"""Repo with no remotes; verify empty output."""
548+
result = git_remote(test_repository)
549+
assert result == ""
550+
551+
def test_git_remote_with_remote(tmp_path):
552+
"""Repo with a remote configured; verify remote name and URL appear in output."""
553+
bare_path = tmp_path / "bare_remote.git"
554+
git.Repo.init(bare_path, bare=True)
555+
556+
local_path = tmp_path / "local_repo"
557+
local_repo = git.Repo.init(local_path)
558+
559+
Path(local_path / "test.txt").write_text("test")
560+
local_repo.index.add(["test.txt"])
561+
local_repo.index.commit("initial commit")
562+
563+
local_repo.create_remote("origin", str(bare_path))
564+
565+
result = git_remote(local_repo)
566+
assert "origin" in result
567+
assert str(bare_path) in result
568+
569+
shutil.rmtree(local_path)
570+
shutil.rmtree(bare_path)

0 commit comments

Comments
 (0)