You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
mcp-server-git: git_status and the git_diff tools print non-ASCII paths as octal escapes, so git_add cannot take them back #5129
In a fresh repository, create an untracked file named 日本.txt.
Call git_status, strip the surrounding double quotes from the name it prints, and pass that name to git_add.
Save the script below as repro.py in src/git and run it.
repro.py
importpathlib, subprocess, tempfileimportgitimportmcp_server_git.serverassd=pathlib.Path(tempfile.mkdtemp())
subprocess.run(["git", "init", "-q", str(d)], check=True)
r=git.Repo(d)
(d/"日本.txt").write_text("a\n")
print("--- git_status")
st=s.git_status(r)
print("\n".join(lforlinst.splitlines() if"Untracked"inlor".txt"inl))
name= [l.strip() forlinst.splitlines() if".txt"inl][0].strip('"')
r.git.add("日本.txt")
print("--- git_diff_staged (headers)")
print("\n".join(lforlins.git_diff_staged(r).splitlines() ifl.startswith("diff --git")))
r.git.rm("--cached", "-q", "日本.txt")
print("--- git_add with the name git_status printed")
try:
s.git_add(r, [name])
exceptExceptionase:
print(type(e).__name__+":", str(e).split("stderr: ")[-1].strip().strip("'"))
print("--- the same status with -c core.quotepath=false")
print("\n".join(l.strip() forlinr.git(c="core.quotepath=false").status().splitlines() if".txt"inl))
$ uv run python repro.py # src/git at 2f8dc42
--- git_status
Untracked files:
"\346\227\245\346\234\254.txt"
--- git_diff_staged (headers)
diff --git "a/\346\227\245\346\234\254.txt" "b/\346\227\245\346\234\254.txt"
--- git_add with the name git_status printed
GitCommandError: fatal: pathspec '\346\227\245\346\234\254.txt' did not match any files
--- the same status with -c core.quotepath=false
日本.txt
Expected behavior
git_status and the three diff tools print 日本.txt, a name a client can pass back to git_add.
Actual behavior
src/git/src/mcp_server_git/server.py:155 calls repo.git.status() with git's default core.quotepath=true, so every non-ASCII path comes back as quoted octal escapes. git_diff_unstaged, git_diff_staged and git_diff do the same.
Already prototyped a fix?
#4879 by @tsurutanmen has the change and four tests for it, written against main. It runs the four commands with -c core.quotepath=false.
Before you submit
I searched existing issues and this is not a duplicate.
This is not a security vulnerability report (those go through the private advisory form).
@cliffhall could this go on the board for the git server?
Independent reproduction on v2/main, and a check of the prototyped fix
I reproduced this on the current development branch and then ran the approach prototyped in #4879 against it, to see whether it delivers the behavior in the Expected behavior section ("a name a client can pass back to git_add"). It does, for the reported case. Two details are worth folding into the change, and one limit is worth not overclaiming.
Revision:v2/main @ 38569bd5 (2026-10-13), server version 2026.10.10 Environment: git 2.43.0, GitPython 3.2.0, Python 3.10.20, Linux, in-process tool calls from src/git after uv sync --frozen --dev
Reproduction
Same shape as the report's repro.py: create 日本.txt, take the line git_status prints, strip the surrounding quotes the way a client would, and pass the result to git_add.
$ cd src/git && uv run python repro.py
git version 2.43.0 / GitPython 3.2.0
git_status untracked line : ['\t"\\346\\227\\245\\346\\234\\254.txt"']
name a client would reuse : '\\346\\227\\245\\346\\234\\254.txt'
git_add(that name) : FAILS: fatal: pathspec '\346\227\245\346\234\254.txt' did not match any files
git_add(the real name) : round-trip OK
The four tools the report names, with one non-ASCII file that has a staged change:
All four hand git's stdout straight to the client while git's default core.quotepath=true is in effect, so every path byte ≥ 0x80 quote_c_styles to \ddd octal.
Those four are the entire surface. git_show (server.py:375-421) is the control: at server.py:411-412 it builds its paths from GitPython's Diff objects (d.a_path, d.b_path), which are decoded, so it prints --- a/日本.txt with no flag needed. That is also the cheapest way to describe the bug — the one tool that parses instead of passing through is the one tool that was already correct.
Verifying the -c core.quotepath=false approach (as prototyped in #4879)
I ported that change and its four tests onto v2/main @ 38569bd5 — helper plus the four call sites, tests for git_status, git_diff_unstaged, git_diff_staged, git_diff with 日本語.txt and core.quotepath=true pinned in the fixture.
Before/after on the report's repro:
before after
git_status '\t"\\346\\227\\245\\346\\234\\254.txt"' '\t日本.txt'
client name '\\346\\227\\245\\346\\234\\254.txt' '日本.txt'
git_add pathspec did not match any files round-trip OK
diff headers 'diff --git "a/\\346\\227..." ...' 'diff --git a/日本.txt b/日本.txt'
Test and gate results, all in that tree:
The four ported tests against unmodified v2/main: 4 failed (assert '日本語.txt' in result → the octal escape is what is in the result). With the change applied: 4 passed.
Ported tests plus the rest: 185 passed (uv run pytest -q, 20.3s).
uv run ruff check . → All checks passed!; uv run ruff format --check . → clean; uv run pyright → 0 errors, 0 warnings, 0 informations.
The setting stays out of the repository: .git/config sha256 is unchanged across a call. core.quotepath is not present in the config afterwards.
ASCII-only repositories are byte-identical: git_status, git_diff_unstaged and git_diff_staged output matches the same calls without the flag, character for character.
Two things worth knowing when implementing it
1. The GitPython spelling matters, and the obvious one is broken.core.quotepath must land before the subcommand, so the per-command kwarg form fails at runtime rather than silently:
2. repo.git(c=...) mutates the shared instance, transiently. It returns repo.git itself (Git.__call__ stores _git_options and return self): while pending, repo.git._git_options == ['-c', 'core.quotepath=false'], and it is cleared when the next command executes. I checked this is not a leak for the server: run_tool builds a fresh git.Repo(repo_path) per call at server.py:670, so there is no second call on the object that could inherit it, and a later plain repo.git.status() on the same object escapes octal again (measured). So the helper is safe here — it is worth a comment in the code, not a redesign.
One limit of the flag (not covered by the four tests)
core.quotepath only governs the high-bit set. Names git C-quotes for other reasons stay quoted, and git_add still cannot take one of them back:
name status shows client reuses it -> git_add
'日本.txt' (the report's case) '日本.txt' '日本.txt' round-trip OK
'café.txt' 'café.txt' 'café.txt' round-trip OK
'🎉.txt' '🎉.txt' '🎉.txt' round-trip OK
'my file.txt' 'my file.txt' 'my file.txt' round-trip OK
'name"quote.txt' '"name\\"quote.txt"' 'name\\"quote.txt' round-trip OK (git's pathspec unescaping does it)
'back\slash.txt' '"back\\\\slash.txt"' 'back\\\\slash.txt' round-trip OK (same reason)
'tab<TAB>here.txt' '"tab\\there.txt"' 'tab\\there.txt' FAILS: pathspec did not match any files
We tested the same set before the change: every high-byte name failed, so the flag fixes exactly the class the report is about and nothing regresses. The residual is names containing control characters, which keeps git_status output one step short of "a name a client can pass back" for them. Low severity next to the reported bug, and cheap to state as a known limit rather than to chase — but the four tests as written assert NON_ASCII_FILENAME in result, which would read as a stronger guarantee than the change provides.
If the round-trip guarantee is wanted for the status path specifically, --porcelain -z never quotes anything:
That is a representation change for a human-readable tool, so I am noting it as an option, not asking for it.
Prompt, per CONTRIBUTING
I did not send a diff or open a pull request. The prompt that describes the change I built and measured:
In src/git/src/mcp_server_git/server.py, add a small helper that runs a git
command with core.quotepath=false for that command only, and use it in the four
tools that print paths: git_status, git_diff_unstaged, git_diff_staged and
git_diff. Do not change git_show: it builds its paths from GitPython's Diff
objects, which are already decoded. Never write the setting into the
repository's config.
Add one test per tool using a non-ASCII file name, with core.quotepath pinned to
true in the test repository so the result does not depend on the user's global
config, asserting that the raw name appears and that the octal escape sequence
does not. Do not claim in the tests or the message that every path becomes
passable back to git_add: with core.quotepath=false git still C-quotes names
containing control characters. Run the full suite, ruff check, ruff format
--check and pyright.
How I verified it, so it can be replayed: the fix and the four ported tests are on SU-AN-coder/servers branch verify/5129-git-quotepath (base 38569bd5, one commit: +12/-4 in server.py, +64 tests) — evidence, not a proposed pull request. The two probes that produced every number above are on branch verify/5129-probes as src/git/probe-5129-paths.py (calls the four tools in-process over seven file names and re-checks the round-trip through git_add) and src/git/probe-5129-idiom.py (the GitPython spellings and the --porcelain variants); either one runs from src/git with uv run python <file> and prints what is quoted above.
I read every line of the diff myself, and every number above comes from a run I did on this machine rather than from reading the source.
Severity 3: git_status and git_diff_unstaged/git_diff_staged/git_diff print non-ASCII paths as quoted octal escapes (git's default core.quotepath), so a client cannot pass the printed name back to git_add.
Urgency 3: idle more than 90 days, or wanted soon
Bonuses: +1 bug label
Still present on v2 (checked by a local reproduction against the current code).
Board: Servers V2 (#43), Status Incoming, awaiting maintainer review (no milestone yet). Part of the backlog triage in #4875.
They are being closed as part of the backlog triage in #4875, since the repository now takes issues rather than outside PRs and their diffs predate the v2 refactor. They stay readable and serve as prototypes for the fix here. Thank you to their authors.
Activity
SU-AN-coder commentedon Oct 18, 2026
Independent reproduction on
v2/main, and a check of the prototyped fixI reproduced this on the current development branch and then ran the approach prototyped in #4879 against it, to see whether it delivers the behavior in the Expected behavior section ("a name a client can pass back to
git_add"). It does, for the reported case. Two details are worth folding into the change, and one limit is worth not overclaiming.Revision:
v2/main@38569bd5(2026-10-13), server version2026.10.10Environment: git 2.43.0, GitPython 3.2.0, Python 3.10.20, Linux, in-process tool calls from
src/gitafteruv sync --frozen --devReproduction
Same shape as the report's
repro.py: create日本.txt, take the linegit_statusprints, strip the surrounding quotes the way a client would, and pass the result togit_add.The four tools the report names, with one non-ASCII file that has a staged change:
Root cause, and the whole surface
38569bd5server.py:155-156def git_status(repo): return repo.git.status()server.py:159-162return repo.git.diff(f"--unified={context_lines}")server.py:165-166return repo.git.diff(f"--unified={context_lines}", "--cached")server.py:169-177return repo.git.diff(f"--unified={context_lines}", target)All four hand git's stdout straight to the client while git's default
core.quotepath=trueis in effect, so every path byte ≥ 0x80quote_c_styles to\dddoctal.Those four are the entire surface.
git_show(server.py:375-421) is the control: atserver.py:411-412it builds its paths from GitPython'sDiffobjects (d.a_path,d.b_path), which are decoded, so it prints--- a/日本.txtwith no flag needed. That is also the cheapest way to describe the bug — the one tool that parses instead of passing through is the one tool that was already correct.Verifying the
-c core.quotepath=falseapproach (as prototyped in #4879)I ported that change and its four tests onto
v2/main@38569bd5— helper plus the four call sites, tests forgit_status,git_diff_unstaged,git_diff_staged,git_diffwith日本語.txtandcore.quotepath=truepinned in the fixture.Before/after on the report's repro:
Test and gate results, all in that tree:
v2/main: 4 failed (assert '日本語.txt' in result→ the octal escape is what is in the result). With the change applied: 4 passed.uv run pytest -q, 20.3s).uv run ruff check .→All checks passed!;uv run ruff format --check .→ clean;uv run pyright→0 errors, 0 warnings, 0 informations..git/configsha256 is unchanged across a call.core.quotepathis not present in the config afterwards.git_status,git_diff_unstagedandgit_diff_stagedoutput matches the same calls without the flag, character for character.Two things worth knowing when implementing it
1. The GitPython spelling matters, and the obvious one is broken.
core.quotepathmust land before the subcommand, so the per-command kwarg form fails at runtime rather than silently:2.
repo.git(c=...)mutates the shared instance, transiently. It returnsrepo.gititself (Git.__call__stores_git_optionsandreturn self): while pending,repo.git._git_options == ['-c', 'core.quotepath=false'], and it is cleared when the next command executes. I checked this is not a leak for the server:run_toolbuilds a freshgit.Repo(repo_path)per call atserver.py:670, so there is no second call on the object that could inherit it, and a later plainrepo.git.status()on the same object escapes octal again (measured). So the helper is safe here — it is worth a comment in the code, not a redesign.One limit of the flag (not covered by the four tests)
core.quotepathonly governs the high-bit set. Names git C-quotes for other reasons stay quoted, andgit_addstill cannot take one of them back:We tested the same set before the change: every high-byte name failed, so the flag fixes exactly the class the report is about and nothing regresses. The residual is names containing control characters, which keeps
git_statusoutput one step short of "a name a client can pass back" for them. Low severity next to the reported bug, and cheap to state as a known limit rather than to chase — but the four tests as written assertNON_ASCII_FILENAME in result, which would read as a stronger guarantee than the change provides.If the round-trip guarantee is wanted for the status path specifically,
--porcelain -znever quotes anything:That is a representation change for a human-readable tool, so I am noting it as an option, not asking for it.
Prompt, per CONTRIBUTING
I did not send a diff or open a pull request. The prompt that describes the change I built and measured:
How I verified it, so it can be replayed: the fix and the four ported tests are on
SU-AN-coder/serversbranchverify/5129-git-quotepath(base38569bd5, one commit:+12/-4inserver.py,+64tests) — evidence, not a proposed pull request. The two probes that produced every number above are on branchverify/5129-probesassrc/git/probe-5129-paths.py(calls the four tools in-process over seven file names and re-checks the round-trip throughgit_add) andsrc/git/probe-5129-idiom.py(the GitPython spellings and the--porcelainvariants); either one runs fromsrc/gitwithuv run python <file>and prints what is quoted above.I read every line of the diff myself, and every number above comes from a run I did on this machine rather than from reading the source.
cliffhall commentedon Oct 19, 2026
Triage: Priority Medium (total 7)
buglabelStill present on v2 (checked by a local reproduction against the current code).
Board: Servers V2 (#43), Status
Incoming, awaiting maintainer review (no milestone yet). Part of the backlog triage in #4875.cliffhall commentedon Oct 20, 2026
Prior work, from the PR backlog triage (#4877). These outside pull requests addressed this issue against the pre-refactor code:
They are being closed as part of the backlog triage in #4875, since the repository now takes issues rather than outside PRs and their diffs predate the v2 refactor. They stay readable and serve as prototypes for the fix here. Thank you to their authors.