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
|
@@ -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"
|
gitstow/core/parallel.py
ADDED
|
@@ -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)
|