gitstow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitstow/__init__.py +3 -0
- gitstow/__main__.py +11 -0
- gitstow/cli/__init__.py +1 -0
- gitstow/cli/add.py +262 -0
- gitstow/cli/config_cmd.py +235 -0
- gitstow/cli/doctor.py +163 -0
- gitstow/cli/exec_cmd.py +159 -0
- gitstow/cli/export_cmd.py +293 -0
- gitstow/cli/helpers.py +113 -0
- gitstow/cli/list_cmd.py +213 -0
- gitstow/cli/main.py +129 -0
- gitstow/cli/manage.py +283 -0
- gitstow/cli/migrate.py +142 -0
- gitstow/cli/onboard.py +234 -0
- gitstow/cli/open_cmd.py +144 -0
- gitstow/cli/pull.py +265 -0
- gitstow/cli/remove.py +88 -0
- gitstow/cli/search.py +199 -0
- gitstow/cli/setup_ai.py +247 -0
- gitstow/cli/shell.py +317 -0
- gitstow/cli/skill_cmd.py +63 -0
- gitstow/cli/stats.py +126 -0
- gitstow/cli/status.py +213 -0
- gitstow/cli/tui.py +27 -0
- gitstow/cli/workspace_cmd.py +202 -0
- gitstow/core/__init__.py +1 -0
- gitstow/core/config.py +130 -0
- gitstow/core/discovery.py +116 -0
- gitstow/core/git.py +261 -0
- gitstow/core/parallel.py +85 -0
- gitstow/core/paths.py +51 -0
- gitstow/core/repo.py +300 -0
- gitstow/core/url_parser.py +159 -0
- gitstow/mcp/__init__.py +1 -0
- gitstow/mcp/server.py +704 -0
- gitstow/skill/SKILL.md +210 -0
- gitstow/tui/__init__.py +1 -0
- gitstow/tui/app.py +384 -0
- gitstow-0.1.0.dist-info/METADATA +306 -0
- gitstow-0.1.0.dist-info/RECORD +43 -0
- gitstow-0.1.0.dist-info/WHEEL +4 -0
- gitstow-0.1.0.dist-info/entry_points.txt +3 -0
- gitstow-0.1.0.dist-info/licenses/LICENSE +21 -0
gitstow/mcp/server.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""gitstow MCP server — tools for managing git repo collections.
|
|
2
|
+
|
|
3
|
+
Exposes gitstow's core functionality via the Model Context Protocol,
|
|
4
|
+
allowing any MCP-compatible AI tool (Claude, Cursor, Windsurf, etc.)
|
|
5
|
+
to manage repo collections.
|
|
6
|
+
|
|
7
|
+
Run with: gitstow-mcp (stdio transport)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from gitstow.core.config import load_config, Workspace
|
|
19
|
+
from gitstow.core.git import (
|
|
20
|
+
clone as git_clone,
|
|
21
|
+
pull as git_pull,
|
|
22
|
+
get_status,
|
|
23
|
+
get_last_commit,
|
|
24
|
+
get_disk_size,
|
|
25
|
+
format_size,
|
|
26
|
+
is_git_repo,
|
|
27
|
+
)
|
|
28
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
29
|
+
from gitstow.core.url_parser import parse_git_url
|
|
30
|
+
|
|
31
|
+
mcp = FastMCP(
|
|
32
|
+
"gitstow",
|
|
33
|
+
instructions="Git repository library manager — clone, organize, and maintain collections of repos across multiple workspaces. Use list_repos to see what's tracked, add_repo to clone new repos, pull_repos to update, search_repos to grep, and list_workspaces to see configured workspaces.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_settings_and_store():
|
|
38
|
+
"""Load settings and store."""
|
|
39
|
+
settings = load_config()
|
|
40
|
+
store = RepoStore()
|
|
41
|
+
return settings, store
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_workspace_for_repo(repo: Repo, settings) -> Workspace | None:
|
|
45
|
+
"""Get the workspace a repo belongs to."""
|
|
46
|
+
return settings.get_workspace(repo.workspace)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _repo_path(repo: Repo, settings) -> str:
|
|
50
|
+
"""Resolve a repo's absolute path via its workspace."""
|
|
51
|
+
ws = _get_workspace_for_repo(repo, settings)
|
|
52
|
+
if ws:
|
|
53
|
+
return str(repo.get_path(ws.get_path()))
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- Tools (actions the AI can perform) ---
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def list_repos(
|
|
62
|
+
tag: Optional[str] = None,
|
|
63
|
+
owner: Optional[str] = None,
|
|
64
|
+
query: Optional[str] = None,
|
|
65
|
+
workspace: Optional[str] = None,
|
|
66
|
+
frozen_only: bool = False,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""List all tracked git repositories, optionally filtered.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tag: Filter repos by this tag (e.g., "ai", "python").
|
|
72
|
+
owner: Filter repos by owner (e.g., "anthropic").
|
|
73
|
+
query: Substring search across repo keys.
|
|
74
|
+
workspace: Filter to a specific workspace label.
|
|
75
|
+
frozen_only: If true, show only frozen repos.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
JSON array of repo objects with key, workspace, remote_url, frozen, tags, added, last_pulled.
|
|
79
|
+
"""
|
|
80
|
+
settings, store = _get_settings_and_store()
|
|
81
|
+
repos = store.list_all()
|
|
82
|
+
|
|
83
|
+
if workspace:
|
|
84
|
+
repos = [r for r in repos if r.workspace == workspace]
|
|
85
|
+
if tag:
|
|
86
|
+
repos = [r for r in repos if tag in r.tags]
|
|
87
|
+
if owner:
|
|
88
|
+
repos = [r for r in repos if r.owner == owner]
|
|
89
|
+
if query:
|
|
90
|
+
q = query.lower()
|
|
91
|
+
repos = [r for r in repos if q in r.key.lower()]
|
|
92
|
+
if frozen_only:
|
|
93
|
+
repos = [r for r in repos if r.frozen]
|
|
94
|
+
|
|
95
|
+
return json.dumps([
|
|
96
|
+
{
|
|
97
|
+
"key": r.key,
|
|
98
|
+
"workspace": r.workspace,
|
|
99
|
+
"remote_url": r.remote_url,
|
|
100
|
+
"path": _repo_path(r, settings),
|
|
101
|
+
"frozen": r.frozen,
|
|
102
|
+
"tags": r.tags,
|
|
103
|
+
"added": r.added,
|
|
104
|
+
"last_pulled": r.last_pulled,
|
|
105
|
+
}
|
|
106
|
+
for r in repos
|
|
107
|
+
], indent=2)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool()
|
|
111
|
+
def add_repo(
|
|
112
|
+
url: str,
|
|
113
|
+
workspace: Optional[str] = None,
|
|
114
|
+
shallow: bool = False,
|
|
115
|
+
tags: Optional[list[str]] = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Clone a git repository into a workspace.
|
|
118
|
+
|
|
119
|
+
Accepts GitHub shorthand (owner/repo), full HTTPS URLs, or SSH URLs.
|
|
120
|
+
Uses the default workspace unless specified.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
url: Git URL or GitHub shorthand (e.g., "anthropic/claude-code").
|
|
124
|
+
workspace: Target workspace label. Defaults to the first workspace.
|
|
125
|
+
shallow: If true, shallow clone (--depth 1) to save disk space.
|
|
126
|
+
tags: Optional tags to apply immediately (e.g., ["ai", "tools"]).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
JSON with success status, repo key, and path.
|
|
130
|
+
"""
|
|
131
|
+
settings, store = _get_settings_and_store()
|
|
132
|
+
ws = settings.get_workspace(workspace) if workspace else settings.get_default_workspace()
|
|
133
|
+
if not ws:
|
|
134
|
+
return json.dumps({"success": False, "error": f"Workspace '{workspace}' not found"})
|
|
135
|
+
|
|
136
|
+
root = ws.get_path()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
parsed = parse_git_url(url, default_host=settings.default_host, prefer_ssh=settings.prefer_ssh)
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
return json.dumps({"success": False, "error": str(e)})
|
|
142
|
+
|
|
143
|
+
# Determine layout
|
|
144
|
+
if ws.layout == "flat":
|
|
145
|
+
target = root / parsed.repo
|
|
146
|
+
repo_owner = ""
|
|
147
|
+
else:
|
|
148
|
+
target = root / parsed.owner / parsed.repo
|
|
149
|
+
repo_owner = parsed.owner
|
|
150
|
+
|
|
151
|
+
repo_key = f"{repo_owner}/{parsed.repo}" if repo_owner else parsed.repo
|
|
152
|
+
all_tags = list(tags or []) + list(ws.auto_tags)
|
|
153
|
+
|
|
154
|
+
# Check if already tracked
|
|
155
|
+
existing = store.get(repo_key, workspace=ws.label)
|
|
156
|
+
if existing:
|
|
157
|
+
return json.dumps({
|
|
158
|
+
"success": True,
|
|
159
|
+
"status": "already_tracked",
|
|
160
|
+
"key": repo_key,
|
|
161
|
+
"workspace": ws.label,
|
|
162
|
+
"path": str(target),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
# Already on disk but not tracked
|
|
166
|
+
if target.exists() and is_git_repo(target):
|
|
167
|
+
repo = Repo(
|
|
168
|
+
owner=repo_owner,
|
|
169
|
+
name=parsed.repo,
|
|
170
|
+
remote_url=parsed.clone_url,
|
|
171
|
+
workspace=ws.label,
|
|
172
|
+
tags=all_tags,
|
|
173
|
+
)
|
|
174
|
+
store.add(repo)
|
|
175
|
+
return json.dumps({
|
|
176
|
+
"success": True,
|
|
177
|
+
"status": "registered",
|
|
178
|
+
"key": repo_key,
|
|
179
|
+
"workspace": ws.label,
|
|
180
|
+
"path": str(target),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
# Clone
|
|
184
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
success, error = git_clone(url=parsed.clone_url, target=target, shallow=shallow)
|
|
186
|
+
|
|
187
|
+
if success:
|
|
188
|
+
repo = Repo(
|
|
189
|
+
owner=repo_owner,
|
|
190
|
+
name=parsed.repo,
|
|
191
|
+
remote_url=parsed.clone_url,
|
|
192
|
+
workspace=ws.label,
|
|
193
|
+
tags=all_tags,
|
|
194
|
+
last_pulled=datetime.now().isoformat(),
|
|
195
|
+
)
|
|
196
|
+
store.add(repo)
|
|
197
|
+
return json.dumps({
|
|
198
|
+
"success": True,
|
|
199
|
+
"status": "cloned",
|
|
200
|
+
"key": repo_key,
|
|
201
|
+
"workspace": ws.label,
|
|
202
|
+
"path": str(target),
|
|
203
|
+
})
|
|
204
|
+
else:
|
|
205
|
+
return json.dumps({"success": False, "error": error})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@mcp.tool()
|
|
209
|
+
def pull_repos(
|
|
210
|
+
tag: Optional[str] = None,
|
|
211
|
+
exclude_tag: Optional[str] = None,
|
|
212
|
+
workspace: Optional[str] = None,
|
|
213
|
+
include_frozen: bool = False,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Pull latest changes for all (or filtered) repos.
|
|
216
|
+
|
|
217
|
+
Frozen repos are skipped unless include_frozen is true.
|
|
218
|
+
Dirty repos are always skipped (never risks losing local changes).
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
tag: Only pull repos with this tag.
|
|
222
|
+
exclude_tag: Skip repos with this tag.
|
|
223
|
+
workspace: Only pull repos in this workspace.
|
|
224
|
+
include_frozen: If true, also pull frozen repos.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
JSON with per-repo results and summary counts.
|
|
228
|
+
"""
|
|
229
|
+
settings, store = _get_settings_and_store()
|
|
230
|
+
repos = store.list_all()
|
|
231
|
+
|
|
232
|
+
if workspace:
|
|
233
|
+
repos = [r for r in repos if r.workspace == workspace]
|
|
234
|
+
if not include_frozen:
|
|
235
|
+
frozen_keys = {r.key for r in repos if r.frozen}
|
|
236
|
+
repos = [r for r in repos if not r.frozen]
|
|
237
|
+
else:
|
|
238
|
+
frozen_keys = set()
|
|
239
|
+
if tag:
|
|
240
|
+
repos = [r for r in repos if tag in r.tags]
|
|
241
|
+
if exclude_tag:
|
|
242
|
+
repos = [r for r in repos if exclude_tag not in r.tags]
|
|
243
|
+
|
|
244
|
+
results = []
|
|
245
|
+
|
|
246
|
+
for repo in repos:
|
|
247
|
+
ws = _get_workspace_for_repo(repo, settings)
|
|
248
|
+
if not ws:
|
|
249
|
+
results.append({"repo": repo.key, "status": "error", "error": "workspace not found"})
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
path = repo.get_path(ws.get_path())
|
|
253
|
+
|
|
254
|
+
if not path.exists() or not is_git_repo(path):
|
|
255
|
+
results.append({"repo": repo.key, "status": "missing"})
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
status = get_status(path)
|
|
259
|
+
if not status.clean:
|
|
260
|
+
results.append({"repo": repo.key, "status": "skipped_dirty", "detail": status.status_symbol})
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
pull_result = git_pull(path)
|
|
264
|
+
if pull_result.success:
|
|
265
|
+
status_str = "up_to_date" if pull_result.already_up_to_date else "pulled"
|
|
266
|
+
store.update(repo.key, workspace=repo.workspace, last_pulled=datetime.now().isoformat())
|
|
267
|
+
results.append({"repo": repo.key, "status": status_str})
|
|
268
|
+
else:
|
|
269
|
+
results.append({"repo": repo.key, "status": "error", "error": pull_result.error})
|
|
270
|
+
|
|
271
|
+
for key in sorted(frozen_keys):
|
|
272
|
+
results.append({"repo": key, "status": "skipped_frozen"})
|
|
273
|
+
|
|
274
|
+
pulled = sum(1 for r in results if r["status"] == "pulled")
|
|
275
|
+
up_to_date = sum(1 for r in results if r["status"] == "up_to_date")
|
|
276
|
+
skipped = sum(1 for r in results if r["status"].startswith("skipped"))
|
|
277
|
+
errors = sum(1 for r in results if r["status"] in ("error", "missing"))
|
|
278
|
+
|
|
279
|
+
return json.dumps({
|
|
280
|
+
"total": len(results),
|
|
281
|
+
"pulled": pulled,
|
|
282
|
+
"up_to_date": up_to_date,
|
|
283
|
+
"skipped": skipped,
|
|
284
|
+
"errors": errors,
|
|
285
|
+
"results": results,
|
|
286
|
+
}, indent=2)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@mcp.tool()
|
|
290
|
+
def repo_status(
|
|
291
|
+
tag: Optional[str] = None,
|
|
292
|
+
owner: Optional[str] = None,
|
|
293
|
+
workspace: Optional[str] = None,
|
|
294
|
+
dirty_only: bool = False,
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Get git status dashboard across all repos.
|
|
297
|
+
|
|
298
|
+
Shows branch, clean/dirty state, ahead/behind counts for each repo.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
tag: Filter by tag.
|
|
302
|
+
owner: Filter by owner.
|
|
303
|
+
workspace: Filter to a specific workspace.
|
|
304
|
+
dirty_only: Only show dirty repos.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
JSON array of repo status objects.
|
|
308
|
+
"""
|
|
309
|
+
settings, store = _get_settings_and_store()
|
|
310
|
+
repos = store.list_all()
|
|
311
|
+
|
|
312
|
+
if workspace:
|
|
313
|
+
repos = [r for r in repos if r.workspace == workspace]
|
|
314
|
+
if tag:
|
|
315
|
+
repos = [r for r in repos if tag in r.tags]
|
|
316
|
+
if owner:
|
|
317
|
+
repos = [r for r in repos if r.owner == owner]
|
|
318
|
+
|
|
319
|
+
statuses = []
|
|
320
|
+
for repo in repos:
|
|
321
|
+
ws = _get_workspace_for_repo(repo, settings)
|
|
322
|
+
if not ws:
|
|
323
|
+
statuses.append({"repo": repo.key, "workspace": repo.workspace, "error": "workspace not found"})
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
path = repo.get_path(ws.get_path())
|
|
327
|
+
if not path.exists() or not is_git_repo(path):
|
|
328
|
+
statuses.append({"repo": repo.key, "workspace": repo.workspace, "error": "not found on disk"})
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
status = get_status(path)
|
|
332
|
+
commit = get_last_commit(path)
|
|
333
|
+
|
|
334
|
+
entry = {
|
|
335
|
+
"repo": repo.key,
|
|
336
|
+
"workspace": repo.workspace,
|
|
337
|
+
"branch": status.branch,
|
|
338
|
+
"clean": status.clean,
|
|
339
|
+
"dirty": status.dirty,
|
|
340
|
+
"staged": status.staged,
|
|
341
|
+
"untracked": status.untracked,
|
|
342
|
+
"ahead": status.ahead,
|
|
343
|
+
"behind": status.behind,
|
|
344
|
+
"frozen": repo.frozen,
|
|
345
|
+
"tags": repo.tags,
|
|
346
|
+
"last_commit": commit.message,
|
|
347
|
+
"last_commit_date": commit.date,
|
|
348
|
+
}
|
|
349
|
+
statuses.append(entry)
|
|
350
|
+
|
|
351
|
+
if dirty_only:
|
|
352
|
+
statuses = [s for s in statuses if not s.get("clean", True)]
|
|
353
|
+
|
|
354
|
+
return json.dumps(statuses, indent=2)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@mcp.tool()
|
|
358
|
+
def repo_info(repo_key: str) -> str:
|
|
359
|
+
"""Get detailed info about a single repo.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
repo_key: The repo identifier (owner/repo or name).
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
JSON with full repo details: remote, path, branch, status, tags, disk size, last commit.
|
|
366
|
+
"""
|
|
367
|
+
settings, store = _get_settings_and_store()
|
|
368
|
+
repo = store.get(repo_key)
|
|
369
|
+
|
|
370
|
+
if not repo:
|
|
371
|
+
return json.dumps({"error": f"'{repo_key}' not tracked"})
|
|
372
|
+
|
|
373
|
+
path_str = _repo_path(repo, settings)
|
|
374
|
+
from pathlib import Path
|
|
375
|
+
path = Path(path_str) if path_str else None
|
|
376
|
+
|
|
377
|
+
info = {
|
|
378
|
+
"key": repo.key,
|
|
379
|
+
"workspace": repo.workspace,
|
|
380
|
+
"remote_url": repo.remote_url,
|
|
381
|
+
"path": path_str,
|
|
382
|
+
"frozen": repo.frozen,
|
|
383
|
+
"tags": repo.tags,
|
|
384
|
+
"added": repo.added,
|
|
385
|
+
"last_pulled": repo.last_pulled,
|
|
386
|
+
"exists_on_disk": path.exists() if path else False,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if path and path.exists() and is_git_repo(path):
|
|
390
|
+
status = get_status(path)
|
|
391
|
+
commit = get_last_commit(path)
|
|
392
|
+
size = get_disk_size(path)
|
|
393
|
+
|
|
394
|
+
info.update({
|
|
395
|
+
"branch": status.branch,
|
|
396
|
+
"clean": status.clean,
|
|
397
|
+
"status_symbol": status.status_symbol,
|
|
398
|
+
"ahead": status.ahead,
|
|
399
|
+
"behind": status.behind,
|
|
400
|
+
"last_commit_hash": commit.hash,
|
|
401
|
+
"last_commit_message": commit.message,
|
|
402
|
+
"last_commit_date": commit.date,
|
|
403
|
+
"disk_size_bytes": size,
|
|
404
|
+
"disk_size": format_size(size),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
return json.dumps(info, indent=2)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@mcp.tool()
|
|
411
|
+
def freeze_repo(repo_key: str) -> str:
|
|
412
|
+
"""Freeze a repo — it will be skipped during pull operations.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
repo_key: The repo identifier (owner/repo).
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
JSON with success status.
|
|
419
|
+
"""
|
|
420
|
+
settings, store = _get_settings_and_store()
|
|
421
|
+
repo = store.get(repo_key)
|
|
422
|
+
if not repo:
|
|
423
|
+
return json.dumps({"success": False, "error": f"'{repo_key}' not tracked"})
|
|
424
|
+
|
|
425
|
+
store.update(repo_key, workspace=repo.workspace, frozen=True)
|
|
426
|
+
return json.dumps({"success": True, "repo": repo_key, "frozen": True})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@mcp.tool()
|
|
430
|
+
def unfreeze_repo(repo_key: str) -> str:
|
|
431
|
+
"""Unfreeze a repo — re-enable it for pull operations.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
repo_key: The repo identifier (owner/repo).
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
JSON with success status.
|
|
438
|
+
"""
|
|
439
|
+
settings, store = _get_settings_and_store()
|
|
440
|
+
repo = store.get(repo_key)
|
|
441
|
+
if not repo:
|
|
442
|
+
return json.dumps({"success": False, "error": f"'{repo_key}' not tracked"})
|
|
443
|
+
|
|
444
|
+
store.update(repo_key, workspace=repo.workspace, frozen=False)
|
|
445
|
+
return json.dumps({"success": True, "repo": repo_key, "frozen": False})
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@mcp.tool()
|
|
449
|
+
def tag_repo(repo_key: str, tags: list[str]) -> str:
|
|
450
|
+
"""Add tags to a repo.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
repo_key: The repo identifier (owner/repo).
|
|
454
|
+
tags: List of tags to add (e.g., ["ai", "tools"]).
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
JSON with updated tag list.
|
|
458
|
+
"""
|
|
459
|
+
settings, store = _get_settings_and_store()
|
|
460
|
+
repo = store.get(repo_key)
|
|
461
|
+
if not repo:
|
|
462
|
+
return json.dumps({"success": False, "error": f"'{repo_key}' not tracked"})
|
|
463
|
+
|
|
464
|
+
new_tags = list(set(repo.tags + [t.lower() for t in tags]))
|
|
465
|
+
store.update(repo_key, workspace=repo.workspace, tags=new_tags)
|
|
466
|
+
return json.dumps({"success": True, "repo": repo_key, "tags": new_tags})
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@mcp.tool()
|
|
470
|
+
def untag_repo(repo_key: str, tags: list[str]) -> str:
|
|
471
|
+
"""Remove tags from a repo.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
repo_key: The repo identifier (owner/repo).
|
|
475
|
+
tags: List of tags to remove.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
JSON with updated tag list.
|
|
479
|
+
"""
|
|
480
|
+
settings, store = _get_settings_and_store()
|
|
481
|
+
repo = store.get(repo_key)
|
|
482
|
+
if not repo:
|
|
483
|
+
return json.dumps({"success": False, "error": f"'{repo_key}' not tracked"})
|
|
484
|
+
|
|
485
|
+
new_tags = [t for t in repo.tags if t not in tags]
|
|
486
|
+
store.update(repo_key, workspace=repo.workspace, tags=new_tags)
|
|
487
|
+
return json.dumps({"success": True, "repo": repo_key, "tags": new_tags})
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@mcp.tool()
|
|
491
|
+
def remove_repo(repo_key: str, delete_from_disk: bool = False) -> str:
|
|
492
|
+
"""Remove a repo from tracking.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
repo_key: The repo identifier (owner/repo).
|
|
496
|
+
delete_from_disk: If true, also delete the files from disk.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
JSON with success status.
|
|
500
|
+
"""
|
|
501
|
+
import shutil
|
|
502
|
+
from pathlib import Path
|
|
503
|
+
|
|
504
|
+
settings, store = _get_settings_and_store()
|
|
505
|
+
repo = store.get(repo_key)
|
|
506
|
+
if not repo:
|
|
507
|
+
return json.dumps({"success": False, "error": f"'{repo_key}' not tracked"})
|
|
508
|
+
|
|
509
|
+
ws = _get_workspace_for_repo(repo, settings)
|
|
510
|
+
path = Path(_repo_path(repo, settings)) if ws else None
|
|
511
|
+
store.remove(repo_key, workspace=repo.workspace)
|
|
512
|
+
|
|
513
|
+
deleted = False
|
|
514
|
+
if delete_from_disk and path and path.exists():
|
|
515
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
516
|
+
deleted = True
|
|
517
|
+
# Clean up empty owner dir (structured layout only)
|
|
518
|
+
if repo.owner and ws:
|
|
519
|
+
owner_dir = ws.get_path() / repo.owner
|
|
520
|
+
if owner_dir.exists() and not any(owner_dir.iterdir()):
|
|
521
|
+
owner_dir.rmdir()
|
|
522
|
+
|
|
523
|
+
return json.dumps({
|
|
524
|
+
"success": True,
|
|
525
|
+
"repo": repo_key,
|
|
526
|
+
"deleted_from_disk": deleted,
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@mcp.tool()
|
|
531
|
+
def search_repos(
|
|
532
|
+
pattern: str,
|
|
533
|
+
tag: Optional[str] = None,
|
|
534
|
+
glob_filter: Optional[str] = None,
|
|
535
|
+
max_results: int = 30,
|
|
536
|
+
) -> str:
|
|
537
|
+
"""Search (grep) across all repos for a pattern.
|
|
538
|
+
|
|
539
|
+
Uses ripgrep if available, falls back to git grep.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
pattern: Search pattern (regex supported with ripgrep).
|
|
543
|
+
tag: Only search repos with this tag.
|
|
544
|
+
glob_filter: File glob pattern (e.g., "*.py", "*.md").
|
|
545
|
+
max_results: Maximum results per repo (default 30).
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
JSON with matches grouped by repo.
|
|
549
|
+
"""
|
|
550
|
+
import subprocess
|
|
551
|
+
import shutil
|
|
552
|
+
from pathlib import Path
|
|
553
|
+
|
|
554
|
+
settings, store = _get_settings_and_store()
|
|
555
|
+
repos = store.list_all()
|
|
556
|
+
|
|
557
|
+
if tag:
|
|
558
|
+
repos = [r for r in repos if tag in r.tags]
|
|
559
|
+
|
|
560
|
+
use_rg = shutil.which("rg") is not None
|
|
561
|
+
all_results = []
|
|
562
|
+
|
|
563
|
+
for repo in repos:
|
|
564
|
+
path_str = _repo_path(repo, settings)
|
|
565
|
+
if not path_str:
|
|
566
|
+
continue
|
|
567
|
+
path = Path(path_str)
|
|
568
|
+
if not path.exists():
|
|
569
|
+
continue
|
|
570
|
+
|
|
571
|
+
if use_rg:
|
|
572
|
+
cmd = ["rg", "--no-heading", "--with-filename", "-n", "--max-count", str(max_results)]
|
|
573
|
+
if glob_filter:
|
|
574
|
+
cmd.extend(["--glob", glob_filter])
|
|
575
|
+
cmd.append(pattern)
|
|
576
|
+
else:
|
|
577
|
+
cmd = ["git", "grep", "-n", pattern]
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
result = subprocess.run(cmd, cwd=path, capture_output=True, text=True, timeout=30)
|
|
581
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
582
|
+
matches = []
|
|
583
|
+
for line in result.stdout.strip().splitlines()[:max_results]:
|
|
584
|
+
parts = line.split(":", 2)
|
|
585
|
+
if len(parts) >= 3:
|
|
586
|
+
matches.append({"file": parts[0], "line": parts[1], "text": parts[2].strip()})
|
|
587
|
+
if matches:
|
|
588
|
+
all_results.append({"repo": repo.key, "matches": matches, "count": len(matches)})
|
|
589
|
+
except (subprocess.TimeoutExpired, Exception):
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
total = sum(r["count"] for r in all_results)
|
|
593
|
+
return json.dumps({
|
|
594
|
+
"pattern": pattern,
|
|
595
|
+
"total_matches": total,
|
|
596
|
+
"repos_with_matches": len(all_results),
|
|
597
|
+
"results": all_results,
|
|
598
|
+
}, indent=2)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@mcp.tool()
|
|
602
|
+
def collection_stats() -> str:
|
|
603
|
+
"""Get collection statistics — total repos, owners, tags, disk usage.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
JSON with collection overview, owner breakdown, tag counts, and largest repos.
|
|
607
|
+
"""
|
|
608
|
+
from collections import defaultdict
|
|
609
|
+
from pathlib import Path
|
|
610
|
+
|
|
611
|
+
settings, store = _get_settings_and_store()
|
|
612
|
+
repos = store.list_all()
|
|
613
|
+
owners = store.all_owners()
|
|
614
|
+
tags = store.all_tags()
|
|
615
|
+
|
|
616
|
+
total_size = 0
|
|
617
|
+
size_by_owner: dict[str, int] = defaultdict(int)
|
|
618
|
+
largest = []
|
|
619
|
+
|
|
620
|
+
for repo in repos:
|
|
621
|
+
path_str = _repo_path(repo, settings)
|
|
622
|
+
if not path_str:
|
|
623
|
+
continue
|
|
624
|
+
path = Path(path_str)
|
|
625
|
+
if path.exists() and is_git_repo(path):
|
|
626
|
+
size = get_disk_size(path)
|
|
627
|
+
total_size += size
|
|
628
|
+
size_by_owner[repo.owner] += size
|
|
629
|
+
largest.append((repo.key, size))
|
|
630
|
+
|
|
631
|
+
largest.sort(key=lambda x: x[1], reverse=True)
|
|
632
|
+
|
|
633
|
+
return json.dumps({
|
|
634
|
+
"total_repos": len(repos),
|
|
635
|
+
"total_owners": len(owners),
|
|
636
|
+
"total_tags": len(tags),
|
|
637
|
+
"frozen_count": len(store.list_frozen()),
|
|
638
|
+
"total_disk_size": format_size(total_size),
|
|
639
|
+
"owners": {k: {"count": v, "size": format_size(size_by_owner.get(k, 0))} for k, v in owners.items()},
|
|
640
|
+
"tags": tags,
|
|
641
|
+
"largest_repos": [{"repo": k, "size": format_size(v)} for k, v in largest[:10]],
|
|
642
|
+
}, indent=2)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# --- Resources (data the AI can read) ---
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@mcp.tool()
|
|
649
|
+
def list_workspaces() -> str:
|
|
650
|
+
"""List all configured workspaces.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
JSON array of workspace objects with label, path, layout, auto_tags, and repo count.
|
|
654
|
+
"""
|
|
655
|
+
settings, store = _get_settings_and_store()
|
|
656
|
+
ws_counts = store.all_workspaces()
|
|
657
|
+
|
|
658
|
+
return json.dumps([
|
|
659
|
+
{
|
|
660
|
+
"label": ws.label,
|
|
661
|
+
"path": ws.path,
|
|
662
|
+
"layout": ws.layout,
|
|
663
|
+
"auto_tags": ws.auto_tags,
|
|
664
|
+
"repo_count": ws_counts.get(ws.label, 0),
|
|
665
|
+
}
|
|
666
|
+
for ws in settings.get_workspaces()
|
|
667
|
+
], indent=2)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@mcp.resource("gitstow://config")
|
|
671
|
+
def get_config() -> str:
|
|
672
|
+
"""Current gitstow configuration."""
|
|
673
|
+
settings = load_config()
|
|
674
|
+
store = RepoStore()
|
|
675
|
+
return json.dumps({
|
|
676
|
+
"workspaces": [ws.to_dict() for ws in settings.get_workspaces()],
|
|
677
|
+
"default_host": settings.default_host,
|
|
678
|
+
"prefer_ssh": settings.prefer_ssh,
|
|
679
|
+
"parallel_limit": settings.parallel_limit,
|
|
680
|
+
"repos_tracked": store.count(),
|
|
681
|
+
}, indent=2)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@mcp.resource("gitstow://tags")
|
|
685
|
+
def get_all_tags() -> str:
|
|
686
|
+
"""All tags with repo counts."""
|
|
687
|
+
store = RepoStore()
|
|
688
|
+
return json.dumps(store.all_tags(), indent=2)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@mcp.resource("gitstow://owners")
|
|
692
|
+
def get_all_owners() -> str:
|
|
693
|
+
"""All owners with repo counts."""
|
|
694
|
+
store = RepoStore()
|
|
695
|
+
return json.dumps(store.all_owners(), indent=2)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main():
|
|
699
|
+
"""Entry point for the MCP server."""
|
|
700
|
+
mcp.run(transport="stdio")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
if __name__ == "__main__":
|
|
704
|
+
main()
|