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
52 changes: 51 additions & 1 deletion src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,43 @@ python -m mcp_server_git

A tool call's `repo_path` must be inside that repository, and so must the directories Git then uses for it: a repository nested inside whose `.git` file, `commondir` or `core.worktree` points outside is refused. A linked worktree can still be the `--repository`, though its Git directory lives under the main repository's `.git`.

### Commit identity

`git_commit` has no identity setting of its own. It takes the author and the committer from the same places `git commit` does, in this order:

1. The server process's environment: `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL` for the author, `GIT_COMMITTER_NAME` and `GIT_COMMITTER_EMAIL` for the committer. The author and the committer are resolved separately, so set all four (usually to the same name and email).
2. `user.name` and `user.email` from Git config: the repository's `.git/config`, then `~/.gitconfig`, then `$XDG_CONFIG_HOME/git/config` (by default `~/.config/git/config`), then `/etc/gitconfig`.

Unlike `git commit`, the server does not refuse to commit when neither is set. It falls back to the operating-system user and host name, so a commit made in a container is recorded as something like `root <root@3f2a1b9c8d7e>`. A local install usually finds your own `~/.gitconfig`; a Docker container has none, so give it an identity explicitly.

For a local command (`uvx`, `python -m mcp_server_git`), add an `env` block to the server's entry in your client configuration:

```json
"mcpServers": {
"git": {
"command": "uvx",
"args": ["mcp-server-git", "--repository", "path/to/git/repo"],
"env": {
"GIT_AUTHOR_NAME": "Your Name",
"GIT_AUTHOR_EMAIL": "you@example.com",
"GIT_COMMITTER_NAME": "Your Name",
"GIT_COMMITTER_EMAIL": "you@example.com"
}
}
}
```

For Docker, pass the variables to `docker run` with `-e`, as the Docker examples below do. Alternatively, mount your gitconfig read-only in place of the four `-e` pairs. The image runs as `root`, so it goes at `/root/.gitconfig`:

```json
"args": [
"run", "--rm", "-i",
"--mount", "type=bind,src=/Users/username/.gitconfig,dst=/root/.gitconfig,readonly",
"--mount", "type=bind,src=/Users/username,dst=/Users/username",
"mcp/git"
]
```

### Usage with Claude Desktop

Add this to your `claude_desktop_config.json`:
Expand All @@ -162,12 +199,21 @@ Add this to your `claude_desktop_config.json`:
<summary>Using docker</summary>

* Note: replace '/Users/username' with a path that you want to be accessible by this tool
* Note: the `-e` lines set the identity `git_commit` records; see [Commit identity](#commit-identity)

```json
"mcpServers": {
"git": {
"command": "docker",
"args": ["run", "--rm", "-i", "--mount", "type=bind,src=/Users/username,dst=/Users/username", "mcp/git"]
"args": [
"run", "--rm", "-i",
"-e", "GIT_AUTHOR_NAME=Your Name",
"-e", "GIT_AUTHOR_EMAIL=you@example.com",
"-e", "GIT_COMMITTER_NAME=Your Name",
"-e", "GIT_COMMITTER_EMAIL=you@example.com",
"--mount", "type=bind,src=/Users/username,dst=/Users/username",
"mcp/git"
]
}
}
```
Expand Down Expand Up @@ -343,6 +389,10 @@ There are two ways to try your changes in a client:
"run",
"--rm",
"-i",
"-e", "GIT_AUTHOR_NAME=Your Name",
"-e", "GIT_AUTHOR_EMAIL=you@example.com",
"-e", "GIT_COMMITTER_NAME=Your Name",
"-e", "GIT_COMMITTER_EMAIL=you@example.com",
"--mount", "type=bind,src=/Users/username/Desktop,dst=/projects/Desktop",
"--mount", "type=bind,src=/path/to/other/allowed/dir,dst=/projects/other/allowed/dir,ro",
"--mount", "type=bind,src=/path/to/file.txt,dst=/projects/path/to/file.txt",
Expand Down
118 changes: 118 additions & 0 deletions src/git/tests/test_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Where `git_commit` takes the commit's author and committer from (#619).

The server sets no identity of its own: `git_commit` goes through GitPython's
`IndexFile.commit`, whose `Actor` reads `GIT_AUTHOR_NAME`/`GIT_AUTHOR_EMAIL`
and `GIT_COMMITTER_NAME`/`GIT_COMMITTER_EMAIL` from the server's environment
first, then `user.name`/`user.email` from the repository's config, the user's
`~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`. The README's "Commit
identity" section tells users to configure the server exactly that way (an
`env` block, `docker run -e`, or a mounted gitconfig), so these tests pin each
route through the protocol: a dependency upgrade or a refactor that stopped
honoring one would otherwise silently commit as `root@<container id>`.
"""

from pathlib import Path

import git
import pytest

from conftest import connect, disable_autocrlf, text_result, wire

IDENTITY_VARS = (
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
)


@pytest.fixture(autouse=True)
def clean_identity_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Start every test with no identity in the environment, whatever CI sets."""
for name in IDENTITY_VARS:
monkeypatch.delenv(name, raising=False)


async def commit_new_file(repo: git.Repo) -> git.Commit:
"""Stage a new file, commit it through the protocol, return the commit."""
assert repo.working_dir is not None
root = Path(repo.working_dir)
(root / "new.txt").write_text("new\n", newline="\n")
repo.index.add(["new.txt"])
async with connect(None) as session:
result = wire(
await session.call_tool(
"git_commit", {"repo_path": str(root), "message": "add new"}
)
)
head = repo.head.commit
assert result == text_result(
f"Changes committed successfully with hash {head.hexsha}"
)
return head


async def test_environment_variables_set_author_and_committer(
repo: git.Repo, monkeypatch: pytest.MonkeyPatch
):
# The `repo` fixture configures user.name/user.email repo-locally; the
# environment wins over it, as it does for `git commit`.
monkeypatch.setenv("GIT_AUTHOR_NAME", "Ada Author")
monkeypatch.setenv("GIT_AUTHOR_EMAIL", "ada@example.com")
monkeypatch.setenv("GIT_COMMITTER_NAME", "Cy Committer")
monkeypatch.setenv("GIT_COMMITTER_EMAIL", "cy@example.com")

head = await commit_new_file(repo)

assert (head.author.name, head.author.email) == ("Ada Author", "ada@example.com")
assert (head.committer.name, head.committer.email) == (
"Cy Committer",
"cy@example.com",
)


async def test_author_variables_alone_leave_the_committer_to_config(
repo: git.Repo, monkeypatch: pytest.MonkeyPatch
):
# Author and committer are resolved separately, which is why the README
# tells users to set all four variables.
monkeypatch.setenv("GIT_AUTHOR_NAME", "Ada Author")
monkeypatch.setenv("GIT_AUTHOR_EMAIL", "ada@example.com")

head = await commit_new_file(repo)

assert (head.author.name, head.author.email) == ("Ada Author", "ada@example.com")
assert (head.committer.name, head.committer.email) == (
"Test User",
"test@example.com",
)


async def test_user_gitconfig_in_home_sets_the_identity(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
# The mounted-gitconfig route: no environment variables and no repo-local
# identity, only a ~/.gitconfig under the server's HOME.
home = tmp_path / "home"
home.mkdir()
(home / ".gitconfig").write_text(
"[user]\n\tname = Gita Config\n\temail = gita@example.com\n"
)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home)) # what expanduser reads on Windows
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)

repo = git.Repo.init(tmp_path / "repo", initial_branch="main")
try:
disable_autocrlf(repo)
head = await commit_new_file(repo)
assert (head.author.name, head.author.email) == (
"Gita Config",
"gita@example.com",
)
assert (head.committer.name, head.committer.email) == (
"Gita Config",
"gita@example.com",
)
finally:
repo.close()
Loading