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/list_cmd.py
ADDED
|
@@ -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()
|