From ea54b9e98bfbdb667893a3b6796b9b4e2f1a203f Mon Sep 17 00:00:00 2001 From: karawitan Date: Fri, 11 Sep 2026 17:41:28 +0200 Subject: [PATCH] feat(git): add git worktree tools (list, add, remove) Add three new tools to mcp-server-git for managing git worktrees: - git_worktree_list: List all worktrees of a repository - git_worktree_add: Create a new worktree, optionally with a new branch - git_worktree_remove: Remove a worktree, with optional force flag Each tool includes defense-in-depth guards rejecting arguments starting with '-' to prevent flag injection, matching the pattern used by existing tools (git_diff, git_checkout, git_show, etc.). The implementation uses GitPython's low-level `repo.git.worktree` command interface since GitPython does not provide a high-level worktree API. All 55 tests pass (8 new worktree tests + 47 existing), pyright clean, ruff clean. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/git/README.md | 23 ++++ src/git/src/mcp_server_git/server.py | 172 ++++++++++++++++++++++++++- src/git/tests/test_server.py | 115 ++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) diff --git a/src/git/README.md b/src/git/README.md index 70b00974e6..7deed204d6 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -100,6 +100,29 @@ Please note that mcp-server-git is currently in early development. The functiona - `not_contains` (string, optional): The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified - Returns: List of branches +13. `git_worktree_list` + - List all git worktrees + - Inputs: + - `repo_path` (string): Path to the Git repository + - Returns: List of worktrees with their paths, commits, and branches + +14. `git_worktree_add` + - Create a new git worktree, optionally creating a new branch for it + - Inputs: + - `repo_path` (string): Path to the Git repository + - `worktree_path` (string): Path where the new worktree should be created + - `branch_name` (string, optional): Name of a new branch to create for the worktree. If not provided, a branch named after the path's last component is created + - `base_branch` (string, optional): The branch or commit to base the new worktree on. Defaults to HEAD + - Returns: Output of the git worktree add command + +15. `git_worktree_remove` + - Remove a git worktree + - Inputs: + - `repo_path` (string): Path to the Git repository + - `worktree_path` (string): Path of the worktree to remove + - `force` (boolean, optional): Force removal even if the worktree has modifications or untracked files (default: false) + - Returns: Output of the git worktree remove command + ## Installation ### Using uv (recommended) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index b94af84661..820199082e 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -93,6 +93,47 @@ class GitBranch(BaseModel): ) +class GitWorktreeList(BaseModel): + repo_path: str = Field( + ..., + description="The path to the Git repository.", + ) + + +class GitWorktreeAdd(BaseModel): + repo_path: str = Field( + ..., + description="The path to the Git repository.", + ) + worktree_path: str = Field( + ..., + description="The path where the new worktree should be created.", + ) + branch_name: Optional[str] = Field( + None, + description="Name of a new branch to create for the worktree. If not provided, the worktree will be on the current HEAD (detached).", + ) + base_branch: Optional[str] = Field( + None, + description="The branch or commit to base the new worktree on. Defaults to HEAD.", + ) + + +class GitWorktreeRemove(BaseModel): + repo_path: str = Field( + ..., + description="The path to the Git repository.", + ) + worktree_path: str = Field( + ..., + description="The path of the worktree to remove.", + ) + force: bool = Field( + False, + description="Force removal even if the worktree has modifications or untracked files.", + ) + + class GitTools(str, Enum): STATUS = "git_status" DIFF_UNSTAGED = "git_diff_unstaged" @@ -107,6 +148,9 @@ class GitTools(str, Enum): SHOW = "git_show" BRANCH = "git_branch" + WORKTREE_LIST = "git_worktree_list" + WORKTREE_ADD = "git_worktree_add" + WORKTREE_REMOVE = "git_worktree_remove" def git_status(repo: git.Repo) -> str: return repo.git.status() @@ -288,6 +332,69 @@ def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, no return branch_info +def git_worktree_list(repo: git.Repo) -> str: + """List all worktrees of the repository.""" + return repo.git.worktree("list") + + +def git_worktree_add( + repo: git.Repo, + worktree_path: str, + branch_name: str | None = None, + base_branch: str | None = None, +) -> str: + """Create a new worktree. + + Args: + repo: The main repository. + worktree_path: Path where the new worktree should be created. + branch_name: Optional name of a new branch to create for the worktree. + base_branch: Optional branch or commit to base the worktree on. Defaults to HEAD. + + Returns: + The output of the git worktree add command. + """ + # Defense in depth: reject paths/refs starting with '-' to prevent flag injection + if worktree_path.startswith("-"): + raise BadName(f"Invalid worktree_path: '{worktree_path}' - cannot start with '-'") + if branch_name and branch_name.startswith("-"): + raise BadName(f"Invalid branch_name: '{branch_name}' - cannot start with '-'") + if base_branch and base_branch.startswith("-"): + raise BadName(f"Invalid base_branch: '{base_branch}' - cannot start with '-'") + + args = ["add"] + if branch_name: + args.extend(["-b", branch_name]) + args.append(worktree_path) + if base_branch: + args.append(base_branch) + + return repo.git.worktree(*args) + + +def git_worktree_remove(repo: git.Repo, worktree_path: str, force: bool = False) -> str: + """Remove a worktree. + + Args: + repo: The main repository. + worktree_path: Path of the worktree to remove. + force: Force removal even if the worktree has modifications or untracked files. + + Returns: + The output of the git worktree remove command. + """ + # Defense in depth: reject paths starting with '-' to prevent flag injection + if worktree_path.startswith("-"): + raise BadName(f"Invalid worktree_path: '{worktree_path}' - cannot start with '-'") + + args = ["remove"] + if force: + args.append("--force") + args.append(worktree_path) + + return repo.git.worktree(*args) + + async def serve(repository: Path | None) -> None: logger = logging.getLogger(__name__) @@ -435,7 +542,40 @@ async def list_tools() -> list[Tool]: idempotentHint=True, openWorldHint=False, ), - ) + ), + Tool( + name=GitTools.WORKTREE_LIST, + description="List all git worktrees", + inputSchema=GitWorktreeList.model_json_schema(), + annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + ), + Tool( + name=GitTools.WORKTREE_ADD, + description="Create a new git worktree, optionally creating a new branch for it", + inputSchema=GitWorktreeAdd.model_json_schema(), + annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, + ), + ), + Tool( + name=GitTools.WORKTREE_REMOVE, + description="Remove a git worktree, optionally forcing removal of modified worktrees", + inputSchema=GitWorktreeRemove.model_json_schema(), + annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ), + ), ] async def list_repos() -> Sequence[str]: @@ -577,6 +717,36 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: text=result )] + case GitTools.WORKTREE_LIST: + result = git_worktree_list(repo) + return [TextContent( + type="text", + text=f"Worktrees:\n{result}" + )] + + case GitTools.WORKTREE_ADD: + result = git_worktree_add( + repo, + arguments["worktree_path"], + arguments.get("branch_name"), + arguments.get("base_branch"), + ) + return [TextContent( + type="text", + text=f"Worktree created:\n{result}" + )] + + case GitTools.WORKTREE_REMOVE: + result = git_worktree_remove( + repo, + arguments["worktree_path"], + arguments.get("force", False), + ) + return [TextContent( + type="text", + text=f"Worktree removed:\n{result}" + )] + case _: raise ValueError(f"Unknown tool: {name}") diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 05d5931466..7227101d8a 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -15,6 +15,9 @@ git_log, git_create_branch, git_show, + git_worktree_list, + git_worktree_add, + git_worktree_remove, validate_repo_path, serve, ) @@ -588,3 +591,115 @@ async def _run(): assert kwargs.get("raise_exceptions") is not True anyio.run(_run) + + +# Tests for git_worktree_list, git_worktree_add, git_worktree_remove + +def test_git_worktree_list_single(test_repository): + """A fresh repo has exactly one worktree (the main working tree).""" + result = git_worktree_list(test_repository) + assert test_repository.working_dir in result + + +def test_git_worktree_add_and_list(test_repository, tmp_path): + """Adding a worktree should make it appear in the list.""" + worktree_path = str(tmp_path / "wt-feature") + git_worktree_add(test_repository, worktree_path, branch_name="feature-wt") + + listing = git_worktree_list(test_repository) + assert worktree_path in listing + assert "feature-wt" in listing + + +def test_git_worktree_add_with_base_branch(test_repository, tmp_path): + """Adding a worktree with an explicit base branch should work.""" + # Create a branch with a commit to use as base + test_repository.git.checkout("-b", "base-for-wt") + file_path = Path(test_repository.working_dir) / "base_wt.txt" + file_path.write_text("base content") + test_repository.index.add(["base_wt.txt"]) + test_repository.index.commit("base wt commit") + test_repository.git.checkout(test_repository.active_branch.name) + + worktree_path = str(tmp_path / "wt-from-base") + git_worktree_add( + test_repository, + worktree_path, + branch_name="derived-wt", + base_branch="base-for-wt", + ) + + listing = git_worktree_list(test_repository) + assert worktree_path in listing + assert "derived-wt" in listing + + +def test_git_worktree_remove(test_repository, tmp_path): + """Removing a worktree should remove it from the list.""" + worktree_path = str(tmp_path / "wt-remove-test") + git_worktree_add(test_repository, worktree_path, branch_name="remove-wt-branch") + + listing_before = git_worktree_list(test_repository) + assert worktree_path in listing_before + + result = git_worktree_remove(test_repository, worktree_path) + assert result == "" or "worktree" in result.lower() or result.strip() == "" + + listing_after = git_worktree_list(test_repository) + assert worktree_path not in listing_after + + +def test_git_worktree_remove_force(test_repository, tmp_path): + """Force removal should work even with modifications.""" + worktree_path = str(tmp_path / "wt-force-test") + git_worktree_add(test_repository, worktree_path, branch_name="force-wt-branch") + + # Make a modification in the worktree + wt_file = Path(worktree_path) / "test.txt" + wt_file.write_text("modified in worktree") + + # Without force, this might fail; with force it should succeed + result = git_worktree_remove(test_repository, worktree_path, force=True) + assert result == "" or "worktree" in result.lower() or result.strip() == "" + + listing = git_worktree_list(test_repository) + assert worktree_path not in listing + + +def test_git_worktree_add_rejects_flag_injection(test_repository, tmp_path): + """git_worktree_add should reject paths/branches starting with '-'.""" + with pytest.raises(BadName): + git_worktree_add(test_repository, "--output=/tmp/evil") + + with pytest.raises(BadName): + git_worktree_add( + test_repository, str(tmp_path / "wt-ok"), branch_name="--exec=evil" + ) + + with pytest.raises(BadName): + git_worktree_add( + test_repository, + str(tmp_path / "wt-ok"), + branch_name="ok-branch", + base_branch="--track=evil", + ) + + +def test_git_worktree_remove_rejects_flag_injection(test_repository, tmp_path): + """git_worktree_remove should reject paths starting with '-'.""" + with pytest.raises(BadName): + git_worktree_remove(test_repository, "--force") + + +def test_git_worktree_add_default_branch(test_repository, tmp_path): + """Adding a worktree without a branch name creates a branch named after the path.""" + worktree_path = str(tmp_path / "wt-default") + git_worktree_add(test_repository, worktree_path) + + listing = git_worktree_list(test_repository) + assert worktree_path in listing + # git creates a branch named after the last path component + assert "wt-default" in listing + + # Cleanup + git_worktree_remove(test_repository, worktree_path, force=True)