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,159 @@
1
+ """gitstow exec — run arbitrary commands across repos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from gitstow.core.config import load_config
14
+ from gitstow.core.repo import RepoStore
15
+ from gitstow.core.parallel import run_parallel_sync
16
+ from gitstow.cli.helpers import iter_repos_with_workspace
17
+
18
+ console = Console()
19
+ err_console = Console(stderr=True)
20
+
21
+
22
+ def _exec_in_repo(repo_path, command: list[str]) -> dict:
23
+ """Execute a command in a repo directory."""
24
+ try:
25
+ result = subprocess.run(
26
+ command,
27
+ cwd=repo_path,
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=120,
31
+ encoding="utf-8",
32
+ errors="replace",
33
+ )
34
+ return {
35
+ "returncode": result.returncode,
36
+ "stdout": result.stdout.strip(),
37
+ "stderr": result.stderr.strip(),
38
+ }
39
+ except subprocess.TimeoutExpired:
40
+ return {"returncode": -1, "stdout": "", "stderr": "Command timed out (2 minutes)"}
41
+ except Exception as e:
42
+ return {"returncode": -1, "stdout": "", "stderr": str(e)}
43
+
44
+
45
+ def exec_cmd(
46
+ ctx: typer.Context,
47
+ command: list[str] = typer.Argument(
48
+ help="Command to run in each repo (e.g., 'git log -1 --oneline').",
49
+ ),
50
+ tag: Optional[list[str]] = typer.Option(
51
+ None, "--tag", "-t", help="Only run in repos with this tag.",
52
+ ),
53
+ owner: Optional[str] = typer.Option(
54
+ None, "--owner", help="Only run in repos from this owner.",
55
+ ),
56
+ frozen_only: bool = typer.Option(
57
+ False, "--frozen", help="Only run in frozen repos.",
58
+ ),
59
+ output_json: bool = typer.Option(
60
+ False, "--json", "-j", help="JSON output.",
61
+ ),
62
+ quiet: bool = typer.Option(
63
+ False, "--quiet", "-q", help="Only show output, no headers.",
64
+ ),
65
+ sequential: bool = typer.Option(
66
+ False, "--sequential", "-s", help="Run sequentially instead of in parallel.",
67
+ ),
68
+ ) -> None:
69
+ """[bold]Exec[/bold] — run a command in every repo.
70
+
71
+ The command is run with CWD set to each repo's directory.
72
+
73
+ \b
74
+ Examples:
75
+ gitstow exec git log -1 --oneline
76
+ gitstow exec -- git branch --show-current
77
+ gitstow exec --tag ai -- wc -l README.md
78
+ gitstow exec -w active -- ls -la
79
+ """
80
+ settings = load_config()
81
+ store = RepoStore()
82
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
83
+
84
+ repo_ws_pairs = iter_repos_with_workspace(store, settings, ws_label)
85
+
86
+ if tag:
87
+ tag_set = set(tag)
88
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if tag_set.intersection(r.tags)]
89
+ if owner:
90
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.owner == owner]
91
+ if frozen_only:
92
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.frozen]
93
+
94
+ if not repo_ws_pairs:
95
+ if not quiet:
96
+ console.print("[dim]No repos match the filter.[/dim]")
97
+ return
98
+
99
+ if not command:
100
+ err_console.print("[red]Error:[/red] No command specified.")
101
+ raise typer.Exit(code=1)
102
+
103
+ results = []
104
+
105
+ if sequential:
106
+ for repo, ws in repo_ws_pairs:
107
+ path = repo.get_path(ws.get_path())
108
+ if not path.exists():
109
+ results.append({"repo": repo.key, "returncode": -1, "stdout": "", "stderr": "Not found on disk"})
110
+ continue
111
+ result = _exec_in_repo(path, command)
112
+ result["repo"] = repo.key
113
+ results.append(result)
114
+
115
+ if not output_json and not quiet:
116
+ _print_repo_result(repo.key, result)
117
+ else:
118
+ tasks = [
119
+ (repo.global_key, lambda r=repo, w=ws: _exec_in_repo(r.get_path(w.get_path()), command))
120
+ for repo, ws in repo_ws_pairs
121
+ ]
122
+ task_results = run_parallel_sync(tasks, max_concurrent=settings.parallel_limit)
123
+
124
+ for task_result in task_results:
125
+ if task_result.success and task_result.data:
126
+ entry = task_result.data
127
+ entry["repo"] = task_result.key
128
+ else:
129
+ entry = {"repo": task_result.key, "returncode": -1, "stdout": "", "stderr": task_result.error}
130
+ results.append(entry)
131
+
132
+ if not output_json:
133
+ for r in sorted(results, key=lambda x: x["repo"]):
134
+ if not quiet:
135
+ _print_repo_result(r["repo"], r)
136
+ elif r["stdout"]:
137
+ console.print(r["stdout"])
138
+
139
+ if output_json:
140
+ json.dump(results, sys.stdout, indent=2)
141
+ print()
142
+
143
+ # Exit with error if any command failed
144
+ if any(r["returncode"] != 0 for r in results):
145
+ raise typer.Exit(code=1)
146
+
147
+
148
+ def _print_repo_result(repo_key: str, result: dict) -> None:
149
+ """Print a single repo's exec result."""
150
+ rc = result["returncode"]
151
+ status = "[green]✓[/green]" if rc == 0 else f"[red]✗ (exit {rc})[/red]"
152
+ console.print(f"\n [bold]{repo_key}[/bold] {status}")
153
+
154
+ if result["stdout"]:
155
+ for line in result["stdout"].splitlines():
156
+ console.print(f" {line}")
157
+ if result["stderr"] and result["returncode"] != 0:
158
+ for line in result["stderr"].splitlines():
159
+ err_console.print(f" [dim]{line}[/dim]")
@@ -0,0 +1,293 @@
1
+ """gitstow export / import — share repo collections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ import yaml
12
+ from rich.console import Console
13
+
14
+ from gitstow.core.config import load_config
15
+ from gitstow.core.repo import Repo, RepoStore
16
+
17
+ export_app = typer.Typer(
18
+ help="Export and import repo collections.",
19
+ no_args_is_help=True,
20
+ )
21
+
22
+ console = Console()
23
+ err_console = Console(stderr=True)
24
+
25
+
26
+ @export_app.command("export")
27
+ def export_collection(
28
+ ctx: typer.Context,
29
+ output: Optional[str] = typer.Option(
30
+ None, "--output", "-o", help="Output file path. Defaults to stdout.",
31
+ ),
32
+ tag: Optional[list[str]] = typer.Option(
33
+ None, "--tag", "-t", help="Only export repos with this tag.",
34
+ ),
35
+ format_type: str = typer.Option(
36
+ "yaml", "--format", "-f", help="Output format: yaml, json, or urls.",
37
+ ),
38
+ ) -> None:
39
+ """[bold]Export[/bold] your repo collection to a portable file.
40
+
41
+ \b
42
+ Formats:
43
+ yaml — Full metadata (default). Can be imported back.
44
+ json — Full metadata as JSON.
45
+ urls — Plain list of clone URLs (one per line).
46
+
47
+ \b
48
+ Examples:
49
+ gitstow collection export # YAML to stdout
50
+ gitstow collection export -o my-repos.yaml # YAML to file
51
+ gitstow collection export --format urls # Just URLs
52
+ gitstow collection export --tag ai -o ai.yaml # Export subset
53
+ """
54
+ store = RepoStore()
55
+ ws_label = (ctx.obj or {}).get("workspace")
56
+
57
+ if ws_label:
58
+ repos = store.list_by_workspace(ws_label)
59
+ else:
60
+ repos = store.list_all()
61
+
62
+ if tag:
63
+ tag_set = set(tag)
64
+ repos = [r for r in repos if tag_set.intersection(r.tags)]
65
+
66
+ if not repos:
67
+ err_console.print("[dim]No repos to export.[/dim]")
68
+ return
69
+
70
+ if format_type == "urls":
71
+ lines = [r.remote_url for r in repos]
72
+ content = "\n".join(lines) + "\n"
73
+ elif format_type == "json":
74
+ data = {
75
+ "version": 1,
76
+ "repos": [
77
+ {
78
+ "key": r.key,
79
+ "workspace": r.workspace,
80
+ "remote_url": r.remote_url,
81
+ "tags": r.tags,
82
+ "frozen": r.frozen,
83
+ }
84
+ for r in repos
85
+ ],
86
+ }
87
+ content = json.dumps(data, indent=2) + "\n"
88
+ else: # yaml
89
+ repos_data = {}
90
+ for r in repos:
91
+ entry = {"remote_url": r.remote_url}
92
+ if r.workspace:
93
+ entry["workspace"] = r.workspace
94
+ if r.tags:
95
+ entry["tags"] = r.tags
96
+ if r.frozen:
97
+ entry["frozen"] = True
98
+ repos_data[r.key] = entry
99
+ data = {"version": 1, "repos": repos_data}
100
+ content = yaml.dump(data, default_flow_style=False, sort_keys=False)
101
+
102
+ if output:
103
+ Path(output).write_text(content)
104
+ console.print(f" [green]✓[/green] Exported {len(repos)} repos to {output}")
105
+ else:
106
+ sys.stdout.write(content)
107
+
108
+
109
+ @export_app.command("import")
110
+ def import_collection(
111
+ ctx: typer.Context,
112
+ file_path: str = typer.Argument(help="File to import (YAML, JSON, or plain URLs)."),
113
+ tag: Optional[list[str]] = typer.Option(
114
+ None, "--tag", "-t", help="Apply tag(s) to all imported repos.",
115
+ ),
116
+ shallow: bool = typer.Option(
117
+ False, "--shallow", "-s", help="Shallow clone imported repos.",
118
+ ),
119
+ dry_run: bool = typer.Option(
120
+ False, "--dry-run", "-n", help="Show what would be imported without doing it.",
121
+ ),
122
+ ) -> None:
123
+ """[bold]Import[/bold] a repo collection from a file.
124
+
125
+ Supports YAML (from gitstow export), JSON, or a plain list of URLs.
126
+ Repos that already exist are skipped.
127
+
128
+ \b
129
+ Examples:
130
+ gitstow collection import my-repos.yaml
131
+ gitstow collection import repos.txt --shallow
132
+ gitstow collection import ai-repos.yaml --tag ai --dry-run
133
+ """
134
+ path = Path(file_path)
135
+ if not path.exists():
136
+ err_console.print(f"[red]Error:[/red] File not found: {file_path}")
137
+ raise typer.Exit(code=1)
138
+
139
+ content = path.read_text()
140
+ tags = list(tag) if tag else []
141
+
142
+ # Detect format
143
+ repos_to_import = _parse_import_file(content, path.suffix)
144
+
145
+ if not repos_to_import:
146
+ err_console.print("[dim]No repos found in file.[/dim]")
147
+ return
148
+
149
+ store = RepoStore()
150
+
151
+ # Check which already exist
152
+ new_repos = []
153
+ existing = []
154
+ for entry in repos_to_import:
155
+ if store.get(entry.get("key", "")):
156
+ existing.append(entry)
157
+ else:
158
+ new_repos.append(entry)
159
+
160
+ console.print(f"\n Found {len(repos_to_import)} repos in file")
161
+ if existing:
162
+ console.print(f" [dim]{len(existing)} already tracked (will skip)[/dim]")
163
+ console.print(f" {len(new_repos)} new repos to import\n")
164
+
165
+ if dry_run:
166
+ for entry in new_repos:
167
+ console.print(f" [dim]Would add:[/dim] {entry.get('key', entry.get('url', 'unknown'))}")
168
+ console.print("\n [dim]Dry run — nothing was changed.[/dim]\n")
169
+ return
170
+
171
+ if not new_repos:
172
+ console.print(" [dim]Nothing new to import.[/dim]\n")
173
+ return
174
+
175
+ # Build add commands
176
+ from gitstow.core.url_parser import parse_git_url
177
+ from gitstow.core.git import clone as git_clone
178
+ from gitstow.cli.helpers import resolve_workspaces
179
+ from datetime import datetime
180
+
181
+ settings = load_config()
182
+ ws_label = (ctx.obj or {}).get("workspace")
183
+ ws_list = resolve_workspaces(settings, ws_label)
184
+ ws = ws_list[0]
185
+ root = ws.get_path()
186
+ succeeded = 0
187
+ failed = 0
188
+
189
+ for entry in new_repos:
190
+ url = entry.get("url") or entry.get("remote_url", "")
191
+ entry_tags = entry.get("tags", []) + tags + list(ws.auto_tags)
192
+ frozen = entry.get("frozen", False)
193
+
194
+ try:
195
+ parsed = parse_git_url(url, default_host=settings.default_host, prefer_ssh=settings.prefer_ssh)
196
+ except ValueError as e:
197
+ err_console.print(f" [red]✗[/red] {url}: {e}")
198
+ failed += 1
199
+ continue
200
+
201
+ if ws.layout == "flat":
202
+ target = root / parsed.repo
203
+ repo_owner = ""
204
+ else:
205
+ target = root / parsed.owner / parsed.repo
206
+ repo_owner = parsed.owner
207
+
208
+ if target.exists():
209
+ console.print(f" [yellow]○[/yellow] {parsed.key} already on disk, registering")
210
+ repo = Repo(owner=repo_owner, name=parsed.repo, remote_url=url, workspace=ws.label, tags=entry_tags, frozen=frozen)
211
+ store.add(repo)
212
+ succeeded += 1
213
+ continue
214
+
215
+ console.print(f" [dim]Cloning[/dim] {parsed.key}...")
216
+ target.parent.mkdir(parents=True, exist_ok=True)
217
+
218
+ success, error = git_clone(url=parsed.clone_url, target=target, shallow=shallow)
219
+ if success:
220
+ repo = Repo(
221
+ owner=repo_owner,
222
+ name=parsed.repo,
223
+ remote_url=parsed.clone_url,
224
+ workspace=ws.label,
225
+ tags=entry_tags,
226
+ frozen=frozen,
227
+ last_pulled=datetime.now().isoformat(),
228
+ )
229
+ store.add(repo)
230
+ console.print(f" [green]✓[/green] {parsed.key}")
231
+ succeeded += 1
232
+ else:
233
+ err_console.print(f" [red]✗[/red] {parsed.key}: {error}")
234
+ failed += 1
235
+
236
+ console.print(f"\n Done: {succeeded} imported", end="")
237
+ if failed:
238
+ console.print(f", [red]{failed} failed[/red]", end="")
239
+ console.print("\n")
240
+
241
+
242
+ EXPORT_FORMAT_VERSION = 1
243
+
244
+
245
+ def _parse_import_file(content: str, suffix: str) -> list[dict]:
246
+ """Parse an import file into a list of repo dicts.
247
+
248
+ Supports versioned format (version: 1) and legacy unversioned format.
249
+ """
250
+ # Try YAML first
251
+ if suffix in (".yaml", ".yml"):
252
+ data = yaml.safe_load(content)
253
+ if isinstance(data, dict):
254
+ # Versioned format: {"version": 1, "repos": {...}}
255
+ if "version" in data and "repos" in data:
256
+ version = data["version"]
257
+ if version > EXPORT_FORMAT_VERSION:
258
+ raise typer.Exit(code=1) # Caller should print error
259
+ repos_data = data["repos"]
260
+ if isinstance(repos_data, dict):
261
+ return [
262
+ {"key": k, "url": v.get("remote_url", ""), "tags": v.get("tags", []), "frozen": v.get("frozen", False)}
263
+ for k, v in repos_data.items()
264
+ if isinstance(v, dict)
265
+ ]
266
+ # Legacy unversioned format: {key: {remote_url: ...}}
267
+ return [
268
+ {"key": k, "url": v.get("remote_url", ""), "tags": v.get("tags", []), "frozen": v.get("frozen", False)}
269
+ for k, v in data.items()
270
+ if isinstance(v, dict)
271
+ ]
272
+ elif isinstance(data, list):
273
+ return [{"url": item} if isinstance(item, str) else item for item in data]
274
+
275
+ # Try JSON
276
+ if suffix == ".json":
277
+ data = json.loads(content)
278
+ # Versioned format: {"version": 1, "repos": [...]}
279
+ if isinstance(data, dict) and "version" in data and "repos" in data:
280
+ version = data["version"]
281
+ if version > EXPORT_FORMAT_VERSION:
282
+ raise typer.Exit(code=1)
283
+ data = data["repos"]
284
+ if isinstance(data, list):
285
+ return [
286
+ {"key": item.get("key", ""), "url": item.get("remote_url", item.get("url", "")), "tags": item.get("tags", []), "frozen": item.get("frozen", False)}
287
+ for item in data
288
+ if isinstance(item, dict)
289
+ ]
290
+
291
+ # Plain text: one URL per line
292
+ lines = [line.strip() for line in content.splitlines() if line.strip() and not line.startswith("#")]
293
+ return [{"url": line} for line in lines]
gitstow/cli/helpers.py ADDED
@@ -0,0 +1,113 @@
1
+ """Shared CLI helpers for workspace resolution and repo lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from gitstow.core.config import Settings, Workspace
11
+ from gitstow.core.repo import Repo, RepoStore
12
+
13
+ err_console = Console(stderr=True)
14
+
15
+
16
+ def resolve_workspaces(
17
+ settings: Settings,
18
+ workspace_label: str | None = None,
19
+ ) -> list[Workspace]:
20
+ """Return workspaces filtered by label, or all if label is None."""
21
+ all_ws = settings.get_workspaces()
22
+ if workspace_label is None:
23
+ return all_ws
24
+ ws = settings.get_workspace(workspace_label)
25
+ if ws is None:
26
+ labels = ", ".join(w.label for w in all_ws)
27
+ err_console.print(
28
+ f"[red]Error:[/red] Unknown workspace [bold]{workspace_label}[/bold]. "
29
+ f"Available: {labels}"
30
+ )
31
+ raise typer.Exit(code=1)
32
+ return [ws]
33
+
34
+
35
+ def get_workspace_for_repo(
36
+ repo: Repo,
37
+ settings: Settings,
38
+ ) -> Workspace | None:
39
+ """Look up the workspace a repo belongs to."""
40
+ return settings.get_workspace(repo.workspace)
41
+
42
+
43
+ def resolve_repo(
44
+ store: RepoStore,
45
+ settings: Settings,
46
+ key: str,
47
+ workspace_label: str | None = None,
48
+ ) -> tuple[Repo, Workspace]:
49
+ """Find a repo by key, prompting interactively if ambiguous.
50
+
51
+ Returns (repo, workspace) or exits with error.
52
+ """
53
+ if workspace_label:
54
+ repo = store.get(key, workspace=workspace_label)
55
+ if repo is None:
56
+ err_console.print(
57
+ f"[red]Error:[/red] Repo [bold]{key}[/bold] not found "
58
+ f"in workspace [bold]{workspace_label}[/bold]."
59
+ )
60
+ raise typer.Exit(code=1)
61
+ ws = settings.get_workspace(workspace_label)
62
+ return repo, ws
63
+
64
+ # Try unique resolution
65
+ matches = store.find_all(key)
66
+ if len(matches) == 0:
67
+ err_console.print(f"[red]Error:[/red] Repo [bold]{key}[/bold] not found.")
68
+ raise typer.Exit(code=1)
69
+ if len(matches) == 1:
70
+ ws = settings.get_workspace(matches[0].workspace)
71
+ return matches[0], ws
72
+
73
+ # Ambiguous — prompt if interactive, error if piped
74
+ if not sys.stdin.isatty():
75
+ ws_labels = ", ".join(r.workspace for r in matches)
76
+ err_console.print(
77
+ f"[red]Error:[/red] Repo [bold]{key}[/bold] exists in multiple workspaces: "
78
+ f"{ws_labels}. Use [bold]--workspace[/bold] to disambiguate."
79
+ )
80
+ raise typer.Exit(code=1)
81
+
82
+ # Interactive prompt
83
+ from beaupy import select as bselect
84
+ options = [
85
+ f"[cyan]{r.workspace}[/cyan] — {r.get_path(settings.get_workspace(r.workspace).get_path())}"
86
+ for r in matches
87
+ ]
88
+ err_console.print(
89
+ f"\n Repo [bold]{key}[/bold] found in {len(matches)} workspaces:\n"
90
+ )
91
+ choice = bselect(options, cursor=">>>", cursor_style="bold cyan")
92
+ if choice is None:
93
+ raise typer.Exit()
94
+ idx = options.index(choice)
95
+ repo = matches[idx]
96
+ ws = settings.get_workspace(repo.workspace)
97
+ return repo, ws
98
+
99
+
100
+ def iter_repos_with_workspace(
101
+ store: RepoStore,
102
+ settings: Settings,
103
+ workspace_label: str | None = None,
104
+ ) -> list[tuple[Repo, Workspace]]:
105
+ """Iterate all repos paired with their workspace, optionally filtered."""
106
+ workspaces = resolve_workspaces(settings, workspace_label)
107
+ ws_map = {ws.label: ws for ws in workspaces}
108
+
109
+ result = []
110
+ for repo in store.list_all():
111
+ if repo.workspace in ws_map:
112
+ result.append((repo, ws_map[repo.workspace]))
113
+ return result