Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Please note that mcp-server-git is currently in early development. The functiona
- Inputs:
- `repo_path` (string): Path to Git repository
- `branch_name` (string): Name of branch to checkout
- Returns: Confirmation of branch switch
- Returns: Confirmation of branch switch, or `HEAD is now detached at <short sha>` when `branch_name` is not a branch (a sha, tag or other revision)
11. `git_show`
- Shows the contents of a commit
- Inputs:
Expand Down
7 changes: 6 additions & 1 deletion src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,12 @@ def git_checkout(repo: git.Repo, branch_name: str) -> str:
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}'"
# rev_parse accepts any revision, so branch_name may have been a sha, tag or
# remote-tracking ref rather than a branch. Report what actually happened instead of
# claiming a branch switch; a detached HEAD is easy to commit onto by mistake.
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}'"


def git_show(repo: git.Repo, revision: str) -> str:
Expand Down
12 changes: 6 additions & 6 deletions src/git/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,26 +661,26 @@ async def test_git_checkout_branch(repo: git.Repo):
assert repo.active_branch.name == "feature"


# KNOWN BUG #4804: pins current (wrong) behavior; the fix changes this assertion.
@pytest.mark.parametrize("revision", ["sha", "tag", "HEAD~1", "refs/heads/feature"])
async def test_git_checkout_non_branch_detaches_head_but_claims_switch(
async def test_git_checkout_non_branch_reports_detached_head(
repo: git.Repo, revision: str
):
# Pins #4804: any revision rev_parse accepts is checked out, detaching
# HEAD, and the reply still says "Switched to branch". Fixing #4804 changes
# this test.
# #4804: any revision rev_parse accepts is checked out, detaching HEAD, and
# the reply says so with the short sha instead of claiming a branch switch.
add_commits(repo, 1)
repo.git.branch("feature")
repo.create_tag("v1", ref="HEAD~1")
name = {
"sha": repo.head.commit.hexsha[:7],
"tag": "v1",
}.get(revision, revision)
target = repo.commit(name).hexsha
result = await call(
None, "git_checkout", {"repo_path": str(root_of(repo)), "branch_name": name}
)
assert result == text_result(f"Switched to branch '{name}'")
assert repo.head.is_detached
assert repo.head.commit.hexsha == target
assert result == text_result(f"HEAD is now detached at {target[:7]}")


async def test_git_checkout_unknown_branch_is_error(repo: git.Repo):
Expand Down
45 changes: 45 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,51 @@ def test_git_checkout_nonexistent_branch(test_repository):
git_checkout(test_repository, "nonexistent-branch")


def test_git_checkout_sha_reports_detached_head(test_repository):
"""rev_parse accepts a sha, so the reply must not claim a branch switch."""
sha = test_repository.head.commit.hexsha
result = git_checkout(test_repository, sha)

assert test_repository.head.is_detached
assert "detached" in result
assert "Switched to branch" not in result


@pytest.mark.parametrize("revision", ["HEAD~1", "refs/heads/feature", "origin/main"])
def test_git_checkout_non_branch_revisions_report_detached_head(
test_repository, tmp_path, revision
):
"""The revisions an agent is most likely to send: a relative ref, a full ref name, and a
remote-tracking ref. None of them is a branch, so none may be reported as a branch switch."""
test_repository.git.branch("feature")
test_repository.index.commit("second commit")
git.Repo.init(tmp_path / "remote.git", bare=True)
test_repository.create_remote("origin", str(tmp_path / "remote.git"))
test_repository.git.push("origin", "HEAD:refs/heads/main")

result = git_checkout(test_repository, revision)

assert test_repository.head.is_detached
assert result.startswith("HEAD is now detached at ")


def test_git_checkout_tag_reports_detached_head(test_repository):
test_repository.create_tag("v1")
result = git_checkout(test_repository, "v1")

assert test_repository.head.is_detached
assert "detached" in result
assert "Switched to branch" not in result


def test_git_checkout_branch_name_still_reports_branch(test_repository):
test_repository.git.branch("attached-checkout")
result = git_checkout(test_repository, "attached-checkout")

assert not test_repository.head.is_detached
assert result == "Switched to branch 'attached-checkout'"


def test_git_branch_local(test_repository):
test_repository.git.branch("new-branch-local")
result = git_branch(test_repository, "local")
Expand Down
Loading