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/stats.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""gitstow stats — collection statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from gitstow.core.config import load_config
|
|
14
|
+
from gitstow.core.git import get_disk_size, format_size, is_git_repo
|
|
15
|
+
from gitstow.core.repo import RepoStore
|
|
16
|
+
from gitstow.cli.helpers import iter_repos_with_workspace
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def stats(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
output_json: bool = typer.Option(
|
|
24
|
+
False, "--json", "-j", help="JSON output.",
|
|
25
|
+
),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""[bold]Stats[/bold] — collection statistics and disk usage.
|
|
28
|
+
|
|
29
|
+
Shows total repos, owners, tags, frozen count, and disk usage.
|
|
30
|
+
"""
|
|
31
|
+
settings = load_config()
|
|
32
|
+
store = RepoStore()
|
|
33
|
+
ws_label = ctx.obj.get("workspace") if ctx.obj else None
|
|
34
|
+
|
|
35
|
+
repo_ws_pairs = iter_repos_with_workspace(store, settings, ws_label)
|
|
36
|
+
repos = [r for r, _ in repo_ws_pairs]
|
|
37
|
+
owners = store.all_owners()
|
|
38
|
+
tags = store.all_tags()
|
|
39
|
+
frozen = store.list_frozen()
|
|
40
|
+
|
|
41
|
+
# Calculate disk usage (can be slow for large repos)
|
|
42
|
+
total_size = 0
|
|
43
|
+
size_by_owner: dict[str, int] = defaultdict(int)
|
|
44
|
+
largest_repos: list[tuple[str, int]] = []
|
|
45
|
+
|
|
46
|
+
for repo, ws in repo_ws_pairs:
|
|
47
|
+
path = repo.get_path(ws.get_path())
|
|
48
|
+
if path.exists() and is_git_repo(path):
|
|
49
|
+
size = get_disk_size(path)
|
|
50
|
+
total_size += size
|
|
51
|
+
size_by_owner[repo.owner] += size
|
|
52
|
+
largest_repos.append((repo.key, size))
|
|
53
|
+
|
|
54
|
+
largest_repos.sort(key=lambda x: x[1], reverse=True)
|
|
55
|
+
|
|
56
|
+
# Find oldest and newest
|
|
57
|
+
added_dates = [r.added for r in repos if r.added]
|
|
58
|
+
oldest = min(added_dates) if added_dates else "unknown"
|
|
59
|
+
newest = max(added_dates) if added_dates else "unknown"
|
|
60
|
+
|
|
61
|
+
# Pull activity
|
|
62
|
+
never_pulled = sum(1 for r in repos if not r.last_pulled)
|
|
63
|
+
|
|
64
|
+
data = {
|
|
65
|
+
"total_repos": len(repos),
|
|
66
|
+
"total_owners": len(owners),
|
|
67
|
+
"total_tags": len(tags),
|
|
68
|
+
"frozen_count": len(frozen),
|
|
69
|
+
"total_disk_size": total_size,
|
|
70
|
+
"total_disk_size_human": format_size(total_size),
|
|
71
|
+
"oldest_added": oldest,
|
|
72
|
+
"newest_added": newest,
|
|
73
|
+
"never_pulled": never_pulled,
|
|
74
|
+
"owners": {k: {"count": v, "size": size_by_owner.get(k, 0), "size_human": format_size(size_by_owner.get(k, 0))} for k, v in owners.items()},
|
|
75
|
+
"tags": tags,
|
|
76
|
+
"largest_repos": [{"repo": k, "size": v, "size_human": format_size(v)} for k, v in largest_repos[:10]],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if output_json:
|
|
80
|
+
json.dump(data, sys.stdout, indent=2)
|
|
81
|
+
print()
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Human display
|
|
85
|
+
console.print("\n [bold]gitstow stats[/bold]\n")
|
|
86
|
+
|
|
87
|
+
# Overview
|
|
88
|
+
console.print(f" Repos: {len(repos)}")
|
|
89
|
+
console.print(f" Owners: {len(owners)}")
|
|
90
|
+
console.print(f" Tags: {len(tags)}")
|
|
91
|
+
console.print(f" Frozen: {len(frozen)}")
|
|
92
|
+
console.print(f" Disk usage: {format_size(total_size)}")
|
|
93
|
+
console.print(f" First added: {oldest}")
|
|
94
|
+
console.print(f" Last added: {newest}")
|
|
95
|
+
if never_pulled:
|
|
96
|
+
console.print(f" Never pulled:{never_pulled}")
|
|
97
|
+
|
|
98
|
+
# Repos by owner
|
|
99
|
+
if owners:
|
|
100
|
+
console.print("\n [bold]By Owner[/bold]\n")
|
|
101
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
102
|
+
table.add_column("Owner")
|
|
103
|
+
table.add_column("Repos", justify="right")
|
|
104
|
+
table.add_column("Disk", justify="right")
|
|
105
|
+
|
|
106
|
+
for owner_name in sorted(owners.keys()):
|
|
107
|
+
table.add_row(
|
|
108
|
+
owner_name,
|
|
109
|
+
str(owners[owner_name]),
|
|
110
|
+
format_size(size_by_owner.get(owner_name, 0)),
|
|
111
|
+
)
|
|
112
|
+
console.print(table)
|
|
113
|
+
|
|
114
|
+
# Tags
|
|
115
|
+
if tags:
|
|
116
|
+
console.print("\n [bold]Tags[/bold]\n")
|
|
117
|
+
for tag_name, count in sorted(tags.items()):
|
|
118
|
+
console.print(f" {tag_name} [dim]({count})[/dim]")
|
|
119
|
+
|
|
120
|
+
# Largest repos
|
|
121
|
+
if largest_repos:
|
|
122
|
+
console.print("\n [bold]Largest Repos[/bold]\n")
|
|
123
|
+
for repo_key, size in largest_repos[:5]:
|
|
124
|
+
console.print(f" {repo_key.ljust(30)} {format_size(size)}")
|
|
125
|
+
|
|
126
|
+
console.print()
|
gitstow/cli/status.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""gitstow status — dashboard showing git status across all repos."""
|
|
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
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from gitstow.core.config import load_config, Workspace
|
|
14
|
+
from gitstow.core.git import get_status, is_git_repo, get_last_commit
|
|
15
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
16
|
+
from gitstow.core.parallel import run_parallel_sync
|
|
17
|
+
from gitstow.cli.helpers import iter_repos_with_workspace
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
err_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_repo_status(repo: Repo, ws: Workspace) -> dict:
|
|
24
|
+
"""Gather status for a single repo."""
|
|
25
|
+
path = repo.get_path(ws.get_path())
|
|
26
|
+
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return {"repo": repo.key, "workspace": repo.workspace, "error": "Not found on disk"}
|
|
29
|
+
|
|
30
|
+
if not is_git_repo(path):
|
|
31
|
+
return {"repo": repo.key, "workspace": repo.workspace, "error": "Not a git repo"}
|
|
32
|
+
|
|
33
|
+
status = get_status(path)
|
|
34
|
+
commit = get_last_commit(path)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"repo": repo.key,
|
|
38
|
+
"workspace": repo.workspace,
|
|
39
|
+
"branch": status.branch,
|
|
40
|
+
"dirty": status.dirty,
|
|
41
|
+
"staged": status.staged,
|
|
42
|
+
"untracked": status.untracked,
|
|
43
|
+
"ahead": status.ahead,
|
|
44
|
+
"behind": status.behind,
|
|
45
|
+
"clean": status.clean,
|
|
46
|
+
"status_symbol": status.status_symbol,
|
|
47
|
+
"ahead_behind": status.ahead_behind_str,
|
|
48
|
+
"frozen": repo.frozen,
|
|
49
|
+
"tags": repo.tags,
|
|
50
|
+
"last_commit": commit.message,
|
|
51
|
+
"last_commit_date": commit.date,
|
|
52
|
+
"last_pulled": repo.last_pulled,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def status(
|
|
57
|
+
ctx: typer.Context,
|
|
58
|
+
tag: Optional[list[str]] = typer.Option(
|
|
59
|
+
None, "--tag", "-t", help="Filter by tag.",
|
|
60
|
+
),
|
|
61
|
+
owner: Optional[str] = typer.Option(
|
|
62
|
+
None, "--owner", help="Filter by owner.",
|
|
63
|
+
),
|
|
64
|
+
dirty_only: bool = typer.Option(
|
|
65
|
+
False, "--dirty", help="Show only dirty repos.",
|
|
66
|
+
),
|
|
67
|
+
output_json: bool = typer.Option(
|
|
68
|
+
False, "--json", "-j", help="JSON output.",
|
|
69
|
+
),
|
|
70
|
+
quiet: bool = typer.Option(
|
|
71
|
+
False, "--quiet", "-q", help="Minimal output.",
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""[bold yellow]Status[/bold yellow] dashboard — git status across all repos.
|
|
75
|
+
|
|
76
|
+
Shows branch, clean/dirty state, ahead/behind, and last commit.
|
|
77
|
+
|
|
78
|
+
\b
|
|
79
|
+
Examples:
|
|
80
|
+
gitstow status # All repos
|
|
81
|
+
gitstow status --dirty # Only dirty repos
|
|
82
|
+
gitstow status --tag ai # Filter by tag
|
|
83
|
+
gitstow status -w active # Filter by workspace
|
|
84
|
+
"""
|
|
85
|
+
settings = load_config()
|
|
86
|
+
store = RepoStore()
|
|
87
|
+
ws_label = ctx.obj.get("workspace") if ctx.obj else None
|
|
88
|
+
|
|
89
|
+
repo_ws_pairs = iter_repos_with_workspace(store, settings, ws_label)
|
|
90
|
+
|
|
91
|
+
if tag:
|
|
92
|
+
tag_set = set(tag)
|
|
93
|
+
repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if tag_set.intersection(r.tags)]
|
|
94
|
+
|
|
95
|
+
if owner:
|
|
96
|
+
repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.owner == owner]
|
|
97
|
+
|
|
98
|
+
if not repo_ws_pairs:
|
|
99
|
+
if not quiet:
|
|
100
|
+
console.print("[dim]No repos tracked.[/dim]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# Gather status in parallel
|
|
104
|
+
tasks = [
|
|
105
|
+
(repo.global_key, lambda r=repo, w=ws: _get_repo_status(r, w))
|
|
106
|
+
for repo, ws in repo_ws_pairs
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
results = run_parallel_sync(tasks, max_concurrent=settings.parallel_limit)
|
|
110
|
+
|
|
111
|
+
# Extract result data
|
|
112
|
+
statuses = []
|
|
113
|
+
for task_result in results:
|
|
114
|
+
if task_result.success and task_result.data:
|
|
115
|
+
statuses.append(task_result.data)
|
|
116
|
+
else:
|
|
117
|
+
statuses.append({
|
|
118
|
+
"repo": task_result.key,
|
|
119
|
+
"error": task_result.error or "Unknown error",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# Filter dirty only
|
|
123
|
+
if dirty_only:
|
|
124
|
+
statuses = [s for s in statuses if not s.get("clean", True)]
|
|
125
|
+
|
|
126
|
+
if output_json:
|
|
127
|
+
json.dump(statuses, sys.stdout, indent=2)
|
|
128
|
+
print()
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if not statuses:
|
|
132
|
+
console.print("[dim]No repos match the filter.[/dim]")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Check if multiple workspaces — show workspace column if so
|
|
136
|
+
ws_labels = {s.get("workspace", "") for s in statuses if "error" not in s or "workspace" in s}
|
|
137
|
+
multi_ws = len(ws_labels) > 1
|
|
138
|
+
|
|
139
|
+
# Rich table
|
|
140
|
+
console.print(f"\n [bold]gitstow status[/bold] — {len(statuses)} repos\n")
|
|
141
|
+
|
|
142
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
143
|
+
if multi_ws:
|
|
144
|
+
table.add_column("Workspace", style="cyan")
|
|
145
|
+
table.add_column("Repo", style="white", min_width=20)
|
|
146
|
+
table.add_column("Branch")
|
|
147
|
+
table.add_column("Status")
|
|
148
|
+
table.add_column("Ahead/Behind")
|
|
149
|
+
table.add_column("Last Commit", style="dim")
|
|
150
|
+
|
|
151
|
+
for s in sorted(statuses, key=lambda x: x.get("workspace", "") + ":" + x["repo"]):
|
|
152
|
+
if "error" in s:
|
|
153
|
+
row = []
|
|
154
|
+
if multi_ws:
|
|
155
|
+
row.append(s.get("workspace", ""))
|
|
156
|
+
row.extend([s["repo"], "", f"[red]✗ {s['error']}[/red]", "", ""])
|
|
157
|
+
table.add_row(*row)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Status styling
|
|
161
|
+
if s.get("frozen"):
|
|
162
|
+
status_str = "[cyan]❄ frozen[/cyan]"
|
|
163
|
+
elif s.get("clean"):
|
|
164
|
+
status_str = "[green]✓ clean[/green]"
|
|
165
|
+
else:
|
|
166
|
+
symbol = s.get("status_symbol", "?")
|
|
167
|
+
dirty_count = s.get("dirty", 0) + s.get("staged", 0) + s.get("untracked", 0)
|
|
168
|
+
status_str = f"[yellow]{symbol} dirty({dirty_count})[/yellow]"
|
|
169
|
+
|
|
170
|
+
# Ahead/behind styling
|
|
171
|
+
ab = s.get("ahead_behind", "—")
|
|
172
|
+
if "↑" in ab and "↓" in ab:
|
|
173
|
+
ab_styled = f"[red]{ab}[/red]"
|
|
174
|
+
elif "↑" in ab:
|
|
175
|
+
ab_styled = f"[blue]{ab}[/blue]"
|
|
176
|
+
elif "↓" in ab:
|
|
177
|
+
ab_styled = f"[magenta]{ab}[/magenta]"
|
|
178
|
+
else:
|
|
179
|
+
ab_styled = f"[dim]{ab}[/dim]"
|
|
180
|
+
|
|
181
|
+
# Last commit
|
|
182
|
+
commit_str = s.get("last_commit", "")
|
|
183
|
+
if len(commit_str) > 40:
|
|
184
|
+
commit_str = commit_str[:37] + "..."
|
|
185
|
+
commit_date = s.get("last_commit_date", "")
|
|
186
|
+
if commit_date:
|
|
187
|
+
commit_str = f"{commit_str} ({commit_date})"
|
|
188
|
+
|
|
189
|
+
row = []
|
|
190
|
+
if multi_ws:
|
|
191
|
+
row.append(s.get("workspace", ""))
|
|
192
|
+
row.extend([s["repo"], s.get("branch", ""), status_str, ab_styled, commit_str])
|
|
193
|
+
table.add_row(*row)
|
|
194
|
+
|
|
195
|
+
console.print(table)
|
|
196
|
+
|
|
197
|
+
# Summary counts
|
|
198
|
+
clean = sum(1 for s in statuses if s.get("clean") and not s.get("frozen"))
|
|
199
|
+
dirty = sum(1 for s in statuses if not s.get("clean") and "error" not in s and not s.get("frozen"))
|
|
200
|
+
frozen = sum(1 for s in statuses if s.get("frozen"))
|
|
201
|
+
errors = sum(1 for s in statuses if "error" in s)
|
|
202
|
+
|
|
203
|
+
summary_parts = []
|
|
204
|
+
if clean:
|
|
205
|
+
summary_parts.append(f"[green]{clean} clean[/green]")
|
|
206
|
+
if dirty:
|
|
207
|
+
summary_parts.append(f"[yellow]{dirty} dirty[/yellow]")
|
|
208
|
+
if frozen:
|
|
209
|
+
summary_parts.append(f"[cyan]{frozen} frozen[/cyan]")
|
|
210
|
+
if errors:
|
|
211
|
+
summary_parts.append(f"[red]{errors} errors[/red]")
|
|
212
|
+
|
|
213
|
+
console.print(f"\n {len(statuses)} repos: {', '.join(summary_parts)}\n")
|
gitstow/cli/tui.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""gitstow tui — interactive terminal dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tui_cmd() -> None:
|
|
12
|
+
"""[bold cyan]TUI[/bold cyan] — interactive terminal dashboard.
|
|
13
|
+
|
|
14
|
+
Browse, filter, and manage repos with keyboard navigation.
|
|
15
|
+
Requires textual: pip install gitstow[tui]
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
from gitstow.tui.app import GitstowApp
|
|
19
|
+
except ImportError:
|
|
20
|
+
console.print(
|
|
21
|
+
"[red]Error:[/red] Textual is not installed. "
|
|
22
|
+
"Install it with: [bold]pip install gitstow[tui][/bold]"
|
|
23
|
+
)
|
|
24
|
+
raise typer.Exit(code=1)
|
|
25
|
+
|
|
26
|
+
app = GitstowApp()
|
|
27
|
+
app.run()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""gitstow workspace — manage workspaces (add, remove, list, scan)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from gitstow.core.config import Workspace, load_config, save_config
|
|
13
|
+
from gitstow.core.discovery import discover_repos
|
|
14
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
15
|
+
|
|
16
|
+
workspace_app = typer.Typer(
|
|
17
|
+
help="Manage workspaces — add, remove, list, scan.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
err_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@workspace_app.command("list")
|
|
26
|
+
def workspace_list(
|
|
27
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="One label per line."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""[bold]List[/bold] all configured workspaces."""
|
|
30
|
+
settings = load_config()
|
|
31
|
+
workspaces = settings.get_workspaces()
|
|
32
|
+
store = RepoStore()
|
|
33
|
+
|
|
34
|
+
if not workspaces:
|
|
35
|
+
if not quiet:
|
|
36
|
+
console.print("[dim]No workspaces configured. Run [bold]gitstow onboard[/bold].[/dim]")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if quiet:
|
|
40
|
+
for ws in workspaces:
|
|
41
|
+
print(ws.label)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
45
|
+
table.add_column("Label", style="cyan")
|
|
46
|
+
table.add_column("Path")
|
|
47
|
+
table.add_column("Layout")
|
|
48
|
+
table.add_column("Auto-tags", style="dim")
|
|
49
|
+
table.add_column("Repos", justify="right")
|
|
50
|
+
|
|
51
|
+
ws_counts = store.all_workspaces()
|
|
52
|
+
for ws in workspaces:
|
|
53
|
+
count = ws_counts.get(ws.label, 0)
|
|
54
|
+
table.add_row(
|
|
55
|
+
ws.label,
|
|
56
|
+
ws.path,
|
|
57
|
+
ws.layout,
|
|
58
|
+
", ".join(ws.auto_tags) if ws.auto_tags else "—",
|
|
59
|
+
str(count),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
console.print()
|
|
63
|
+
console.print(table)
|
|
64
|
+
console.print()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@workspace_app.command("add")
|
|
68
|
+
def workspace_add(
|
|
69
|
+
path: str = typer.Argument(help="Path to the workspace directory."),
|
|
70
|
+
label: str = typer.Option(..., "--label", "-l", help="Unique label for this workspace."),
|
|
71
|
+
layout: str = typer.Option(
|
|
72
|
+
"structured",
|
|
73
|
+
"--layout",
|
|
74
|
+
help="Directory layout: 'structured' (owner/repo) or 'flat'.",
|
|
75
|
+
),
|
|
76
|
+
auto_tags: Optional[list[str]] = typer.Option(
|
|
77
|
+
None, "--auto-tag", "-t", help="Tags to auto-apply to repos discovered in this workspace.",
|
|
78
|
+
),
|
|
79
|
+
scan: bool = typer.Option(
|
|
80
|
+
True, "--scan/--no-scan", help="Scan for existing repos after adding.",
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""[bold green]Add[/bold green] a new workspace."""
|
|
84
|
+
settings = load_config()
|
|
85
|
+
|
|
86
|
+
# Validate label uniqueness
|
|
87
|
+
if settings.get_workspace(label):
|
|
88
|
+
err_console.print(f"[red]Error:[/red] Workspace [bold]{label}[/bold] already exists.")
|
|
89
|
+
raise typer.Exit(code=1)
|
|
90
|
+
|
|
91
|
+
# Validate layout
|
|
92
|
+
if layout not in ("structured", "flat"):
|
|
93
|
+
err_console.print(f"[red]Error:[/red] Layout must be 'structured' or 'flat', got '{layout}'.")
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
resolved = Path(path).expanduser().resolve()
|
|
97
|
+
ws = Workspace(
|
|
98
|
+
path=str(resolved),
|
|
99
|
+
label=label,
|
|
100
|
+
layout=layout,
|
|
101
|
+
auto_tags=auto_tags or [],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
settings.workspaces.append(ws)
|
|
105
|
+
save_config(settings)
|
|
106
|
+
|
|
107
|
+
# Create directory if needed
|
|
108
|
+
if not resolved.exists():
|
|
109
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
console.print(f" [green]✓[/green] Created {resolved}")
|
|
111
|
+
|
|
112
|
+
console.print(f" [green]✓[/green] Workspace [bold]{label}[/bold] added ({layout} layout)")
|
|
113
|
+
|
|
114
|
+
if scan and resolved.exists():
|
|
115
|
+
_scan_workspace(ws)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@workspace_app.command("remove")
|
|
119
|
+
def workspace_remove(
|
|
120
|
+
label: str = typer.Argument(help="Label of the workspace to remove."),
|
|
121
|
+
keep_repos: bool = typer.Option(
|
|
122
|
+
True, "--keep-repos/--untrack-repos",
|
|
123
|
+
help="Keep tracked repos in the store (default) or untrack them.",
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""[bold red]Remove[/bold red] a workspace from the configuration.
|
|
127
|
+
|
|
128
|
+
Does not delete files on disk. By default, repos remain tracked in the store.
|
|
129
|
+
"""
|
|
130
|
+
settings = load_config()
|
|
131
|
+
ws = settings.get_workspace(label)
|
|
132
|
+
if ws is None:
|
|
133
|
+
err_console.print(f"[red]Error:[/red] Workspace [bold]{label}[/bold] not found.")
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
|
|
136
|
+
if len(settings.get_workspaces()) == 1:
|
|
137
|
+
err_console.print("[red]Error:[/red] Cannot remove the only workspace.")
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
|
|
140
|
+
settings.workspaces = [w for w in settings.workspaces if w.label != label]
|
|
141
|
+
save_config(settings)
|
|
142
|
+
|
|
143
|
+
if not keep_repos:
|
|
144
|
+
store = RepoStore()
|
|
145
|
+
repos = store.list_by_workspace(label)
|
|
146
|
+
for repo in repos:
|
|
147
|
+
store.remove(repo.key, workspace=label)
|
|
148
|
+
console.print(f" [yellow]○[/yellow] Untracked {len(repos)} repos from [bold]{label}[/bold]")
|
|
149
|
+
|
|
150
|
+
console.print(f" [green]✓[/green] Workspace [bold]{label}[/bold] removed")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@workspace_app.command("scan")
|
|
154
|
+
def workspace_scan(
|
|
155
|
+
label: str = typer.Argument(help="Label of the workspace to scan."),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""[bold]Scan[/bold] a workspace to discover and register repos on disk."""
|
|
158
|
+
settings = load_config()
|
|
159
|
+
ws = settings.get_workspace(label)
|
|
160
|
+
if ws is None:
|
|
161
|
+
err_console.print(f"[red]Error:[/red] Workspace [bold]{label}[/bold] not found.")
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
_scan_workspace(ws)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _scan_workspace(ws: Workspace) -> None:
|
|
168
|
+
"""Discover repos in a workspace and register any untracked ones."""
|
|
169
|
+
store = RepoStore()
|
|
170
|
+
resolved = ws.get_path()
|
|
171
|
+
|
|
172
|
+
if not resolved.is_dir():
|
|
173
|
+
console.print(f" [dim]Workspace directory does not exist: {resolved}[/dim]")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
found = discover_repos(resolved, layout=ws.layout)
|
|
177
|
+
existing = {r.key for r in store.list_by_workspace(ws.label)}
|
|
178
|
+
|
|
179
|
+
new_repos = [r for r in found if r.key not in existing]
|
|
180
|
+
|
|
181
|
+
if not new_repos:
|
|
182
|
+
console.print(f" [dim]No new repos found in [bold]{ws.label}[/bold].[/dim]")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
console.print(f" Found {len(new_repos)} new repo{'s' if len(new_repos) != 1 else ''}:")
|
|
186
|
+
for dr in new_repos:
|
|
187
|
+
console.print(f" {dr.key}")
|
|
188
|
+
|
|
189
|
+
# Register them
|
|
190
|
+
for dr in new_repos:
|
|
191
|
+
repo = Repo(
|
|
192
|
+
owner=dr.owner,
|
|
193
|
+
name=dr.name,
|
|
194
|
+
remote_url=dr.remote_url or "",
|
|
195
|
+
workspace=ws.label,
|
|
196
|
+
tags=list(ws.auto_tags),
|
|
197
|
+
)
|
|
198
|
+
store.add(repo)
|
|
199
|
+
|
|
200
|
+
console.print(
|
|
201
|
+
f" [green]✓[/green] Registered {len(new_repos)} repos in [bold]{ws.label}[/bold]"
|
|
202
|
+
)
|
gitstow/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gitstow core — business logic, git operations, and state management."""
|
gitstow/core/config.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Settings management — load, save, and validate config.
|
|
2
|
+
|
|
3
|
+
Supports multiple workspaces, each with its own path and layout mode.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from gitstow.core.paths import CONFIG_FILE, DEFAULT_ROOT, ensure_app_dirs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Workspace:
|
|
18
|
+
"""A configured workspace — a directory that gitstow manages repos in."""
|
|
19
|
+
|
|
20
|
+
path: str # e.g., "~/oss"
|
|
21
|
+
label: str # e.g., "oss" (unique identifier)
|
|
22
|
+
layout: str = "structured" # "structured" (owner/repo) or "flat"
|
|
23
|
+
auto_tags: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def get_path(self) -> Path:
|
|
26
|
+
"""Resolve the workspace path to an absolute Path."""
|
|
27
|
+
return Path(self.path).expanduser().resolve()
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
d: dict = {"path": self.path, "label": self.label, "layout": self.layout}
|
|
31
|
+
if self.auto_tags:
|
|
32
|
+
d["auto_tags"] = self.auto_tags
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict) -> Workspace:
|
|
37
|
+
return cls(
|
|
38
|
+
path=data.get("path", ""),
|
|
39
|
+
label=data.get("label", ""),
|
|
40
|
+
layout=data.get("layout", "structured"),
|
|
41
|
+
auto_tags=data.get("auto_tags", []),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Settings:
|
|
47
|
+
workspaces: list[Workspace] = field(default_factory=list)
|
|
48
|
+
default_host: str = "github.com"
|
|
49
|
+
prefer_ssh: bool = False
|
|
50
|
+
parallel_limit: int = 6
|
|
51
|
+
|
|
52
|
+
# Legacy field — only used for migration from pre-workspace configs
|
|
53
|
+
root_path: str = ""
|
|
54
|
+
|
|
55
|
+
def get_workspaces(self) -> list[Workspace]:
|
|
56
|
+
"""Return all workspaces. If none configured, synthesize one from legacy root_path."""
|
|
57
|
+
if self.workspaces:
|
|
58
|
+
return self.workspaces
|
|
59
|
+
# Backward compat: synthesize a single workspace from legacy root_path
|
|
60
|
+
path = self.root_path or str(DEFAULT_ROOT)
|
|
61
|
+
return [Workspace(path=path, label="oss", layout="structured")]
|
|
62
|
+
|
|
63
|
+
def get_workspace(self, label: str) -> Workspace | None:
|
|
64
|
+
"""Look up a workspace by label."""
|
|
65
|
+
for ws in self.get_workspaces():
|
|
66
|
+
if ws.label == label:
|
|
67
|
+
return ws
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def get_default_workspace(self) -> Workspace:
|
|
71
|
+
"""Return the first workspace (used as default for add, etc.)."""
|
|
72
|
+
return self.get_workspaces()[0]
|
|
73
|
+
|
|
74
|
+
def get_root(self) -> Path:
|
|
75
|
+
"""Deprecated — returns the default workspace path for backward compat."""
|
|
76
|
+
return self.get_default_workspace().get_path()
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
d: dict = {
|
|
80
|
+
"workspaces": [ws.to_dict() for ws in self.workspaces],
|
|
81
|
+
"default_host": self.default_host,
|
|
82
|
+
"prefer_ssh": self.prefer_ssh,
|
|
83
|
+
"parallel_limit": self.parallel_limit,
|
|
84
|
+
}
|
|
85
|
+
# Don't serialize root_path if workspaces are configured
|
|
86
|
+
if not self.workspaces and self.root_path:
|
|
87
|
+
d["root_path"] = self.root_path
|
|
88
|
+
return d
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict) -> Settings:
|
|
92
|
+
workspaces_data = data.get("workspaces", [])
|
|
93
|
+
workspaces = [Workspace.from_dict(ws) for ws in workspaces_data]
|
|
94
|
+
return cls(
|
|
95
|
+
workspaces=workspaces,
|
|
96
|
+
default_host=data.get("default_host", "github.com"),
|
|
97
|
+
prefer_ssh=data.get("prefer_ssh", False),
|
|
98
|
+
parallel_limit=data.get("parallel_limit", 6),
|
|
99
|
+
root_path=data.get("root_path", ""),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_config() -> Settings:
|
|
104
|
+
"""Load settings from config.yaml. Returns defaults if file doesn't exist."""
|
|
105
|
+
if not CONFIG_FILE.exists():
|
|
106
|
+
return Settings()
|
|
107
|
+
with open(CONFIG_FILE) as f:
|
|
108
|
+
data = yaml.safe_load(f) or {}
|
|
109
|
+
settings = Settings.from_dict(data)
|
|
110
|
+
|
|
111
|
+
# Auto-migrate: if legacy root_path is set but no workspaces, migrate
|
|
112
|
+
if not settings.workspaces and settings.root_path:
|
|
113
|
+
settings.workspaces = [
|
|
114
|
+
Workspace(
|
|
115
|
+
path=settings.root_path,
|
|
116
|
+
label="oss",
|
|
117
|
+
layout="structured",
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
settings.root_path = ""
|
|
121
|
+
save_config(settings)
|
|
122
|
+
|
|
123
|
+
return settings
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_config(settings: Settings) -> None:
|
|
127
|
+
"""Write settings to config.yaml."""
|
|
128
|
+
ensure_app_dirs()
|
|
129
|
+
with open(CONFIG_FILE, "w") as f:
|
|
130
|
+
yaml.dump(settings.to_dict(), f, default_flow_style=False, sort_keys=False)
|