Skip to content
Open
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
29 changes: 28 additions & 1 deletion src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class GitCommit(BaseModel):

class GitAdd(BaseModel):
repo_path: str
files: list[str]
files: list[str] = Field(..., min_length=1)

class GitReset(BaseModel):
repo_path: str
Expand Down Expand Up @@ -125,11 +125,29 @@ def git_diff(repo: git.Repo, target: str, context_lines: int = DEFAULT_CONTEXT_L
repo.rev_parse(target) # Validates target is a real git ref, throws BadName if not
return repo.git.diff(f"--unified={context_lines}", target)

def _staged_paths(repo: git.Repo) -> list[str]:
"""Paths the index currently holds as changes against HEAD."""
if not repo.head.is_valid():
# Unborn branch: everything in the index is staged for the first commit.
return [str(path) for path, _stage in repo.index.entries]
# A rename or delete leaves one side of the diff unset, so take whichever
# path the entry does carry and drop any entry with neither.
return [
path
for diff in repo.index.diff(repo.head.commit)
if (path := diff.a_path or diff.b_path) is not None
]

def git_commit(repo: git.Repo, message: str) -> str:
commit = repo.index.commit(message)
return f"Changes committed successfully with hash {commit.hexsha}"

def git_add(repo: git.Repo, files: list[str]) -> str:
if not files:
raise ValueError(
"No files provided to stage. Pass one or more paths, "
"or ['.'] to stage everything."
)
if files == ["."]:
repo.git.add(".")
else:
Expand All @@ -150,6 +168,15 @@ def git_add(repo: git.Repo, files: list[str]) -> str:
)
# Use '--' to prevent files starting with '-' from being interpreted as options
repo.git.add("--", *files)

# `git add` exits 0 when it stages nothing -- an empty pathspec, or '.' on a
# tree with no changes -- so the outcome has to be read back from the index
# rather than assumed from the exit status.
if not _staged_paths(repo):
return (
"No files staged: the given paths matched no changes. "
"git_status shows what is modified or untracked."
)
return "Files staged successfully"

def git_reset(repo: git.Repo) -> str:
Expand Down
23 changes: 23 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
import shutil
import unittest.mock as mock
from pydantic import ValidationError

@pytest.fixture
def test_repository(tmp_path: Path):
Expand Down Expand Up @@ -111,6 +112,28 @@ def test_git_add_specific_files(test_repository):
assert "file2.txt" not in staged_files
assert result == "Files staged successfully"

def test_git_add_reports_when_nothing_was_staged(test_repository):
"""`git add` exits 0 when it stages nothing, so the old unconditional
"Files staged successfully" claimed work on an unchanged tree."""
assert not test_repository.is_dirty(untracked_files=True)

result = git_add(test_repository, ["."])

assert result != "Files staged successfully"
assert "No files staged" in result
assert not test_repository.index.diff(test_repository.head.commit)

def test_git_add_rejects_an_empty_file_list(test_repository):
"""`git add --` with no pathspec is a no-op that exits 0."""
with pytest.raises(ValueError, match="No files provided to stage"):
git_add(test_repository, [])

def test_git_add_schema_rejects_an_empty_file_list():
from mcp_server_git.server import GitAdd

with pytest.raises(ValidationError):
GitAdd(repo_path=".", files=[])

def test_git_add_rejects_path_traversal(test_repository):
# Security invariant (CVE-2026-27735): a relative path escaping the
# repository must never be staged. Accept rejection from either the
Expand Down