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/cli/pull.py ADDED
@@ -0,0 +1,265 @@
1
+ """gitstow pull — bulk update repos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from gitstow.core.config import load_config, Workspace
15
+ from gitstow.core.git import pull as git_pull, get_status, is_git_repo
16
+ from gitstow.core.repo import Repo, RepoStore
17
+ from gitstow.core.parallel import run_parallel_sync
18
+ from gitstow.cli.helpers import iter_repos_with_workspace
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+
24
+ def _pull_one_repo(repo: Repo, ws: Workspace) -> dict:
25
+ """Pull a single repo. Returns a result dict."""
26
+ path = repo.get_path(ws.get_path())
27
+
28
+ if not path.exists():
29
+ return {"repo": repo.key, "status": "missing", "detail": "Directory not found on disk"}
30
+
31
+ if not is_git_repo(path):
32
+ return {"repo": repo.key, "status": "error", "detail": "Not a git repo"}
33
+
34
+ status = get_status(path)
35
+ if not status.clean:
36
+ return {
37
+ "repo": repo.key,
38
+ "status": "skipped",
39
+ "detail": f"Dirty working tree ({status.status_symbol})",
40
+ }
41
+
42
+ result = git_pull(path)
43
+ if result.success:
44
+ if result.already_up_to_date:
45
+ return {"repo": repo.key, "status": "up_to_date", "detail": "Already up to date"}
46
+ else:
47
+ return {"repo": repo.key, "status": "pulled", "detail": result.output.split("\n")[0]}
48
+ else:
49
+ return {"repo": repo.key, "status": "error", "detail": result.error}
50
+
51
+
52
+ def pull(
53
+ ctx: typer.Context,
54
+ repos: Optional[list[str]] = typer.Argument(
55
+ default=None,
56
+ help="Specific repos to pull (owner/repo). Omit for all.",
57
+ ),
58
+ tag: Optional[list[str]] = typer.Option(
59
+ None, "--tag", "-t", help="Only pull repos with this tag.",
60
+ ),
61
+ exclude_tag: Optional[list[str]] = typer.Option(
62
+ None, "--exclude-tag", help="Skip repos with this tag.",
63
+ ),
64
+ owner: Optional[str] = typer.Option(
65
+ None, "--owner", help="Only pull repos from this owner.",
66
+ ),
67
+ include_frozen: bool = typer.Option(
68
+ False, "--include-frozen", help="Include frozen repos.",
69
+ ),
70
+ output_json: bool = typer.Option(
71
+ False, "--json", "-j", help="JSON output.",
72
+ ),
73
+ retry: int = typer.Option(
74
+ 0, "--retry", help="Retry failed repos N times.",
75
+ ),
76
+ quiet: bool = typer.Option(
77
+ False, "--quiet", "-q", help="Suppress per-repo progress.",
78
+ ),
79
+ ) -> None:
80
+ """[bold blue]Pull[/bold blue] latest changes for all (or filtered) repos.
81
+
82
+ Frozen repos are skipped unless --include-frozen is set.
83
+ Dirty repos are always skipped (never risk losing local changes).
84
+
85
+ \b
86
+ Examples:
87
+ gitstow pull # All unfrozen repos
88
+ gitstow pull --tag ai # Only repos tagged 'ai'
89
+ gitstow pull --exclude-tag stale
90
+ gitstow pull -w oss # Only repos in oss workspace
91
+ """
92
+ settings = load_config()
93
+ store = RepoStore()
94
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
95
+
96
+ # Resolve target repos
97
+ if repos:
98
+ targets = []
99
+ for key in repos:
100
+ repo = store.get(key)
101
+ if repo:
102
+ ws = settings.get_workspace(repo.workspace)
103
+ if ws:
104
+ targets.append((repo, ws))
105
+ else:
106
+ err_console.print(f"[yellow]Warning:[/yellow] '{key}' not tracked. Skipping.")
107
+ else:
108
+ targets = iter_repos_with_workspace(store, settings, ws_label)
109
+
110
+ # Apply filters
111
+ if not include_frozen:
112
+ frozen_keys = {r.key for r, _ in targets if r.frozen}
113
+ targets = [(r, ws) for r, ws in targets if not r.frozen]
114
+ else:
115
+ frozen_keys = set()
116
+
117
+ if tag:
118
+ tag_set = set(tag)
119
+ targets = [(r, ws) for r, ws in targets if tag_set.intersection(r.tags)]
120
+
121
+ if exclude_tag:
122
+ exclude_set = set(exclude_tag)
123
+ targets = [(r, ws) for r, ws in targets if not exclude_set.intersection(r.tags)]
124
+
125
+ if owner:
126
+ targets = [(r, ws) for r, ws in targets if r.owner == owner]
127
+
128
+ if not targets:
129
+ if not quiet:
130
+ console.print("[yellow]No repos to pull.[/yellow]")
131
+ if output_json:
132
+ json.dump({"total": 0, "results": []}, sys.stdout, indent=2)
133
+ print()
134
+ return
135
+
136
+ total_count = len(targets)
137
+ if not quiet:
138
+ console.print(f"\n Pulling {total_count} repo{'s' if total_count != 1 else ''}...\n")
139
+
140
+ # Run pulls in parallel (with retry)
141
+ remaining_targets = list(targets)
142
+ result_dicts: list[dict] = []
143
+
144
+ for attempt in range(1 + retry):
145
+ if attempt > 0 and not quiet:
146
+ console.print(f"\n [dim]Retry {attempt}/{retry} — {len(remaining_targets)} failed repos...[/dim]\n")
147
+
148
+ tasks = [
149
+ (repo.global_key, lambda r=repo, w=ws: _pull_one_repo(r, w))
150
+ for repo, ws in remaining_targets
151
+ ]
152
+
153
+ progress_count = [0]
154
+
155
+ def _on_progress(key: str, success: bool, message: str) -> None:
156
+ progress_count[0] += 1
157
+ if not quiet:
158
+ console.print(
159
+ f" [{progress_count[0]}/{len(tasks)}] {key.split(':', 1)[-1]}",
160
+ end="\r",
161
+ highlight=False,
162
+ )
163
+
164
+ results = run_parallel_sync(
165
+ tasks,
166
+ max_concurrent=settings.parallel_limit,
167
+ on_progress=None if quiet else _on_progress,
168
+ )
169
+
170
+ # Process results and update timestamps
171
+ failed_keys: set[str] = set()
172
+ for task_result in results:
173
+ if task_result.success and task_result.data:
174
+ data = task_result.data
175
+ if data["status"] in ("pulled", "up_to_date", "skipped"):
176
+ result_dicts.append(data)
177
+ if data["status"] in ("pulled", "up_to_date"):
178
+ parts = task_result.key.split(":", 1)
179
+ if len(parts) == 2:
180
+ store.update(parts[1], workspace=parts[0], last_pulled=datetime.now().isoformat())
181
+ else:
182
+ # error or missing — candidate for retry
183
+ if attempt < retry:
184
+ failed_keys.add(task_result.key)
185
+ else:
186
+ result_dicts.append(data)
187
+ else:
188
+ if attempt < retry:
189
+ failed_keys.add(task_result.key)
190
+ else:
191
+ result_dicts.append({
192
+ "repo": task_result.key,
193
+ "status": "error",
194
+ "detail": task_result.error,
195
+ })
196
+
197
+ if not failed_keys:
198
+ break
199
+ remaining_targets = [(r, ws) for r, ws in remaining_targets if r.global_key in failed_keys]
200
+
201
+ # Add frozen repos to results for completeness
202
+ if not include_frozen and frozen_keys:
203
+ for key in sorted(frozen_keys):
204
+ result_dicts.append({"repo": key, "status": "frozen", "detail": "Skipped (frozen)"})
205
+
206
+ # Output
207
+ if output_json:
208
+ pulled = sum(1 for r in result_dicts if r["status"] == "pulled")
209
+ up_to_date = sum(1 for r in result_dicts if r["status"] == "up_to_date")
210
+ skipped = sum(1 for r in result_dicts if r["status"] in ("skipped", "frozen"))
211
+ errors = sum(1 for r in result_dicts if r["status"] == "error")
212
+ missing = sum(1 for r in result_dicts if r["status"] == "missing")
213
+
214
+ json.dump(
215
+ {
216
+ "total": len(result_dicts),
217
+ "pulled": pulled,
218
+ "up_to_date": up_to_date,
219
+ "skipped": skipped,
220
+ "errors": errors + missing,
221
+ "results": result_dicts,
222
+ },
223
+ sys.stdout,
224
+ indent=2,
225
+ )
226
+ print()
227
+ else:
228
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
229
+ table.add_column("Repo", style="white")
230
+ table.add_column("Status")
231
+ table.add_column("Details", style="dim")
232
+
233
+ status_styles = {
234
+ "pulled": "[green]✓ Pulled[/green]",
235
+ "up_to_date": "[dim]○ Up to date[/dim]",
236
+ "frozen": "[cyan]❄ Frozen[/cyan]",
237
+ "skipped": "[yellow]⚠ Skipped[/yellow]",
238
+ "error": "[red]✗ Error[/red]",
239
+ "missing": "[red]✗ Missing[/red]",
240
+ }
241
+
242
+ for r in sorted(result_dicts, key=lambda x: x["repo"]):
243
+ status_text = status_styles.get(r["status"], r["status"])
244
+ table.add_row(r["repo"], status_text, r.get("detail", ""))
245
+
246
+ console.print(table)
247
+
248
+ pulled = sum(1 for r in result_dicts if r["status"] == "pulled")
249
+ up_to_date = sum(1 for r in result_dicts if r["status"] == "up_to_date")
250
+ skipped = sum(1 for r in result_dicts if r["status"] in ("skipped", "frozen"))
251
+ errors = sum(1 for r in result_dicts if r["status"] in ("error", "missing"))
252
+
253
+ parts = []
254
+ if pulled:
255
+ parts.append(f"[green]{pulled} pulled[/green]")
256
+ if up_to_date:
257
+ parts.append(f"[dim]{up_to_date} up to date[/dim]")
258
+ if skipped:
259
+ parts.append(f"[cyan]{skipped} skipped[/cyan]")
260
+ if errors:
261
+ parts.append(f"[red]{errors} errors[/red]")
262
+ console.print(f"\n {' | '.join(parts)}\n")
263
+
264
+ if any(r["status"] in ("error", "missing") for r in result_dicts):
265
+ raise typer.Exit(code=1)
gitstow/cli/remove.py ADDED
@@ -0,0 +1,88 @@
1
+ """gitstow remove — remove a repo from the collection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from gitstow.core.config import load_config
13
+ from gitstow.core.repo import RepoStore
14
+ from gitstow.cli.helpers import resolve_repo
15
+
16
+ console = Console()
17
+ err_console = Console(stderr=True)
18
+
19
+
20
+ def remove(
21
+ ctx: typer.Context,
22
+ repo_key: str = typer.Argument(
23
+ help="Repo to remove (owner/repo or name).",
24
+ ),
25
+ yes: bool = typer.Option(
26
+ False, "--yes", "-y", help="Skip confirmation prompt.",
27
+ ),
28
+ delete_files: bool = typer.Option(
29
+ False, "--delete", help="Also delete the repo from disk.",
30
+ ),
31
+ output_json: bool = typer.Option(
32
+ False, "--json", "-j", help="JSON output.",
33
+ ),
34
+ ) -> None:
35
+ """[bold red]Remove[/bold red] a repo from tracking.
36
+
37
+ By default, only removes from gitstow's registry. The files stay on disk.
38
+ Use --delete to also remove the directory.
39
+
40
+ \b
41
+ Examples:
42
+ gitstow remove facebook/react
43
+ gitstow remove facebook/react --delete --yes
44
+ gitstow remove -w active gitstow
45
+ """
46
+ settings = load_config()
47
+ store = RepoStore()
48
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
49
+
50
+ repo, ws = resolve_repo(store, settings, repo_key, ws_label)
51
+ path = repo.get_path(ws.get_path())
52
+
53
+ # Confirmation
54
+ if not yes:
55
+ action = "remove from tracking and delete from disk" if delete_files else "remove from tracking"
56
+ if not typer.confirm(f" {action}: {repo.key} ({ws.label})?"):
57
+ console.print(" [dim]Cancelled.[/dim]")
58
+ raise typer.Exit()
59
+
60
+ # Remove from store
61
+ store.remove(repo.key, workspace=ws.label)
62
+
63
+ # Optionally delete from disk
64
+ deleted = False
65
+ if delete_files and path.exists():
66
+ shutil.rmtree(path, ignore_errors=True)
67
+ deleted = True
68
+
69
+ # Clean up empty owner directory (only for structured layout)
70
+ if repo.owner:
71
+ owner_dir = ws.get_path() / repo.owner
72
+ if owner_dir.exists() and not any(owner_dir.iterdir()):
73
+ owner_dir.rmdir()
74
+
75
+ if output_json:
76
+ json.dump(
77
+ {"success": True, "repo": repo.key, "workspace": ws.label, "deleted_from_disk": deleted},
78
+ sys.stdout,
79
+ indent=2,
80
+ )
81
+ print()
82
+ else:
83
+ if deleted:
84
+ console.print(f" [green]✓[/green] {repo.key} removed and deleted from disk.")
85
+ else:
86
+ console.print(f" [green]✓[/green] {repo.key} removed from tracking.")
87
+ if path.exists():
88
+ console.print(f" [dim]Files still at: {path}[/dim]")
gitstow/cli/search.py ADDED
@@ -0,0 +1,199 @@
1
+ """gitstow search — grep across all 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.cli.helpers import iter_repos_with_workspace
16
+
17
+ console = Console()
18
+ err_console = Console(stderr=True)
19
+
20
+
21
+ def search(
22
+ ctx: typer.Context,
23
+ pattern: str = typer.Argument(help="Search pattern (regex supported if using ripgrep)."),
24
+ tag: Optional[list[str]] = typer.Option(
25
+ None, "--tag", "-t", help="Only search in repos with this tag.",
26
+ ),
27
+ owner: Optional[str] = typer.Option(
28
+ None, "--owner", help="Only search in repos from this owner.",
29
+ ),
30
+ glob_filter: Optional[str] = typer.Option(
31
+ None, "--glob", "-g", help="File glob pattern (e.g., '*.py', '*.md').",
32
+ ),
33
+ case_insensitive: bool = typer.Option(
34
+ False, "--ignore-case", "-i", help="Case-insensitive search.",
35
+ ),
36
+ files_only: bool = typer.Option(
37
+ False, "--files", "-l", help="Only show file paths, not matching lines.",
38
+ ),
39
+ max_results: int = typer.Option(
40
+ 50, "--max", "-m", help="Max results per repo.",
41
+ ),
42
+ output_json: bool = typer.Option(
43
+ False, "--json", "-j", help="JSON output.",
44
+ ),
45
+ quiet: bool = typer.Option(
46
+ False, "--quiet", "-q", help="Suppress headers.",
47
+ ),
48
+ ) -> None:
49
+ """[bold magenta]Search[/bold magenta] — grep across all repos.
50
+
51
+ Uses ripgrep (rg) if available, falls back to git grep.
52
+
53
+ \b
54
+ Examples:
55
+ gitstow search "TODO"
56
+ gitstow search "def main" --glob "*.py"
57
+ gitstow search "import React" --tag frontend
58
+ gitstow search -w active "error" -i --files
59
+ """
60
+ settings = load_config()
61
+ store = RepoStore()
62
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
63
+
64
+ repo_ws_pairs = iter_repos_with_workspace(store, settings, ws_label)
65
+
66
+ if tag:
67
+ tag_set = set(tag)
68
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if tag_set.intersection(r.tags)]
69
+ if owner:
70
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.owner == owner]
71
+
72
+ if not repo_ws_pairs:
73
+ if not quiet:
74
+ console.print("[dim]No repos match the filter.[/dim]")
75
+ return
76
+
77
+ # Detect search tool
78
+ use_rg = _has_ripgrep()
79
+ all_results = []
80
+ total_matches = 0
81
+
82
+ for repo, ws in repo_ws_pairs:
83
+ path = repo.get_path(ws.get_path())
84
+ if not path.exists():
85
+ continue
86
+
87
+ matches = _search_repo(
88
+ path, pattern, use_rg,
89
+ glob_filter=glob_filter,
90
+ case_insensitive=case_insensitive,
91
+ files_only=files_only,
92
+ max_results=max_results,
93
+ )
94
+
95
+ if matches:
96
+ total_matches += len(matches)
97
+ repo_result = {
98
+ "repo": repo.key,
99
+ "matches": matches,
100
+ "count": len(matches),
101
+ }
102
+ all_results.append(repo_result)
103
+
104
+ if not output_json and not quiet:
105
+ console.print(f"\n [bold]{repo.key}[/bold] [dim]({len(matches)} matches)[/dim]")
106
+ for match in matches:
107
+ if files_only:
108
+ console.print(f" {match['file']}")
109
+ else:
110
+ console.print(f" [dim]{match['file']}:{match.get('line_number', '')}:[/dim] {match.get('text', '')}")
111
+
112
+ if output_json:
113
+ json.dump({
114
+ "pattern": pattern,
115
+ "total_matches": total_matches,
116
+ "repos_with_matches": len(all_results),
117
+ "results": all_results,
118
+ }, sys.stdout, indent=2)
119
+ print()
120
+ elif not quiet:
121
+ if total_matches == 0:
122
+ console.print(f"\n [dim]No matches for '{pattern}' across {len(repo_ws_pairs)} repos.[/dim]\n")
123
+ else:
124
+ console.print(f"\n [green]{total_matches} matches[/green] across {len(all_results)} repos\n")
125
+
126
+
127
+ def _has_ripgrep() -> bool:
128
+ """Check if ripgrep (rg) is available."""
129
+ import shutil
130
+ return shutil.which("rg") is not None
131
+
132
+
133
+ def _search_repo(
134
+ path,
135
+ pattern: str,
136
+ use_rg: bool,
137
+ glob_filter: str | None = None,
138
+ case_insensitive: bool = False,
139
+ files_only: bool = False,
140
+ max_results: int = 50,
141
+ ) -> list[dict]:
142
+ """Search a single repo, return list of match dicts."""
143
+ if use_rg:
144
+ cmd = ["rg", "--no-heading", "--with-filename", "--line-number"]
145
+ if case_insensitive:
146
+ cmd.append("-i")
147
+ if files_only:
148
+ cmd.append("-l")
149
+ if glob_filter:
150
+ cmd.extend(["--glob", glob_filter])
151
+ cmd.extend(["--max-count", str(max_results)])
152
+ cmd.append(pattern)
153
+ else:
154
+ # Fall back to git grep
155
+ cmd = ["git", "grep", "-n"]
156
+ if case_insensitive:
157
+ cmd.append("-i")
158
+ if files_only:
159
+ cmd.append("-l")
160
+ cmd.extend(["--max-depth", "10"])
161
+ cmd.append(pattern)
162
+
163
+ try:
164
+ result = subprocess.run(
165
+ cmd,
166
+ cwd=path,
167
+ capture_output=True,
168
+ text=True,
169
+ timeout=30,
170
+ encoding="utf-8",
171
+ errors="replace",
172
+ )
173
+
174
+ if result.returncode not in (0, 1): # 1 = no matches (normal)
175
+ return []
176
+
177
+ matches = []
178
+ for line in result.stdout.strip().splitlines():
179
+ if not line:
180
+ continue
181
+ if files_only:
182
+ matches.append({"file": line.strip()})
183
+ else:
184
+ # Parse file:line:text format
185
+ parts = line.split(":", 2)
186
+ if len(parts) >= 3:
187
+ matches.append({
188
+ "file": parts[0],
189
+ "line_number": parts[1],
190
+ "text": parts[2].strip(),
191
+ })
192
+ elif len(parts) == 2:
193
+ matches.append({"file": parts[0], "text": parts[1].strip()})
194
+ else:
195
+ matches.append({"file": line, "text": ""})
196
+
197
+ return matches[:max_results]
198
+ except (subprocess.TimeoutExpired, Exception):
199
+ return []