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
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 []
|