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/migrate.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""gitstow migrate — adopt existing repos into the organized structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from gitstow.core.config import load_config
|
|
14
|
+
from gitstow.core.git import is_git_repo, get_remote_url
|
|
15
|
+
from gitstow.core.url_parser import parse_git_url
|
|
16
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
err_console = Console(stderr=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def migrate(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
paths: list[str] = typer.Argument(
|
|
25
|
+
help="Path(s) to existing git repos to adopt.",
|
|
26
|
+
),
|
|
27
|
+
output_json: bool = typer.Option(
|
|
28
|
+
False, "--json", "-j", help="JSON output.",
|
|
29
|
+
),
|
|
30
|
+
quiet: bool = typer.Option(
|
|
31
|
+
False, "--quiet", "-q", help="Suppress progress messages.",
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""[bold]Migrate[/bold] existing repos into the gitstow structure.
|
|
35
|
+
|
|
36
|
+
Moves repos into the target workspace directory and registers them.
|
|
37
|
+
Uses the default workspace unless -w is specified.
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
Examples:
|
|
41
|
+
gitstow migrate ~/old-projects/some-repo
|
|
42
|
+
gitstow migrate -w active ~/random-clones/repo1
|
|
43
|
+
"""
|
|
44
|
+
settings = load_config()
|
|
45
|
+
store = RepoStore()
|
|
46
|
+
ws_label = (ctx.obj or {}).get("workspace")
|
|
47
|
+
|
|
48
|
+
from gitstow.cli.helpers import resolve_workspaces
|
|
49
|
+
ws_list = resolve_workspaces(settings, ws_label)
|
|
50
|
+
ws = ws_list[0]
|
|
51
|
+
root = ws.get_path()
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
for path_str in paths:
|
|
55
|
+
path = Path(path_str).expanduser().resolve()
|
|
56
|
+
|
|
57
|
+
if not path.exists():
|
|
58
|
+
results.append({"path": path_str, "status": "error", "detail": "Path does not exist"})
|
|
59
|
+
if not quiet:
|
|
60
|
+
err_console.print(f" [red]✗[/red] {path_str}: path does not exist")
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if not is_git_repo(path):
|
|
64
|
+
results.append({"path": path_str, "status": "error", "detail": "Not a git repo"})
|
|
65
|
+
if not quiet:
|
|
66
|
+
err_console.print(f" [red]✗[/red] {path_str}: not a git repo")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
remote_url = get_remote_url(path)
|
|
70
|
+
if not remote_url:
|
|
71
|
+
results.append({"path": path_str, "status": "error", "detail": "No remote URL configured"})
|
|
72
|
+
if not quiet:
|
|
73
|
+
err_console.print(f" [red]✗[/red] {path_str}: no remote URL (can't determine owner/repo)")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
parsed = parse_git_url(remote_url, default_host=settings.default_host)
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
results.append({"path": path_str, "status": "error", "detail": f"Can't parse remote: {e}"})
|
|
80
|
+
if not quiet:
|
|
81
|
+
err_console.print(f" [red]✗[/red] {path_str}: can't parse remote URL: {e}")
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Determine target based on workspace layout
|
|
85
|
+
if ws.layout == "flat":
|
|
86
|
+
target = root / parsed.repo
|
|
87
|
+
repo_owner = ""
|
|
88
|
+
else:
|
|
89
|
+
target = root / parsed.owner / parsed.repo
|
|
90
|
+
repo_owner = parsed.owner
|
|
91
|
+
|
|
92
|
+
repo_key = f"{repo_owner}/{parsed.repo}" if repo_owner else parsed.repo
|
|
93
|
+
|
|
94
|
+
# Already in the right place?
|
|
95
|
+
if path == target:
|
|
96
|
+
repo = Repo(owner=repo_owner, name=parsed.repo, remote_url=remote_url, workspace=ws.label)
|
|
97
|
+
store.add(repo)
|
|
98
|
+
results.append({"path": path_str, "status": "registered", "key": repo_key})
|
|
99
|
+
if not quiet:
|
|
100
|
+
console.print(f" [green]✓[/green] {repo_key} registered (already in place)")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Already tracked?
|
|
104
|
+
existing = store.get(repo_key, workspace=ws.label)
|
|
105
|
+
if existing:
|
|
106
|
+
results.append({"path": path_str, "status": "exists", "key": repo_key})
|
|
107
|
+
if not quiet:
|
|
108
|
+
console.print(f" [yellow]○[/yellow] {repo_key} already tracked")
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Target already exists?
|
|
112
|
+
if target.exists():
|
|
113
|
+
results.append({"path": path_str, "status": "conflict", "key": repo_key, "detail": f"Target exists: {target}"})
|
|
114
|
+
if not quiet:
|
|
115
|
+
err_console.print(f" [red]✗[/red] {repo_key}: target path already exists: {target}")
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if not quiet:
|
|
119
|
+
console.print(f" [dim]Moving[/dim] {path_str} → {target}...")
|
|
120
|
+
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
path.rename(target)
|
|
125
|
+
except OSError:
|
|
126
|
+
shutil.copytree(path, target, symlinks=True)
|
|
127
|
+
shutil.rmtree(path)
|
|
128
|
+
|
|
129
|
+
repo = Repo(owner=repo_owner, name=parsed.repo, remote_url=remote_url, workspace=ws.label)
|
|
130
|
+
store.add(repo)
|
|
131
|
+
results.append({"path": path_str, "status": "migrated", "key": repo_key, "target": str(target)})
|
|
132
|
+
if not quiet:
|
|
133
|
+
console.print(f" [green]✓[/green] {repo_key} migrated to {target}")
|
|
134
|
+
|
|
135
|
+
if output_json:
|
|
136
|
+
json.dump(results, sys.stdout, indent=2)
|
|
137
|
+
print()
|
|
138
|
+
elif not quiet and len(results) > 1:
|
|
139
|
+
migrated = sum(1 for r in results if r["status"] == "migrated")
|
|
140
|
+
registered = sum(1 for r in results if r["status"] == "registered")
|
|
141
|
+
errors = sum(1 for r in results if r["status"] in ("error", "conflict"))
|
|
142
|
+
console.print(f"\n Done: {migrated} migrated, {registered} registered, {errors} errors\n")
|
gitstow/cli/onboard.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""gitstow onboard — first-run setup wizard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from beaupy import confirm as bconfirm, select as bselect
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from gitstow.core.config import Settings, Workspace, save_config
|
|
13
|
+
from gitstow.core.paths import CONFIG_FILE, ensure_app_dirs, DEFAULT_ROOT
|
|
14
|
+
from gitstow.core.git import is_git_installed
|
|
15
|
+
from gitstow.core.discovery import discover_repos
|
|
16
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
HOST_OPTIONS = [
|
|
22
|
+
"[cyan]github.com[/cyan] — most common (default)",
|
|
23
|
+
"[cyan]gitlab.com[/cyan] — GitLab",
|
|
24
|
+
"[cyan]bitbucket.org[/cyan] — Bitbucket",
|
|
25
|
+
"[cyan]codeberg.org[/cyan] — Codeberg",
|
|
26
|
+
"[cyan]Custom[/cyan] — enter your own host",
|
|
27
|
+
]
|
|
28
|
+
HOST_VALUES = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "__custom__"]
|
|
29
|
+
|
|
30
|
+
LAYOUT_OPTIONS = [
|
|
31
|
+
"[cyan]structured[/cyan] — owner/repo directories (e.g., anthropic/claude-code/)",
|
|
32
|
+
"[cyan]flat[/cyan] — repos directly in the workspace (e.g., claude-code/)",
|
|
33
|
+
]
|
|
34
|
+
LAYOUT_VALUES = ["structured", "flat"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def onboard(
|
|
38
|
+
force: bool = typer.Option(
|
|
39
|
+
False, "--force", "-f", help="Re-run setup even if already configured.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""[bold]Set up[/bold] gitstow for first use.
|
|
43
|
+
|
|
44
|
+
Interactive wizard to configure workspaces, default host, and preferences.
|
|
45
|
+
"""
|
|
46
|
+
if CONFIG_FILE.exists() and not force:
|
|
47
|
+
console.print(
|
|
48
|
+
"\n [yellow]gitstow is already configured.[/yellow] "
|
|
49
|
+
"Use [bold]--force[/bold] to reconfigure.\n"
|
|
50
|
+
)
|
|
51
|
+
console.print(f" Config: {CONFIG_FILE}")
|
|
52
|
+
console.print(" Run [bold]gitstow config show[/bold] to see current settings.\n")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Welcome
|
|
56
|
+
console.print()
|
|
57
|
+
console.print(Panel(
|
|
58
|
+
"[bold]Welcome to gitstow![/bold]\n\n"
|
|
59
|
+
"A git repository manager — clone, organize, and maintain\n"
|
|
60
|
+
"collections of repos across multiple workspaces.\n\n"
|
|
61
|
+
"Let's set up your configuration.",
|
|
62
|
+
border_style="cyan",
|
|
63
|
+
padding=(1, 2),
|
|
64
|
+
))
|
|
65
|
+
console.print()
|
|
66
|
+
|
|
67
|
+
# Check git
|
|
68
|
+
git_ok, git_version = is_git_installed()
|
|
69
|
+
if not git_ok:
|
|
70
|
+
console.print(" [red]✗ git is not installed.[/red] Please install git first.")
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
console.print(f" [green]✓[/green] git {git_version} found\n")
|
|
73
|
+
|
|
74
|
+
settings = Settings()
|
|
75
|
+
|
|
76
|
+
# 1. First workspace
|
|
77
|
+
console.print(" [bold]1. Set up your first workspace[/bold]")
|
|
78
|
+
console.print(" A workspace is a directory where gitstow manages repos.\n")
|
|
79
|
+
|
|
80
|
+
ws = _setup_workspace(
|
|
81
|
+
default_path=str(DEFAULT_ROOT),
|
|
82
|
+
default_label="oss",
|
|
83
|
+
step_num=1,
|
|
84
|
+
)
|
|
85
|
+
settings.workspaces.append(ws)
|
|
86
|
+
|
|
87
|
+
# Offer to add more workspaces
|
|
88
|
+
console.print()
|
|
89
|
+
add_more = bconfirm(" Add another workspace? (e.g., for active projects)", default=False)
|
|
90
|
+
while add_more:
|
|
91
|
+
extra_ws = _setup_workspace(
|
|
92
|
+
default_path="",
|
|
93
|
+
default_label="",
|
|
94
|
+
step_num=None,
|
|
95
|
+
)
|
|
96
|
+
if extra_ws:
|
|
97
|
+
settings.workspaces.append(extra_ws)
|
|
98
|
+
add_more = bconfirm(" Add another workspace?", default=False)
|
|
99
|
+
|
|
100
|
+
# 2. Default host
|
|
101
|
+
console.print("\n [bold]2. Default Git host[/bold] (used when you type 'owner/repo')")
|
|
102
|
+
console.print()
|
|
103
|
+
host_choice = bselect(HOST_OPTIONS, cursor=">>>", cursor_style="bold cyan")
|
|
104
|
+
|
|
105
|
+
if host_choice is None:
|
|
106
|
+
console.print(" [dim]Cancelled.[/dim]")
|
|
107
|
+
raise typer.Exit()
|
|
108
|
+
|
|
109
|
+
host_idx = HOST_OPTIONS.index(host_choice)
|
|
110
|
+
if HOST_VALUES[host_idx] == "__custom__":
|
|
111
|
+
custom_host = typer.prompt(" Enter your host", default="github.com")
|
|
112
|
+
settings.default_host = custom_host
|
|
113
|
+
else:
|
|
114
|
+
settings.default_host = HOST_VALUES[host_idx]
|
|
115
|
+
console.print(f" → {settings.default_host}\n")
|
|
116
|
+
|
|
117
|
+
# 3. SSH preference
|
|
118
|
+
console.print(" [bold]3. Clone protocol preference[/bold]")
|
|
119
|
+
console.print()
|
|
120
|
+
prefer_ssh = bconfirm(" Prefer SSH over HTTPS?", default=False)
|
|
121
|
+
settings.prefer_ssh = prefer_ssh if prefer_ssh is not None else False
|
|
122
|
+
proto = "SSH" if settings.prefer_ssh else "HTTPS"
|
|
123
|
+
console.print(f" → {proto}\n")
|
|
124
|
+
|
|
125
|
+
# Save config
|
|
126
|
+
ensure_app_dirs()
|
|
127
|
+
save_config(settings)
|
|
128
|
+
console.print(f" [green]✓[/green] Config saved to {CONFIG_FILE}\n")
|
|
129
|
+
|
|
130
|
+
# 4. Create directories and scan
|
|
131
|
+
for ws in settings.workspaces:
|
|
132
|
+
ws_path = ws.get_path()
|
|
133
|
+
if not ws_path.exists():
|
|
134
|
+
create = bconfirm(f" Create {ws_path}?", default=True)
|
|
135
|
+
if create:
|
|
136
|
+
ws_path.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
console.print(f" [green]✓[/green] Created {ws_path}")
|
|
138
|
+
if ws_path.exists():
|
|
139
|
+
_scan_workspace_repos(ws)
|
|
140
|
+
|
|
141
|
+
# 5. AI integration setup
|
|
142
|
+
from gitstow.cli.setup_ai import _setup_ai_integrations
|
|
143
|
+
_setup_ai_integrations()
|
|
144
|
+
|
|
145
|
+
# Done
|
|
146
|
+
ws_summary = ", ".join(f"[cyan]{ws.label}[/cyan]" for ws in settings.workspaces)
|
|
147
|
+
console.print(Panel(
|
|
148
|
+
"[bold green]Setup complete![/bold green]\n\n"
|
|
149
|
+
f"Workspaces: {ws_summary}\n\n"
|
|
150
|
+
"Quick start:\n"
|
|
151
|
+
" [cyan]gitstow add owner/repo[/cyan] Clone a repo\n"
|
|
152
|
+
" [cyan]gitstow status[/cyan] Git status dashboard\n"
|
|
153
|
+
" [cyan]gitstow status -w active[/cyan] Status for one workspace\n"
|
|
154
|
+
" [cyan]gitstow workspace list[/cyan] See all workspaces\n"
|
|
155
|
+
" [cyan]gitstow workspace add <path>[/cyan] Add a new workspace\n\n"
|
|
156
|
+
"AI integration:\n"
|
|
157
|
+
" Your AI tools are configured to manage repos for you.\n"
|
|
158
|
+
" Re-run anytime with: [cyan]gitstow setup-ai[/cyan]",
|
|
159
|
+
border_style="green",
|
|
160
|
+
padding=(1, 2),
|
|
161
|
+
))
|
|
162
|
+
console.print()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _setup_workspace(default_path: str, default_label: str, step_num: int | None) -> Workspace:
|
|
166
|
+
"""Interactive setup for a single workspace."""
|
|
167
|
+
if default_path:
|
|
168
|
+
console.print(f" Default path: [cyan]{default_path}[/cyan]")
|
|
169
|
+
path_input = typer.prompt(
|
|
170
|
+
" Workspace path",
|
|
171
|
+
default=default_path or None,
|
|
172
|
+
show_default=False,
|
|
173
|
+
)
|
|
174
|
+
ws_path = Path(path_input).expanduser().resolve()
|
|
175
|
+
|
|
176
|
+
label_default = default_label or ws_path.name.lower()
|
|
177
|
+
label = typer.prompt(" Label", default=label_default, show_default=True)
|
|
178
|
+
|
|
179
|
+
console.print("\n Directory layout:")
|
|
180
|
+
layout_choice = bselect(LAYOUT_OPTIONS, cursor=">>>", cursor_style="bold cyan")
|
|
181
|
+
layout = LAYOUT_VALUES[LAYOUT_OPTIONS.index(layout_choice)] if layout_choice else "structured"
|
|
182
|
+
console.print(f" → {layout}\n")
|
|
183
|
+
|
|
184
|
+
auto_tags_input = typer.prompt(
|
|
185
|
+
" Auto-tags (comma-separated, or empty)",
|
|
186
|
+
default="",
|
|
187
|
+
show_default=False,
|
|
188
|
+
)
|
|
189
|
+
auto_tags = [t.strip().lower() for t in auto_tags_input.split(",") if t.strip()]
|
|
190
|
+
|
|
191
|
+
return Workspace(
|
|
192
|
+
path=str(ws_path),
|
|
193
|
+
label=label,
|
|
194
|
+
layout=layout,
|
|
195
|
+
auto_tags=auto_tags,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _scan_workspace_repos(ws: Workspace) -> None:
|
|
200
|
+
"""Scan a workspace for existing repos and offer to register them."""
|
|
201
|
+
console.print(f"\n [bold]Scanning {ws.label} for existing repos...[/bold]")
|
|
202
|
+
|
|
203
|
+
store = RepoStore()
|
|
204
|
+
ws_path = ws.get_path()
|
|
205
|
+
|
|
206
|
+
found = discover_repos(ws_path, layout=ws.layout)
|
|
207
|
+
existing_keys = {r.key for r in store.list_by_workspace(ws.label)}
|
|
208
|
+
new_repos = [r for r in found if r.key not in existing_keys]
|
|
209
|
+
|
|
210
|
+
if not new_repos:
|
|
211
|
+
console.print(" [dim]No untracked repos found.[/dim]\n")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
console.print(f" Found {len(new_repos)} untracked repo{'s' if len(new_repos) != 1 else ''}:\n")
|
|
215
|
+
for dr in new_repos:
|
|
216
|
+
remote_short = dr.remote_url[:60] + "..." if dr.remote_url and len(dr.remote_url) > 60 else dr.remote_url or "[dim]no remote[/dim]"
|
|
217
|
+
console.print(f" {dr.key} [dim]({remote_short})[/dim]")
|
|
218
|
+
|
|
219
|
+
console.print()
|
|
220
|
+
register = bconfirm(f" Register all {len(new_repos)} repos?", default=True)
|
|
221
|
+
|
|
222
|
+
if register:
|
|
223
|
+
for dr in new_repos:
|
|
224
|
+
repo = Repo(
|
|
225
|
+
owner=dr.owner,
|
|
226
|
+
name=dr.name,
|
|
227
|
+
remote_url=dr.remote_url or "",
|
|
228
|
+
workspace=ws.label,
|
|
229
|
+
tags=list(ws.auto_tags),
|
|
230
|
+
)
|
|
231
|
+
store.add(repo)
|
|
232
|
+
console.print(f" [green]✓[/green] Registered {len(new_repos)} repos in [bold]{ws.label}[/bold].\n")
|
|
233
|
+
else:
|
|
234
|
+
console.print(" [dim]Skipped. You can scan later with 'gitstow workspace scan'.[/dim]\n")
|
gitstow/cli/open_cmd.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""gitstow open — open a repo in editor, browser, or Finder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from gitstow.core.config import load_config
|
|
13
|
+
from gitstow.core.git import get_remote_url
|
|
14
|
+
from gitstow.core.repo import RepoStore
|
|
15
|
+
from gitstow.cli.helpers import resolve_repo
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
err_console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def open_repo(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
repo_key: str = typer.Argument(help="Repo to open (owner/repo or name)."),
|
|
24
|
+
editor: bool = typer.Option(
|
|
25
|
+
False, "--editor", "-e", help="Open in default editor (VS Code, etc.).",
|
|
26
|
+
),
|
|
27
|
+
browser: bool = typer.Option(
|
|
28
|
+
False, "--browser", "-b", help="Open on GitHub/GitLab in browser.",
|
|
29
|
+
),
|
|
30
|
+
finder: bool = typer.Option(
|
|
31
|
+
False, "--finder", "-f", help="Open in Finder/file manager.",
|
|
32
|
+
),
|
|
33
|
+
path_only: bool = typer.Option(
|
|
34
|
+
False, "--path", "-p", help="Just print the path (for cd, piping, etc.).",
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""[bold]Open[/bold] a repo in your editor, browser, or file manager.
|
|
38
|
+
|
|
39
|
+
With no flags, opens in the default editor.
|
|
40
|
+
|
|
41
|
+
\b
|
|
42
|
+
Examples:
|
|
43
|
+
gitstow open anthropic/claude-code # Default editor
|
|
44
|
+
gitstow open anthropic/claude-code --browser # Open on GitHub
|
|
45
|
+
gitstow open anthropic/claude-code --finder # Open in Finder
|
|
46
|
+
gitstow open anthropic/claude-code --path # Print path
|
|
47
|
+
cd "$(gitstow open anthropic/claude-code -p)" # cd into repo
|
|
48
|
+
"""
|
|
49
|
+
settings = load_config()
|
|
50
|
+
store = RepoStore()
|
|
51
|
+
ws_label = ctx.obj.get("workspace") if ctx.obj else None
|
|
52
|
+
|
|
53
|
+
repo, ws = resolve_repo(store, settings, repo_key, ws_label)
|
|
54
|
+
repo_path = repo.get_path(ws.get_path())
|
|
55
|
+
|
|
56
|
+
if not repo_path.exists():
|
|
57
|
+
err_console.print(f"[red]Error:[/red] '{repo_key}' not found on disk at {repo_path}")
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
# Print path mode
|
|
61
|
+
if path_only:
|
|
62
|
+
print(str(repo_path))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Browser mode — open remote URL
|
|
66
|
+
if browser:
|
|
67
|
+
remote = get_remote_url(repo_path) or repo.remote_url
|
|
68
|
+
if remote:
|
|
69
|
+
web_url = _remote_to_web_url(remote)
|
|
70
|
+
webbrowser.open(web_url)
|
|
71
|
+
console.print(f" [green]✓[/green] Opened {web_url}")
|
|
72
|
+
else:
|
|
73
|
+
err_console.print(f"[red]Error:[/red] No remote URL for {repo_key}")
|
|
74
|
+
raise typer.Exit(code=1)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Finder mode
|
|
78
|
+
if finder:
|
|
79
|
+
_open_in_file_manager(repo_path)
|
|
80
|
+
console.print(f" [green]✓[/green] Opened {repo_path} in file manager")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Editor mode (default)
|
|
84
|
+
_open_in_editor(repo_path)
|
|
85
|
+
console.print(f" [green]✓[/green] Opened {repo_key} in editor")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _remote_to_web_url(remote: str) -> str:
|
|
89
|
+
"""Convert a git remote URL to a browser URL."""
|
|
90
|
+
url = remote.strip()
|
|
91
|
+
|
|
92
|
+
# SSH: git@github.com:owner/repo.git → https://github.com/owner/repo
|
|
93
|
+
if url.startswith("git@"):
|
|
94
|
+
url = url.replace(":", "/", 1).replace("git@", "https://")
|
|
95
|
+
|
|
96
|
+
# ssh:// scheme
|
|
97
|
+
if url.startswith("ssh://"):
|
|
98
|
+
url = url.replace("ssh://", "https://")
|
|
99
|
+
# Remove user@ if present
|
|
100
|
+
if "@" in url.split("/")[2]:
|
|
101
|
+
parts = url.split("/")
|
|
102
|
+
parts[2] = parts[2].split("@")[1]
|
|
103
|
+
url = "/".join(parts)
|
|
104
|
+
|
|
105
|
+
# Strip .git suffix
|
|
106
|
+
if url.endswith(".git"):
|
|
107
|
+
url = url[:-4]
|
|
108
|
+
|
|
109
|
+
# Ensure https://
|
|
110
|
+
if not url.startswith("http"):
|
|
111
|
+
url = f"https://{url}"
|
|
112
|
+
|
|
113
|
+
return url
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _open_in_editor(path) -> None:
|
|
117
|
+
"""Open a directory in the default editor."""
|
|
118
|
+
import os
|
|
119
|
+
import shutil
|
|
120
|
+
|
|
121
|
+
# Try common editors in order
|
|
122
|
+
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
|
|
123
|
+
|
|
124
|
+
if shutil.which("code"):
|
|
125
|
+
subprocess.Popen(["code", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
126
|
+
elif shutil.which("cursor"):
|
|
127
|
+
subprocess.Popen(["cursor", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
128
|
+
elif editor:
|
|
129
|
+
subprocess.Popen([editor, str(path)])
|
|
130
|
+
elif platform.system() == "Darwin":
|
|
131
|
+
subprocess.Popen(["open", "-a", "TextEdit", str(path)])
|
|
132
|
+
else:
|
|
133
|
+
subprocess.Popen(["xdg-open", str(path)])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _open_in_file_manager(path) -> None:
|
|
137
|
+
"""Open a directory in the system file manager."""
|
|
138
|
+
system = platform.system()
|
|
139
|
+
if system == "Darwin":
|
|
140
|
+
subprocess.Popen(["open", str(path)])
|
|
141
|
+
elif system == "Windows":
|
|
142
|
+
subprocess.Popen(["explorer", str(path)])
|
|
143
|
+
else:
|
|
144
|
+
subprocess.Popen(["xdg-open", str(path)])
|