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.
@@ -0,0 +1,116 @@
1
+ """Discovery — walk directory tree to find repos and reconcile with store.
2
+
3
+ Supports two layout modes:
4
+ - structured: root/owner/repo/.git (two-level walk)
5
+ - flat: root/repo/.git (one-level walk)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from gitstow.core.git import is_git_repo, get_remote_url
14
+ from gitstow.core.repo import Repo
15
+
16
+
17
+ @dataclass
18
+ class DiscoveredRepo:
19
+ """A git repo found on disk."""
20
+
21
+ key: str # "owner/repo" (structured) or "repo" (flat)
22
+ owner: str # "" for flat layout
23
+ name: str
24
+ path: Path
25
+ remote_url: str | None
26
+
27
+
28
+ def discover_repos(root: Path, layout: str = "structured") -> list[DiscoveredRepo]:
29
+ """Walk a workspace directory looking for git repos.
30
+
31
+ Args:
32
+ root: The workspace path to scan.
33
+ layout: "structured" (root/owner/repo/.git) or "flat" (root/repo/.git).
34
+ """
35
+ if not root.is_dir():
36
+ return []
37
+
38
+ if layout == "flat":
39
+ return _discover_flat(root)
40
+ return _discover_structured(root)
41
+
42
+
43
+ def _discover_structured(root: Path) -> list[DiscoveredRepo]:
44
+ """Two-level walk: root/owner/repo/.git. Skips hidden directories."""
45
+ found = []
46
+ for owner_dir in sorted(root.iterdir()):
47
+ if not owner_dir.is_dir() or owner_dir.name.startswith("."):
48
+ continue
49
+ for repo_dir in sorted(owner_dir.iterdir()):
50
+ if not repo_dir.is_dir() or repo_dir.name.startswith("."):
51
+ continue
52
+ if is_git_repo(repo_dir):
53
+ remote = get_remote_url(repo_dir)
54
+ found.append(DiscoveredRepo(
55
+ key=f"{owner_dir.name}/{repo_dir.name}",
56
+ owner=owner_dir.name,
57
+ name=repo_dir.name,
58
+ path=repo_dir,
59
+ remote_url=remote,
60
+ ))
61
+ return found
62
+
63
+
64
+ def _discover_flat(root: Path) -> list[DiscoveredRepo]:
65
+ """One-level walk: root/repo/.git. Skips hidden directories."""
66
+ found = []
67
+ for repo_dir in sorted(root.iterdir()):
68
+ if not repo_dir.is_dir() or repo_dir.name.startswith("."):
69
+ continue
70
+ if is_git_repo(repo_dir):
71
+ remote = get_remote_url(repo_dir)
72
+ found.append(DiscoveredRepo(
73
+ key=repo_dir.name,
74
+ owner="",
75
+ name=repo_dir.name,
76
+ path=repo_dir,
77
+ remote_url=remote,
78
+ ))
79
+ return found
80
+
81
+
82
+ def reconcile(
83
+ on_disk: list[DiscoveredRepo],
84
+ in_store: dict[str, Repo],
85
+ ) -> dict:
86
+ """Compare disk vs store and return differences.
87
+
88
+ Returns:
89
+ {
90
+ "matched": list of keys that are in both,
91
+ "orphaned": list of dicts for repos on disk but not tracked,
92
+ "missing": list of keys for repos tracked but not on disk,
93
+ }
94
+ """
95
+ disk_keys = {r.key for r in on_disk}
96
+ store_keys = set(in_store.keys())
97
+
98
+ matched = sorted(disk_keys & store_keys)
99
+ orphaned_keys = sorted(disk_keys - store_keys)
100
+ missing_keys = sorted(store_keys - disk_keys)
101
+
102
+ disk_map = {r.key: r for r in on_disk}
103
+ orphaned = [
104
+ {
105
+ "key": key,
106
+ "path": str(disk_map[key].path),
107
+ "remote_url": disk_map[key].remote_url,
108
+ }
109
+ for key in orphaned_keys
110
+ ]
111
+
112
+ return {
113
+ "matched": matched,
114
+ "orphaned": orphaned,
115
+ "missing": missing_keys,
116
+ }
gitstow/core/git.py ADDED
@@ -0,0 +1,261 @@
1
+ """Git operations — thin wrappers around git subprocess calls.
2
+
3
+ All git interaction goes through this module. Nothing else shells out to git.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass
14
+ class PullResult:
15
+ """Result of a git pull operation."""
16
+
17
+ success: bool
18
+ already_up_to_date: bool = False
19
+ error: str = ""
20
+ output: str = ""
21
+
22
+
23
+ @dataclass
24
+ class RepoStatus:
25
+ """Git status of a repository."""
26
+
27
+ branch: str = ""
28
+ dirty: int = 0 # Count of modified (unstaged) files
29
+ staged: int = 0 # Count of staged files
30
+ untracked: int = 0 # Count of untracked files
31
+ ahead: int = 0 # Commits ahead of upstream
32
+ behind: int = 0 # Commits behind upstream
33
+ has_upstream: bool = True # Whether upstream is configured
34
+
35
+ @property
36
+ def clean(self) -> bool:
37
+ return self.dirty == 0 and self.staged == 0 and self.untracked == 0
38
+
39
+ @property
40
+ def status_symbol(self) -> str:
41
+ """Short status indicator: ✓ *+? etc."""
42
+ if self.clean:
43
+ return "✓"
44
+ parts = []
45
+ if self.dirty:
46
+ parts.append("*")
47
+ if self.staged:
48
+ parts.append("+")
49
+ if self.untracked:
50
+ parts.append("?")
51
+ return "".join(parts)
52
+
53
+ @property
54
+ def ahead_behind_str(self) -> str:
55
+ """Format ahead/behind as ↑3 ↓5 or —."""
56
+ parts = []
57
+ if self.ahead:
58
+ parts.append(f"↑{self.ahead}")
59
+ if self.behind:
60
+ parts.append(f"↓{self.behind}")
61
+ return " ".join(parts) if parts else "—"
62
+
63
+
64
+ @dataclass
65
+ class CommitInfo:
66
+ """Information about a single commit."""
67
+
68
+ hash: str = ""
69
+ message: str = ""
70
+ date: str = "" # Relative date (e.g., "2 hours ago")
71
+ author: str = ""
72
+
73
+
74
+ def _run_git(
75
+ args: list[str],
76
+ cwd: Path | None = None,
77
+ timeout: int = 60,
78
+ ) -> subprocess.CompletedProcess:
79
+ """Run a git command and return the result."""
80
+ cmd = ["git"] + args
81
+ return subprocess.run(
82
+ cmd,
83
+ cwd=cwd,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=timeout,
87
+ encoding="utf-8",
88
+ errors="replace",
89
+ )
90
+
91
+
92
+ def is_git_installed() -> tuple[bool, str]:
93
+ """Check if git is installed and return version."""
94
+ try:
95
+ result = _run_git(["--version"])
96
+ if result.returncode == 0:
97
+ version = result.stdout.strip().replace("git version ", "")
98
+ return True, version
99
+ return False, ""
100
+ except (FileNotFoundError, subprocess.TimeoutExpired):
101
+ return False, ""
102
+
103
+
104
+ def is_git_repo(path: Path) -> bool:
105
+ """Check if a path is a git repository."""
106
+ git_dir = path / ".git"
107
+ return git_dir.exists() and (git_dir.is_dir() or git_dir.is_file())
108
+
109
+
110
+ def clone(
111
+ url: str,
112
+ target: Path,
113
+ shallow: bool = False,
114
+ branch: str | None = None,
115
+ recursive: bool = False,
116
+ ) -> tuple[bool, str]:
117
+ """Clone a repository.
118
+
119
+ Returns:
120
+ (success, error_message)
121
+ """
122
+ args = ["clone", "--progress"]
123
+ if shallow:
124
+ args.extend(["--depth", "1"])
125
+ if branch:
126
+ args.extend(["--branch", branch, "--single-branch"])
127
+ if recursive:
128
+ args.append("--recurse-submodules")
129
+ args.extend(["--", url, str(target)])
130
+
131
+ try:
132
+ result = _run_git(args, timeout=300) # 5 min for large repos
133
+ if result.returncode == 0:
134
+ return True, ""
135
+ return False, result.stderr.strip()
136
+ except subprocess.TimeoutExpired:
137
+ return False, "Clone timed out (5 minutes)"
138
+
139
+
140
+ def pull(repo_path: Path) -> PullResult:
141
+ """Pull latest changes (fast-forward only).
142
+
143
+ Returns PullResult with success/error status.
144
+ """
145
+ try:
146
+ result = _run_git(["pull", "--ff-only"], cwd=repo_path, timeout=120)
147
+ output = result.stdout.strip()
148
+ stderr = result.stderr.strip()
149
+
150
+ if result.returncode == 0:
151
+ up_to_date = "Already up to date" in output or "Already up-to-date" in output
152
+ return PullResult(
153
+ success=True,
154
+ already_up_to_date=up_to_date,
155
+ output=output,
156
+ )
157
+ else:
158
+ return PullResult(success=False, error=stderr or output)
159
+ except subprocess.TimeoutExpired:
160
+ return PullResult(success=False, error="Pull timed out (2 minutes)")
161
+
162
+
163
+ def get_status(repo_path: Path) -> RepoStatus:
164
+ """Get full repository status in a single git call.
165
+
166
+ Uses `git status --porcelain=v2 --branch` which gives:
167
+ - Branch name and upstream tracking
168
+ - Ahead/behind counts
169
+ - Per-file status (modified, staged, untracked)
170
+
171
+ This is ONE subprocess call vs gita's 4-5 separate calls.
172
+ """
173
+ result = _run_git(["status", "--porcelain=v2", "--branch"], cwd=repo_path)
174
+ if result.returncode != 0:
175
+ # Fallback: just get the branch name
176
+ branch_result = _run_git(["branch", "--show-current"], cwd=repo_path)
177
+ return RepoStatus(branch=branch_result.stdout.strip() or "unknown")
178
+
179
+ status = RepoStatus()
180
+ for line in result.stdout.splitlines():
181
+ if line.startswith("# branch.head "):
182
+ status.branch = line.split(" ", 2)[2]
183
+ elif line.startswith("# branch.ab "):
184
+ # Format: # branch.ab +3 -5
185
+ parts = line.split()
186
+ if len(parts) >= 4:
187
+ status.ahead = int(parts[2].lstrip("+"))
188
+ status.behind = abs(int(parts[3]))
189
+ elif line.startswith("# branch.upstream "):
190
+ status.has_upstream = True
191
+ elif line.startswith("1 ") or line.startswith("2 "):
192
+ # Changed entry: 1 XY ... or 2 XY ...
193
+ # X = staged, Y = unstaged
194
+ xy = line.split(" ")[1]
195
+ if len(xy) >= 2:
196
+ if xy[0] != ".":
197
+ status.staged += 1
198
+ if xy[1] != ".":
199
+ status.dirty += 1
200
+ elif line.startswith("? "):
201
+ status.untracked += 1
202
+ elif line.startswith("u "):
203
+ # Unmerged entry — count as dirty
204
+ status.dirty += 1
205
+
206
+ # Detect if no upstream is set (branch.upstream line is absent)
207
+ if "# branch.upstream" not in result.stdout:
208
+ status.has_upstream = False
209
+
210
+ return status
211
+
212
+
213
+ def get_remote_url(repo_path: Path) -> str | None:
214
+ """Get the remote origin URL."""
215
+ result = _run_git(["remote", "get-url", "origin"], cwd=repo_path)
216
+ if result.returncode == 0:
217
+ return result.stdout.strip()
218
+ return None
219
+
220
+
221
+ def get_last_commit(repo_path: Path) -> CommitInfo:
222
+ """Get info about the last commit."""
223
+ result = _run_git(
224
+ ["log", "-1", "--format=%h%n%s%n%cd%n%an", "--date=relative"],
225
+ cwd=repo_path,
226
+ )
227
+ if result.returncode != 0 or not result.stdout.strip():
228
+ return CommitInfo()
229
+
230
+ lines = result.stdout.strip().splitlines()
231
+ return CommitInfo(
232
+ hash=lines[0] if len(lines) > 0 else "",
233
+ message=lines[1] if len(lines) > 1 else "",
234
+ date=lines[2] if len(lines) > 2 else "",
235
+ author=lines[3] if len(lines) > 3 else "",
236
+ )
237
+
238
+
239
+ def get_branch(repo_path: Path) -> str:
240
+ """Get the current branch name."""
241
+ result = _run_git(["branch", "--show-current"], cwd=repo_path)
242
+ if result.returncode == 0 and result.stdout.strip():
243
+ return result.stdout.strip()
244
+ # Detached HEAD — try to get the short hash
245
+ result = _run_git(["rev-parse", "--short", "HEAD"], cwd=repo_path)
246
+ return f"({result.stdout.strip()})" if result.returncode == 0 else "unknown"
247
+
248
+
249
+ def get_disk_size(path: Path) -> int:
250
+ """Get total disk size of a directory in bytes."""
251
+ total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
252
+ return total
253
+
254
+
255
+ def format_size(size_bytes: int) -> str:
256
+ """Format bytes as human-readable size."""
257
+ for unit in ["B", "KB", "MB", "GB"]:
258
+ if size_bytes < 1024:
259
+ return f"{size_bytes:.0f} {unit}" if unit == "B" else f"{size_bytes:.1f} {unit}"
260
+ size_bytes /= 1024
261
+ return f"{size_bytes:.1f} TB"
@@ -0,0 +1,85 @@
1
+ """Parallel execution — async operations with bounded concurrency.
2
+
3
+ Uses asyncio with a semaphore to prevent SSH connection storms
4
+ (gita's biggest complaint: 87 repos opens 87 SSH connections simultaneously).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from typing import Any, Callable, TypeVar
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ @dataclass
17
+ class TaskResult:
18
+ """Result of a single parallel task."""
19
+
20
+ key: str # Identifier (e.g., "owner/repo")
21
+ success: bool
22
+ data: Any = None # The result value on success
23
+ error: str = "" # Error message on failure
24
+
25
+
26
+ async def _run_in_executor(
27
+ func: Callable,
28
+ *args,
29
+ **kwargs,
30
+ ) -> Any:
31
+ """Run a blocking function in the default thread pool executor."""
32
+ loop = asyncio.get_running_loop()
33
+ return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
34
+
35
+
36
+ async def run_parallel(
37
+ tasks: list[tuple[str, Callable]],
38
+ max_concurrent: int = 6,
39
+ on_progress: Callable[[str, bool, str], None] | None = None,
40
+ ) -> list[TaskResult]:
41
+ """Run tasks concurrently with bounded concurrency.
42
+
43
+ Args:
44
+ tasks: List of (key, callable) pairs. Each callable takes no args.
45
+ max_concurrent: Maximum concurrent operations (semaphore size).
46
+ on_progress: Optional callback(key, success, message) called after each task.
47
+
48
+ Returns:
49
+ List of TaskResult in the same order as input tasks.
50
+ """
51
+ semaphore = asyncio.Semaphore(max_concurrent)
52
+
53
+ async def bounded_task(key: str, func: Callable) -> TaskResult:
54
+ async with semaphore:
55
+ try:
56
+ result = await _run_in_executor(func)
57
+ task_result = TaskResult(key=key, success=True, data=result)
58
+ except Exception as e:
59
+ task_result = TaskResult(key=key, success=False, error=str(e))
60
+
61
+ if on_progress:
62
+ on_progress(
63
+ key,
64
+ task_result.success,
65
+ task_result.error or "OK",
66
+ )
67
+ return task_result
68
+
69
+ gathered = await asyncio.gather(
70
+ *[bounded_task(key, func) for key, func in tasks],
71
+ return_exceptions=False,
72
+ )
73
+ return list(gathered)
74
+
75
+
76
+ def run_parallel_sync(
77
+ tasks: list[tuple[str, Callable]],
78
+ max_concurrent: int = 6,
79
+ on_progress: Callable[[str, bool, str], None] | None = None,
80
+ ) -> list[TaskResult]:
81
+ """Synchronous wrapper for run_parallel.
82
+
83
+ Use this from non-async CLI code.
84
+ """
85
+ return asyncio.run(run_parallel(tasks, max_concurrent, on_progress))
gitstow/core/paths.py ADDED
@@ -0,0 +1,51 @@
1
+ """All path constants for gitstow."""
2
+
3
+ from importlib.resources import files as pkg_files
4
+ from pathlib import Path
5
+
6
+ # App home — hidden config directory (always in home dir)
7
+ APP_HOME = Path.home() / ".gitstow"
8
+ CONFIG_FILE = APP_HOME / "config.yaml"
9
+
10
+ # Central repos file — always in app home (since workspace support)
11
+ REPOS_FILE = APP_HOME / "repos.yaml"
12
+
13
+ # Legacy location: root/.gitstow/repos.yaml (pre-workspace, v0.2.0 era)
14
+ # Migrated to central REPOS_FILE on first access.
15
+ LEGACY_REPOS_FILE = REPOS_FILE # alias for imports that used this name
16
+
17
+ # Default root for repos (used when no workspaces configured)
18
+ DEFAULT_ROOT = Path.home() / "opensource"
19
+
20
+ # Claude Code skill installation paths
21
+ CLAUDE_HOME = Path.home() / ".claude"
22
+ CLAUDE_SKILLS_DIR = CLAUDE_HOME / "skills"
23
+ SKILL_TARGET = CLAUDE_SKILLS_DIR / "gitstow"
24
+
25
+
26
+ def get_repos_file(root: Path | None = None) -> Path:
27
+ """Get the repos.yaml path.
28
+
29
+ Since workspace support, repos.yaml is always central at ~/.gitstow/repos.yaml.
30
+ Auto-migrates from the old root/.gitstow/repos.yaml location if found.
31
+ """
32
+ # Auto-migrate from old per-root location
33
+ if root is not None:
34
+ old_path = root / ".gitstow" / "repos.yaml"
35
+ if old_path.exists() and not REPOS_FILE.exists():
36
+ REPOS_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ import shutil
38
+ shutil.copy2(old_path, REPOS_FILE)
39
+ old_path.rename(old_path.with_suffix(".yaml.migrated"))
40
+
41
+ return REPOS_FILE
42
+
43
+
44
+ def get_skill_source_dir():
45
+ """Return path to bundled skill files in the package."""
46
+ return pkg_files("gitstow").joinpath("skill")
47
+
48
+
49
+ def ensure_app_dirs() -> None:
50
+ """Create required app directories if they don't exist."""
51
+ APP_HOME.mkdir(parents=True, exist_ok=True)