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/exec_cmd.py
ADDED
|
@@ -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
|