Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ Please note that mcp-server-git is currently in early development. The functiona
- `branch_name` (string): Name of branch to checkout
- Returns: Confirmation of branch switch
11. `git_show`
- Shows the contents of a commit
- Shows the contents of a commit, or of a file or directory at a revision
- Inputs:
- `repo_path` (string): Path to Git repository
- `revision` (string): The revision (commit hash, branch name, tag) to show
- Returns: Contents of the specified commit
- `revision` (string): The revision (commit hash, branch name, tag) or object spec (`HEAD:path/to/file`) to show
- Returns: Contents of the specified commit, file content, or directory listing

12. `git_branch`
- List Git branches
Expand Down
31 changes: 24 additions & 7 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,24 +240,41 @@ def git_show(repo: git.Repo, revision: str) -> str:
# even if a malicious ref with that name exists (e.g. via filesystem manipulation)
if revision.startswith("-"):
raise BadName(f"Invalid revision: '{revision}' - cannot start with '-'")
obj = repo.rev_parse(revision)
if isinstance(obj, git.Blob):
return obj.data_stream.read().decode("utf-8", errors="replace")
if isinstance(obj, git.Tree):
return "\n".join(
f"{item.name}/" if isinstance(item, git.Tree) else item.name for item in obj
)
commit = repo.commit(revision)
message = commit.message
if isinstance(message, bytes): # pragma: no cover
# GitPython already decodes the message, falling back to errors="replace".
message = message.decode("utf-8", errors="replace")
# The header follows `git show --date=iso`: the sha, `Name <email>`, an
# ISO date, and the message indented by four spaces.
output = [
f"Commit: {commit.hexsha!r}\n"
f"Author: {commit.author!r}\n"
f"Date: {commit.authored_datetime!r}\n"
f"Message: {commit.message!r}\n"
f"commit {commit.hexsha}\n"
f"Author: {commit.author.name} <{commit.author.email}>\n"
f"Date: {commit.authored_datetime.strftime('%Y-%m-%d %H:%M:%S %z')}\n"
"\n" + "".join(f" {line}\n" for line in message.rstrip("\n").split("\n"))
]
if commit.parents:
parent = commit.parents[0]
diff = parent.diff(commit, create_patch=True)
else:
diff = commit.diff(git.NULL_TREE, create_patch=True)
for d in diff:
output.append(f"\n--- {d.a_path}\n+++ {d.b_path}\n")
# git prints /dev/null for the missing side of an added or deleted file.
a_path = "/dev/null" if d.new_file or d.a_path is None else d.a_path
b_path = "/dev/null" if d.deleted_file or d.b_path is None else d.b_path
output.append(f"\n--- {a_path}\n+++ {b_path}\n")
if d.diff is None:
continue # pragma: no cover # with create_patch=True GitPython always assigns the patch as bytes
if isinstance(d.diff, bytes):
output.append(d.diff.decode("utf-8"))
# A non-UTF-8 file (Latin-1, say) must not fail the whole call.
output.append(d.diff.decode("utf-8", errors="replace"))
else: # pragma: no cover # with create_patch=True GitPython always assigns the patch as bytes
output.append(d.diff)
return "".join(output)
Expand Down Expand Up @@ -454,7 +471,7 @@ async def list_tools() -> list[Tool]:
),
Tool(
name=GitTools.SHOW,
description="Shows the contents of a commit",
description="Shows the contents of a commit, or of a file or directory given as <revision>:<path>",
inputSchema=GitShow.model_json_schema(),
annotations=ToolAnnotations(
readOnlyHint=True,
Expand Down
2 changes: 1 addition & 1 deletion src/git/tests/snapshots/list_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@
},
{
"name": "git_show",
"description": "Shows the contents of a commit",
"description": "Shows the contents of a commit, or of a file or directory given as <revision>:<path>",
"inputSchema": {
"properties": {
"repo_path": {
Expand Down
115 changes: 76 additions & 39 deletions src/git/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,29 +706,23 @@ async def test_git_checkout_rejects_flag_injection(repo: git.Repo):
# --------------------------------------------------------------------------


def without_addresses(text: str) -> str:
"""Mask CPython object addresses (`object at 0x...`)."""
return re.sub(r" at 0x[0-9a-fA-F]+", " at 0x?", text)


def show_text(result: dict[str, Any]) -> str:
assert result["isError"] is False
return without_addresses(result["content"][0]["text"])
return result["content"][0]["text"]


def show_header(commit: git.Commit) -> str:
# git_show formats its header with !r, so the reply carries Python reprs:
# a quoted sha, `<git.Actor ...>`, and a `datetime.datetime(...)` whose
# tzinfo repr includes a memory address. Compared with addresses masked.
return without_addresses(
f"Commit: {commit.hexsha!r}\n"
f"Author: {commit.author!r}\n"
f"Date: {commit.authored_datetime!r}\n"
f"Message: {commit.message!r}\n"
# git_show's header follows `git show --date=iso`.
date = commit.authored_datetime.strftime("%Y-%m-%d %H:%M:%S %z")
lines = str(commit.message).rstrip("\n").split("\n")
return (
f"commit {commit.hexsha}\n"
"Author: Test User <test@example.com>\n"
f"Date: {date}\n"
"\n" + "".join(f" {line}\n" for line in lines)
)


# KNOWN BUG #4998: git_show prints Python reprs (quoted sha, <git.Actor>, datetime with a memory address) instead of git's format; the fix changes this assertion.
async def test_git_show_commit_with_parent(repo: git.Repo):
root = root_of(repo)
(root / "test.txt").write_text("line 1\nline 2\nSHOWN\nline 4\nline 5\n")
Expand All @@ -743,29 +737,52 @@ async def test_git_show_commit_with_parent(repo: git.Repo):
+ "@@ -1,5 +1,5 @@\n line 1\n line 2\n-line 3\n+SHOWN\n line 4\n line 5\n"
)
text = show_text(result)
assert f"Commit: '{commit.hexsha}'\n" in text
assert 'Author: <git.Actor "Test User <test@example.com>">\n' in text
assert "Date: datetime.datetime(" in text
assert "Message: 'show me'\n" in text
assert text.startswith(
f"commit {commit.hexsha}\nAuthor: Test User <test@example.com>\n"
)
assert re.search(
r"\nDate: \d{4}-\d\d-\d\d \d\d:\d\d:\d\d [+-]\d{4}\n\n show me\n", text
)


async def test_git_show_multiline_message_is_indented(repo: git.Repo):
root = root_of(repo)
(root / "test.txt").write_text("changed\n")
repo.index.add(["test.txt"])
commit = repo.index.commit("subject\n\nbody line\n")
result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"})
text = show_text(result)
assert text.startswith(show_header(commit))
assert "\n\n subject\n \n body line\n\n--- test.txt\n" in text


# KNOWN BUG #4998: git_show prints Python reprs (with a memory address) and "--- None" where git prints /dev/null; the fix changes this assertion.
async def test_git_show_initial_commit_diffs_against_empty_tree(repo: git.Repo):
# With no parent the commit is diffed against NULL_TREE. An added file has
# no a_path, so the header prints Python's `None` where git prints
# `/dev/null`.
# no a_path, so the header prints /dev/null, as git does.
initial = repo.head.commit
result = await call(
None, "git_show", {"repo_path": str(root_of(repo)), "revision": "HEAD"}
)
assert show_text(result) == (
show_header(initial)
+ "\n--- None\n+++ test.txt\n"
+ "\n--- /dev/null\n+++ test.txt\n"
+ "@@ -0,0 +1,5 @@\n+line 1\n+line 2\n+line 3\n+line 4\n+line 5\n"
)


# KNOWN BUG #4998: git_show prints Python reprs (quoted sha, <git.Actor>, datetime with a memory address) instead of git's format; the fix changes this assertion.
async def test_git_show_deleted_file_diffs_to_dev_null(repo: git.Repo):
repo.index.remove(["test.txt"], working_tree=True)
commit = repo.index.commit("delete")
result = await call(
None, "git_show", {"repo_path": str(root_of(repo)), "revision": "HEAD"}
)
assert show_text(result) == (
show_header(commit)
+ "\n--- test.txt\n+++ /dev/null\n"
+ "@@ -1,5 +0,0 @@\n-line 1\n-line 2\n-line 3\n-line 4\n-line 5\n"
)


async def test_git_show_rename_only_commit_has_header_and_no_patch(repo: git.Repo):
# A pure rename yields a diff entry whose patch is empty bytes.
repo.index.move(["test.txt", "renamed.txt"])
Expand All @@ -778,7 +795,6 @@ async def test_git_show_rename_only_commit_has_header_and_no_patch(repo: git.Rep
)


# KNOWN BUG #4998: git_show prints Python reprs (quoted sha, <git.Actor>, datetime with a memory address) instead of git's format; the fix changes this assertion.
async def test_git_show_binary_commit(repo: git.Repo):
root = root_of(repo)
(root / "blob.bin").write_bytes(bytes(range(256)))
Expand All @@ -787,36 +803,57 @@ async def test_git_show_binary_commit(repo: git.Repo):
result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"})
assert show_text(result) == (
show_header(commit)
+ "\n--- blob.bin\n+++ blob.bin\n"
+ "\n--- /dev/null\n+++ blob.bin\n"
+ "Binary files /dev/null and b/blob.bin differ\n"
)


# KNOWN BUG #4997: git_show decodes the patch as strict UTF-8, so a Latin-1 file fails the whole call; the fix changes this assertion.
async def test_git_show_non_utf8_patch_is_decode_error(repo: git.Repo):
# Characterization: the patch bytes are decoded as strict UTF-8, so a
# Latin-1 text file makes the whole call fail.
async def test_git_show_non_utf8_patch_is_decoded_with_replacement(repo: git.Repo):
# A Latin-1 text file's patch is not valid UTF-8; the undecodable byte
# becomes U+FFFD and the rest of the commit is still shown.
root = root_of(repo)
(root / "latin.txt").write_bytes("caf\xe9\n".encode("latin-1"))
repo.index.add(["latin.txt"])
repo.index.commit("latin-1")
commit = repo.index.commit("latin-1")
result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"})
assert result["isError"] is True
assert "codec can't decode byte 0xe9" in result["content"][0]["text"]
assert show_text(result) == (
show_header(commit)
+ "\n--- /dev/null\n+++ latin.txt\n"
+ "@@ -0,0 +1 @@\n+caf\ufffd\n"
)


# KNOWN BUG #1682: pins current (wrong) behavior; the fix changes this assertion.
async def test_git_show_revision_path_syntax_is_error(repo: git.Repo):
# Pins #1682's current error: `<rev>:<path>` names a blob, not a commit,
# and repo.commit() appends `^0`, so the call fails with this message
# rather than crashing the server.
async def test_git_show_revision_path_shows_the_file(repo: git.Repo):
# `<rev>:<path>` names a blob; git_show returns its content (#1682).
result = await call(
None,
"git_show",
{"repo_path": str(root_of(repo)), "revision": "HEAD:test.txt"},
)
# Compared with the raw bytes: the fixture writes text, so CRLF on Windows.
expected = (root_of(repo) / "test.txt").read_bytes().decode()
assert result == text_result(expected)


async def test_git_show_revision_path_to_a_directory_lists_it(repo: git.Repo):
# `<rev>:<dir>` names a tree; git_show lists it, subdirectories with `/`.
root = root_of(repo)
(root / "logic").mkdir()
(root / "logic" / "infos.py").write_text("print('infos')\n")
repo.index.add(["logic/infos.py"])
repo.index.commit("add infos")
result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD:"})
assert result == text_result("logic/\ntest.txt")


async def test_git_show_revision_path_not_found_is_error(repo: git.Repo):
result = await call(
None,
"git_show",
{"repo_path": str(root_of(repo)), "revision": "HEAD:nope.txt"},
)
assert result == text_result(
"\"Blob or Tree named 'test.txt^0' not found\"", is_error=True
"\"Blob or Tree named 'nope.txt' not found\"", is_error=True
)


Expand Down
28 changes: 26 additions & 2 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def test_git_show(test_repository):

result = git_show(test_repository, commit_sha)

assert "Commit:" in result
assert result.startswith("commit ")
assert "Author:" in result
assert "show test commit" in result
assert "show_test.txt" in result
Expand All @@ -289,11 +289,35 @@ def test_git_show_initial_commit(test_repository):

result = git_show(test_repository, initial_commit.hexsha)

assert "Commit:" in result
assert result.startswith("commit ")
assert "initial commit" in result
assert "test.txt" in result


def test_git_show_blob_object_spec(test_repository):
file_path = Path(test_repository.working_dir) / "logic" / "infos.py"
file_path.parent.mkdir()
file_path.write_bytes(b"print('infos')\n")
test_repository.index.add(["logic/infos.py"])
test_repository.index.commit("add infos")

result = git_show(test_repository, "HEAD:logic/infos.py")

assert result == "print('infos')\n"


def test_git_show_tree_object_spec(test_repository):
file_path = Path(test_repository.working_dir) / "logic" / "infos.py"
file_path.parent.mkdir()
file_path.write_text("print('infos')\n")
test_repository.index.add(["logic/infos.py"])
test_repository.index.commit("add infos")

result = git_show(test_repository, "HEAD:")

assert result == "logic/\ntest.txt"


# Tests for validate_repo_path (repository scoping security fix)


Expand Down
Loading