Skip to content

Commit eb4d785

Browse files
authored
feat(git): add git_clone tool to lean interface (#177) (#179)
Adds first-class git_clone(repo_url, target_path, branch, depth, single_branch, recurse_submodules) to the lean git domain so multi-repo standardization flows no longer need the git_init + git_remote_add + git_fetch + git_checkout workaround. - Implementation in git/_remote_ops.py using Repo.clone_from with multi_options=['--branch=...', '--depth=...', '--single-branch', '--recurse-submodules'] (=-form required by GitPython arg handling). - GitClone Pydantic model in git/models.py mirrors GitInit pattern. - Re-export through git/operations.py. - ToolDefinition added to lean/registry_git.py between git_init and git_push, using model_json_schema() like git_init (not the inline dict form of git_fetch). - 7 unit tests in tests/unit/git/test_git_clone.py cover happy path, branch checkout, shallow depth (via file:// URL since local-path clone short-circuits --depth), single-branch flag, rejection of non-empty target and missing parent, and GitCommandError handling. - README.md tool count updated 50 -> 51, git count 25 -> 26. Closes #177
1 parent 170d186 commit eb4d785

6 files changed

Lines changed: 231 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ The server provides Azure DevOps integration for monitoring and analyzing Azure
137137

138138
### Lean MCP Interface (Context-Optimized)
139139

140-
The server provides an alternative **lean interface** that reduces context consumption by ~97% (from ~30k tokens to ~900 tokens). Instead of exposing all 51 tools upfront, it uses a 3-meta-tool pattern:
140+
The server provides an alternative **lean interface** that reduces context consumption by ~97% (from ~30k tokens to ~900 tokens). Instead of exposing all 52 tools upfront, it uses a 3-meta-tool pattern:
141141

142142
| Meta-Tool | Purpose |
143143
|-----------|---------|
144144
| `discover_tools(pattern)` | List available tools with optional filtering |
145145
| `get_tool_spec(tool_name)` | Get full schema for a specific tool on-demand |
146146
| `execute_tool(tool_name, params)` | Execute any tool dynamically |
147147

148-
**Tool Coverage**: All 50 tools remain accessible (25 git, 22 GitHub, 3 Azure DevOps).
148+
**Tool Coverage**: All 51 tools remain accessible (26 git, 22 GitHub, 3 Azure DevOps).
149149

150150
**Usage Example**:
151151
```python

src/mcp_server_git/git/_remote_ops.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
__all__ = [
1717
"_get_github_token_from_cli",
18+
"git_clone",
1819
"git_push",
1920
"git_pull",
2021
"git_remote_list",
@@ -459,6 +460,45 @@ def git_remote_get_url(repo: Repo, name: str) -> str:
459460
return f"❌ Remote get-url error: {str(e)}"
460461

461462

463+
def git_clone(
464+
repo_url: str,
465+
target_path: str,
466+
branch: str | None = None,
467+
depth: int | None = None,
468+
single_branch: bool = False,
469+
recurse_submodules: bool = False,
470+
) -> str:
471+
"""Clone a remote repository to a local path"""
472+
try:
473+
target = Path(target_path)
474+
if not target.parent.exists():
475+
raise ValueError(f"Parent directory does not exist: {target.parent}")
476+
if target.exists() and any(target.iterdir()):
477+
raise ValueError(
478+
f"Target path already exists and is not empty: {target_path}"
479+
)
480+
481+
multi_options = []
482+
if branch:
483+
multi_options.append(f"--branch={branch}")
484+
if depth is not None:
485+
multi_options.append(f"--depth={depth}")
486+
if single_branch:
487+
multi_options.append("--single-branch")
488+
if recurse_submodules:
489+
multi_options.append("--recurse-submodules")
490+
491+
Repo.clone_from(repo_url, target_path, multi_options=multi_options)
492+
493+
return f"✅ Cloned {repo_url} to {target_path}"
494+
except ValueError:
495+
raise
496+
except GitCommandError as e:
497+
return f"❌ Clone failed: {str(e)}"
498+
except Exception as e:
499+
return f"❌ Clone error: {str(e)}"
500+
501+
462502
def git_fetch(
463503
repo: Repo,
464504
remote: str = "origin",

src/mcp_server_git/git/models.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ class GitInit(BaseModel):
105105
repo_path: str
106106

107107

108+
class GitClone(BaseModel):
109+
repo_url: str
110+
target_path: str
111+
branch: str | None = None
112+
depth: int | None = None
113+
single_branch: bool = False
114+
recurse_submodules: bool = False
115+
116+
108117
class GitPush(BaseModel):
109118
repo_path: str
110119
remote: str = "origin"
@@ -240,12 +249,13 @@ class GitBranchList(BaseModel):
240249
None, description="commit-ish; only branches containing this commit"
241250
)
242251
merged: bool | None = Field(
243-
None, description="True=only merged into HEAD, False=only unmerged, None=no filter"
252+
None,
253+
description="True=only merged into HEAD, False=only unmerged, None=no filter",
244254
)
245255
sort: str | None = Field(
246256
None,
247257
description="Sort key, e.g. '-committerdate' (most-recent first), 'refname', 'authordate'. "
248-
"Mirrors `git for-each-ref --sort=<key>` semantics. None = no ordering guarantee.",
258+
"Mirrors `git for-each-ref --sort=<key>` semantics. None = no ordering guarantee.",
249259
)
250260

251261

src/mcp_server_git/git/operations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from ._init_ops import git_init
3131
from ._remote_ops import (
32+
git_clone,
3233
git_fetch,
3334
git_pull,
3435
git_push,
@@ -68,6 +69,7 @@
6869
)
6970

7071
__all__ = [
72+
"git_clone",
7173
"git_status",
7274
"git_diff_unstaged",
7375
"git_diff_staged",

src/mcp_server_git/lean/registry_git.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
GitBranchUpdate,
1212
GitCheckout,
1313
GitCherryPick,
14+
GitClone,
1415
GitCommit,
1516
GitConfigGet,
1617
GitConfigList,
@@ -168,6 +169,14 @@ def wrapper(repo_path: str, **kwargs):
168169
domain="git",
169170
complexity="core",
170171
),
172+
ToolDefinition(
173+
name="git_clone",
174+
implementation=git_ops.git_clone, # git_clone takes no Repo, operates directly
175+
description="Clone a remote repository to a local path",
176+
schema=GitClone.model_json_schema(),
177+
domain="git",
178+
complexity="core",
179+
),
171180
ToolDefinition(
172181
name="git_push",
173182
implementation=wrap_repo_op(git_ops.git_push),

tests/unit/git/test_git_clone.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for git_clone operation"""
2+
3+
import shutil
4+
import tempfile
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from src.mcp_server_git.utils.git_import import Repo
10+
11+
12+
def _make_source_repo(
13+
path: Path, num_commits: int = 1, branch: str | None = None
14+
) -> Repo:
15+
"""Helper: initialise a bare-ish source repo with commits for use as a local remote."""
16+
repo = Repo.init(str(path))
17+
18+
# Configure a minimal identity so commits work without system config
19+
with repo.config_writer() as cw:
20+
cw.set_value("user", "name", "Test User")
21+
cw.set_value("user", "email", "test@example.com")
22+
23+
# Write and commit files
24+
for i in range(num_commits):
25+
dummy = path / f"file_{i}.txt"
26+
dummy.write_text(f"content {i}")
27+
repo.index.add([str(dummy)])
28+
repo.index.commit(f"commit {i}")
29+
30+
if branch is not None:
31+
repo.create_head(branch)
32+
33+
return repo
34+
35+
36+
class TestGitClone:
37+
"""Test git_clone function"""
38+
39+
def setup_method(self):
40+
self._tmpdirs: list[str] = []
41+
42+
def teardown_method(self):
43+
for d in self._tmpdirs:
44+
shutil.rmtree(d, ignore_errors=True)
45+
46+
def _tmpdir(self) -> Path:
47+
d = tempfile.mkdtemp()
48+
self._tmpdirs.append(d)
49+
return Path(d)
50+
51+
# ------------------------------------------------------------------
52+
# Happy-path tests
53+
# ------------------------------------------------------------------
54+
55+
def test_clone_happy_path(self):
56+
"""Clone a local source repo into a fresh empty target and verify success."""
57+
from src.mcp_server_git.git.operations import git_clone
58+
59+
src = self._tmpdir()
60+
src_repo = _make_source_repo(src)
61+
expected_sha = src_repo.head.commit.hexsha
62+
63+
target = self._tmpdir() / "cloned"
64+
65+
result = git_clone(str(src), str(target))
66+
67+
assert "✅" in result
68+
assert str(src) in result
69+
assert str(target) in result
70+
71+
cloned_repo = Repo(str(target))
72+
assert cloned_repo.head.commit.hexsha == expected_sha
73+
74+
def test_clone_with_branch(self):
75+
"""Clone with branch= and verify active branch in cloned repo."""
76+
from src.mcp_server_git.git.operations import git_clone
77+
78+
src = self._tmpdir()
79+
_make_source_repo(src, num_commits=1, branch="feature-x")
80+
81+
target = self._tmpdir() / "cloned"
82+
83+
result = git_clone(str(src), str(target), branch="feature-x")
84+
85+
assert "✅" in result
86+
87+
cloned_repo = Repo(str(target))
88+
assert cloned_repo.active_branch.name == "feature-x"
89+
90+
def test_clone_with_depth(self):
91+
"""Clone with depth=1 from a 3-commit repo and verify shallow history."""
92+
from src.mcp_server_git.git.operations import git_clone
93+
94+
src = self._tmpdir()
95+
_make_source_repo(src, num_commits=3)
96+
97+
target = self._tmpdir() / "cloned"
98+
99+
result = git_clone(f"file://{src}", str(target), depth=1)
100+
101+
assert "✅" in result
102+
103+
cloned_repo = Repo(str(target))
104+
commit_count = cloned_repo.git.rev_list("--count", "HEAD")
105+
assert commit_count.strip() == "1"
106+
107+
def test_clone_single_branch_flag(self):
108+
"""Clone with single_branch=True succeeds."""
109+
from src.mcp_server_git.git.operations import git_clone
110+
111+
src = self._tmpdir()
112+
_make_source_repo(src)
113+
114+
target = self._tmpdir() / "cloned"
115+
116+
result = git_clone(str(src), str(target), single_branch=True)
117+
118+
assert "✅" in result
119+
120+
# ------------------------------------------------------------------
121+
# Validation / rejection tests
122+
# ------------------------------------------------------------------
123+
124+
def test_clone_rejects_existing_nonempty_target(self):
125+
"""Raise ValueError when target directory already contains files."""
126+
from src.mcp_server_git.git.operations import git_clone
127+
128+
src = self._tmpdir()
129+
_make_source_repo(src)
130+
131+
target = self._tmpdir() / "nonempty"
132+
target.mkdir()
133+
(target / "existing.txt").write_text("not empty")
134+
135+
with pytest.raises(ValueError, match="not empty"):
136+
git_clone(str(src), str(target))
137+
138+
def test_clone_rejects_missing_parent(self):
139+
"""Raise ValueError when the parent of target_path does not exist."""
140+
from src.mcp_server_git.git.operations import git_clone
141+
142+
src = self._tmpdir()
143+
_make_source_repo(src)
144+
145+
nonexistent_parent = self._tmpdir() / "ghost" / "cloned"
146+
147+
with pytest.raises(ValueError, match="Parent directory does not exist"):
148+
git_clone(str(src), str(nonexistent_parent))
149+
150+
# ------------------------------------------------------------------
151+
# Error-path tests
152+
# ------------------------------------------------------------------
153+
154+
def test_clone_bad_url_returns_error_message(self):
155+
"""Return a '❌' string when repo_url does not point to a valid repo."""
156+
from src.mcp_server_git.git.operations import git_clone
157+
158+
target = self._tmpdir() / "cloned"
159+
160+
# A path that does not exist is not a valid git repo; GitPython raises
161+
# GitCommandError which the implementation catches and returns as "❌ Clone failed: ..."
162+
bad_url = "/tmp/this_path_does_not_exist_at_all_xyz123"
163+
164+
result = git_clone(bad_url, str(target))
165+
166+
assert "❌" in result

0 commit comments

Comments
 (0)