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
24 changes: 22 additions & 2 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
from pathlib import Path
from typing import Sequence, Optional
Expand Down Expand Up @@ -42,7 +43,10 @@ class GitCommit(BaseModel):

class GitAdd(BaseModel):
repo_path: str
files: list[str]
files: list[str] | str = Field(
...,
description="The files to stage. Normally a list of paths. A JSON-encoded array string such as '[\"a.py\", \"b.py\"]' or a single path string are also accepted for leniency.",
)

class GitReset(BaseModel):
repo_path: str
Expand Down Expand Up @@ -129,7 +133,23 @@ 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:
def normalize_file_list(files: list[str] | str) -> list[str]:
# Some clients send the files argument as a JSON-encoded array string,
# e.g. '["a.py", "b.py"]', instead of a real array. Accept that form, and
# also accept a single bare path string, so a stray string does not fail
# the call outright.
if isinstance(files, str):
try:
parsed = json.loads(files)
except json.JSONDecodeError:
return [files]
if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
return parsed
return [files]
return files

def git_add(repo: git.Repo, files: list[str] | str) -> str:
files = normalize_file_list(files)
if files == ["."]:
repo.git.add(".")
else:
Expand Down
40 changes: 40 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
git_checkout,
git_branch,
git_add,
normalize_file_list,
git_status,
git_diff_unstaged,
git_diff_staged,
Expand Down Expand Up @@ -109,6 +110,45 @@ def test_git_add_specific_files(test_repository):
assert "file2.txt" not in staged_files
assert result == "Files staged successfully"

def test_git_add_json_encoded_string(test_repository):
file1 = Path(test_repository.working_dir) / "file1.txt"
file2 = Path(test_repository.working_dir) / "file2.txt"
file1.write_text("file 1 content")
file2.write_text("file 2 content")

# Some clients send the files argument as a JSON-encoded array string.
result = git_add(test_repository, '["file1.txt", "file2.txt"]')

staged_files = [item.a_path for item in test_repository.index.diff("HEAD")]
assert "file1.txt" in staged_files
assert "file2.txt" in staged_files
assert result == "Files staged successfully"

def test_git_add_single_path_string(test_repository):
file1 = Path(test_repository.working_dir) / "file1.txt"
file2 = Path(test_repository.working_dir) / "file2.txt"
file1.write_text("file 1 content")
file2.write_text("file 2 content")

# A single bare path string is treated as one file, not split apart.
result = git_add(test_repository, "file1.txt")

staged_files = [item.a_path for item in test_repository.index.diff("HEAD")]
assert "file1.txt" in staged_files
assert "file2.txt" not in staged_files
assert result == "Files staged successfully"

def test_normalize_file_list():
# Real lists pass through unchanged.
assert normalize_file_list(["a.py", "b.py"]) == ["a.py", "b.py"]
# JSON-encoded array strings are parsed into a list.
assert normalize_file_list('["a.py", "b.py"]') == ["a.py", "b.py"]
# A bare path that is not valid JSON is kept as a single entry.
assert normalize_file_list("a.py") == ["a.py"]
# A JSON value that is not a list of strings is treated as a single path.
assert normalize_file_list('{"a": 1}') == ['{"a": 1}']
assert normalize_file_list("[1, 2]") == ["[1, 2]"]

def test_git_status(test_repository):
result = git_status(test_repository)

Expand Down
Loading