#4804·servers

git_checkout reports "Switched to branch 'X'" when the checkout actually detached HEAD

Author: buyan201430-codeCreated Sep 14, 2026Updated Sep 15, 2026

What happens

git_checkout validates branch_name with repo.rev_parse(...), which resolves any revision — a commit sha, a tag, HEAD~1, a remote-tracking ref — not only branch names. It then checks out and returns a fixed string:

repo.rev_parse(branch_name)  # Validates branch_name is a real git ref, throws BadName if not
repo.git.checkout(branch_name)
return f"Switched to branch '{branch_name}'"

src/git/src/mcp_server_git/server.py lines 202-204 on main (the flag-injection guard above them is elided). The tool description is Switches branches, the parameter is branch_name, and the README says the same: "git_checkout … Returns: Confirmation of branch switch". For every revision that is not a branch, the checkout detaches HEAD and the reply still claims a branch switch.

Reproduction

mcp-server-git 2026.8.18, mcp 1.30.0, over stdio, on a fresh repo with three commits, a tag v1 and a branch feature:

branch_name "feature"            -> Switched to branch 'feature'            | On branch feature   (correct)
branch_name "nonexistent"        -> isError=true, repo unchanged                                  (correct)
branch_name "49cea63" (a sha)    -> Switched to branch '49cea63'            | HEAD detached at 49cea63
branch_name "v1" (a tag)         -> Switched to branch 'v1'                 | HEAD detached at v1
branch_name "HEAD~1"             -> Switched to branch 'HEAD~1'             | HEAD detached at f1de938
branch_name "origin/main"        -> Switched to branch 'origin/main'        | HEAD detached at origin/main
branch_name "refs/heads/feature" -> Switched to branch 'refs/heads/feature' | HEAD detached at refs/heads/feature

The last two are the realistic ones: an agent writes origin/main or a full ref name far more often than a raw sha.

Why this matters for the calling model

The reply is the only thing the model sees: content is a single TextContent, structuredContent is null, isError is false, the tool declares no outputSchema, and its annotations are static. Nothing in the response distinguishes an attached checkout from a detached one.

Git itself does distinguish them. git checkout <sha> writes the You are in 'detached HEAD' state advisory to stderr, and leaving a detached HEAD prints Warning: you are leaving 1 commit behind together with the git branch <new-branch-name> <sha> recipe. repo.git.checkout() returns '' and that advisory is discarded, so the server replaces git's own warning with a success sentence. Work committed from there is on no branch and reachable only through the reflog until gc expires it; the tool never says so, and nothing in its reply suggests calling git_status.

Suggested fix

Report the state rather than assume it:

repo.git.checkout(branch_name)
if repo.head.is_detached:
    return f"HEAD is now detached at {repo.head.commit.hexsha[:7]}"
return f"Switched to branch '{repo.active_branch.name}'"

Verified against main: src/git/tests/test_server.py stays at 47 passed, and git_checkout on a branch name still returns Switched to branch '<name>'. Rejecting non-branch revisions would also close it, but that removes a capability some callers use deliberately.

Same root cause as #4762 and #4763 — no verification of the outcome after the git call — so this could fold into the same fix if that is easier to review.

Source: modelcontextprotocol/servers