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/core/repo.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Repo dataclass and RepoStore — CRUD for repos.yaml.
|
|
2
|
+
|
|
3
|
+
The RepoStore is the single interface for reading and writing per-repo metadata.
|
|
4
|
+
repos.yaml is central (~/.gitstow/repos.yaml), nested by workspace label.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from gitstow.core.paths import get_repos_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Repo:
|
|
20
|
+
"""A managed git repository."""
|
|
21
|
+
|
|
22
|
+
owner: str # "anthropic" (may be "" for flat-layout repos)
|
|
23
|
+
name: str # "claude-code"
|
|
24
|
+
remote_url: str # "https://github.com/anthropic/claude-code.git"
|
|
25
|
+
workspace: str = "" # workspace label this repo belongs to
|
|
26
|
+
frozen: bool = False
|
|
27
|
+
tags: list[str] = field(default_factory=list)
|
|
28
|
+
added: str = "" # ISO date (YYYY-MM-DD)
|
|
29
|
+
last_pulled: str = "" # ISO datetime
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def key(self) -> str:
|
|
33
|
+
"""Unique identifier within a workspace: owner/repo or just name."""
|
|
34
|
+
if self.owner:
|
|
35
|
+
return f"{self.owner}/{self.name}"
|
|
36
|
+
return self.name
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def global_key(self) -> str:
|
|
40
|
+
"""Globally unique identifier: workspace:key."""
|
|
41
|
+
return f"{self.workspace}:{self.key}"
|
|
42
|
+
|
|
43
|
+
def get_path(self, workspace_path: Path) -> Path:
|
|
44
|
+
"""Absolute path on disk, using the workspace's resolved path."""
|
|
45
|
+
if self.owner:
|
|
46
|
+
return workspace_path / self.owner / self.name
|
|
47
|
+
return workspace_path / self.name
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
"""Serialize for YAML (excludes owner/name/workspace — those are structural keys)."""
|
|
51
|
+
return {
|
|
52
|
+
"remote_url": self.remote_url,
|
|
53
|
+
"frozen": self.frozen,
|
|
54
|
+
"tags": self.tags,
|
|
55
|
+
"added": self.added,
|
|
56
|
+
"last_pulled": self.last_pulled,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, key: str, data: dict, workspace: str = "") -> Repo:
|
|
61
|
+
"""Deserialize from YAML entry."""
|
|
62
|
+
parts = key.split("/", 1)
|
|
63
|
+
owner = parts[0] if len(parts) > 1 else ""
|
|
64
|
+
name = parts[1] if len(parts) > 1 else parts[0]
|
|
65
|
+
return cls(
|
|
66
|
+
owner=owner,
|
|
67
|
+
name=name,
|
|
68
|
+
remote_url=data.get("remote_url", ""),
|
|
69
|
+
workspace=workspace,
|
|
70
|
+
frozen=data.get("frozen", False),
|
|
71
|
+
tags=data.get("tags", []),
|
|
72
|
+
added=data.get("added", ""),
|
|
73
|
+
last_pulled=data.get("last_pulled", ""),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_legacy_format(data: dict) -> bool:
|
|
78
|
+
"""Detect old flat repos.yaml format (pre-workspace).
|
|
79
|
+
|
|
80
|
+
Legacy format has repo keys (containing '/') directly at the top level
|
|
81
|
+
with 'remote_url' in their values. New format has workspace labels at
|
|
82
|
+
the top level, each containing a dict of repos.
|
|
83
|
+
"""
|
|
84
|
+
if not data:
|
|
85
|
+
return False
|
|
86
|
+
for key, value in data.items():
|
|
87
|
+
if isinstance(value, dict) and "remote_url" in value:
|
|
88
|
+
return True
|
|
89
|
+
break # Only need to check the first entry
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RepoStore:
|
|
94
|
+
"""CRUD operations on repos.yaml.
|
|
95
|
+
|
|
96
|
+
repos.yaml is central (~/.gitstow/repos.yaml), nested by workspace label:
|
|
97
|
+
|
|
98
|
+
oss:
|
|
99
|
+
anthropic/claude-code:
|
|
100
|
+
remote_url: ...
|
|
101
|
+
active:
|
|
102
|
+
gitstow:
|
|
103
|
+
remote_url: ...
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, path: Path | None = None):
|
|
107
|
+
self._path = path or get_repos_file()
|
|
108
|
+
self._repos: dict[str, Repo] = {} # keyed by global_key (workspace:key)
|
|
109
|
+
self._loaded = False
|
|
110
|
+
|
|
111
|
+
def _ensure_loaded(self) -> None:
|
|
112
|
+
if not self._loaded:
|
|
113
|
+
self.load()
|
|
114
|
+
|
|
115
|
+
def load(self) -> None:
|
|
116
|
+
"""Load repos from repos.yaml, handling both legacy and new formats."""
|
|
117
|
+
self._repos = {}
|
|
118
|
+
if not self._path.exists():
|
|
119
|
+
self._loaded = True
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
with open(self._path) as f:
|
|
123
|
+
data = yaml.safe_load(f) or {}
|
|
124
|
+
|
|
125
|
+
if _is_legacy_format(data):
|
|
126
|
+
# Legacy flat format — wrap under "oss" workspace and re-save
|
|
127
|
+
for key, repo_data in data.items():
|
|
128
|
+
if isinstance(repo_data, dict):
|
|
129
|
+
repo = Repo.from_dict(key, repo_data, workspace="oss")
|
|
130
|
+
self._repos[repo.global_key] = repo
|
|
131
|
+
self._loaded = True
|
|
132
|
+
self.save() # Migrate to new format on disk
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# New nested format: {workspace_label: {repo_key: repo_data}}
|
|
136
|
+
for ws_label, ws_repos in data.items():
|
|
137
|
+
if not isinstance(ws_repos, dict):
|
|
138
|
+
continue
|
|
139
|
+
for key, repo_data in ws_repos.items():
|
|
140
|
+
if isinstance(repo_data, dict):
|
|
141
|
+
repo = Repo.from_dict(key, repo_data, workspace=ws_label)
|
|
142
|
+
self._repos[repo.global_key] = repo
|
|
143
|
+
|
|
144
|
+
self._loaded = True
|
|
145
|
+
|
|
146
|
+
def save(self) -> None:
|
|
147
|
+
"""Write repos to repos.yaml in nested workspace format."""
|
|
148
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Group by workspace
|
|
151
|
+
by_workspace: dict[str, dict[str, dict]] = {}
|
|
152
|
+
for repo in sorted(self._repos.values(), key=lambda r: r.global_key):
|
|
153
|
+
ws = repo.workspace or "oss"
|
|
154
|
+
if ws not in by_workspace:
|
|
155
|
+
by_workspace[ws] = {}
|
|
156
|
+
by_workspace[ws][repo.key] = repo.to_dict()
|
|
157
|
+
|
|
158
|
+
# Sort workspace labels
|
|
159
|
+
data = {k: by_workspace[k] for k in sorted(by_workspace)}
|
|
160
|
+
with open(self._path, "w") as f:
|
|
161
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
162
|
+
|
|
163
|
+
def add(self, repo: Repo) -> None:
|
|
164
|
+
"""Add a repo. Overwrites if global_key already exists."""
|
|
165
|
+
self._ensure_loaded()
|
|
166
|
+
if not repo.added:
|
|
167
|
+
repo.added = datetime.now().strftime("%Y-%m-%d")
|
|
168
|
+
self._repos[repo.global_key] = repo
|
|
169
|
+
self.save()
|
|
170
|
+
|
|
171
|
+
def remove(self, key: str, workspace: str | None = None) -> bool:
|
|
172
|
+
"""Remove a repo by key. If workspace is None, tries to find a unique match."""
|
|
173
|
+
self._ensure_loaded()
|
|
174
|
+
global_key = self._resolve_global_key(key, workspace)
|
|
175
|
+
if global_key and global_key in self._repos:
|
|
176
|
+
del self._repos[global_key]
|
|
177
|
+
self.save()
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def get(self, key: str, workspace: str | None = None) -> Repo | None:
|
|
182
|
+
"""Get a repo by key. If workspace is None, tries to find a unique match."""
|
|
183
|
+
self._ensure_loaded()
|
|
184
|
+
global_key = self._resolve_global_key(key, workspace)
|
|
185
|
+
if global_key:
|
|
186
|
+
return self._repos.get(global_key)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def find_all(self, key: str) -> list[Repo]:
|
|
190
|
+
"""Find all repos matching a key across all workspaces."""
|
|
191
|
+
self._ensure_loaded()
|
|
192
|
+
return [r for r in self._repos.values() if r.key == key]
|
|
193
|
+
|
|
194
|
+
def _resolve_global_key(self, key: str, workspace: str | None = None) -> str | None:
|
|
195
|
+
"""Resolve a repo key to a global_key.
|
|
196
|
+
|
|
197
|
+
If workspace is given, uses workspace:key directly.
|
|
198
|
+
Otherwise, looks for a unique match across all workspaces.
|
|
199
|
+
"""
|
|
200
|
+
if workspace:
|
|
201
|
+
return f"{workspace}:{key}"
|
|
202
|
+
|
|
203
|
+
# Try exact global key first (user passed workspace:key)
|
|
204
|
+
if ":" in key and key in self._repos:
|
|
205
|
+
return key
|
|
206
|
+
|
|
207
|
+
# Search across all workspaces
|
|
208
|
+
matches = [gk for gk, r in self._repos.items() if r.key == key]
|
|
209
|
+
if len(matches) == 1:
|
|
210
|
+
return matches[0]
|
|
211
|
+
# Ambiguous or not found — caller handles
|
|
212
|
+
return matches[0] if len(matches) == 1 else None
|
|
213
|
+
|
|
214
|
+
def list_all(self) -> list[Repo]:
|
|
215
|
+
"""Return all repos, sorted by global key."""
|
|
216
|
+
self._ensure_loaded()
|
|
217
|
+
return sorted(self._repos.values(), key=lambda r: r.global_key)
|
|
218
|
+
|
|
219
|
+
def list_by_workspace(self, workspace: str) -> list[Repo]:
|
|
220
|
+
"""Return repos in a specific workspace."""
|
|
221
|
+
self._ensure_loaded()
|
|
222
|
+
return sorted(
|
|
223
|
+
[r for r in self._repos.values() if r.workspace == workspace],
|
|
224
|
+
key=lambda r: r.key,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def list_by_tag(self, tag: str) -> list[Repo]:
|
|
228
|
+
"""Return repos that have a specific tag."""
|
|
229
|
+
self._ensure_loaded()
|
|
230
|
+
return sorted(
|
|
231
|
+
[r for r in self._repos.values() if tag in r.tags],
|
|
232
|
+
key=lambda r: r.global_key,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def list_by_owner(self, owner: str) -> list[Repo]:
|
|
236
|
+
"""Return repos owned by a specific owner."""
|
|
237
|
+
self._ensure_loaded()
|
|
238
|
+
return sorted(
|
|
239
|
+
[r for r in self._repos.values() if r.owner == owner],
|
|
240
|
+
key=lambda r: r.global_key,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def list_frozen(self) -> list[Repo]:
|
|
244
|
+
"""Return frozen repos."""
|
|
245
|
+
self._ensure_loaded()
|
|
246
|
+
return [r for r in self._repos.values() if r.frozen]
|
|
247
|
+
|
|
248
|
+
def list_unfrozen(self) -> list[Repo]:
|
|
249
|
+
"""Return unfrozen repos."""
|
|
250
|
+
self._ensure_loaded()
|
|
251
|
+
return sorted(
|
|
252
|
+
[r for r in self._repos.values() if not r.frozen],
|
|
253
|
+
key=lambda r: r.global_key,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def update(self, key: str, workspace: str | None = None, **kwargs) -> bool:
|
|
257
|
+
"""Update specific fields on a repo. Returns True if repo exists."""
|
|
258
|
+
self._ensure_loaded()
|
|
259
|
+
global_key = self._resolve_global_key(key, workspace)
|
|
260
|
+
if not global_key:
|
|
261
|
+
return False
|
|
262
|
+
repo = self._repos.get(global_key)
|
|
263
|
+
if not repo:
|
|
264
|
+
return False
|
|
265
|
+
for field_name, value in kwargs.items():
|
|
266
|
+
if hasattr(repo, field_name):
|
|
267
|
+
setattr(repo, field_name, value)
|
|
268
|
+
self.save()
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def all_tags(self) -> dict[str, int]:
|
|
272
|
+
"""Return all tags with repo counts."""
|
|
273
|
+
self._ensure_loaded()
|
|
274
|
+
tags: dict[str, int] = {}
|
|
275
|
+
for repo in self._repos.values():
|
|
276
|
+
for tag in repo.tags:
|
|
277
|
+
tags[tag] = tags.get(tag, 0) + 1
|
|
278
|
+
return dict(sorted(tags.items()))
|
|
279
|
+
|
|
280
|
+
def all_owners(self) -> dict[str, int]:
|
|
281
|
+
"""Return all owners with repo counts."""
|
|
282
|
+
self._ensure_loaded()
|
|
283
|
+
owners: dict[str, int] = {}
|
|
284
|
+
for repo in self._repos.values():
|
|
285
|
+
owners[repo.owner] = owners.get(repo.owner, 0) + 1
|
|
286
|
+
return dict(sorted(owners.items()))
|
|
287
|
+
|
|
288
|
+
def all_workspaces(self) -> dict[str, int]:
|
|
289
|
+
"""Return all workspace labels with repo counts."""
|
|
290
|
+
self._ensure_loaded()
|
|
291
|
+
workspaces: dict[str, int] = {}
|
|
292
|
+
for repo in self._repos.values():
|
|
293
|
+
ws = repo.workspace or "oss"
|
|
294
|
+
workspaces[ws] = workspaces.get(ws, 0) + 1
|
|
295
|
+
return dict(sorted(workspaces.items()))
|
|
296
|
+
|
|
297
|
+
def count(self) -> int:
|
|
298
|
+
"""Total number of tracked repos."""
|
|
299
|
+
self._ensure_loaded()
|
|
300
|
+
return len(self._repos)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""URL parser — extract host/owner/repo from any git URL.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- Full URLs: https://github.com/owner/repo
|
|
5
|
+
- SSH URLs: git@github.com:owner/repo.git
|
|
6
|
+
- Shorthand: owner/repo (assumes default_host)
|
|
7
|
+
- Host-prefixed: github.com/owner/repo
|
|
8
|
+
- Nested groups: gitlab.com/group/subgroup/repo
|
|
9
|
+
- Azure DevOps: dev.azure.com/org/project/_git/repo
|
|
10
|
+
|
|
11
|
+
Resolution algorithm adopted from ghq (3.5k stars) with improvements.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
# Patterns
|
|
21
|
+
_HAS_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
|
22
|
+
_SCP_LIKE = re.compile(r"^([^@]+@)?([^:]+):(.+)$")
|
|
23
|
+
_LOOKS_LIKE_HOST = re.compile(r"[A-Za-z0-9][-A-Za-z0-9]*\.[A-Za-z]{2,}(?::\d{1,5})?$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ParsedURL:
|
|
28
|
+
"""Result of parsing a git URL."""
|
|
29
|
+
|
|
30
|
+
host: str # "github.com"
|
|
31
|
+
owner: str # "anthropic" or "group/subgroup" for nested
|
|
32
|
+
repo: str # "claude-code"
|
|
33
|
+
clone_url: str # Normalized URL for git clone
|
|
34
|
+
original: str # What the user typed
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def key(self) -> str:
|
|
38
|
+
"""owner/repo — used as folder path and unique identifier."""
|
|
39
|
+
return f"{self.owner}/{self.repo}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_git_url(
|
|
43
|
+
raw: str,
|
|
44
|
+
default_host: str = "github.com",
|
|
45
|
+
prefer_ssh: bool = False,
|
|
46
|
+
) -> ParsedURL:
|
|
47
|
+
"""Parse any git URL format into structured components.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
raw: The input — full URL, SCP-style, or shorthand (owner/repo).
|
|
51
|
+
default_host: Host to assume for shorthand URLs (default: github.com).
|
|
52
|
+
prefer_ssh: If True, convert HTTPS URLs to SSH for cloning.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
ParsedURL with host, owner, repo, and normalized clone URL.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If the input can't be parsed into a valid git URL.
|
|
59
|
+
"""
|
|
60
|
+
raw = raw.strip().rstrip("/")
|
|
61
|
+
if not raw:
|
|
62
|
+
raise ValueError("Empty URL")
|
|
63
|
+
|
|
64
|
+
original = raw
|
|
65
|
+
|
|
66
|
+
# Step 1: Convert SCP-style (git@host:path) to ssh:// URL
|
|
67
|
+
if not _HAS_SCHEME.match(raw):
|
|
68
|
+
scp_match = _SCP_LIKE.match(raw)
|
|
69
|
+
if scp_match and ":" in raw and "/" not in raw.split(":")[0]:
|
|
70
|
+
user_part = scp_match.group(1) or ""
|
|
71
|
+
host_part = scp_match.group(2)
|
|
72
|
+
path_part = scp_match.group(3)
|
|
73
|
+
# Ensure path doesn't start with //
|
|
74
|
+
path_part = path_part.lstrip("/")
|
|
75
|
+
raw = f"ssh://{user_part}{host_part}/{path_part}"
|
|
76
|
+
|
|
77
|
+
# Step 2: If no scheme, try to detect if it starts with a hostname
|
|
78
|
+
if not _HAS_SCHEME.match(raw):
|
|
79
|
+
first_segment = raw.split("/")[0]
|
|
80
|
+
if _LOOKS_LIKE_HOST.match(first_segment):
|
|
81
|
+
# Looks like github.com/owner/repo — prepend https://
|
|
82
|
+
raw = f"https://{raw}"
|
|
83
|
+
elif "/" in raw:
|
|
84
|
+
# Looks like owner/repo — prepend default host
|
|
85
|
+
raw = f"https://{default_host}/{raw}"
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Cannot parse '{original}': expected owner/repo or a full URL"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Step 3: Parse the URL
|
|
92
|
+
parsed = urlparse(raw)
|
|
93
|
+
host = parsed.hostname or ""
|
|
94
|
+
if not host:
|
|
95
|
+
raise ValueError(f"Cannot extract host from '{original}'")
|
|
96
|
+
|
|
97
|
+
# Step 4: Extract path segments
|
|
98
|
+
path = parsed.path.strip("/")
|
|
99
|
+
|
|
100
|
+
# Strip .git suffix
|
|
101
|
+
if path.endswith(".git"):
|
|
102
|
+
path = path[:-4]
|
|
103
|
+
|
|
104
|
+
# Azure DevOps: dev.azure.com/org/project/_git/repo
|
|
105
|
+
if "dev.azure.com" in host or "visualstudio.com" in host:
|
|
106
|
+
parts = path.split("/")
|
|
107
|
+
if "_git" in parts:
|
|
108
|
+
git_idx = parts.index("_git")
|
|
109
|
+
owner = "/".join(parts[:git_idx])
|
|
110
|
+
repo = "/".join(parts[git_idx + 1 :])
|
|
111
|
+
else:
|
|
112
|
+
owner, repo = _split_owner_repo(path)
|
|
113
|
+
else:
|
|
114
|
+
owner, repo = _split_owner_repo(path)
|
|
115
|
+
|
|
116
|
+
if not owner or not repo:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Cannot extract owner/repo from '{original}'. "
|
|
119
|
+
f"Expected format: owner/repo or https://host/owner/repo"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Step 5: Build the clone URL
|
|
123
|
+
clone_url = _build_clone_url(host, owner, repo, parsed.scheme, prefer_ssh)
|
|
124
|
+
|
|
125
|
+
return ParsedURL(
|
|
126
|
+
host=host,
|
|
127
|
+
owner=owner,
|
|
128
|
+
repo=repo,
|
|
129
|
+
clone_url=clone_url,
|
|
130
|
+
original=original,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _split_owner_repo(path: str) -> tuple[str, str]:
|
|
135
|
+
"""Split a URL path into owner and repo.
|
|
136
|
+
|
|
137
|
+
Handles nested groups: group/subgroup/repo → owner="group/subgroup", repo="repo"
|
|
138
|
+
"""
|
|
139
|
+
parts = path.split("/")
|
|
140
|
+
if len(parts) < 2:
|
|
141
|
+
return "", path if parts else ""
|
|
142
|
+
|
|
143
|
+
repo = parts[-1]
|
|
144
|
+
owner = "/".join(parts[:-1])
|
|
145
|
+
return owner, repo
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_clone_url(
|
|
149
|
+
host: str,
|
|
150
|
+
owner: str,
|
|
151
|
+
repo: str,
|
|
152
|
+
scheme: str,
|
|
153
|
+
prefer_ssh: bool,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Build the clone URL from components."""
|
|
156
|
+
if prefer_ssh or scheme == "ssh":
|
|
157
|
+
return f"git@{host}:{owner}/{repo}.git"
|
|
158
|
+
else:
|
|
159
|
+
return f"https://{host}/{owner}/{repo}.git"
|
gitstow/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gitstow MCP server — expose repo management tools to any AI agent."""
|