Skip to content

git: git_create_branch is marked non-destructive but silently resets an existing packed branch #5059

Closed as not planned

Description

@ranausmanai

Summary

git_create_branch is annotated destructiveHint: false, but if a branch with the requested name already exists as a packed ref (as after git gc, git pack-refs, or in a fresh clone), the call silently moves that branch to the new base. The previous tip becomes unreachable from every ref, and no reflog entry records the reset. git branch <name> on the CLI would refuse.

Where

Reproduce

  1. In a scratch repo: create main, then a branch feature with one extra commit, and run git pack-refs --all.
  2. Start the git server on that repo.
  3. Call git_create_branch with {"repo_path": "<repo>", "branch_name": "feature", "base_branch": "main"}.
  4. The call reports Created branch 'feature' from 'main'; feature now points at main, its previous commit is unreachable, and git reflog show feature has no entry for the move.

Why it matters

MCP clients relax confirmation for tools that declare destructiveHint: false, so this call can be auto-approved, and it can discard commits.

Possible fix

Check for an existing branch (loose or packed) and return an error, or require an explicit force argument, matching the git CLI. While a force path exists, destructiveHint: true would be accurate.

Found during an audit of MCP tool annotations (AI-assisted source review, then reproduced locally). Happy to send a PR.

Activity

  1. CryoThrust commented on Oct 7, 2026

    @CryoThrust
    Contributor

    Confirmed the mechanism end to end on GitPython 3.1.50, and there's a wrinkle worth recording: the report is accurate against main, but the active line already fixes it — and the fix there has no test for this case.

    The mechanism, reproduced. With a repo whose branches are packed (.git/refs/heads/ empty, both entries only in .git/packed-refs):

    heads: ['feature', 'main']                      # packed refs are visible here
    create_head('feature', heads['main'])
      -> SUCCEEDED, feature now at 8ade8bb4          # the old tip is gone, no reflog entry
    

    So two things are true at once: repo.heads does see packed refs, and create_head still overwrites one silently. That combination is what makes this easy to fix incorrectly — a check against .git/refs/heads/<name> passes every loose-ref test and still loses commits after pack-refs.

    Where the code stands. main has no existence check in git_create_branch at all, so the report is right for that line. v2/main does — server.py raises Cannot create branch '<name>': refs/heads/<name> already exists, refined by ffb7a76d to compare head names. Because it goes through repo.heads, it covers the packed case. What's missing there is coverage: the tests only exercise creating a new branch, so nothing pins the rejection or the packed variant.

    Why the packed variant is the one to pin. I ran the two candidate tests against a deliberately incomplete implementation — replacing the check with a loose-ref-only one — and the loose test passes while the packed test fails. That is the whole difference between the two cases, and it is the case this issue is about.

    Since the policy here is issues-and-prompts rather than diffs, here is the prompt I'd use to reproduce the coverage on v2/main:

    In src/git/tests/test_server.py, add two tests after test_git_create_branch_from_base. Each creates a feature branch with a commit of its own, records its tip, returns to the default branch (read it from active_branch, don't assume master), and asserts that git_create_branch(repo, "feature") raises ValueError matching already exists and that feature's tip is unchanged. The second test additionally runs git pack_refs --all and asserts the loose ref file for feature does not exist before the call, so it cannot silently stop covering the packed path. Verify both tests fail with the existence check removed, and that only the packed one fails if the check is narrowed to the filesystem.

  2. ranausmanai commented on Oct 8, 2026

    @ranausmanai
    Author

    Thanks for confirming this end to end, and for pointing out the v2/main fix. Agreed that the packed-ref test is the one to pin, since a loose-ref check passes everything else.

    A question for the maintainers: will the currently published mcp-server-git on main get the existence check, or is v2 the release that fixes it? Once creating over an existing name is rejected, destructiveHint: false becomes accurate, since the call can only add a ref.

  3. cliffhall commented on Oct 19, 2026

    @cliffhall
    Member

    Closing: superseded by the v2 release.

    Thank you for reporting this, and apologies for the long wait for a response.

    Since this was filed, the reference servers have been rebuilt for the 2026-07-28 MCP specification (#4857) and released as v2 (2.0.0 on npm for the TypeScript servers, 2026.10.19 on PyPI for the Python servers). Each server now runs on v2 of its SDK, and the behavior described here has either been fixed or no longer exists in the same form. On v2/main git_create_branch refuses a name that already exists, packed or loose, so it can no longer move an existing branch.

    This is not a judgment on whether the problem was real; it's a consequence of the code it describes having been replaced. This is happening as part of the backlog triage in #4875.

    If this still happens on the v2 release, we'd genuinely like to know. Please open a fresh issue with the version you're running and steps to reproduce, and we'll look at it. Note that this repository accepts contributions as issues rather than pull requests; see CONTRIBUTING.md.

    Thanks for taking the time to report it.

  4. added
    closed-superseded-v2Closed: fixed or made obsolete by the v2 / 2026-07-28 refactor (#4875)
    on Oct 19, 2026
  5. ranausmanai commented on Oct 20, 2026

    @ranausmanai
    Author

    Thanks for the fix in v2 and the clear explanation.

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

    closed-superseded-v2Closed: fixed or made obsolete by the v2 / 2026-07-28 refactor (#4875)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions