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 ADDED
@@ -0,0 +1,3 @@
1
+ """gitstow — a git repository library manager."""
2
+
3
+ __version__ = "0.1.0"
gitstow/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Allow running as `python -m gitstow`.
2
+
3
+ Useful on Windows when the Scripts directory is not on PATH:
4
+
5
+ python -m gitstow onboard
6
+ python -m gitstow add owner/repo
7
+ """
8
+
9
+ from gitstow.cli.main import app
10
+
11
+ app()
@@ -0,0 +1 @@
1
+ """gitstow CLI — Typer command layer."""
gitstow/cli/add.py ADDED
@@ -0,0 +1,262 @@
1
+ """gitstow add — clone repos into the organized structure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from gitstow.core.config import load_config
13
+ from gitstow.core.url_parser import parse_git_url
14
+ from gitstow.core.git import clone as git_clone, is_git_repo, get_remote_url
15
+ from gitstow.core.repo import Repo, RepoStore
16
+ from gitstow.cli.helpers import resolve_workspaces
17
+
18
+ console = Console()
19
+ err_console = Console(stderr=True)
20
+
21
+
22
+ def add(
23
+ ctx: typer.Context,
24
+ urls: list[str] = typer.Argument(
25
+ default=None,
26
+ help="Git URLs or owner/repo shorthand. Reads from stdin if omitted.",
27
+ ),
28
+ shallow: bool = typer.Option(
29
+ False, "--shallow", "-s", help="Shallow clone (--depth 1)."
30
+ ),
31
+ branch: Optional[str] = typer.Option(
32
+ None, "--branch", "-b", help="Clone specific branch."
33
+ ),
34
+ update: bool = typer.Option(
35
+ False, "--update", "-u", help="Pull if repo already exists."
36
+ ),
37
+ tag: Optional[list[str]] = typer.Option(
38
+ None, "--tag", "-t", help="Tag(s) to apply to added repos."
39
+ ),
40
+ recursive: bool = typer.Option(
41
+ False, "--recursive", "-r", help="Initialize submodules after clone."
42
+ ),
43
+ ssh: bool = typer.Option(
44
+ False, "--ssh", help="Force SSH clone URL."
45
+ ),
46
+ retry: int = typer.Option(
47
+ 0, "--retry", help="Retry failed clones N times."
48
+ ),
49
+ output_json: bool = typer.Option(
50
+ False, "--json", "-j", help="JSON output."
51
+ ),
52
+ quiet: bool = typer.Option(
53
+ False, "--quiet", "-q", help="Suppress progress messages."
54
+ ),
55
+ ) -> None:
56
+ """[bold green]Add[/bold green] repos — clone into organized structure.
57
+
58
+ Accepts full URLs, SSH URLs, or shorthand (owner/repo assumes GitHub).
59
+ Uses the default workspace unless -w is specified.
60
+
61
+ \b
62
+ Examples:
63
+ gitstow add anthropic/claude-code
64
+ gitstow add https://github.com/facebook/react
65
+ gitstow add -w active anthropic/claude-code
66
+ cat urls.txt | gitstow add
67
+ """
68
+ settings = load_config()
69
+ store = RepoStore()
70
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
71
+ ws_list = resolve_workspaces(settings, ws_label)
72
+ ws = ws_list[0] # Use the specified or default workspace
73
+ root = ws.get_path()
74
+ tags = list(tag or []) + list(ws.auto_tags)
75
+
76
+ # Read from stdin if no URLs provided and stdin is piped
77
+ if not urls:
78
+ if sys.stdin.isatty():
79
+ err_console.print("[red]Error:[/red] No URLs provided. Pass URLs as arguments or pipe via stdin.")
80
+ raise typer.Exit(code=1)
81
+ urls = [line.strip() for line in sys.stdin if line.strip() and not line.startswith("#")]
82
+
83
+ if not urls:
84
+ err_console.print("[red]Error:[/red] No URLs to add.")
85
+ raise typer.Exit(code=1)
86
+
87
+ # Parse all URLs first (fail fast on bad input)
88
+ parsed_urls = []
89
+ for url in urls:
90
+ try:
91
+ parsed = parse_git_url(
92
+ url,
93
+ default_host=settings.default_host,
94
+ prefer_ssh=ssh or settings.prefer_ssh,
95
+ )
96
+ parsed_urls.append(parsed)
97
+ except ValueError as e:
98
+ err_console.print(f"[red]Error:[/red] {e}")
99
+ if output_json:
100
+ json.dump({"success": False, "error": str(e), "url": url}, sys.stdout, indent=2)
101
+ raise typer.Exit(code=1)
102
+
103
+ results = []
104
+
105
+ for parsed in parsed_urls:
106
+ # Determine target path based on workspace layout
107
+ if ws.layout == "flat":
108
+ target = root / parsed.repo
109
+ repo_owner = "" # Flat layout stores no owner in path
110
+ else:
111
+ target = root / parsed.owner / parsed.repo
112
+ repo_owner = parsed.owner
113
+
114
+ repo_key = f"{repo_owner}/{parsed.repo}" if repo_owner else parsed.repo
115
+ existing = store.get(repo_key, workspace=ws.label)
116
+
117
+ # Already tracked in this workspace
118
+ if existing:
119
+ if update:
120
+ if not quiet:
121
+ console.print(f" [dim]Updating[/dim] {repo_key}...")
122
+ from gitstow.core.git import pull as git_pull
123
+ pull_result = git_pull(target)
124
+ if pull_result.success:
125
+ from datetime import datetime
126
+ store.update(repo_key, workspace=ws.label, last_pulled=datetime.now().isoformat())
127
+ results.append({"repo": repo_key, "status": "updated"})
128
+ if not quiet:
129
+ console.print(f" [green]✓[/green] {repo_key} updated")
130
+ else:
131
+ results.append({"repo": repo_key, "status": "error", "error": pull_result.error})
132
+ if not quiet:
133
+ err_console.print(f" [red]✗[/red] {repo_key}: {pull_result.error}")
134
+ else:
135
+ results.append({"repo": repo_key, "status": "exists"})
136
+ if not quiet:
137
+ console.print(f" [yellow]○[/yellow] {repo_key} already tracked. Use --update to pull.")
138
+ continue
139
+
140
+ # Path exists on disk but not tracked
141
+ if target.exists() and is_git_repo(target):
142
+ remote = get_remote_url(target)
143
+ if remote:
144
+ repo = Repo(
145
+ owner=repo_owner,
146
+ name=parsed.repo,
147
+ remote_url=remote,
148
+ workspace=ws.label,
149
+ tags=list(tags),
150
+ )
151
+ store.add(repo)
152
+ results.append({"repo": repo_key, "status": "registered"})
153
+ if not quiet:
154
+ console.print(f" [green]✓[/green] {repo_key} registered (already on disk)")
155
+ continue
156
+
157
+ # Path exists but is not a git repo
158
+ if target.exists() and not is_git_repo(target):
159
+ results.append({"repo": repo_key, "status": "error", "error": "Path exists but is not a git repo"})
160
+ if not quiet:
161
+ err_console.print(f" [red]✗[/red] {repo_key}: path exists but is not a git repo")
162
+ continue
163
+
164
+ # Clone (with retry)
165
+ if not quiet:
166
+ console.print(f" [dim]Cloning[/dim] {repo_key} → {ws.label}...")
167
+
168
+ target.parent.mkdir(parents=True, exist_ok=True)
169
+
170
+ success, error = False, ""
171
+ for attempt in range(1 + retry):
172
+ if attempt > 0:
173
+ # Clean up partial clone before retrying
174
+ import shutil
175
+ if target.exists():
176
+ shutil.rmtree(target, ignore_errors=True)
177
+ if not quiet:
178
+ err_console.print(f" [dim]Retry {attempt}/{retry}...[/dim]")
179
+ success, error = git_clone(
180
+ url=parsed.clone_url,
181
+ target=target,
182
+ shallow=shallow,
183
+ branch=branch,
184
+ recursive=recursive,
185
+ )
186
+ if success:
187
+ break
188
+
189
+ if success:
190
+ from datetime import datetime
191
+ repo = Repo(
192
+ owner=repo_owner,
193
+ name=parsed.repo,
194
+ remote_url=parsed.clone_url,
195
+ workspace=ws.label,
196
+ tags=list(tags),
197
+ last_pulled=datetime.now().isoformat(),
198
+ )
199
+ store.add(repo)
200
+ results.append({"repo": repo_key, "status": "cloned"})
201
+ if not quiet:
202
+ console.print(f" [green]✓[/green] {repo_key} cloned")
203
+ else:
204
+ results.append({"repo": repo_key, "status": "error", "error": error})
205
+ if not quiet:
206
+ err_console.print(f" [red]✗[/red] {repo_key}: {error}")
207
+ hint = _clone_error_hint(error)
208
+ if hint:
209
+ err_console.print(f" [dim]{hint}[/dim]")
210
+
211
+ # Summary
212
+ if output_json:
213
+ cloned = sum(1 for r in results if r["status"] == "cloned")
214
+ registered = sum(1 for r in results if r["status"] == "registered")
215
+ errors = sum(1 for r in results if r["status"] == "error")
216
+ json.dump(
217
+ {
218
+ "total": len(results),
219
+ "cloned": cloned,
220
+ "registered": registered,
221
+ "errors": errors,
222
+ "results": results,
223
+ },
224
+ sys.stdout,
225
+ indent=2,
226
+ )
227
+ print()
228
+ elif not quiet and len(results) > 1:
229
+ cloned = sum(1 for r in results if r["status"] == "cloned")
230
+ registered = sum(1 for r in results if r["status"] == "registered")
231
+ existed = sum(1 for r in results if r["status"] == "exists")
232
+ errors = sum(1 for r in results if r["status"] == "error")
233
+ console.print()
234
+ parts = []
235
+ if cloned:
236
+ parts.append(f"[green]{cloned} cloned[/green]")
237
+ if registered:
238
+ parts.append(f"[green]{registered} registered[/green]")
239
+ if existed:
240
+ parts.append(f"[yellow]{existed} already tracked[/yellow]")
241
+ if errors:
242
+ parts.append(f"[red]{errors} failed[/red]")
243
+ console.print(f" Done: {' | '.join(parts)}")
244
+
245
+ if any(r["status"] == "error" for r in results):
246
+ raise typer.Exit(code=1)
247
+
248
+
249
+ def _clone_error_hint(error: str) -> str:
250
+ """Return a user-friendly hint based on common clone error patterns."""
251
+ err = error.lower()
252
+ if "permission denied (publickey)" in err:
253
+ return "Hint: SSH key not configured. Try --ssh=false or check ssh-add -l"
254
+ if "repository not found" in err or "does not exist" in err:
255
+ return "Hint: Check the URL. If this is a private repo, ensure you have access."
256
+ if "timed out" in err:
257
+ return "Hint: Try --shallow for large repos, or check your network connection."
258
+ if "could not resolve host" in err:
259
+ return "Hint: DNS resolution failed. Check your network connection."
260
+ if "already exists and is not an empty directory" in err:
261
+ return "Hint: Target directory already exists. Use --update to pull instead."
262
+ return ""
@@ -0,0 +1,235 @@
1
+ """gitstow config — view and modify settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from gitstow.core.config import load_config, save_config
12
+ from gitstow.core.paths import CONFIG_FILE, REPOS_FILE
13
+ from gitstow.core.repo import RepoStore
14
+
15
+ config_app = typer.Typer(
16
+ help="View and modify gitstow settings.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+
24
+ @config_app.command("show")
25
+ def config_show(
26
+ output_json: bool = typer.Option(False, "--json", "-j", help="JSON output."),
27
+ ) -> None:
28
+ """Show current configuration."""
29
+ settings = load_config()
30
+ store = RepoStore()
31
+
32
+ if output_json:
33
+ data = settings.to_dict()
34
+ data["config_file"] = str(CONFIG_FILE)
35
+ data["repos_file"] = str(REPOS_FILE)
36
+ data["repos_tracked"] = store.count()
37
+ json.dump(data, sys.stdout, indent=2)
38
+ print()
39
+ return
40
+
41
+ console.print("\n [bold]gitstow config[/bold]\n")
42
+
43
+ rows = [
44
+ ("default_host", settings.default_host),
45
+ ("prefer_ssh", str(settings.prefer_ssh).lower()),
46
+ ("parallel_limit", str(settings.parallel_limit)),
47
+ ]
48
+
49
+ max_label = max(len(r[0]) for r in rows)
50
+ for label, value in rows:
51
+ console.print(f" {label.ljust(max_label + 2)}{value}")
52
+
53
+ console.print()
54
+
55
+ # Show workspaces
56
+ workspaces = settings.get_workspaces()
57
+ console.print(f" [bold]Workspaces ({len(workspaces)}):[/bold]")
58
+ ws_counts = store.all_workspaces()
59
+ for ws in workspaces:
60
+ count = ws_counts.get(ws.label, 0)
61
+ tags_str = f" auto_tags: [{', '.join(ws.auto_tags)}]" if ws.auto_tags else ""
62
+ console.print(f" [cyan]{ws.label}[/cyan] — {ws.path} ({ws.layout}, {count} repos){tags_str}")
63
+
64
+ console.print()
65
+ console.print(f" [dim]Config file: {CONFIG_FILE}[/dim]")
66
+ console.print(f" [dim]Repos file: {REPOS_FILE}[/dim]")
67
+ console.print(f" [dim]Repos tracked: {store.count()}[/dim]")
68
+ console.print()
69
+
70
+
71
+ @config_app.command("set")
72
+ def config_set(
73
+ key: str = typer.Argument(help="Setting key (root_path, default_host, prefer_ssh, parallel_limit)."),
74
+ value: str = typer.Argument(help="New value."),
75
+ ) -> None:
76
+ """Set a configuration value.
77
+
78
+ \b
79
+ Examples:
80
+ gitstow config set root_path ~/labs/OSS
81
+ gitstow config set default_host gitlab.com
82
+ gitstow config set prefer_ssh true
83
+ gitstow config set parallel_limit 8
84
+ """
85
+ settings = load_config()
86
+
87
+ valid_keys = {"default_host", "prefer_ssh", "parallel_limit"}
88
+ if key not in valid_keys:
89
+ err_console.print(
90
+ f"[red]Error:[/red] Unknown key '{key}'. Valid keys: {', '.join(sorted(valid_keys))}\n"
91
+ f" [dim]Use 'gitstow workspace add/remove' to manage workspace paths.[/dim]"
92
+ )
93
+ raise typer.Exit(code=1)
94
+
95
+ if key == "prefer_ssh":
96
+ if value.lower() in ("true", "yes", "1"):
97
+ setattr(settings, key, True)
98
+ elif value.lower() in ("false", "no", "0"):
99
+ setattr(settings, key, False)
100
+ else:
101
+ err_console.print("[red]Error:[/red] prefer_ssh must be true or false.")
102
+ raise typer.Exit(code=1)
103
+ elif key == "parallel_limit":
104
+ try:
105
+ setattr(settings, key, int(value))
106
+ except ValueError:
107
+ err_console.print("[red]Error:[/red] parallel_limit must be a number.")
108
+ raise typer.Exit(code=1)
109
+ else:
110
+ setattr(settings, key, value)
111
+
112
+ save_config(settings)
113
+ console.print(f" [green]✓[/green] {key} = {value}")
114
+
115
+
116
+ @config_app.command("path")
117
+ def config_path() -> None:
118
+ """Show the config file path."""
119
+ console.print(str(CONFIG_FILE))
120
+
121
+
122
+ @config_app.command("migrate-root")
123
+ def config_migrate_root(
124
+ new_root: str = typer.Argument(help="New root path for repos."),
125
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
126
+ copy: bool = typer.Option(False, "--copy", help="Copy instead of move (keeps old root)."),
127
+ ) -> None:
128
+ """Move all repos to a new root directory.
129
+
130
+ Moves (or copies) every repo from the current root to the new root,
131
+ preserving the owner/repo structure, and updates the config.
132
+
133
+ \b
134
+ Examples:
135
+ gitstow config migrate-root ~/new-location
136
+ gitstow config migrate-root ~/new-location --copy
137
+ """
138
+ import shutil
139
+ from pathlib import Path
140
+
141
+ from gitstow.core.git import is_git_repo
142
+
143
+ settings = load_config()
144
+ store = RepoStore()
145
+ old_root = settings.get_root()
146
+ new_root_path = Path(new_root).expanduser().resolve()
147
+
148
+ if old_root == new_root_path:
149
+ console.print(" [dim]New root is the same as current root. Nothing to do.[/dim]")
150
+ return
151
+
152
+ repos = store.list_all()
153
+ if not repos:
154
+ # No repos — just update the pointer
155
+ settings.root_path = str(new_root_path)
156
+ save_config(settings)
157
+ new_root_path.mkdir(parents=True, exist_ok=True)
158
+ console.print(f" [green]✓[/green] Root updated to {new_root_path} (no repos to move).")
159
+ return
160
+
161
+ # Show what will happen
162
+ action = "Copy" if copy else "Move"
163
+ console.print(f"\n [bold]{action} {len(repos)} repos[/bold]\n")
164
+ console.print(f" From: {old_root}")
165
+ console.print(f" To: {new_root_path}\n")
166
+
167
+ # Check which repos actually exist on disk
168
+ movable = []
169
+ missing = []
170
+ for repo in repos:
171
+ src = repo.get_path(old_root)
172
+ if src.exists() and is_git_repo(src):
173
+ movable.append(repo)
174
+ else:
175
+ missing.append(repo)
176
+
177
+ if missing:
178
+ console.print(f" [yellow]⚠ {len(missing)} repos not found on disk (will update config only):[/yellow]")
179
+ for r in missing:
180
+ console.print(f" {r.key}")
181
+ console.print()
182
+
183
+ console.print(f" {len(movable)} repos to {action.lower()}")
184
+
185
+ if not yes:
186
+ if not typer.confirm(f"\n Proceed with {action.lower()}?"):
187
+ console.print(" [dim]Cancelled.[/dim]")
188
+ raise typer.Exit()
189
+
190
+ # Create new root
191
+ new_root_path.mkdir(parents=True, exist_ok=True)
192
+
193
+ # Move/copy each repo
194
+ succeeded = 0
195
+ failed = 0
196
+ for repo in movable:
197
+ src = repo.get_path(old_root)
198
+ dst = repo.get_path(new_root_path)
199
+ dst.parent.mkdir(parents=True, exist_ok=True)
200
+
201
+ try:
202
+ if dst.exists():
203
+ console.print(f" [yellow]⚠[/yellow] {repo.key}: target already exists, skipping")
204
+ continue
205
+
206
+ if copy:
207
+ shutil.copytree(src, dst, symlinks=True)
208
+ else:
209
+ try:
210
+ src.rename(dst)
211
+ except OSError:
212
+ # Cross-device: copy + delete
213
+ shutil.copytree(src, dst, symlinks=True)
214
+ shutil.rmtree(src)
215
+
216
+ succeeded += 1
217
+ console.print(f" [green]✓[/green] {repo.key}")
218
+ except Exception as e:
219
+ failed += 1
220
+ err_console.print(f" [red]✗[/red] {repo.key}: {e}")
221
+
222
+ # Update config
223
+ settings.root_path = str(new_root_path)
224
+ save_config(settings)
225
+
226
+ # Clean up empty owner dirs in old root (if moved, not copied)
227
+ if not copy and old_root.exists():
228
+ for owner_dir in old_root.iterdir():
229
+ if owner_dir.is_dir() and not any(owner_dir.iterdir()):
230
+ owner_dir.rmdir()
231
+
232
+ console.print(f"\n Done: {succeeded} {action.lower()}d", end="")
233
+ if failed:
234
+ console.print(f", [red]{failed} failed[/red]", end="")
235
+ console.print(f"\n Root updated to: {new_root_path}\n")
gitstow/cli/doctor.py ADDED
@@ -0,0 +1,163 @@
1
+ """gitstow doctor — health check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from gitstow import __version__
12
+ from gitstow.core.config import load_config
13
+ from gitstow.core.paths import APP_HOME, CONFIG_FILE, get_repos_file
14
+ from gitstow.core.git import is_git_installed
15
+ from gitstow.core.repo import RepoStore
16
+ from gitstow.core.discovery import discover_repos, reconcile
17
+
18
+ console = Console()
19
+
20
+
21
+ def doctor(
22
+ output_json: bool = typer.Option(False, "--json", "-j", help="JSON output."),
23
+ ) -> None:
24
+ """[bold]Check[/bold] system health — git, config, and repo integrity."""
25
+ settings = load_config()
26
+ store = RepoStore()
27
+ workspaces = settings.get_workspaces()
28
+
29
+ checks: dict = {}
30
+
31
+ # 1. System
32
+ git_ok, git_version = is_git_installed()
33
+ checks["system"] = {
34
+ "git_installed": git_ok,
35
+ "git_version": git_version,
36
+ "gitstow_version": __version__,
37
+ }
38
+
39
+ # 2. Configuration
40
+ repos_file = get_repos_file()
41
+ checks["config"] = {
42
+ "app_dir_exists": APP_HOME.exists(),
43
+ "config_file_exists": CONFIG_FILE.exists(),
44
+ "repos_file_exists": repos_file.exists(),
45
+ "repos_file_path": str(repos_file),
46
+ "workspaces": len(workspaces),
47
+ "repos_tracked": store.count(),
48
+ }
49
+
50
+ # 3. Per-workspace repo integrity
51
+ checks["workspaces"] = {}
52
+ total_on_disk = 0
53
+ total_orphaned = []
54
+ total_missing = []
55
+
56
+ for ws in workspaces:
57
+ root = ws.get_path()
58
+ ws_check: dict = {"path": str(root), "layout": ws.layout, "exists": root.exists()}
59
+
60
+ if root.exists():
61
+ on_disk = discover_repos(root, layout=ws.layout)
62
+ tracked = store.list_by_workspace(ws.label)
63
+ tracked_map = {r.key: r for r in tracked}
64
+ reconciled = reconcile(on_disk, tracked_map)
65
+
66
+ ws_check["on_disk"] = len(on_disk)
67
+ ws_check["tracked"] = len(tracked)
68
+ ws_check["matched"] = len(reconciled["matched"])
69
+ ws_check["orphaned"] = [r["key"] for r in reconciled["orphaned"]]
70
+ ws_check["missing"] = reconciled["missing"]
71
+
72
+ total_on_disk += len(on_disk)
73
+ total_orphaned.extend([(ws.label, r["key"]) for r in reconciled["orphaned"]])
74
+ total_missing.extend([(ws.label, k) for k in reconciled["missing"]])
75
+ else:
76
+ ws_check["error"] = "Directory does not exist"
77
+
78
+ checks["workspaces"][ws.label] = ws_check
79
+
80
+ if output_json:
81
+ json.dump(checks, sys.stdout, indent=2)
82
+ print()
83
+ return
84
+
85
+ # Human output
86
+ console.print("\n [bold]gitstow doctor[/bold]\n")
87
+
88
+ # System
89
+ console.print(" [bold]1. System[/bold]\n")
90
+ git_status = f"[green]{git_version}[/green]" if git_ok else "[red]not installed[/red]"
91
+ console.print(f" git: {git_status}")
92
+ console.print(f" gitstow: v{__version__}")
93
+
94
+ # Config
95
+ console.print("\n [bold]2. Configuration[/bold]\n")
96
+ _check("App directory", APP_HOME.exists())
97
+ _check("Config file", CONFIG_FILE.exists())
98
+ _check("Repos file", repos_file.exists(), str(repos_file))
99
+ console.print(f" Workspaces: {len(workspaces)}")
100
+ console.print(f" Repos tracked: {store.count()}")
101
+
102
+ if not CONFIG_FILE.exists():
103
+ console.print("\n [yellow]Run [bold]gitstow onboard[/bold] to set up.[/yellow]")
104
+
105
+ # Per-workspace health
106
+ console.print("\n [bold]3. Workspace Health[/bold]\n")
107
+ for ws in workspaces:
108
+ ws_info = checks["workspaces"].get(ws.label, {})
109
+ exists = ws_info.get("exists", False)
110
+ status_str = "[green]OK[/green]" if exists else "[red]Missing[/red]"
111
+ console.print(f" [cyan]{ws.label}[/cyan] ({ws.layout}) — {status_str}")
112
+ console.print(f" Path: {ws.path}")
113
+
114
+ if exists and "error" not in ws_info:
115
+ console.print(f" On disk: {ws_info.get('on_disk', 0)} Tracked: {ws_info.get('tracked', 0)}")
116
+
117
+ if total_orphaned:
118
+ console.print(f"\n [yellow]⚠ {len(total_orphaned)} untracked repos on disk:[/yellow]")
119
+ for ws_name, key in total_orphaned:
120
+ console.print(f" [{ws_name}] {key}")
121
+
122
+ if total_missing:
123
+ console.print(f"\n [yellow]⚠ {len(total_missing)} tracked but missing from disk:[/yellow]")
124
+ for ws_name, key in total_missing:
125
+ console.print(f" [{ws_name}] {key}")
126
+
127
+ if not total_orphaned and not total_missing:
128
+ console.print(f"\n [green]✓ All repos in sync across {len(workspaces)} workspace(s)[/green]")
129
+
130
+ # 4. SSH connectivity hint
131
+ ssh_repos = [r for r in store.list_all() if r.remote_url and r.remote_url.startswith("git@")]
132
+ if ssh_repos:
133
+ console.print("\n [bold]4. SSH Connectivity[/bold]\n")
134
+ ssh_ok = _check_ssh_connectivity()
135
+ if ssh_ok:
136
+ console.print(" [green]✓[/green] SSH connection to github.com works")
137
+ else:
138
+ console.print(" [yellow]⚠ SSH connection to github.com failed[/yellow]")
139
+ console.print(" [dim]You have SSH repos but ssh-agent may not be running.[/dim]")
140
+ console.print(" [dim]Check: ssh -T git@github.com[/dim]")
141
+
142
+ console.print()
143
+
144
+
145
+ def _check_ssh_connectivity() -> bool:
146
+ """Quick check if SSH to github.com works (exit code 1 = auth ok, 255 = failure)."""
147
+ import subprocess
148
+ try:
149
+ result = subprocess.run(
150
+ ["ssh", "-T", "-o", "ConnectTimeout=5", "git@github.com"],
151
+ capture_output=True, text=True, timeout=10,
152
+ )
153
+ # GitHub returns exit code 1 with "successfully authenticated" on success
154
+ return result.returncode == 1 and "successfully authenticated" in result.stderr
155
+ except (subprocess.TimeoutExpired, FileNotFoundError):
156
+ return False
157
+
158
+
159
+ def _check(label: str, ok: bool, detail: str = "") -> None:
160
+ """Print a check result."""
161
+ status = "[green]OK[/green]" if ok else "[red]Missing[/red]"
162
+ detail_str = f" [dim]({detail})[/dim]" if detail else ""
163
+ console.print(f" {label}: {status}{detail_str}")