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/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")
@@ -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)])