Skip to content

fix(git): accept revision ranges in git_diff - #4815

Open
BlueX888 wants to merge 3 commits into
modelcontextprotocol:mainfrom
BlueX888:fix/prep-git-diff-rejects-revision-ranges
Open

BlueX888 wants to merge 3 commits into
modelcontextprotocol:mainfrom
BlueX888:fix/prep-git-diff-rejects-revision-ranges

Conversation

@BlueX888

Copy link
Copy Markdown

Description

git_diff rejects revision ranges. git_diff(repo, "main..feature") raises BadName: Ref 'main..range-feature' did not resolve to an object, even though git diff main..feature is a valid command and the tool passes target straight through to git diff.

Root cause: the CWE-88 flag-injection hardening in 9e5d5b8 added repo.rev_parse(target) at src/git/src/mcp_server_git/server.py:125 to confirm the target resolves to a git ref. rev_parse resolves a single object name, so a range such as main..feature, main...feature, or HEAD~1..HEAD is treated as one ref name and rejected before it ever reaches git diff.

The fix splits the target on .. / ... and validates each endpoint separately, so ranges are accepted while flag-like or unresolvable targets are still rejected.

Server Details

  • Server: git
  • Changes to: tools

Motivation and Context

A revision range is an ordinary single argument to git diff (git diff main..feature), and the git_diff tool documents target as the branch or commit to compare with. Asking for a branch-to-branch comparison as a range is a natural thing for a client to do, and it worked before 9e5d5b8; the added ref validation silently broke it. Validating each endpoint keeps the CWE-88 protection intact: the - prefix check still runs first on the whole target, and every endpoint must resolve to a real git ref.

How Has This Been Tested?

Added test_git_diff_allows_revision_ranges in src/git/tests/test_server.py, which builds a repository with a divergent branch and asserts working diffs for:

  • main..range-feature, range-feature..main (both directions)
  • main...range-feature (three-dot)
  • HEAD~1..HEAD and <sha>..HEAD (commit ranges)

and that invalid inputs still raise BadName: nonexistent..HEAD and main..--output=/tmp/evil.

The test fails on the unpatched tree (gitdb.exc.BadName: Ref 'main..range-feature' did not resolve to an object) and passes with the fix.

Commands run from src/git:

Command Result
uv run pytest -q 48 passed
uv run pytest tests/test_server.py -q -k test_git_diff_allows_revision_ranges 1 passed
uv run ruff check . All checks passed!
uv run --frozen pyright 0 errors, 0 warnings, 0 informations

Breaking Changes

None. Targets that resolved before still resolve; the only change is that revision ranges are no longer rejected. git_diff still raises BadName for flag-like targets and for targets whose endpoints are not real refs.

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 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
  • I have documented all environment variables and configuration options

Additional context

The - prefix guard and the per-endpoint rev_parse check are the existing CWE-88 defense from 9e5d5b8, unchanged in spirit: a target may not start with -, and every component of the target must resolve to a real git ref. Endpoints are not forwarded to the git CLI individually — the original target string is still passed to git diff as a single argument, so no new flag-parsing surface is introduced. No new environment variables or configuration options are added.

Copilot AI balanced review requested due to automatic review settings September 16, 2026 18:45

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.

🟡 Changes recommended

Empty endpoints bypass validation, and the README needs clarification.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Updates git_diff to accept two- and three-dot revision ranges while validating endpoints.

Changes:

  • Adds range endpoint validation.
  • Adds valid and invalid range tests.
  • Documents revision-range support.
File summaries
File Summary
src/git/src/mcp_server_git/server.py Adds range parsing and validation. Moderate (2 votes): reject empty endpoints such as ..HEAD, HEAD.., and ....
src/git/tests/test_server.py Adds regression coverage for valid and invalid ranges.
src/git/README.md Documents range support. Nit (2 votes): clarify behavior for revision ranges versus single revisions.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


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

Comment thread src/git/src/mcp_server_git/server.py Outdated
Comment thread src/git/README.md Outdated
Splitting the target on `..`/`...` and skipping empty pieces accepted
`..HEAD`, `HEAD..` and `...`, which have no second endpoint, and `....`,
which is not a range at all. `git diff` takes the first three as a range
against HEAD and returns an empty diff, while `....` reached git and failed
with a raw GitCommandError instead of BadName.

`rev_parse` rejected all four before this change, so requiring both
endpoints keeps the previous contract for everything except the two-endpoint
ranges this PR adds. Ranges with more than one separator are rejected too.

Also corrects the README: for a revision range, git compares the two
endpoints rather than the current state with the target.
Copilot AI review requested due to automatic review settings September 17, 2026 02:06

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.

🟡 Changes recommended

Three moderate validation issues remain unresolved in server.py.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

src/git/src/mcp_server_git/server.py:128

  • re.split runs before validating the whole target, so this regresses valid single-revision selectors whose search expression contains .. (for example, Git accepts :/fix..bug as a commit-message revision). The previous repo.rev_parse(target) accepted that target, but this now validates :/fix and bug separately and raises BadName; try the whole target first and only fall back to endpoint validation when that fails.
    # target may be a revision range (e.g. 'main..feature' or 'main...feature'),
    # so validate each endpoint is a real git ref rather than the range as a whole
    revisions = re.split(r"\.\.\.?", target)

src/git/src/mcp_server_git/server.py:136

  • The loop only rejects empty endpoints before calling rev_parse, so an option-like endpoint is still handed to Git's option parser. For example, rev-parse --all can resolve to a commit when the repository has one ref, allowing git_diff(repo, "HEAD..--all") past validation and then producing a raw ambiguous-argument error from git diff instead of BadName. Apply the --prefix guard to each extracted endpoint as well.
    for revision in revisions:
        if not revision:
            raise BadName(f"Invalid target: '{target}' - empty range endpoint")
        repo.rev_parse(revision)
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

Comment thread src/git/src/mcp_server_git/server.py Outdated
Trying the target unchanged first keeps single revisions that contain '..',
such as the commit-message selector ':/fix..bug', from being split into the
endpoints ':/fix' and 'bug' and rejected. rev_parse reports such a target as
BadName, and rejects a spec its own parser cannot tokenize with ValueError, so
the range fallback catches both.

The range fallback also rejects a run of four or more dots outright: 'HEAD....'
is not a range with two endpoints, and relying on the trailing '.' failing to
resolve reports the wrong part of the input as the problem.
Copilot AI review requested due to automatic review settings September 17, 2026 09:11
@BlueX888

Copy link
Copy Markdown
Author

@/tmp/reply_issue.md

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.

🔵 Needs a closer look

Range parsing must handle separators inside nested revision selectors.

Review details

Suppressed comments (1)

src/git/src/mcp_server_git/server.py:145

  • The fallback splits on every ../..., including dots inside a nested revision selector. Git accepts ranges such as HEAD^{/fix..bug}..HEAD, where the first endpoint is the valid HEAD^{/fix..bug} selector, but this produces three pieces and raises BadName instead of diffing the range. Split only on a top-level range separator (and add a regression test for a dotted selector endpoint).
        revisions = re.split(r"\.\.\.?", target)
        if len(revisions) > 2:
            raise BadName(
                f"Invalid target: '{target}' - expected a revision or a single range"
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants