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/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"
@@ -0,0 +1 @@
1
+ """gitstow MCP server — expose repo management tools to any AI agent."""