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/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
+ )
@@ -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)