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/skill/SKILL.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gitstow
|
|
3
|
+
description: >
|
|
4
|
+
ALWAYS use this skill when the user wants to clone, add, or download a git
|
|
5
|
+
repository — gitstow replaces raw `git clone`. Also activate for: "update repos",
|
|
6
|
+
"pull all", "list repos", "check repo status", "freeze/tag a repo", "search across
|
|
7
|
+
repos", "which repos need pushing", or any multi-repo management. Trigger on any
|
|
8
|
+
git URL (github.com, gitlab.com, etc.) or owner/repo shorthand.
|
|
9
|
+
allowed-tools: Bash(gitstow *), Read
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# gitstow — Repo Manager Operator Guide
|
|
13
|
+
|
|
14
|
+
You are an expert operator of `gitstow`, a CLI tool that replaces `git clone` and manages repos across multiple workspaces. **Every repo should be added via `gitstow add`, never raw `git clone`** — this ensures it's tracked, organized, and visible in status dashboards.
|
|
15
|
+
|
|
16
|
+
## Key Concept: Workspaces
|
|
17
|
+
|
|
18
|
+
gitstow organizes repos into **workspaces** — directories with a label, layout mode, and optional auto-tags:
|
|
19
|
+
|
|
20
|
+
- **structured** layout: `workspace/owner/repo/` (good for open-source collections)
|
|
21
|
+
- **flat** layout: `workspace/repo/` (good for active projects)
|
|
22
|
+
|
|
23
|
+
Use `-w <label>` on any command to filter to a specific workspace.
|
|
24
|
+
|
|
25
|
+
## Cardinal Rule: Never Use Raw `git clone`
|
|
26
|
+
|
|
27
|
+
When the user asks to clone, download, or get a repo, **always use `gitstow add`**. Never fall back to `git clone` — even for a single repo. `gitstow add` clones AND tracks the repo, so it appears in status dashboards and bulk operations.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# WRONG — repo cloned but invisible to gitstow
|
|
31
|
+
git clone https://github.com/owner/repo
|
|
32
|
+
|
|
33
|
+
# RIGHT — repo tracked, organized into workspace, visible everywhere
|
|
34
|
+
gitstow add owner/repo
|
|
35
|
+
gitstow -w active add owner/repo # clone into a specific workspace
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Before Running Any Command
|
|
39
|
+
|
|
40
|
+
**Pre-flight check** — on first use in a session, verify gitstow is available:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
gitstow --version
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If not installed: suggest `pip install gitstow` or `pipx install gitstow`.
|
|
47
|
+
If installed but not configured (no `~/.gitstow/config.yaml`): guide with `gitstow onboard`.
|
|
48
|
+
|
|
49
|
+
## Core Principle: Use --json for Machine Output
|
|
50
|
+
|
|
51
|
+
When running gitstow commands programmatically, always use `--json --quiet` flags to parse structured output. Present a clean summary to the user, not raw JSON.
|
|
52
|
+
|
|
53
|
+
For commands shown to the user to run themselves, use the human-readable form (no --json).
|
|
54
|
+
|
|
55
|
+
## Command Decision Tree
|
|
56
|
+
|
|
57
|
+
| User wants to... | Command |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Add/clone a repo | `gitstow add "owner/repo"` or `gitstow add "url"` |
|
|
60
|
+
| Add to specific workspace | `gitstow -w active add "owner/repo"` |
|
|
61
|
+
| Add multiple repos | `gitstow add repo1 repo2 repo3` |
|
|
62
|
+
| Update all repos | `gitstow pull` |
|
|
63
|
+
| Update one workspace | `gitstow -w oss pull` |
|
|
64
|
+
| Update repos by tag | `gitstow pull --tag ai` |
|
|
65
|
+
| List all repos | `gitstow list` |
|
|
66
|
+
| List one workspace | `gitstow -w active list` |
|
|
67
|
+
| Search repos | `gitstow list searchterm` |
|
|
68
|
+
| Check git status | `gitstow status` |
|
|
69
|
+
| Status for one workspace | `gitstow -w active status` |
|
|
70
|
+
| See dirty repos only | `gitstow status --dirty` |
|
|
71
|
+
| Freeze a repo | `gitstow repo freeze owner/repo` |
|
|
72
|
+
| Unfreeze a repo | `gitstow repo unfreeze owner/repo` |
|
|
73
|
+
| Tag a repo | `gitstow repo tag owner/repo tagname` |
|
|
74
|
+
| Remove a tag | `gitstow repo untag owner/repo tagname` |
|
|
75
|
+
| List all tags | `gitstow repo tags` |
|
|
76
|
+
| Repo details | `gitstow repo info owner/repo` |
|
|
77
|
+
| Remove a repo | `gitstow remove owner/repo` |
|
|
78
|
+
| Adopt existing repos | `gitstow migrate /path/to/repo` |
|
|
79
|
+
| Run command in all repos | `gitstow exec -- git log -1 --oneline` |
|
|
80
|
+
| Search across all repos | `gitstow search "pattern" --glob "*.py"` |
|
|
81
|
+
| Open in editor | `gitstow open owner/repo` |
|
|
82
|
+
| Open in browser | `gitstow open owner/repo --browser` |
|
|
83
|
+
| Collection stats | `gitstow stats` |
|
|
84
|
+
| Export collection | `gitstow collection export -o repos.yaml` |
|
|
85
|
+
| Import collection | `gitstow collection import repos.yaml` |
|
|
86
|
+
| See current config | `gitstow config show` |
|
|
87
|
+
| Change settings | `gitstow config set key value` |
|
|
88
|
+
| List workspaces | `gitstow workspace list` |
|
|
89
|
+
| Add a workspace | `gitstow workspace add ~/path --label name --layout flat` |
|
|
90
|
+
| Remove a workspace | `gitstow workspace remove name` |
|
|
91
|
+
| Scan workspace for repos | `gitstow workspace scan name` |
|
|
92
|
+
| Run setup wizard | `gitstow onboard` |
|
|
93
|
+
| Health check | `gitstow doctor` |
|
|
94
|
+
|
|
95
|
+
## URL Shorthand
|
|
96
|
+
|
|
97
|
+
gitstow supports shorthand — GitHub is the default host:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# These are equivalent:
|
|
101
|
+
gitstow add anthropic/claude-code
|
|
102
|
+
gitstow add https://github.com/anthropic/claude-code
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For non-GitHub repos, use full URLs:
|
|
106
|
+
```bash
|
|
107
|
+
gitstow add https://gitlab.com/group/project
|
|
108
|
+
gitstow add git@bitbucket.org:owner/repo.git
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Common Workflows
|
|
112
|
+
|
|
113
|
+
### "Add this repo" / "Clone this"
|
|
114
|
+
```bash
|
|
115
|
+
gitstow add "owner/repo" --json --quiet
|
|
116
|
+
```
|
|
117
|
+
Parse the JSON result and tell the user: repo name, where it was cloned, success/failure.
|
|
118
|
+
|
|
119
|
+
### "Add to my active projects workspace"
|
|
120
|
+
```bash
|
|
121
|
+
gitstow -w active add "owner/repo" --json --quiet
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### "Update everything" / "Pull all repos"
|
|
125
|
+
```bash
|
|
126
|
+
gitstow pull --json --quiet
|
|
127
|
+
```
|
|
128
|
+
Parse results and summarize: N pulled, N up to date, N skipped, N errors.
|
|
129
|
+
|
|
130
|
+
### "What repos do I have?"
|
|
131
|
+
```bash
|
|
132
|
+
gitstow list --json
|
|
133
|
+
```
|
|
134
|
+
Present as a clean grouped list. Mention total count, frozen repos, and tags.
|
|
135
|
+
|
|
136
|
+
### "Which repos need pushing?" / "What's dirty?"
|
|
137
|
+
```bash
|
|
138
|
+
gitstow status --dirty --json
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### "Show me just my active project status"
|
|
142
|
+
```bash
|
|
143
|
+
gitstow -w active status --json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### "Freeze this repo" / "Don't update this anymore"
|
|
147
|
+
```bash
|
|
148
|
+
gitstow repo freeze owner/repo
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### "What's the status of X?"
|
|
152
|
+
```bash
|
|
153
|
+
gitstow repo info owner/repo --json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### "Search for something across all repos"
|
|
157
|
+
```bash
|
|
158
|
+
gitstow search "pattern" --json --quiet
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### "Run this command in every repo"
|
|
162
|
+
```bash
|
|
163
|
+
gitstow exec --json -- git log -1 --oneline
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### "How much disk space do my repos use?"
|
|
167
|
+
```bash
|
|
168
|
+
gitstow stats --json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### "Share my repo list" / "Export my collection"
|
|
172
|
+
```bash
|
|
173
|
+
gitstow collection export -o /tmp/repos.yaml
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### "Point gitstow at my existing projects"
|
|
177
|
+
```bash
|
|
178
|
+
gitstow workspace add ~/labs/projects --label active --layout flat --auto-tag active
|
|
179
|
+
gitstow workspace scan active
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Bulk Operations from a File
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
cat > /tmp/repos.txt << 'EOF'
|
|
186
|
+
anthropic/claude-code
|
|
187
|
+
facebook/react
|
|
188
|
+
torvalds/linux
|
|
189
|
+
EOF
|
|
190
|
+
|
|
191
|
+
cat /tmp/repos.txt | gitstow add --quiet --json
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Safety Rules
|
|
195
|
+
|
|
196
|
+
1. **Always quote URLs** in shell commands
|
|
197
|
+
2. **Don't run `gitstow onboard`** without telling the user — it's interactive
|
|
198
|
+
3. **Don't run `gitstow remove --delete`** without explicit user confirmation — it deletes files
|
|
199
|
+
4. **Use `--json --quiet`** when parsing output programmatically
|
|
200
|
+
5. **Check `gitstow doctor`** if something seems broken
|
|
201
|
+
|
|
202
|
+
## Note on MCP Server
|
|
203
|
+
|
|
204
|
+
An optional MCP server exists (`pip install gitstow[mcp]`, run `gitstow-mcp`) for AI tools that can't run CLI commands (Claude Desktop, Cursor). **You don't need it** — this skill gives you full access to all gitstow commands via Bash. The MCP server provides the same operations but costs tokens in every conversation even when idle.
|
|
205
|
+
|
|
206
|
+
## File Locations
|
|
207
|
+
|
|
208
|
+
- Config: `~/.gitstow/config.yaml`
|
|
209
|
+
- Repo metadata: `~/.gitstow/repos.yaml` (central, nested by workspace)
|
|
210
|
+
- Repos: across configured workspaces (default first workspace: `~/oss/`)
|
gitstow/tui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gitstow TUI — interactive terminal dashboard built with Textual."""
|
gitstow/tui/app.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""gitstow TUI — main application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Container, Horizontal
|
|
8
|
+
from textual.widgets import DataTable, Footer, Header, Static, Input
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual import work
|
|
11
|
+
|
|
12
|
+
from gitstow.core.config import load_config, Workspace
|
|
13
|
+
from gitstow.core.git import get_status, is_git_repo, pull as git_pull, RepoStatus
|
|
14
|
+
from gitstow.core.repo import Repo, RepoStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RepoDetailScreen(ModalScreen):
|
|
18
|
+
"""Modal screen showing detailed info for a repo."""
|
|
19
|
+
|
|
20
|
+
BINDINGS = [
|
|
21
|
+
Binding("escape", "dismiss", "Close"),
|
|
22
|
+
Binding("f", "toggle_freeze", "Freeze/Unfreeze"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
def __init__(self, repo: Repo, status: RepoStatus | None, ws: Workspace):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.repo = repo
|
|
28
|
+
self.repo_status = status
|
|
29
|
+
self.ws = ws
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
frozen_str = "YES" if self.repo.frozen else "no"
|
|
33
|
+
tags_str = ", ".join(self.repo.tags) if self.repo.tags else "none"
|
|
34
|
+
branch = self.repo_status.branch if self.repo_status else "unknown"
|
|
35
|
+
status_str = self.repo_status.status_symbol if self.repo_status else "?"
|
|
36
|
+
|
|
37
|
+
info = (
|
|
38
|
+
f"[bold]{self.repo.key}[/bold] [dim]({self.repo.workspace})[/dim]\n\n"
|
|
39
|
+
f" Remote: {self.repo.remote_url}\n"
|
|
40
|
+
f" Path: {self.repo.get_path(self.ws.get_path())}\n"
|
|
41
|
+
f" Branch: {branch}\n"
|
|
42
|
+
f" Status: {status_str}\n"
|
|
43
|
+
f" Frozen: {frozen_str}\n"
|
|
44
|
+
f" Tags: {tags_str}\n"
|
|
45
|
+
f" Workspace: {self.repo.workspace}\n"
|
|
46
|
+
f" Added: {self.repo.added or 'unknown'}\n"
|
|
47
|
+
f" Last pulled: {self.repo.last_pulled or 'never'}\n\n"
|
|
48
|
+
f" [dim]Press [bold]f[/bold] to toggle freeze, [bold]Escape[/bold] to close[/dim]"
|
|
49
|
+
)
|
|
50
|
+
yield Container(
|
|
51
|
+
Static(info, id="detail-content"),
|
|
52
|
+
id="detail-modal",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def action_toggle_freeze(self) -> None:
|
|
56
|
+
store = RepoStore()
|
|
57
|
+
store.update(self.repo.key, workspace=self.repo.workspace, frozen=not self.repo.frozen)
|
|
58
|
+
self.repo.frozen = not self.repo.frozen
|
|
59
|
+
self.dismiss(True) # Signal refresh needed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GitstowApp(App):
|
|
63
|
+
"""gitstow interactive dashboard."""
|
|
64
|
+
|
|
65
|
+
TITLE = "gitstow"
|
|
66
|
+
SUB_TITLE = "repo library manager"
|
|
67
|
+
|
|
68
|
+
CSS = """
|
|
69
|
+
#header-bar {
|
|
70
|
+
dock: top;
|
|
71
|
+
height: 1;
|
|
72
|
+
background: $primary;
|
|
73
|
+
color: $text;
|
|
74
|
+
padding: 0 1;
|
|
75
|
+
}
|
|
76
|
+
#filter-bar {
|
|
77
|
+
dock: top;
|
|
78
|
+
height: 3;
|
|
79
|
+
padding: 0 1;
|
|
80
|
+
}
|
|
81
|
+
#filter-input {
|
|
82
|
+
width: 40;
|
|
83
|
+
}
|
|
84
|
+
#summary-bar {
|
|
85
|
+
dock: bottom;
|
|
86
|
+
height: 1;
|
|
87
|
+
background: $surface;
|
|
88
|
+
color: $text-muted;
|
|
89
|
+
padding: 0 1;
|
|
90
|
+
}
|
|
91
|
+
#detail-modal {
|
|
92
|
+
align: center middle;
|
|
93
|
+
width: 70;
|
|
94
|
+
height: 20;
|
|
95
|
+
border: tall $primary;
|
|
96
|
+
background: $surface;
|
|
97
|
+
padding: 1 2;
|
|
98
|
+
}
|
|
99
|
+
DataTable {
|
|
100
|
+
height: 1fr;
|
|
101
|
+
}
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
BINDINGS = [
|
|
105
|
+
Binding("q", "quit", "Quit"),
|
|
106
|
+
Binding("r", "refresh", "Refresh"),
|
|
107
|
+
Binding("p", "pull_all", "Pull All"),
|
|
108
|
+
Binding("P", "pull_selected", "Pull Selected", show=False),
|
|
109
|
+
Binding("f", "toggle_freeze", "Freeze/Unfreeze"),
|
|
110
|
+
Binding("w", "cycle_workspace", "Workspace"),
|
|
111
|
+
Binding("t", "cycle_tag", "Tag"),
|
|
112
|
+
Binding("enter", "show_detail", "Details"),
|
|
113
|
+
Binding("/", "focus_filter", "Filter"),
|
|
114
|
+
Binding("escape", "clear_filter", "Clear Filter"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def __init__(self):
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.settings = load_config()
|
|
120
|
+
self.store = RepoStore()
|
|
121
|
+
self._ws_map: dict[str, Workspace] = {} # global_key -> workspace
|
|
122
|
+
self._repos: list[Repo] = []
|
|
123
|
+
self._statuses: dict[str, RepoStatus] = {}
|
|
124
|
+
self._filter = ""
|
|
125
|
+
self._ws_filter: str = "" # "" = all workspaces
|
|
126
|
+
self._tag_filter: str = "" # "" = all tags
|
|
127
|
+
|
|
128
|
+
def compose(self) -> ComposeResult:
|
|
129
|
+
yield Header()
|
|
130
|
+
yield Horizontal(
|
|
131
|
+
Static(" Filter: ", id="filter-label"),
|
|
132
|
+
Input(placeholder="type to filter...", id="filter-input"),
|
|
133
|
+
id="filter-bar",
|
|
134
|
+
)
|
|
135
|
+
yield DataTable(id="repo-table")
|
|
136
|
+
yield Static("Loading...", id="summary-bar")
|
|
137
|
+
yield Footer()
|
|
138
|
+
|
|
139
|
+
def on_mount(self) -> None:
|
|
140
|
+
table = self.query_one("#repo-table", DataTable)
|
|
141
|
+
table.cursor_type = "row"
|
|
142
|
+
table.add_columns(
|
|
143
|
+
"Repo", "Branch", "Status", "Ahead/Behind",
|
|
144
|
+
"Frozen", "Tags", "Last Pulled",
|
|
145
|
+
)
|
|
146
|
+
self.load_repos()
|
|
147
|
+
|
|
148
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
149
|
+
if event.input.id == "filter-input":
|
|
150
|
+
self._filter = event.value.lower()
|
|
151
|
+
self._refresh_table()
|
|
152
|
+
|
|
153
|
+
def action_focus_filter(self) -> None:
|
|
154
|
+
self.query_one("#filter-input", Input).focus()
|
|
155
|
+
|
|
156
|
+
def action_clear_filter(self) -> None:
|
|
157
|
+
inp = self.query_one("#filter-input", Input)
|
|
158
|
+
inp.value = ""
|
|
159
|
+
self._filter = ""
|
|
160
|
+
self._refresh_table()
|
|
161
|
+
self.query_one("#repo-table", DataTable).focus()
|
|
162
|
+
|
|
163
|
+
@work(thread=True)
|
|
164
|
+
def load_repos(self) -> None:
|
|
165
|
+
"""Load repos and their statuses in a background thread."""
|
|
166
|
+
self.store.load()
|
|
167
|
+
self._repos = self.store.list_all()
|
|
168
|
+
|
|
169
|
+
# Build workspace map
|
|
170
|
+
self._ws_map = {}
|
|
171
|
+
for repo in self._repos:
|
|
172
|
+
ws = self.settings.get_workspace(repo.workspace)
|
|
173
|
+
if ws:
|
|
174
|
+
self._ws_map[repo.global_key] = ws
|
|
175
|
+
|
|
176
|
+
for repo in self._repos:
|
|
177
|
+
ws = self._ws_map.get(repo.global_key)
|
|
178
|
+
if not ws:
|
|
179
|
+
continue
|
|
180
|
+
path = repo.get_path(ws.get_path())
|
|
181
|
+
if path.exists() and is_git_repo(path):
|
|
182
|
+
self._statuses[repo.global_key] = get_status(path)
|
|
183
|
+
|
|
184
|
+
self.call_from_thread(self._refresh_table)
|
|
185
|
+
|
|
186
|
+
def _refresh_table(self) -> None:
|
|
187
|
+
table = self.query_one("#repo-table", DataTable)
|
|
188
|
+
table.clear()
|
|
189
|
+
|
|
190
|
+
filtered = self._repos
|
|
191
|
+
if self._ws_filter:
|
|
192
|
+
filtered = [r for r in filtered if r.workspace == self._ws_filter]
|
|
193
|
+
if self._tag_filter:
|
|
194
|
+
filtered = [r for r in filtered if self._tag_filter in r.tags]
|
|
195
|
+
if self._filter:
|
|
196
|
+
filtered = [
|
|
197
|
+
r for r in filtered
|
|
198
|
+
if self._filter in r.key.lower()
|
|
199
|
+
or any(self._filter in t for t in r.tags)
|
|
200
|
+
or self._filter in r.owner.lower()
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
for repo in filtered:
|
|
204
|
+
status = self._statuses.get(repo.global_key)
|
|
205
|
+
|
|
206
|
+
branch = status.branch if status else "?"
|
|
207
|
+
status_str = status.status_symbol if status else "?"
|
|
208
|
+
ab = status.ahead_behind_str if status else "?"
|
|
209
|
+
frozen = "❄" if repo.frozen else ""
|
|
210
|
+
tags = ", ".join(repo.tags) if repo.tags else ""
|
|
211
|
+
pulled = self._format_pulled(repo.last_pulled)
|
|
212
|
+
|
|
213
|
+
table.add_row(
|
|
214
|
+
repo.key, branch, status_str, ab,
|
|
215
|
+
frozen, tags, pulled,
|
|
216
|
+
key=repo.global_key,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Summary
|
|
220
|
+
total = len(filtered)
|
|
221
|
+
clean = sum(1 for r in filtered if self._statuses.get(r.global_key, None) and self._statuses[r.global_key].clean)
|
|
222
|
+
frozen = sum(1 for r in filtered if r.frozen)
|
|
223
|
+
dirty = total - clean - frozen
|
|
224
|
+
summary = f" {total} repos | {clean} clean | {dirty} dirty | {frozen} frozen"
|
|
225
|
+
if self._ws_filter:
|
|
226
|
+
summary += f" | ws: {self._ws_filter}"
|
|
227
|
+
if self._tag_filter:
|
|
228
|
+
summary += f" | tag: {self._tag_filter}"
|
|
229
|
+
if self._filter:
|
|
230
|
+
summary += f" | filter: '{self._filter}'"
|
|
231
|
+
self.query_one("#summary-bar", Static).update(summary)
|
|
232
|
+
|
|
233
|
+
def action_refresh(self) -> None:
|
|
234
|
+
self.query_one("#summary-bar", Static).update(" Refreshing...")
|
|
235
|
+
self.load_repos()
|
|
236
|
+
|
|
237
|
+
@work(thread=True)
|
|
238
|
+
def action_pull_all(self) -> None:
|
|
239
|
+
"""Pull all unfrozen repos."""
|
|
240
|
+
self.call_from_thread(
|
|
241
|
+
lambda: self.query_one("#summary-bar", Static).update(" Pulling...")
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
from datetime import datetime
|
|
245
|
+
pulled = 0
|
|
246
|
+
for repo in self._repos:
|
|
247
|
+
if repo.frozen:
|
|
248
|
+
continue
|
|
249
|
+
ws = self._ws_map.get(repo.global_key)
|
|
250
|
+
if not ws:
|
|
251
|
+
continue
|
|
252
|
+
path = repo.get_path(ws.get_path())
|
|
253
|
+
if not path.exists() or not is_git_repo(path):
|
|
254
|
+
continue
|
|
255
|
+
status = self._statuses.get(repo.global_key)
|
|
256
|
+
if status and not status.clean:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
result = git_pull(path)
|
|
260
|
+
if result.success:
|
|
261
|
+
pulled += 1
|
|
262
|
+
self.store.update(repo.key, workspace=repo.workspace, last_pulled=datetime.now().isoformat())
|
|
263
|
+
|
|
264
|
+
self.call_from_thread(self.load_repos)
|
|
265
|
+
self.call_from_thread(
|
|
266
|
+
lambda: self.notify(f"Pulled {pulled} repos")
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def action_cycle_workspace(self) -> None:
|
|
270
|
+
"""Cycle workspace filter: all → ws1 → ws2 → ... → all."""
|
|
271
|
+
labels = [ws.label for ws in self.settings.get_workspaces()]
|
|
272
|
+
if not labels:
|
|
273
|
+
return
|
|
274
|
+
if not self._ws_filter:
|
|
275
|
+
self._ws_filter = labels[0]
|
|
276
|
+
else:
|
|
277
|
+
try:
|
|
278
|
+
idx = labels.index(self._ws_filter)
|
|
279
|
+
self._ws_filter = labels[idx + 1] if idx + 1 < len(labels) else ""
|
|
280
|
+
except ValueError:
|
|
281
|
+
self._ws_filter = ""
|
|
282
|
+
self._refresh_table()
|
|
283
|
+
label = self._ws_filter or "all"
|
|
284
|
+
self.notify(f"Workspace: {label}")
|
|
285
|
+
|
|
286
|
+
def action_cycle_tag(self) -> None:
|
|
287
|
+
"""Cycle tag filter: all → tag1 → tag2 → ... → all."""
|
|
288
|
+
all_tags = sorted(self.store.all_tags())
|
|
289
|
+
if not all_tags:
|
|
290
|
+
return
|
|
291
|
+
if not self._tag_filter:
|
|
292
|
+
self._tag_filter = all_tags[0]
|
|
293
|
+
else:
|
|
294
|
+
try:
|
|
295
|
+
idx = all_tags.index(self._tag_filter)
|
|
296
|
+
self._tag_filter = all_tags[idx + 1] if idx + 1 < len(all_tags) else ""
|
|
297
|
+
except ValueError:
|
|
298
|
+
self._tag_filter = ""
|
|
299
|
+
self._refresh_table()
|
|
300
|
+
label = self._tag_filter or "all"
|
|
301
|
+
self.notify(f"Tag: {label}")
|
|
302
|
+
|
|
303
|
+
@work(thread=True)
|
|
304
|
+
def action_pull_selected(self) -> None:
|
|
305
|
+
"""Pull only the selected repo."""
|
|
306
|
+
repo, ws = self.call_from_thread(self._get_selected_repo)
|
|
307
|
+
if not repo or not ws:
|
|
308
|
+
return
|
|
309
|
+
if repo.frozen:
|
|
310
|
+
self.call_from_thread(lambda: self.notify(f"{repo.key} is frozen", severity="warning"))
|
|
311
|
+
return
|
|
312
|
+
path = repo.get_path(ws.get_path())
|
|
313
|
+
if not path.exists() or not is_git_repo(path):
|
|
314
|
+
self.call_from_thread(lambda: self.notify(f"{repo.key} not on disk", severity="error"))
|
|
315
|
+
return
|
|
316
|
+
self.call_from_thread(
|
|
317
|
+
lambda: self.query_one("#summary-bar", Static).update(f" Pulling {repo.key}...")
|
|
318
|
+
)
|
|
319
|
+
result = git_pull(path)
|
|
320
|
+
if result.success:
|
|
321
|
+
from datetime import datetime
|
|
322
|
+
self.store.update(repo.key, workspace=repo.workspace, last_pulled=datetime.now().isoformat())
|
|
323
|
+
self.call_from_thread(lambda: self.notify(f"Pulled {repo.key}"))
|
|
324
|
+
else:
|
|
325
|
+
self.call_from_thread(lambda: self.notify(f"Failed: {result.error}", severity="error"))
|
|
326
|
+
self.call_from_thread(self.load_repos)
|
|
327
|
+
|
|
328
|
+
def _get_selected_repo(self) -> tuple[Repo | None, Workspace | None]:
|
|
329
|
+
"""Get the repo and workspace for the currently selected table row."""
|
|
330
|
+
table = self.query_one("#repo-table", DataTable)
|
|
331
|
+
if table.cursor_row is None:
|
|
332
|
+
return None, None
|
|
333
|
+
row_key = table.get_row_at(table.cursor_row)
|
|
334
|
+
global_key = str(row_key.value) if row_key else None
|
|
335
|
+
if not global_key:
|
|
336
|
+
return None, None
|
|
337
|
+
# Find repo by global_key
|
|
338
|
+
for repo in self._repos:
|
|
339
|
+
if repo.global_key == global_key:
|
|
340
|
+
ws = self._ws_map.get(global_key)
|
|
341
|
+
return repo, ws
|
|
342
|
+
return None, None
|
|
343
|
+
|
|
344
|
+
def action_toggle_freeze(self) -> None:
|
|
345
|
+
repo, ws = self._get_selected_repo()
|
|
346
|
+
if repo:
|
|
347
|
+
self.store.update(repo.key, workspace=repo.workspace, frozen=not repo.frozen)
|
|
348
|
+
repo.frozen = not repo.frozen
|
|
349
|
+
self._refresh_table()
|
|
350
|
+
action = "Froze" if repo.frozen else "Unfroze"
|
|
351
|
+
self.notify(f"{action} {repo.key}")
|
|
352
|
+
|
|
353
|
+
def action_show_detail(self) -> None:
|
|
354
|
+
repo, ws = self._get_selected_repo()
|
|
355
|
+
if repo and ws:
|
|
356
|
+
status = self._statuses.get(repo.global_key)
|
|
357
|
+
|
|
358
|
+
def on_dismiss(refresh_needed: bool) -> None:
|
|
359
|
+
if refresh_needed:
|
|
360
|
+
self.store.load()
|
|
361
|
+
self._repos = self.store.list_all()
|
|
362
|
+
self._refresh_table()
|
|
363
|
+
|
|
364
|
+
self.push_screen(
|
|
365
|
+
RepoDetailScreen(repo, status, ws),
|
|
366
|
+
callback=on_dismiss,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _format_pulled(iso_str: str) -> str:
|
|
371
|
+
if not iso_str:
|
|
372
|
+
return "never"
|
|
373
|
+
from datetime import datetime
|
|
374
|
+
try:
|
|
375
|
+
dt = datetime.fromisoformat(iso_str)
|
|
376
|
+
diff = (datetime.now() - dt).total_seconds()
|
|
377
|
+
if diff < 3600:
|
|
378
|
+
return f"{int(diff/60)}m ago"
|
|
379
|
+
elif diff < 86400:
|
|
380
|
+
return f"{int(diff/3600)}h ago"
|
|
381
|
+
else:
|
|
382
|
+
return f"{int(diff/86400)}d ago"
|
|
383
|
+
except (ValueError, TypeError):
|
|
384
|
+
return "?"
|