Skip to content
Open
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
23 changes: 23 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
172 changes: 171 additions & 1 deletion src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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__)

Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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}")

Expand Down
115 changes: 115 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
git_log,
git_create_branch,
git_show,
git_worktree_list,
git_worktree_add,
git_worktree_remove,
validate_repo_path,
serve,
)
Expand Down Expand Up @@ -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)