From 6df2727c10ad81c5fe609593717b71ed7ccd3422 Mon Sep 17 00:00:00 2001 From: Jay Zhou <190699450+jayzhou2309@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:40:27 +0800 Subject: [PATCH 1/4] test(git): cover git_show with blob and tree object specs Signed-off-by: cliffhall --- src/git/tests/test_server.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 954f1b739e..1f2d1aa2c6 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -293,6 +293,28 @@ def test_git_show_initial_commit(test_repository): 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_text("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) From 15eabc8c74ca4bacabbb1de21d7dad661e4dedc8 Mon Sep 17 00:00:00 2001 From: Jay Zhou <190699450+jayzhou2309@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:41:22 +0800 Subject: [PATCH 2/4] fix(git): show blobs and trees for object specs in git_show repo.commit() peels every revision with ^0, so HEAD:path/to/file failed with "Blob or Tree named 'file^0' not found". Resolve the revision with rev_parse first, return file content for a blob and a listing for a tree, and keep the commit output for everything else. Fixes #1682 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/README.md | 6 +++--- src/git/src/mcp_server_git/server.py | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index 62e59dfa2a..c3d7d9cc1b 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -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 diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 8fcb4a6a15..78990453fd 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -240,6 +240,13 @@ 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) output = [ f"Commit: {commit.hexsha!r}\n" @@ -454,7 +461,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 :", inputSchema=GitShow.model_json_schema(), annotations=ToolAnnotations( readOnlyHint=True, From 9a81d42b159f6e079898b0c54aec09ad050631cc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 19:30:53 -0400 Subject: [PATCH 3/4] fix(git): print git_show in git's format and decode non-UTF-8 patches git_show built its header from Python reprs (a quoted sha, a repr and a datetime repr with a memory address) and printed "--- None" for an added file. It now follows `git show --date=iso`: "commit ", "Author: Name ", an ISO date and the message indented by four spaces, with /dev/null for the missing side of an added or deleted file (#4998). Each patch was decoded as strict UTF-8, so a commit touching a Latin-1 file failed the whole call. Patches now decode with errors="replace" (#4997). The pinning tests assert the corrected behavior and lose their KNOWN BUG markers, including #1682's, whose fix is the cherry-picked #4881. The list_tools snapshot picks up git_show's new description. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/src/mcp_server_git/server.py | 22 +++-- src/git/tests/snapshots/list_tools.json | 2 +- src/git/tests/test_protocol.py | 113 ++++++++++++++++-------- src/git/tests/test_server.py | 6 +- 4 files changed, 95 insertions(+), 48 deletions(-) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 78990453fd..3512aa4a40 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -248,11 +248,17 @@ def git_show(repo: git.Repo, revision: str) -> str: 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 `, 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] @@ -260,11 +266,15 @@ def git_show(repo: git.Repo, revision: str) -> str: 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) diff --git a/src/git/tests/snapshots/list_tools.json b/src/git/tests/snapshots/list_tools.json index ae58bc39f8..15ff4a7798 100644 --- a/src/git/tests/snapshots/list_tools.json +++ b/src/git/tests/snapshots/list_tools.json @@ -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 :", "inputSchema": { "properties": { "repo_path": { diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index bf0aa041ae..faf390fd8e 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -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, ``, 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 \n" + f"Date: {date}\n" + "\n" + "".join(f" {line}\n" for line in lines) ) -# KNOWN BUG #4998: git_show prints Python reprs (quoted sha, , 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") @@ -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: ">\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 \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, , 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"]) @@ -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, , 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))) @@ -787,36 +803,55 @@ 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: `:` 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): + # `:` 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"}, ) + assert result == text_result("line 1\nline 2\nline 3\nline 4\nline 5\n") + + +async def test_git_show_revision_path_to_a_directory_lists_it(repo: git.Repo): + # `:` 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 ) diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 1f2d1aa2c6..6bc2b859e9 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -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 @@ -289,10 +289,11 @@ 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() @@ -304,6 +305,7 @@ def test_git_show_blob_object_spec(test_repository): 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() From 8602098031a4ba5dc18c7ce4a2b19c93396f763a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 20:08:58 -0400 Subject: [PATCH 4/4] test(git): compare git_show blob output with the file's raw bytes The blob tests assumed LF line endings, but write_text() writes CRLF on Windows and git_show returns the blob's bytes as stored. The protocol test now compares with the fixture file's raw bytes, and the unit test writes explicit LF bytes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/tests/test_protocol.py | 4 +++- src/git/tests/test_server.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index faf390fd8e..8cec86e56b 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -830,7 +830,9 @@ async def test_git_show_revision_path_shows_the_file(repo: git.Repo): "git_show", {"repo_path": str(root_of(repo)), "revision": "HEAD:test.txt"}, ) - assert result == text_result("line 1\nline 2\nline 3\nline 4\nline 5\n") + # 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): diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 6bc2b859e9..f3821f47fb 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -297,7 +297,7 @@ def test_git_show_initial_commit(test_repository): 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_text("print('infos')\n") + file_path.write_bytes(b"print('infos')\n") test_repository.index.add(["logic/infos.py"]) test_repository.index.commit("add infos")