From 9863a1056d5807db105b039c16e73cc07aa7852f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 Oct 2026 18:16:28 -0400 Subject: [PATCH] docs(git): document setting the commit identity via env vars or gitconfig git_commit already takes the author and committer from GIT_AUTHOR_* and GIT_COMMITTER_* in the server's environment, then from user.name/user.email in Git config (GitPython's Actor). Document both routes in a new Commit identity section, add -e lines to the Docker examples, and pin each route with protocol-level tests. Closes #619 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/README.md | 52 ++++++++++++++- src/git/tests/test_identity.py | 118 +++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/git/tests/test_identity.py diff --git a/src/git/README.md b/src/git/README.md index 01ae034782..903a611add 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -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 `. 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`: @@ -162,12 +199,21 @@ Add this to your `claude_desktop_config.json`: Using docker * 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" + ] } } ``` @@ -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", diff --git a/src/git/tests/test_identity.py b/src/git/tests/test_identity.py new file mode 100644 index 0000000000..8fbfd9b356 --- /dev/null +++ b/src/git/tests/test_identity.py @@ -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@`. +""" + +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()