Skip to content

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

Description

@buyan201430-code

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions