Skip to content

fix(git): report a detached HEAD from git_checkout (#4804) - #5023

Merged
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/4804-git-checkout-detached-head
Oct 5, 2026
Merged

cliffhall merged 3 commits into
v2/mainfrom
v2/fix/4804-git-checkout-detached-head

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #4804

Part of Wave 6 (git) of #5004.

Description

git_checkout validates branch_name with repo.rev_parse(), which accepts any revision (a sha, a tag, HEAD~1, refs/heads/<x>, a remote-tracking ref). For anything that is not a branch, git checkout detaches HEAD, yet the tool always replied Switched to branch '<name>'. The reply is the only thing the calling model sees, so it was told it was on a branch when it was not.

After the checkout the tool now reads the resulting state:

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}'"

Non-branch revisions are still accepted (rejecting them would remove a capability callers use on purpose); the reply is just truthful now.

This ports candidate PR #4805 by @CryoThrust, cherry-picked onto v2/main with authorship kept (commits fix(git): report a detached HEAD from git_checkout and test(git): pin non-branch checkout revisions ...). The only change from #4805 in those commits is conflict resolution plus ruff format in tests/test_server.py. A third commit replaces the Wave 1 pin.

Server Details

  • Server: git (src/git, mcp-server-git, legacy era, mcp>=1,<2)
  • Changed: the git_checkout tool's reply (server.py); tests/test_server.py (cases from fix(git): report a detached HEAD from git_checkout #4805); tests/test_protocol.py (the KNOWN BUG #4804 pin now asserts the correct reply and that HEAD is detached at the commit the revision names; marker removed); README's git_checkout "Returns" line.

Motivation and Context

#4804. A detached HEAD reported as a branch switch leads an agent to commit onto no branch, and that work is reachable only through the reflog. git grep "KNOWN BUG #4804" now returns nothing.

How Has This Been Tested?

Unit / protocol tests: uv run pytest in src/git: 145 passed; per-file branch coverage server.py 100%. ruff check, ruff format --check, pyright: clean.

Gate: npm run local:gate exited 0 (all stages, including the per-file coverage gate: fetch, git, time PASS; boot smoke 9/9).

Inspector CLI (@modelcontextprotocol/inspector@2.9.0 --cli, legacy era, 2025-11-25). Fixture repo: commits c1, c2 (113299d, tagged v1), c3 (cdd4d1a, on main and feature). Each row resets to main, then calls:

npx -y @modelcontextprotocol/inspector@2.9.0 --cli \
  uv --directory src/git run python -m mcp_server_git --repository "$DEMO" -- \
  --method tools/call --tool-name git_checkout \
  --tool-arg repo_path="$DEMO" --tool-arg branch_name=<rev> --format json

Before (v2/main's server.py):

feature              exit=0  reply="Switched to branch 'feature'"            | git status: On branch feature
cdd4d1a              exit=0  reply="Switched to branch 'cdd4d1a'"            | git status: HEAD detached at cdd4d1a
v1                   exit=0  reply="Switched to branch 'v1'"                 | git status: HEAD detached at v1
HEAD~1               exit=0  reply="Switched to branch 'HEAD~1'"             | git status: HEAD detached at 113299d
refs/heads/feature   exit=0  reply="Switched to branch 'refs/heads/feature'" | git status: HEAD detached at refs/heads/feature
main                 exit=0  reply="Switched to branch 'main'"               | git status: On branch main

After (this branch):

feature              exit=0  reply="Switched to branch 'feature'"            | git status: On branch feature
cdd4d1a              exit=0  reply="HEAD is now detached at cdd4d1a"         | git status: HEAD detached at cdd4d1a
v1                   exit=0  reply="HEAD is now detached at 113299d"         | git status: HEAD detached at v1
HEAD~1               exit=0  reply="HEAD is now detached at 113299d"         | git status: HEAD detached at 113299d
refs/heads/feature   exit=0  reply="HEAD is now detached at cdd4d1a"         | git status: HEAD detached at refs/heads/feature
main                 exit=0  reply="Switched to branch 'main'"               | git status: On branch main

2026-07-28 era: not available. The server is still on mcp 1.x and does not implement server/discover, so --protocol-era modern fails as expected for an unmigrated server: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) (exit 1). Modern-era support arrives with #4853.

LLM client: Claude Code 2.1.289, headless (claude -p --mcp-config <local build> --strict-mcp-config --allowedTools mcp__git__git_checkout). The era cannot be chosen from this client; against a v1-SDK server the connection is legacy. Prompt: "Call the git_checkout tool of the git server with repo_path '' and branch_name 'v1'. Then reply with exactly the text the tool returned, and on a second line say in a few words whether you are now on a branch."

  • Before: Switched to branch 'v1' / "No, I'm not on a branch: v1 is a tag, so HEAD is now detached at 113299da, even though the tool says "branch"." (The model had to second-guess the tool's reply from the name; on the first attempt it ran out of turns trying to verify.)
  • After: HEAD is now detached at 113299d / "No, I'm not on a branch. HEAD is detached."

Breaking Changes

None for client configuration. The text of git_checkout's reply changes for non-branch revisions only.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follow MCP security best practices
  • I have updated the server's README accordingly
  • I have added a changeset (npm run changeset) if this changes what a TypeScript server publishes (not applicable: Python server)
  • I have tested this with an LLM client
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling (not applicable: no new failure path; the existing BadName handling is unchanged)
  • I have documented all environment variables and configuration options (not applicable: none added)

Additional context

Credit to @CryoThrust for the fix and tests in #4805, which this ports; that PR targets main and is reference only.

🤖 Generated with Claude Code

CryoThrust and others added 3 commits October 4, 2026 19:28
git_checkout validated branch_name with rev_parse, which resolves any revision
(a sha, tag, HEAD~1, or a remote-tracking ref), then always replied
"Switched to branch '<name>'". For anything that is not a branch git actually
detached HEAD, so the tool handed the model a success sentence over a state
where new commits belong to no branch.

Report the resulting state instead: say HEAD is detached, with the short sha,
when it is; keep the branch wording when a branch was really checked out.

Signed-off-by: cliffhall <cliff@futurescale.com>
…a branch

Adds the parametrized case suggested in review: HEAD~1, refs/heads/feature and
origin/main are the revisions a caller is most likely to send and none of them
is a branch, so none may be reported as a branch switch. All three fail against
the old unconditional return and pass with the fix.

Signed-off-by: cliffhall <cliff@futurescale.com>
Replace the KNOWN BUG #4804 pin in test_protocol.py: a non-branch revision
now gets "HEAD is now detached at <short sha>", and the detached commit is
the one the revision names. Document the detached reply in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 label Oct 4, 2026
@changeset-bot

changeset-bot Bot commented Oct 4, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6bc2f92

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The implementation matches the issue requirements and is covered by focused unit and protocol tests.

Review effort: Balanced
Findings: None

What changed in this PR

Fixes git_checkout to accurately report detached HEAD states while preserving support for non-branch revisions.

Changes:

  • Reports detached HEAD with the checked-out short SHA.
  • Adds unit and protocol regression coverage.
  • Documents the updated response behavior.
File Description
src/​git/​src/​mcp_server_git/​server.py Reports actual post-checkout state.
src/​git/​tests/​test_server.py Tests branches and non-branch revisions.
src/​git/​tests/​test_protocol.py Updates the protocol-level bug pin.
src/​git/​README.md Documents detached HEAD responses.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: clean (no findings, no suppressed comments). The review loop ends on the clean-round exit.

@cliffhall
cliffhall merged commit 9dc29d1 into v2/main Oct 5, 2026
45 checks passed
@cliffhall
cliffhall deleted the v2/fix/4804-git-checkout-detached-head branch October 5, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants