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/__init__.py
ADDED
gitstow/__main__.py
ADDED
gitstow/cli/__init__.py
ADDED
|
@@ -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}")
|