gitdirector 1.5.0__tar.gz → 1.5.2__tar.gz
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.
- {gitdirector-1.5.0 → gitdirector-1.5.2}/PKG-INFO +65 -2
- {gitdirector-1.5.0 → gitdirector-1.5.2}/README.md +64 -1
- {gitdirector-1.5.0 → gitdirector-1.5.2}/pyproject.toml +1 -1
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/cli.py +2 -0
- gitdirector-1.5.2/src/gitdirector/commands/autoclean.py +39 -0
- gitdirector-1.5.2/src/gitdirector/commands/gd_tmux.py +79 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/help.py +2 -1
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app.py +25 -4
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/diff_renderer.py +40 -1
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/_shared.py +3 -2
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/commit.py +17 -14
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/diff_files.py +20 -5
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/panels.py +41 -21
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/repos.py +9 -8
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/sessions.py +3 -2
- gitdirector-1.5.2/src/gitdirector/commands/tui/terminal_caps.py +133 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/terminal_widget.py +28 -13
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/core.py +11 -2
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/monitor.py +13 -4
- gitdirector-1.5.0/src/gitdirector/commands/autoclean.py +0 -85
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/cd.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/info.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/link.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/listt.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/pull.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/status.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_panels.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_repos.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_sessions.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_ui.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/constants.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/panel_view.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/panels.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/diff.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/unlink.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/config.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/info.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/__init__.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/panels.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/manager.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/repo.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/storage.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/ui_theme.py +0 -0
- {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gitdirector
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
4
4
|
Summary: A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place.
|
|
5
5
|
Keywords: git,repository,manager,cli,synchronization,batch
|
|
6
6
|
Author: Anito Anto
|
|
@@ -74,8 +74,9 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
|
|
|
74
74
|
| `gitdirector status` | Show repositories with staged/unstaged files |
|
|
75
75
|
| `gitdirector pull` | Pull latest changes for all tracked repositories |
|
|
76
76
|
| `gitdirector cd NAME` | Open or switch to a tmux session for a repository |
|
|
77
|
-
| `gitdirector autoclean
|
|
77
|
+
| `gitdirector autoclean` | Remove broken repository links from tracking |
|
|
78
78
|
| `gitdirector info PATH\|NAME [--full]` | Show file statistics for a repository |
|
|
79
|
+
| `gitdirector gd-tmux PATH\|NAME "command"` | Create a gd tmux session and run a command in it |
|
|
79
80
|
| `gitdirector help` | Show help |
|
|
80
81
|
|
|
81
82
|
### link
|
|
@@ -175,6 +176,68 @@ Opens a [tmux](https://github.com/tmux/tmux) session rooted at the repository di
|
|
|
175
176
|
> Debian/Ubuntu: `sudo apt install tmux`
|
|
176
177
|
> Arch: `sudo pacman -S tmux`
|
|
177
178
|
|
|
179
|
+
### gd-tmux
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
gitdirector gd-tmux my-repo "npm test"
|
|
183
|
+
gitdirector gd-tmux /path/to/my-repo "make build"
|
|
184
|
+
gitdirector gd-tmux my-repo 'echo "hello world"'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Creates a new gd tmux session rooted at the given repository, runs the supplied command inside it, and attaches the session. The session is created using the same code path the TUI's **Launch AI agent** menu uses: it is a real `gd/{repo}/{command}/{N}` session, visible in the Sessions tab of `gitdirector console`, and it self-destructs when the command exits.
|
|
188
|
+
|
|
189
|
+
- `PATH|NAME` — accept either an absolute path to a tracked repository or its basename, with the same disambiguation rules as `gitdirector unlink`.
|
|
190
|
+
- `"command"` — the shell command to run inside the new session. Pass it quoted so the shell hands the whole string to gitdirector; quotes and arguments are preserved verbatim.
|
|
191
|
+
|
|
192
|
+
The command is the same as opening the repo in the console, picking **Launch AI agent** from the action menu, and entering the command — but you can do it from any shell without entering the TUI first. The new session shows up in the Sessions tab immediately.
|
|
193
|
+
|
|
194
|
+
### autoclean
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
gitdirector autoclean
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Scans the tracked repositories in `~/.gitdirector/config.yaml` for paths that no longer exist on disk and removes them from the config. Lists any broken links for review and asks for confirmation before deleting them.
|
|
201
|
+
|
|
202
|
+
## Note for AI coding agents
|
|
203
|
+
|
|
204
|
+
If you are an AI coding agent reading this project: when the user asks you to run a long-lived process inside a tracked repository — a dev server, a frontend bundler, a backend API, a watcher, a database, a test runner with a long timeout, or anything else that does not exit on its own — **do not run it in the foreground**. A foreground process will block your shell for the rest of the session and the user will not be able to interact with you.
|
|
205
|
+
|
|
206
|
+
Use `gitdirector gd-tmux` to launch the process in a detached gd tmux session that the user can monitor and tear down themselves. The session shows up in the Sessions tab of `gitdirector console`, auto-cleans up when the process exits, and supports path-or-name lookup the same way every other gitdirector command does.
|
|
207
|
+
|
|
208
|
+
**Always prefer the absolute path over the repo name.** The by-name lookup matches the *directory name on disk* verbatim (via `Path.name`) — case-sensitive, dots and spaces included — and is ambiguous if two tracked repos share the same basename. When in doubt, look up the path with `gitdirector list` and use the absolute path. Quoting a path is fine; an absolute path is never ambiguous.
|
|
209
|
+
|
|
210
|
+
Examples:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# Frontend dev server in the user's web repo — use the absolute path
|
|
214
|
+
gitdirector gd-tmux /Users/me/projects/webapp "npm run dev"
|
|
215
|
+
|
|
216
|
+
# Backend API in the api repo
|
|
217
|
+
gitdirector gd-tmux /Users/me/projects/api "uvicorn app.main:app --reload --port 8000"
|
|
218
|
+
|
|
219
|
+
# A long-running integration test with output captured to a log
|
|
220
|
+
gitdirector gd-tmux /Users/me/projects/myrepo "pytest -x -v 2>&1 | tee /tmp/test.log"
|
|
221
|
+
|
|
222
|
+
# A watcher or build loop
|
|
223
|
+
gitdirector gd-tmux /Users/me/projects/myrepo "npm run watch"
|
|
224
|
+
|
|
225
|
+
# A repo whose directory name contains a space — quote both the path and the command
|
|
226
|
+
gitdirector gd-tmux "/Users/me/projects/My Repo" "npm run dev"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The by-name form is supported for ergonomic use by humans in interactive shells, but agents should default to the absolute path to avoid any chance of matching the wrong repo.
|
|
230
|
+
|
|
231
|
+
Rules of thumb:
|
|
232
|
+
|
|
233
|
+
- **Use the absolute path.** It is unambiguous and survives the user renaming or moving the directory. The by-name form is convenient shorthand, not a slug — it is the raw directory name, case-sensitive.
|
|
234
|
+
- **Repo names with spaces, dots, or punctuation are matched verbatim** when you do use the by-name form. A repo at `/Users/me/My Repo` is matched by `"My Repo"`, not by `My` or `Repo`. Quote the name to keep the shell from splitting it.
|
|
235
|
+
- **Quoting the command matters.** The command string is handed to `sh -lc` inside the new session, so write it as a single quoted string. `'echo "hi"'` (single-quote outer, double-quote inner) is the safest pattern for embedded double quotes.
|
|
236
|
+
- **Path separators in the command are fine.** Commands like `python /path/to/script.py` or `find . -name foo/bar` work; the command is embedded verbatim into the inner shell, while the session-name label is auto-sanitized.
|
|
237
|
+
- **The session self-destructs** when the command exits (success or failure), so you do not need to clean it up.
|
|
238
|
+
- **If you need to capture output**, redirect it to a file in the command itself (`> /tmp/out 2>&1`) and read the file later — the session is gone once the command exits.
|
|
239
|
+
- **If the user asks you to stop a running process**, kill the matching session: `gitdirector console` → Sessions tab, or directly with `tmux kill-session -t =<session-name>`.
|
|
240
|
+
|
|
178
241
|
## Configuration
|
|
179
242
|
|
|
180
243
|
Config is stored at `~/.gitdirector/config.yaml`.
|
|
@@ -31,8 +31,9 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
|
|
|
31
31
|
| `gitdirector status` | Show repositories with staged/unstaged files |
|
|
32
32
|
| `gitdirector pull` | Pull latest changes for all tracked repositories |
|
|
33
33
|
| `gitdirector cd NAME` | Open or switch to a tmux session for a repository |
|
|
34
|
-
| `gitdirector autoclean
|
|
34
|
+
| `gitdirector autoclean` | Remove broken repository links from tracking |
|
|
35
35
|
| `gitdirector info PATH\|NAME [--full]` | Show file statistics for a repository |
|
|
36
|
+
| `gitdirector gd-tmux PATH\|NAME "command"` | Create a gd tmux session and run a command in it |
|
|
36
37
|
| `gitdirector help` | Show help |
|
|
37
38
|
|
|
38
39
|
### link
|
|
@@ -132,6 +133,68 @@ Opens a [tmux](https://github.com/tmux/tmux) session rooted at the repository di
|
|
|
132
133
|
> Debian/Ubuntu: `sudo apt install tmux`
|
|
133
134
|
> Arch: `sudo pacman -S tmux`
|
|
134
135
|
|
|
136
|
+
### gd-tmux
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
gitdirector gd-tmux my-repo "npm test"
|
|
140
|
+
gitdirector gd-tmux /path/to/my-repo "make build"
|
|
141
|
+
gitdirector gd-tmux my-repo 'echo "hello world"'
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Creates a new gd tmux session rooted at the given repository, runs the supplied command inside it, and attaches the session. The session is created using the same code path the TUI's **Launch AI agent** menu uses: it is a real `gd/{repo}/{command}/{N}` session, visible in the Sessions tab of `gitdirector console`, and it self-destructs when the command exits.
|
|
145
|
+
|
|
146
|
+
- `PATH|NAME` — accept either an absolute path to a tracked repository or its basename, with the same disambiguation rules as `gitdirector unlink`.
|
|
147
|
+
- `"command"` — the shell command to run inside the new session. Pass it quoted so the shell hands the whole string to gitdirector; quotes and arguments are preserved verbatim.
|
|
148
|
+
|
|
149
|
+
The command is the same as opening the repo in the console, picking **Launch AI agent** from the action menu, and entering the command — but you can do it from any shell without entering the TUI first. The new session shows up in the Sessions tab immediately.
|
|
150
|
+
|
|
151
|
+
### autoclean
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
gitdirector autoclean
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Scans the tracked repositories in `~/.gitdirector/config.yaml` for paths that no longer exist on disk and removes them from the config. Lists any broken links for review and asks for confirmation before deleting them.
|
|
158
|
+
|
|
159
|
+
## Note for AI coding agents
|
|
160
|
+
|
|
161
|
+
If you are an AI coding agent reading this project: when the user asks you to run a long-lived process inside a tracked repository — a dev server, a frontend bundler, a backend API, a watcher, a database, a test runner with a long timeout, or anything else that does not exit on its own — **do not run it in the foreground**. A foreground process will block your shell for the rest of the session and the user will not be able to interact with you.
|
|
162
|
+
|
|
163
|
+
Use `gitdirector gd-tmux` to launch the process in a detached gd tmux session that the user can monitor and tear down themselves. The session shows up in the Sessions tab of `gitdirector console`, auto-cleans up when the process exits, and supports path-or-name lookup the same way every other gitdirector command does.
|
|
164
|
+
|
|
165
|
+
**Always prefer the absolute path over the repo name.** The by-name lookup matches the *directory name on disk* verbatim (via `Path.name`) — case-sensitive, dots and spaces included — and is ambiguous if two tracked repos share the same basename. When in doubt, look up the path with `gitdirector list` and use the absolute path. Quoting a path is fine; an absolute path is never ambiguous.
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Frontend dev server in the user's web repo — use the absolute path
|
|
171
|
+
gitdirector gd-tmux /Users/me/projects/webapp "npm run dev"
|
|
172
|
+
|
|
173
|
+
# Backend API in the api repo
|
|
174
|
+
gitdirector gd-tmux /Users/me/projects/api "uvicorn app.main:app --reload --port 8000"
|
|
175
|
+
|
|
176
|
+
# A long-running integration test with output captured to a log
|
|
177
|
+
gitdirector gd-tmux /Users/me/projects/myrepo "pytest -x -v 2>&1 | tee /tmp/test.log"
|
|
178
|
+
|
|
179
|
+
# A watcher or build loop
|
|
180
|
+
gitdirector gd-tmux /Users/me/projects/myrepo "npm run watch"
|
|
181
|
+
|
|
182
|
+
# A repo whose directory name contains a space — quote both the path and the command
|
|
183
|
+
gitdirector gd-tmux "/Users/me/projects/My Repo" "npm run dev"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The by-name form is supported for ergonomic use by humans in interactive shells, but agents should default to the absolute path to avoid any chance of matching the wrong repo.
|
|
187
|
+
|
|
188
|
+
Rules of thumb:
|
|
189
|
+
|
|
190
|
+
- **Use the absolute path.** It is unambiguous and survives the user renaming or moving the directory. The by-name form is convenient shorthand, not a slug — it is the raw directory name, case-sensitive.
|
|
191
|
+
- **Repo names with spaces, dots, or punctuation are matched verbatim** when you do use the by-name form. A repo at `/Users/me/My Repo` is matched by `"My Repo"`, not by `My` or `Repo`. Quote the name to keep the shell from splitting it.
|
|
192
|
+
- **Quoting the command matters.** The command string is handed to `sh -lc` inside the new session, so write it as a single quoted string. `'echo "hi"'` (single-quote outer, double-quote inner) is the safest pattern for embedded double quotes.
|
|
193
|
+
- **Path separators in the command are fine.** Commands like `python /path/to/script.py` or `find . -name foo/bar` work; the command is embedded verbatim into the inner shell, while the session-name label is auto-sanitized.
|
|
194
|
+
- **The session self-destructs** when the command exits (success or failure), so you do not need to clean it up.
|
|
195
|
+
- **If you need to capture output**, redirect it to a file in the command itself (`> /tmp/out 2>&1`) and read the file later — the session is gone once the command exits.
|
|
196
|
+
- **If the user asks you to stop a running process**, kill the matching session: `gitdirector console` → Sessions tab, or directly with `tmux kill-session -t =<session-name>`.
|
|
197
|
+
|
|
135
198
|
## Configuration
|
|
136
199
|
|
|
137
200
|
Config is stored at `~/.gitdirector/config.yaml`.
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitdirector"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.2"
|
|
8
8
|
description = "A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -10,6 +10,7 @@ from .commands import (
|
|
|
10
10
|
autoclean,
|
|
11
11
|
cd,
|
|
12
12
|
console,
|
|
13
|
+
gd_tmux,
|
|
13
14
|
help,
|
|
14
15
|
info,
|
|
15
16
|
link,
|
|
@@ -55,6 +56,7 @@ help.register(cli)
|
|
|
55
56
|
tui.register(cli)
|
|
56
57
|
autoclean.register(cli)
|
|
57
58
|
info.register(cli)
|
|
59
|
+
gd_tmux.register(cli)
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
def main():
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ..config import Config
|
|
4
|
+
from . import console
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register(cli: click.Group):
|
|
8
|
+
@cli.command()
|
|
9
|
+
def autoclean():
|
|
10
|
+
_autoclean_links()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _autoclean_links():
|
|
14
|
+
config = Config()
|
|
15
|
+
broken = [p for p in config.repositories if not p.exists()]
|
|
16
|
+
|
|
17
|
+
if not broken:
|
|
18
|
+
console.print()
|
|
19
|
+
console.print(" [green]All links are valid.[/green]")
|
|
20
|
+
console.print()
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
console.print()
|
|
24
|
+
console.print(f" Found [yellow]{len(broken)}[/yellow] broken link(s):\n")
|
|
25
|
+
for p in broken:
|
|
26
|
+
console.print(f" [red]✕[/red] {p}", soft_wrap=True)
|
|
27
|
+
console.print()
|
|
28
|
+
|
|
29
|
+
if not click.confirm(" Remove these broken links?"):
|
|
30
|
+
console.print()
|
|
31
|
+
console.print(" [dim]Cancelled.[/dim]")
|
|
32
|
+
console.print()
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
config.remove_repositories(broken)
|
|
36
|
+
|
|
37
|
+
console.print()
|
|
38
|
+
console.print(f" [green]Removed {len(broken)} broken link(s).[/green]")
|
|
39
|
+
console.print()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..manager import RepositoryManager
|
|
6
|
+
from ..storage import normalize_repository_path
|
|
7
|
+
from . import console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_repo(target: str) -> Path | None:
|
|
11
|
+
"""Resolve *target* to a tracked repository path.
|
|
12
|
+
|
|
13
|
+
Tries the value as a filesystem path first. If no path-based match is
|
|
14
|
+
found and the value does not look path-like, falls back to looking it up
|
|
15
|
+
by repository name. Prints a rich error and returns ``None`` on missing
|
|
16
|
+
or ambiguous targets.
|
|
17
|
+
"""
|
|
18
|
+
manager = RepositoryManager()
|
|
19
|
+
candidate = Path(target)
|
|
20
|
+
is_path_like = (
|
|
21
|
+
"/" in target
|
|
22
|
+
or "\\" in target
|
|
23
|
+
or target in (".", "..")
|
|
24
|
+
or candidate.is_absolute()
|
|
25
|
+
or target.startswith("~")
|
|
26
|
+
or candidate.exists()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if is_path_like:
|
|
30
|
+
normalized = normalize_repository_path(candidate)
|
|
31
|
+
if manager.config.has_repository(normalized):
|
|
32
|
+
return normalized
|
|
33
|
+
console.print(f"\n [red]No tracked repository at path: {normalized}[/red]\n")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
matches = [r for r in manager.config.repositories if r.name == target]
|
|
37
|
+
if not matches:
|
|
38
|
+
console.print(f"\n [red]No tracked repository named: {target}[/red]\n")
|
|
39
|
+
return None
|
|
40
|
+
if len(matches) > 1:
|
|
41
|
+
paths_list = "\n".join(f" {p}" for p in matches)
|
|
42
|
+
console.print(
|
|
43
|
+
f"\n [red]Multiple repositories named '{target}' — use the full path:[/red]\n"
|
|
44
|
+
f"{paths_list}\n"
|
|
45
|
+
)
|
|
46
|
+
return None
|
|
47
|
+
return matches[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register(cli: click.Group):
|
|
51
|
+
@cli.command()
|
|
52
|
+
@click.argument("target", metavar="PATH|NAME")
|
|
53
|
+
@click.argument("command")
|
|
54
|
+
def gd_tmux(target: str, command: str):
|
|
55
|
+
"""Create a gd tmux session for a repository and run a command in it."""
|
|
56
|
+
if not command.strip():
|
|
57
|
+
console.print("\n [red]Command must not be empty.[/red]\n")
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
|
|
60
|
+
repo_path = _resolve_repo(target)
|
|
61
|
+
if repo_path is None:
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
from ..integrations.tmux import (
|
|
66
|
+
attach_tmux_session,
|
|
67
|
+
create_tmux_session,
|
|
68
|
+
launch_command_in_tmux_session,
|
|
69
|
+
)
|
|
70
|
+
except ImportError:
|
|
71
|
+
console.print(
|
|
72
|
+
"\n [red]The tmux integration is unavailable for the gd-tmux command.[/red]\n"
|
|
73
|
+
" Reinstall gitdirector or check your installation.\n"
|
|
74
|
+
)
|
|
75
|
+
raise SystemExit(1)
|
|
76
|
+
|
|
77
|
+
session_name = create_tmux_session(repo_path.name, repo_path, purpose=command)
|
|
78
|
+
launch_command_in_tmux_session(session_name, command)
|
|
79
|
+
attach_tmux_session(session_name, skip_config_sync=True)
|
|
@@ -32,8 +32,9 @@ def show_help():
|
|
|
32
32
|
("pull", "Pull latest changes for all tracked repositories"),
|
|
33
33
|
("cd NAME", "Open or switch to a tmux session for a repository"),
|
|
34
34
|
("console", "Interactive TUI for browsing and opening repositories"),
|
|
35
|
-
("autoclean
|
|
35
|
+
("autoclean", "Remove broken repository links from tracking"),
|
|
36
36
|
("info PATH|NAME [--full]", "Show file statistics for a repository"),
|
|
37
|
+
('gd-tmux PATH|NAME "cmd"', "Create a gd tmux session and run a command in it"),
|
|
37
38
|
("help", "Show this help message"),
|
|
38
39
|
]:
|
|
39
40
|
cmd_table.add_row(cmd, desc)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Callable
|
|
@@ -55,6 +56,9 @@ __all__ = [
|
|
|
55
56
|
]
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
58
62
|
class GitDirectorConsole(
|
|
59
63
|
ConsolePanelsMixin,
|
|
60
64
|
ConsoleSessionsMixin,
|
|
@@ -355,13 +359,13 @@ class GitDirectorConsole(
|
|
|
355
359
|
if path is None:
|
|
356
360
|
return
|
|
357
361
|
|
|
358
|
-
from ...integrations.tmux import create_tmux_session,
|
|
362
|
+
from ...integrations.tmux import create_tmux_session, launch_command_in_tmux_session
|
|
359
363
|
|
|
360
364
|
purpose = agent_cmd if agent_cmd else "shell"
|
|
361
365
|
session_name = create_tmux_session(path.name, path, purpose=purpose)
|
|
362
366
|
|
|
363
367
|
if agent_cmd:
|
|
364
|
-
ready_marker =
|
|
368
|
+
ready_marker = launch_command_in_tmux_session(session_name, agent_cmd)
|
|
365
369
|
self.push_screen(
|
|
366
370
|
AgentLoadingScreen(agent_cmd, session_name, ready_marker),
|
|
367
371
|
callback=lambda _: self.set_timer(0.2, lambda: self._refresh_repo_for_path(path)),
|
|
@@ -657,10 +661,27 @@ class GitDirectorConsole(
|
|
|
657
661
|
|
|
658
662
|
from ..pull import pull_repository
|
|
659
663
|
|
|
660
|
-
|
|
664
|
+
try:
|
|
665
|
+
result = pull_repository(path)
|
|
666
|
+
except Exception as exc:
|
|
667
|
+
logger.exception("pull worker crashed")
|
|
668
|
+
error_result = (path.name, False, f"Pull failed: {exc}")
|
|
669
|
+
if self._background_shutdown_requested(worker):
|
|
670
|
+
return
|
|
671
|
+
try:
|
|
672
|
+
self.call_from_thread(
|
|
673
|
+
self._show_pull_result, loading_screen, path, command, error_result
|
|
674
|
+
)
|
|
675
|
+
except Exception:
|
|
676
|
+
logger.debug("Failed to post pull error to UI", exc_info=True)
|
|
677
|
+
return
|
|
678
|
+
|
|
661
679
|
if self._background_shutdown_requested(worker):
|
|
662
680
|
return
|
|
663
|
-
|
|
681
|
+
try:
|
|
682
|
+
self.call_from_thread(self._show_pull_result, loading_screen, path, command, result)
|
|
683
|
+
except Exception:
|
|
684
|
+
logger.debug("Failed to post pull result to UI", exc_info=True)
|
|
664
685
|
|
|
665
686
|
def _show_pull_result(
|
|
666
687
|
self,
|
|
@@ -218,6 +218,7 @@ class ChangedFile:
|
|
|
218
218
|
additions: int = 0
|
|
219
219
|
deletions: int = 0
|
|
220
220
|
is_binary: bool = False
|
|
221
|
+
is_image: bool = False
|
|
221
222
|
is_rename: bool = False
|
|
222
223
|
old_path: str | None = None
|
|
223
224
|
diff_text: str = ""
|
|
@@ -263,6 +264,35 @@ _RENAME_TO_RE = re.compile(r"^rename to (.+)$")
|
|
|
263
264
|
_BINARY_RE = re.compile(r"^Binary files .* differ$")
|
|
264
265
|
_HUNK_HEADER_RE = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@")
|
|
265
266
|
|
|
267
|
+
_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
|
268
|
+
{
|
|
269
|
+
".png",
|
|
270
|
+
".jpg",
|
|
271
|
+
".jpeg",
|
|
272
|
+
".gif",
|
|
273
|
+
".bmp",
|
|
274
|
+
".ico",
|
|
275
|
+
".webp",
|
|
276
|
+
".tiff",
|
|
277
|
+
".tif",
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_image_file(path: str) -> bool:
|
|
283
|
+
"""Return ``True`` if ``path`` has a raster-image extension.
|
|
284
|
+
|
|
285
|
+
Used to decide whether the diff viewer should suppress the right-hand
|
|
286
|
+
diff/preview pane entirely for a file. SVG is intentionally excluded
|
|
287
|
+
because it's text-based and the regular diff renderer handles it fine.
|
|
288
|
+
"""
|
|
289
|
+
if not path:
|
|
290
|
+
return False
|
|
291
|
+
name = PurePosixPath(path).name.lower()
|
|
292
|
+
if "." not in name:
|
|
293
|
+
return False
|
|
294
|
+
return "." + name.rsplit(".", 1)[1] in _IMAGE_EXTENSIONS
|
|
295
|
+
|
|
266
296
|
|
|
267
297
|
def _parse_hunk_start(header: str) -> tuple[int, int]:
|
|
268
298
|
"""Return ``(old_start, new_start)`` from an ``@@`` hunk header."""
|
|
@@ -373,6 +403,7 @@ def parse_diff_files(diff_text: str) -> list[ChangedFile]:
|
|
|
373
403
|
additions=current.additions,
|
|
374
404
|
deletions=current.deletions,
|
|
375
405
|
is_binary=current.is_binary,
|
|
406
|
+
is_image=current.is_image,
|
|
376
407
|
is_rename=current.is_rename,
|
|
377
408
|
old_path=current.old_path,
|
|
378
409
|
diff_text="\n".join(current_lines).rstrip("\n"),
|
|
@@ -399,6 +430,7 @@ def parse_diff_files(diff_text: str) -> list[ChangedFile]:
|
|
|
399
430
|
path=new_name,
|
|
400
431
|
status="M",
|
|
401
432
|
is_rename=is_rename,
|
|
433
|
+
is_image=is_image_file(new_name),
|
|
402
434
|
old_path=old_name if is_rename else None,
|
|
403
435
|
)
|
|
404
436
|
current_lines = [raw_line]
|
|
@@ -469,6 +501,7 @@ def build_diff_bundle(diff_text: str, untracked_paths: list[str], untracked_look
|
|
|
469
501
|
path=rel_path,
|
|
470
502
|
status="?",
|
|
471
503
|
additions=line_count,
|
|
504
|
+
is_image=is_image_file(rel_path),
|
|
472
505
|
diff_text=synthetic.rstrip("\n"),
|
|
473
506
|
raw_untracked_text=text,
|
|
474
507
|
)
|
|
@@ -512,6 +545,9 @@ def render_change_summary(
|
|
|
512
545
|
if file.is_binary:
|
|
513
546
|
text.append(" ")
|
|
514
547
|
text.append("[binary]", style="dim")
|
|
548
|
+
elif file.is_image:
|
|
549
|
+
text.append(" ")
|
|
550
|
+
text.append("[image]", style="dim")
|
|
515
551
|
elif file.additions or file.deletions:
|
|
516
552
|
text.append(" ")
|
|
517
553
|
text.append(f"+{file.additions}", style="#3fb950")
|
|
@@ -539,9 +575,12 @@ def render_file_diff(
|
|
|
539
575
|
|
|
540
576
|
pieces.append(_render_file_header(file))
|
|
541
577
|
|
|
578
|
+
if file.is_image:
|
|
579
|
+
return Group(*pieces)
|
|
580
|
+
|
|
542
581
|
if file.is_binary:
|
|
543
582
|
body = RichText(
|
|
544
|
-
"\n Binary file differs from HEAD.\n
|
|
583
|
+
"\n Binary file differs from HEAD.\n Diff is not shown for binary files.\n",
|
|
545
584
|
style="italic dim",
|
|
546
585
|
)
|
|
547
586
|
pieces.append(Padding(body, (1, 2)))
|
|
@@ -10,6 +10,7 @@ from textual.widgets import OptionList, Static
|
|
|
10
10
|
from textual.widgets.option_list import Option
|
|
11
11
|
|
|
12
12
|
from ..constants import _MODAL_BINDINGS, _MODAL_CSS, _SORT_COLUMN_NAMES
|
|
13
|
+
from ..terminal_caps import strip_unsupported_css as _safe_css
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def _render_ansi_output(output: str) -> Text:
|
|
@@ -21,7 +22,7 @@ class ConfirmScreen(ModalScreen[bool]):
|
|
|
21
22
|
|
|
22
23
|
BINDINGS = _MODAL_BINDINGS
|
|
23
24
|
|
|
24
|
-
CSS = (
|
|
25
|
+
CSS = _safe_css(
|
|
25
26
|
"ConfirmScreen { align: center middle; background: $panel 80%; hatch: right $primary 30%; }"
|
|
26
27
|
+ _MODAL_CSS
|
|
27
28
|
)
|
|
@@ -61,7 +62,7 @@ class SortMenuScreen(ModalScreen[tuple | None]):
|
|
|
61
62
|
|
|
62
63
|
BINDINGS = _MODAL_BINDINGS
|
|
63
64
|
|
|
64
|
-
CSS = (
|
|
65
|
+
CSS = _safe_css(
|
|
65
66
|
"SortMenuScreen {"
|
|
66
67
|
" align: center middle; background: $panel 80%; hatch: right $primary 30%;"
|
|
67
68
|
" }" + _MODAL_CSS
|
|
@@ -33,6 +33,7 @@ from textual.widgets import Input, LoadingIndicator, OptionList, Static
|
|
|
33
33
|
from textual.widgets.option_list import Option
|
|
34
34
|
|
|
35
35
|
from ..constants import _MODAL_BINDINGS, _MODAL_CSS
|
|
36
|
+
from ..terminal_caps import strip_unsupported_css as _safe_css
|
|
36
37
|
|
|
37
38
|
# ---------------------------------------------------------------------------
|
|
38
39
|
# Stage-all confirm
|
|
@@ -52,7 +53,7 @@ class StageFilesConfirmScreen(ModalScreen[bool]):
|
|
|
52
53
|
|
|
53
54
|
BINDINGS = _MODAL_BINDINGS
|
|
54
55
|
|
|
55
|
-
CSS = (
|
|
56
|
+
CSS = _safe_css(
|
|
56
57
|
"StageFilesConfirmScreen {"
|
|
57
58
|
" align: center middle; background: $panel 80%; hatch: right $primary 30%;"
|
|
58
59
|
" }" + _MODAL_CSS
|
|
@@ -86,7 +87,7 @@ class StageFilesConfirmScreen(ModalScreen[bool]):
|
|
|
86
87
|
id="action-menu",
|
|
87
88
|
)
|
|
88
89
|
yield Static(
|
|
89
|
-
"[green]+N[/green] additions [red]-N[/red] deletions
|
|
90
|
+
"[green]+N[/green] additions [red]-N[/red] deletions \u00b7 esc to cancel",
|
|
90
91
|
id="menu-hint",
|
|
91
92
|
)
|
|
92
93
|
|
|
@@ -142,10 +143,12 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
|
|
|
142
143
|
Binding("up", "action_cursor_up", "\u2191 action", show=False),
|
|
143
144
|
]
|
|
144
145
|
|
|
145
|
-
CSS = (
|
|
146
|
+
CSS = _safe_css(
|
|
146
147
|
"CommitMessageScreen {"
|
|
147
148
|
" align: center middle; background: $panel 80%; hatch: right $primary 30%;"
|
|
148
|
-
" }"
|
|
149
|
+
" }"
|
|
150
|
+
+ _MODAL_CSS
|
|
151
|
+
+ """
|
|
149
152
|
#commit-message-container {
|
|
150
153
|
width: 60%;
|
|
151
154
|
height: auto;
|
|
@@ -202,8 +205,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
|
|
|
202
205
|
noun = "file" if self.file_count == 1 else "files"
|
|
203
206
|
with Vertical(id="commit-message-container"):
|
|
204
207
|
yield Static(
|
|
205
|
-
f"[bold white]Commit changes in[/bold white] "
|
|
206
|
-
f"[cyan]{escape(self.repo_name)}[/cyan]",
|
|
208
|
+
f"[bold white]Commit changes in[/bold white] [cyan]{escape(self.repo_name)}[/cyan]",
|
|
207
209
|
id="commit-message-title",
|
|
208
210
|
)
|
|
209
211
|
yield Static(
|
|
@@ -222,10 +224,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
|
|
|
222
224
|
id="commit-message-action-list",
|
|
223
225
|
)
|
|
224
226
|
yield Static(
|
|
225
|
-
"type message "
|
|
226
|
-
"[\u2191\u2193] pick action "
|
|
227
|
-
"[enter] confirm "
|
|
228
|
-
"[esc] cancel",
|
|
227
|
+
"type message [\u2191\u2193] pick action [enter] confirm [esc] cancel",
|
|
229
228
|
id="commit-message-hint",
|
|
230
229
|
)
|
|
231
230
|
|
|
@@ -304,10 +303,12 @@ class CommitLoadingScreen(ModalScreen[None]):
|
|
|
304
303
|
|
|
305
304
|
BINDINGS = [Binding("escape", "noop", "Esc", show=False)]
|
|
306
305
|
|
|
307
|
-
CSS = (
|
|
306
|
+
CSS = _safe_css(
|
|
308
307
|
"CommitLoadingScreen {"
|
|
309
308
|
" align: center middle; background: $panel 80%; hatch: right $primary 30%;"
|
|
310
|
-
" }"
|
|
309
|
+
" }"
|
|
310
|
+
+ _MODAL_CSS
|
|
311
|
+
+ """
|
|
311
312
|
#commit-loading-container {
|
|
312
313
|
width: 50%;
|
|
313
314
|
height: auto;
|
|
@@ -368,10 +369,12 @@ class CommitResultScreen(ModalScreen[None]):
|
|
|
368
369
|
|
|
369
370
|
BINDINGS = _MODAL_BINDINGS
|
|
370
371
|
|
|
371
|
-
CSS = (
|
|
372
|
+
CSS = _safe_css(
|
|
372
373
|
"CommitResultScreen {"
|
|
373
374
|
" align: center middle; background: $panel 80%; hatch: right $primary 30%;"
|
|
374
|
-
" }"
|
|
375
|
+
" }"
|
|
376
|
+
+ _MODAL_CSS
|
|
377
|
+
+ """
|
|
375
378
|
#commit-result-container {
|
|
376
379
|
width: 60%;
|
|
377
380
|
height: auto;
|
|
@@ -353,6 +353,9 @@ class FileTile(Static):
|
|
|
353
353
|
if self._spec.file.is_binary:
|
|
354
354
|
text.append("[binary]", style="#8b949e")
|
|
355
355
|
return text
|
|
356
|
+
if self._spec.file.is_image:
|
|
357
|
+
text.append("[image]", style="#8b949e")
|
|
358
|
+
return text
|
|
356
359
|
additions = self._spec.file.additions
|
|
357
360
|
deletions = self._spec.file.deletions
|
|
358
361
|
text.append(f"+{additions}", style="bold #3fb950")
|
|
@@ -445,6 +448,9 @@ class FileTileList(ListView):
|
|
|
445
448
|
super().__init__(**kwargs)
|
|
446
449
|
self._specs: list[_FileTileSpec] = []
|
|
447
450
|
self._repo_dir: str = ""
|
|
451
|
+
self._pending_index: int | None = None
|
|
452
|
+
self._pending_retry_count = 0
|
|
453
|
+
self._PENDING_RETRY_LIMIT = 50
|
|
448
454
|
|
|
449
455
|
def set_files(self, files: list[ChangedFile], repo_dir: str = "") -> None:
|
|
450
456
|
self._repo_dir = repo_dir
|
|
@@ -455,19 +461,28 @@ class FileTileList(ListView):
|
|
|
455
461
|
tile = FileTile(spec)
|
|
456
462
|
self.append(ListItem(tile, id=f"file-tile-{id(tile)}"))
|
|
457
463
|
if self._specs:
|
|
458
|
-
# The ListItem children mount asynchronously, so we set the
|
|
459
|
-
# initial index via a deferred call. Trying to set ``index``
|
|
460
|
-
# now would fire ``watch_index`` against half-mounted children
|
|
461
|
-
# and crash with NoMatches.
|
|
462
464
|
self._suppress_watch = True
|
|
463
465
|
self._pending_index = 0
|
|
466
|
+
self._pending_retry_count = 0
|
|
464
467
|
self.call_after_refresh(self._apply_initial_selection)
|
|
468
|
+
else:
|
|
469
|
+
self._suppress_watch = False
|
|
470
|
+
self._pending_index = None
|
|
465
471
|
|
|
466
472
|
def _apply_initial_selection(self) -> None:
|
|
467
|
-
pending =
|
|
473
|
+
pending = self._pending_index
|
|
468
474
|
if pending is None:
|
|
469
475
|
return
|
|
476
|
+
if len(self._nodes) <= pending:
|
|
477
|
+
self._pending_retry_count += 1
|
|
478
|
+
if self._pending_retry_count > self._PENDING_RETRY_LIMIT:
|
|
479
|
+
self._pending_index = None
|
|
480
|
+
self._suppress_watch = False
|
|
481
|
+
return
|
|
482
|
+
self.call_after_refresh(self._apply_initial_selection)
|
|
483
|
+
return
|
|
470
484
|
self._pending_index = None
|
|
485
|
+
self._pending_retry_count = 0
|
|
471
486
|
self._suppress_watch = False
|
|
472
487
|
self.index = pending
|
|
473
488
|
|