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.
@@ -0,0 +1,213 @@
1
+ """gitstow list — show all repos grouped by owner or workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections import defaultdict
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from gitstow.core.config import load_config
14
+ from gitstow.core.repo import RepoStore
15
+ from gitstow.cli.helpers import iter_repos_with_workspace
16
+
17
+ console = Console()
18
+
19
+
20
+ def list_repos(
21
+ ctx: typer.Context,
22
+ query: Optional[str] = typer.Argument(
23
+ default=None,
24
+ help="Filter repos by substring match.",
25
+ ),
26
+ tag: Optional[list[str]] = typer.Option(
27
+ None, "--tag", "-t", help="Filter by tag.",
28
+ ),
29
+ owner: Optional[str] = typer.Option(
30
+ None, "--owner", help="Filter by owner.",
31
+ ),
32
+ frozen_only: bool = typer.Option(
33
+ False, "--frozen", help="Show only frozen repos.",
34
+ ),
35
+ show_paths: bool = typer.Option(
36
+ False, "--paths", "-p", help="Show full paths.",
37
+ ),
38
+ output_json: bool = typer.Option(
39
+ False, "--json", "-j", help="JSON output.",
40
+ ),
41
+ quiet: bool = typer.Option(
42
+ False, "--quiet", "-q", help="Minimal output.",
43
+ ),
44
+ ) -> None:
45
+ """[bold]List[/bold] all tracked repos, grouped by owner.
46
+
47
+ \b
48
+ Examples:
49
+ gitstow list # All repos
50
+ gitstow list react # Substring search
51
+ gitstow list --tag ai # Filter by tag
52
+ gitstow list --owner anthropic # Filter by owner
53
+ gitstow list --frozen # Only frozen repos
54
+ gitstow list -w active # Filter by workspace
55
+ """
56
+ settings = load_config()
57
+ store = RepoStore()
58
+ ws_label = ctx.obj.get("workspace") if ctx.obj else None
59
+
60
+ repo_ws_pairs = iter_repos_with_workspace(store, settings, ws_label)
61
+
62
+ if tag:
63
+ tag_set = set(tag)
64
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if tag_set.intersection(r.tags)]
65
+
66
+ if owner:
67
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.owner == owner]
68
+
69
+ if frozen_only:
70
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if r.frozen]
71
+
72
+ if query:
73
+ q = query.lower()
74
+ repo_ws_pairs = [(r, ws) for r, ws in repo_ws_pairs if q in r.key.lower()]
75
+
76
+ if not repo_ws_pairs:
77
+ if not quiet:
78
+ console.print("[dim]No repos found.[/dim]")
79
+ if output_json:
80
+ json.dump([], sys.stdout, indent=2)
81
+ print()
82
+ return
83
+
84
+ # Quiet mode: one key per line (for shell completions and scripting)
85
+ if quiet:
86
+ for r, _ in repo_ws_pairs:
87
+ print(r.key)
88
+ return
89
+
90
+ if output_json:
91
+ json.dump(
92
+ [
93
+ {
94
+ "owner": r.owner,
95
+ "name": r.name,
96
+ "key": r.key,
97
+ "workspace": r.workspace,
98
+ "remote_url": r.remote_url,
99
+ "path": str(r.get_path(ws.get_path())),
100
+ "frozen": r.frozen,
101
+ "tags": r.tags,
102
+ "added": r.added,
103
+ "last_pulled": r.last_pulled,
104
+ }
105
+ for r, ws in repo_ws_pairs
106
+ ],
107
+ sys.stdout,
108
+ indent=2,
109
+ )
110
+ print()
111
+ return
112
+
113
+ repos = [r for r, _ in repo_ws_pairs]
114
+ ws_map = {r.global_key: ws for r, ws in repo_ws_pairs}
115
+
116
+ # Check if multiple workspaces
117
+ ws_labels = {r.workspace for r in repos}
118
+ multi_ws = len(ws_labels) > 1
119
+
120
+ # Group by workspace then owner
121
+ if multi_ws:
122
+ by_ws: dict[str, list] = defaultdict(list)
123
+ for r in repos:
124
+ by_ws[r.workspace].append(r)
125
+
126
+ total = len(repos)
127
+ console.print(
128
+ f"\n [bold]gitstow[/bold] — {total} repo{'s' if total != 1 else ''} "
129
+ f"across {len(by_ws)} workspace{'s' if len(by_ws) != 1 else ''}\n"
130
+ )
131
+
132
+ max_name_len = max(len(r.name) for r in repos) if repos else 0
133
+
134
+ for ws_name in sorted(by_ws.keys()):
135
+ ws_repos = by_ws[ws_name]
136
+ console.print(f" [bold cyan]{ws_name}[/bold cyan] ({len(ws_repos)} repos)")
137
+ _print_repos_grouped(ws_repos, ws_map, max_name_len, show_paths)
138
+ console.print()
139
+ else:
140
+ # Single workspace — group by owner (original behavior)
141
+ by_owner: dict[str, list] = defaultdict(list)
142
+ for r in repos:
143
+ by_owner[r.owner or r.workspace].append(r)
144
+
145
+ total = len(repos)
146
+ group_count = len(by_owner)
147
+ console.print(
148
+ f"\n [bold]gitstow[/bold] — {total} repo{'s' if total != 1 else ''} "
149
+ f"across {group_count} owner{'s' if group_count != 1 else ''}\n"
150
+ )
151
+
152
+ max_name_len = max(len(r.name) for r in repos) if repos else 0
153
+ _print_repos_grouped(repos, ws_map, max_name_len, show_paths)
154
+
155
+
156
+ def _print_repos_grouped(repos, ws_map, max_name_len, show_paths):
157
+ """Print repos grouped by owner."""
158
+ by_owner: dict[str, list] = defaultdict(list)
159
+ for r in repos:
160
+ by_owner[r.owner or "(flat)"].append(r)
161
+
162
+ for owner_name in sorted(by_owner.keys()):
163
+ owner_repos = by_owner[owner_name]
164
+ if owner_name != "(flat)":
165
+ console.print(f" [bold]{owner_name}/[/bold] ({len(owner_repos)} repo{'s' if len(owner_repos) != 1 else ''})")
166
+ indent = " " if owner_name != "(flat)" else " "
167
+
168
+ for r in sorted(owner_repos, key=lambda x: x.name):
169
+ name_padded = r.name.ljust(max_name_len)
170
+ frozen_str = " [cyan]❄ frozen[/cyan]" if r.frozen else ""
171
+ tags_str = f" [dim][{', '.join(r.tags)}][/dim]" if r.tags else ""
172
+ pulled_str = ""
173
+ if r.last_pulled:
174
+ pulled_str = f" [dim]{_format_relative_time(r.last_pulled)}[/dim]"
175
+
176
+ if show_paths:
177
+ ws = ws_map.get(r.global_key)
178
+ path = r.get_path(ws.get_path()) if ws else ""
179
+ path_str = f" [dim]{path}[/dim]"
180
+ console.print(f"{indent}{name_padded}{frozen_str}{tags_str}{path_str}")
181
+ else:
182
+ console.print(f"{indent}{name_padded}{frozen_str}{tags_str}{pulled_str}")
183
+
184
+
185
+ def _format_relative_time(iso_str: str) -> str:
186
+ """Format an ISO datetime as a relative time string."""
187
+ from datetime import datetime
188
+
189
+ try:
190
+ dt = datetime.fromisoformat(iso_str)
191
+ now = datetime.now()
192
+ diff = now - dt
193
+
194
+ seconds = diff.total_seconds()
195
+ if seconds < 60:
196
+ return "just now"
197
+ elif seconds < 3600:
198
+ mins = int(seconds / 60)
199
+ return f"{mins}m ago"
200
+ elif seconds < 86400:
201
+ hours = int(seconds / 3600)
202
+ return f"{hours}h ago"
203
+ elif seconds < 604800:
204
+ days = int(seconds / 86400)
205
+ return f"{days}d ago"
206
+ elif seconds < 2592000:
207
+ weeks = int(seconds / 604800)
208
+ return f"{weeks}w ago"
209
+ else:
210
+ months = int(seconds / 2592000)
211
+ return f"{months}mo ago"
212
+ except (ValueError, TypeError):
213
+ return ""
gitstow/cli/main.py ADDED
@@ -0,0 +1,129 @@
1
+ """gitstow — main CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from gitstow import __version__
11
+ from gitstow.core.paths import CLAUDE_SKILLS_DIR
12
+
13
+ app = typer.Typer(
14
+ name="gitstow",
15
+ help="A git repository library manager — clone, organize, and maintain collections of repos.",
16
+ rich_markup_mode="rich",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+
24
+ def version_callback(value: bool) -> None:
25
+ if value:
26
+ console.print(f"gitstow v{__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ def _auto_update_skill() -> None:
31
+ """Silently update the Claude Code skill if the version has changed.
32
+
33
+ Checks a .version marker file in the installed skill directory.
34
+ If it doesn't match the current package version, re-copies SKILL.md.
35
+ This runs on every invocation but is fast (one file read + compare).
36
+ """
37
+ skill_dir = CLAUDE_SKILLS_DIR / "gitstow"
38
+ version_marker = skill_dir / ".version"
39
+
40
+ if not skill_dir.exists():
41
+ # Skill not installed — don't auto-install (user hasn't opted in)
42
+ return
43
+
44
+ # Check version marker
45
+ try:
46
+ installed_version = version_marker.read_text().strip()
47
+ except (FileNotFoundError, OSError):
48
+ installed_version = ""
49
+
50
+ if installed_version == __version__:
51
+ return # Already up to date
52
+
53
+ # Version mismatch — silently update
54
+ try:
55
+ from gitstow.cli.skill_cmd import _do_install_skill
56
+ _do_install_skill(quiet=True)
57
+ version_marker.write_text(__version__)
58
+ except Exception:
59
+ pass # Never block CLI on skill update failure
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ ctx: typer.Context,
65
+ version: Optional[bool] = typer.Option(
66
+ None,
67
+ "--version",
68
+ "-v",
69
+ help="Show version and exit.",
70
+ callback=version_callback,
71
+ is_eager=True,
72
+ ),
73
+ workspace: Optional[str] = typer.Option(
74
+ None,
75
+ "--workspace",
76
+ "-w",
77
+ help="Filter to a specific workspace.",
78
+ ),
79
+ ) -> None:
80
+ """[bold]gitstow[/bold] — clone, organize, and maintain collections of git repos."""
81
+ ctx.ensure_object(dict)
82
+ ctx.obj["workspace"] = workspace
83
+ _auto_update_skill()
84
+
85
+
86
+ # --- Register Stage 1 commands ---
87
+ from gitstow.cli.add import add # noqa: E402
88
+ from gitstow.cli.pull import pull # noqa: E402
89
+ from gitstow.cli.list_cmd import list_repos # noqa: E402
90
+ from gitstow.cli.status import status # noqa: E402
91
+ from gitstow.cli.remove import remove # noqa: E402
92
+ from gitstow.cli.manage import manage_app # noqa: E402
93
+ from gitstow.cli.migrate import migrate # noqa: E402
94
+ from gitstow.cli.config_cmd import config_app # noqa: E402
95
+ from gitstow.cli.onboard import onboard # noqa: E402
96
+ from gitstow.cli.skill_cmd import install_skill # noqa: E402
97
+ from gitstow.cli.doctor import doctor # noqa: E402
98
+
99
+ # --- Register Stage 2 commands ---
100
+ from gitstow.cli.exec_cmd import exec_cmd # noqa: E402
101
+ from gitstow.cli.search import search # noqa: E402
102
+ from gitstow.cli.open_cmd import open_repo # noqa: E402
103
+ from gitstow.cli.stats import stats # noqa: E402
104
+ from gitstow.cli.export_cmd import export_app # noqa: E402
105
+ from gitstow.cli.shell import shell_app # noqa: E402
106
+ from gitstow.cli.tui import tui_cmd # noqa: E402
107
+ from gitstow.cli.setup_ai import setup_ai # noqa: E402
108
+ from gitstow.cli.workspace_cmd import workspace_app # noqa: E402
109
+
110
+ app.command()(add)
111
+ app.command()(pull)
112
+ app.command("list")(list_repos)
113
+ app.command()(status)
114
+ app.command()(remove)
115
+ app.command()(migrate)
116
+ app.command()(onboard)
117
+ app.command("install-skill")(install_skill)
118
+ app.command()(doctor)
119
+ app.command("exec")(exec_cmd)
120
+ app.command()(search)
121
+ app.command("open")(open_repo)
122
+ app.command()(stats)
123
+ app.add_typer(config_app, name="config")
124
+ app.add_typer(manage_app, name="repo", help="Manage individual repos — freeze, tag, info.")
125
+ app.add_typer(export_app, name="collection", help="Export and import repo collections.")
126
+ app.command("tui")(tui_cmd)
127
+ app.command("setup-ai")(setup_ai)
128
+ app.add_typer(shell_app, name="shell", help="Shell integration — fzf picker, cd helper, setup.")
129
+ app.add_typer(workspace_app, name="workspace", help="Manage workspaces — add, remove, list, scan.")
gitstow/cli/manage.py ADDED
@@ -0,0 +1,283 @@
1
+ """gitstow repo — manage individual repos (freeze, tag, info)."""
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.git import (
14
+ get_status,
15
+ get_last_commit,
16
+ get_disk_size,
17
+ format_size,
18
+ is_git_repo,
19
+ )
20
+ from gitstow.core.repo import RepoStore
21
+ from gitstow.cli.helpers import resolve_repo
22
+
23
+ manage_app = typer.Typer(
24
+ help="Manage individual repos — freeze, tag, info.",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ console = Console()
29
+ err_console = Console(stderr=True)
30
+
31
+
32
+ @manage_app.command()
33
+ def freeze(
34
+ repo_key: Optional[str] = typer.Argument(default=None, help="Repo to freeze (owner/repo). Optional if --tag is used."),
35
+ tag_filter: Optional[str] = typer.Option(
36
+ None, "--tag", "-t", help="Freeze all repos with this tag instead.",
37
+ ),
38
+ ) -> None:
39
+ """[bold cyan]Freeze[/bold cyan] a repo — skip it during pull.
40
+
41
+ \b
42
+ Examples:
43
+ gitstow repo freeze facebook/react
44
+ gitstow repo freeze --tag archived
45
+ """
46
+ store = RepoStore()
47
+
48
+ if tag_filter:
49
+ repos = store.list_by_tag(tag_filter)
50
+ if not repos:
51
+ err_console.print(f"[yellow]No repos with tag '{tag_filter}'.[/yellow]")
52
+ return
53
+ for repo in repos:
54
+ store.update(repo.key, frozen=True)
55
+ console.print(f" [cyan]❄[/cyan] Froze {len(repos)} repos with tag '{tag_filter}'.")
56
+ return
57
+
58
+ if not repo_key:
59
+ err_console.print("[red]Error:[/red] Provide a repo key or use --tag.")
60
+ raise typer.Exit(code=1)
61
+
62
+ repo = store.get(repo_key)
63
+ if not repo:
64
+ err_console.print(f"[red]Error:[/red] '{repo_key}' not tracked.")
65
+ raise typer.Exit(code=1)
66
+
67
+ if repo.frozen:
68
+ console.print(f" [dim]{repo_key} is already frozen.[/dim]")
69
+ return
70
+
71
+ store.update(repo_key, frozen=True)
72
+ console.print(f" [cyan]❄[/cyan] {repo_key} frozen. It will be skipped during pull.")
73
+
74
+
75
+ @manage_app.command()
76
+ def unfreeze(
77
+ repo_key: Optional[str] = typer.Argument(default=None, help="Repo to unfreeze (owner/repo). Optional if --tag is used."),
78
+ tag_filter: Optional[str] = typer.Option(
79
+ None, "--tag", "-t", help="Unfreeze all repos with this tag.",
80
+ ),
81
+ ) -> None:
82
+ """[bold green]Unfreeze[/bold green] a repo — re-enable pulling.
83
+
84
+ \b
85
+ Examples:
86
+ gitstow repo unfreeze facebook/react
87
+ gitstow repo unfreeze --tag archived
88
+ """
89
+ store = RepoStore()
90
+
91
+ if tag_filter:
92
+ repos = store.list_by_tag(tag_filter)
93
+ frozen = [r for r in repos if r.frozen]
94
+ if not frozen:
95
+ console.print(f" [dim]No frozen repos with tag '{tag_filter}'.[/dim]")
96
+ return
97
+ for repo in frozen:
98
+ store.update(repo.key, frozen=False)
99
+ console.print(f" [green]✓[/green] Unfroze {len(frozen)} repos with tag '{tag_filter}'.")
100
+ return
101
+
102
+ if not repo_key:
103
+ err_console.print("[red]Error:[/red] Provide a repo key or use --tag.")
104
+ raise typer.Exit(code=1)
105
+
106
+ repo = store.get(repo_key)
107
+ if not repo:
108
+ err_console.print(f"[red]Error:[/red] '{repo_key}' not tracked.")
109
+ raise typer.Exit(code=1)
110
+
111
+ if not repo.frozen:
112
+ console.print(f" [dim]{repo_key} is not frozen.[/dim]")
113
+ return
114
+
115
+ store.update(repo_key, frozen=False)
116
+ console.print(f" [green]✓[/green] {repo_key} unfrozen.")
117
+
118
+
119
+ @manage_app.command("tag")
120
+ def add_tags(
121
+ repo_key: str = typer.Argument(help="Repo to tag (owner/repo)."),
122
+ tags: list[str] = typer.Argument(help="Tag(s) to add."),
123
+ ) -> None:
124
+ """[bold]Tag[/bold] a repo with one or more labels.
125
+
126
+ \b
127
+ Examples:
128
+ gitstow repo tag anthropic/claude-code ai tools
129
+ """
130
+ store = RepoStore()
131
+ repo = store.get(repo_key)
132
+ if not repo:
133
+ err_console.print(f"[red]Error:[/red] '{repo_key}' not tracked.")
134
+ raise typer.Exit(code=1)
135
+
136
+ new_tags = list(set(repo.tags + [t.lower() for t in tags]))
137
+ added = [t for t in new_tags if t not in repo.tags]
138
+ store.update(repo_key, tags=new_tags)
139
+
140
+ if added:
141
+ console.print(f" [green]✓[/green] {repo_key} tagged: {', '.join(added)}")
142
+ else:
143
+ console.print(" [dim]No new tags added (already tagged).[/dim]")
144
+
145
+
146
+ @manage_app.command("untag")
147
+ def remove_tags(
148
+ repo_key: str = typer.Argument(help="Repo to untag (owner/repo)."),
149
+ tags: list[str] = typer.Argument(help="Tag(s) to remove."),
150
+ ) -> None:
151
+ """Remove tag(s) from a repo.
152
+
153
+ \b
154
+ Examples:
155
+ gitstow repo untag anthropic/claude-code tools
156
+ """
157
+ store = RepoStore()
158
+ repo = store.get(repo_key)
159
+ if not repo:
160
+ err_console.print(f"[red]Error:[/red] '{repo_key}' not tracked.")
161
+ raise typer.Exit(code=1)
162
+
163
+ removed = [t for t in tags if t in repo.tags]
164
+ new_tags = [t for t in repo.tags if t not in tags]
165
+ store.update(repo_key, tags=new_tags)
166
+
167
+ if removed:
168
+ console.print(f" [green]✓[/green] Removed tags from {repo_key}: {', '.join(removed)}")
169
+ else:
170
+ console.print(f" [dim]None of those tags were on {repo_key}.[/dim]")
171
+
172
+
173
+ @manage_app.command("tags")
174
+ def list_tags(
175
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="One tag per line."),
176
+ ) -> None:
177
+ """List all tags with repo counts."""
178
+ store = RepoStore()
179
+ tags = store.all_tags()
180
+
181
+ if not tags:
182
+ if not quiet:
183
+ console.print("[dim]No tags defined.[/dim]")
184
+ return
185
+
186
+ if quiet:
187
+ for tag in sorted(tags):
188
+ print(tag)
189
+ return
190
+
191
+ console.print("\n [bold]Tags[/bold]\n")
192
+ for tag, count in tags.items():
193
+ console.print(f" {tag} [dim]({count} repo{'s' if count != 1 else ''})[/dim]")
194
+ console.print()
195
+
196
+
197
+ @manage_app.command()
198
+ def info(
199
+ ctx: typer.Context,
200
+ repo_key: str = typer.Argument(help="Repo to inspect (owner/repo or name)."),
201
+ output_json: bool = typer.Option(
202
+ False, "--json", "-j", help="JSON output.",
203
+ ),
204
+ ) -> None:
205
+ """[bold]Info[/bold] — detailed view of a single repo.
206
+
207
+ \b
208
+ Examples:
209
+ gitstow repo info anthropic/claude-code
210
+ """
211
+ settings = load_config()
212
+ store = RepoStore()
213
+ ws_label = (ctx.obj or {}).get("workspace")
214
+
215
+ repo, ws = resolve_repo(store, settings, repo_key, ws_label)
216
+ path = repo.get_path(ws.get_path())
217
+ info_data = {
218
+ "repo": repo.key,
219
+ "workspace": repo.workspace,
220
+ "remote_url": repo.remote_url,
221
+ "path": str(path),
222
+ "frozen": repo.frozen,
223
+ "tags": repo.tags,
224
+ "added": repo.added,
225
+ "last_pulled": repo.last_pulled,
226
+ "exists_on_disk": path.exists(),
227
+ }
228
+
229
+ # Git info (if repo exists on disk)
230
+ if path.exists() and is_git_repo(path):
231
+ status = get_status(path)
232
+ commit = get_last_commit(path)
233
+ size = get_disk_size(path)
234
+
235
+ info_data.update({
236
+ "branch": status.branch,
237
+ "status": "clean" if status.clean else status.status_symbol,
238
+ "ahead": status.ahead,
239
+ "behind": status.behind,
240
+ "last_commit_hash": commit.hash,
241
+ "last_commit_message": commit.message,
242
+ "last_commit_date": commit.date,
243
+ "last_commit_author": commit.author,
244
+ "disk_size": size,
245
+ "disk_size_human": format_size(size),
246
+ })
247
+
248
+ if output_json:
249
+ json.dump(info_data, sys.stdout, indent=2)
250
+ print()
251
+ return
252
+
253
+ # Human-readable display
254
+ console.print(f"\n [bold]{repo.key}[/bold]\n")
255
+
256
+ rows = [
257
+ ("Remote", repo.remote_url),
258
+ ("Path", str(path)),
259
+ ]
260
+
261
+ if path.exists() and is_git_repo(path):
262
+ rows.extend([
263
+ ("Branch", info_data.get("branch", "unknown")),
264
+ ("Status", info_data.get("status", "unknown")),
265
+ ("Frozen", "[cyan]yes[/cyan]" if repo.frozen else "no"),
266
+ ("Tags", ", ".join(repo.tags) if repo.tags else "[dim]none[/dim]"),
267
+ ("Added", repo.added or "[dim]unknown[/dim]"),
268
+ ("Last pulled", repo.last_pulled or "[dim]never[/dim]"),
269
+ ("Disk size", info_data.get("disk_size_human", "unknown")),
270
+ ("Last commit", f"{info_data.get('last_commit_message', '')} ({info_data.get('last_commit_date', '')})"),
271
+ ])
272
+ else:
273
+ rows.extend([
274
+ ("Frozen", "[cyan]yes[/cyan]" if repo.frozen else "no"),
275
+ ("Tags", ", ".join(repo.tags) if repo.tags else "[dim]none[/dim]"),
276
+ ("On disk", "[red]not found[/red]" if not path.exists() else "[yellow]not a git repo[/yellow]"),
277
+ ])
278
+
279
+ max_label = max(len(r[0]) for r in rows)
280
+ for label, value in rows:
281
+ console.print(f" {label.ljust(max_label + 2)}{value}")
282
+
283
+ console.print()