v1.2.0: TUI paginat, timeouts de xarxa i barres de progrés
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# gitswarm
|
# gitswarm
|
||||||
|
|
||||||
Lista y actualiza repos git de primer nivel dentro de una carpeta. REPL interactivo con colores y tabla.
|
TUI para listar y actualizar repos git de primer nivel dentro de una carpeta. Soporta muchos repos (paginación automática) y nunca se bloquea pidiendo credenciales.
|
||||||
|
|
||||||
## Uso
|
## Uso
|
||||||
|
|
||||||
@@ -8,16 +8,51 @@ Lista y actualiza repos git de primer nivel dentro de una carpeta. REPL interact
|
|||||||
gitswarm /ruta/a/escanear
|
gitswarm /ruta/a/escanear
|
||||||
```
|
```
|
||||||
|
|
||||||
Te muestra una tabla con los repos detectados y entra a un prompt:
|
Te muestra una tabla paginada con todos los repos detectados y un cursor para navegarlos. La paginación se ajusta sola al alto del terminal.
|
||||||
|
|
||||||
- `list` / `ls` — vuelve a mostrar la tabla
|
### Atajos
|
||||||
- `refresh` / `r` — re-escanea haciendo `git fetch`
|
|
||||||
- `update <repo>` — `git pull --ff-only` (acepta nº, nombre exacto o fragmento)
|
|
||||||
- `update all` — actualiza todos los que estén behind
|
|
||||||
- `help` / `?`
|
|
||||||
- `quit` / `q` (también Ctrl-D / Ctrl-C)
|
|
||||||
|
|
||||||
El estado se calcula con `git fetch` al arrancar y al hacer `refresh`. `update` aborta si el repo tiene cambios locales sin commitear.
|
| Tecla | Acción |
|
||||||
|
|---|---|
|
||||||
|
| `↑` `↓` `j` `k` | mover cursor |
|
||||||
|
| `←` `→` `h` `l` | cambiar de página |
|
||||||
|
| `g` `G` | inicio / final |
|
||||||
|
| `Space` | marcar/desmarcar el repo bajo el cursor |
|
||||||
|
| `a` `n` | marcar todos los que estén behind / desmarcar todos |
|
||||||
|
| `Enter` | `git pull --ff-only` en los marcados (en paralelo) |
|
||||||
|
| `s` | mostrar `git status` del repo bajo cursor |
|
||||||
|
| `c` | commit en el repo bajo cursor (`git add -A` + mensaje) |
|
||||||
|
| `r` | refrescar (re-fetch) |
|
||||||
|
| `?` | ayuda |
|
||||||
|
| `q` | salir (también `Ctrl-C` / `Ctrl-D`) |
|
||||||
|
|
||||||
|
El estado se calcula con `git fetch` al arrancar y en cada `refresh`. `Enter` aborta cualquier repo con cambios locales sin commitear.
|
||||||
|
|
||||||
|
## Robustez de red
|
||||||
|
|
||||||
|
gitswarm **nunca se cuelga**: ejecuta git con timeouts cortos (SSH `ConnectTimeout=5`, HTTP aborta si no llegan datos en 10s, fetch global cap de 30s), `GIT_TERMINAL_PROMPT=0` y askpass desactivado. Cada repo problemático se marca y se pasa al siguiente:
|
||||||
|
|
||||||
|
- 🔒 **`auth`** — el repo necesita credenciales que no están configuradas.
|
||||||
|
- 🔌 **`sin red`** — el host del remote no resuelve, no responde o tarda demasiado.
|
||||||
|
- **`ERROR: …`** — fallo de git distinto a los anteriores.
|
||||||
|
|
||||||
|
## Credenciales (repos privados)
|
||||||
|
|
||||||
|
Para que funcionen los repos privados, configura un credential helper de git **una sola vez** (no es trabajo de gitswarm, esto es la forma estándar de git):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Opción 1 — store sencillo en disco (~/.git-credentials, sin cifrar):
|
||||||
|
git config --global credential.helper store
|
||||||
|
|
||||||
|
# Opción 2 — libsecret (gnome-keyring, KDE wallet, etc., recomendado en Linux):
|
||||||
|
sudo apt install libsecret-1-0 libsecret-1-dev
|
||||||
|
sudo make -C /usr/share/doc/git/contrib/credential/libsecret
|
||||||
|
git config --global credential.helper /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret
|
||||||
|
```
|
||||||
|
|
||||||
|
La primera vez que hagas `git pull` a mano sobre un repo privado, git te pedirá usuario/token y el helper lo guardará. A partir de ahí gitswarm puede actualizarlo sin intervención.
|
||||||
|
|
||||||
|
Para repos por SSH lo equivalente es tener la clave cargada en `ssh-agent`.
|
||||||
|
|
||||||
## Requisitos en el sistema
|
## Requisitos en el sistema
|
||||||
|
|
||||||
@@ -26,7 +61,7 @@ Necesarios siempre:
|
|||||||
- `python3` (≥ 3.10)
|
- `python3` (≥ 3.10)
|
||||||
- `git`
|
- `git`
|
||||||
|
|
||||||
Para desarrollar (con venv y rich pip-installed):
|
Para desarrollar (con venv y dependencias pip-installed):
|
||||||
|
|
||||||
- `python3-venv`
|
- `python3-venv`
|
||||||
|
|
||||||
@@ -47,7 +82,7 @@ sudo apt install python3 python3-venv python3-dev git gcc patchelf
|
|||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
./gitswarm /ruta/a/escanear # wrapper que usa el venv
|
.venv/bin/python gitswarm.py /ruta/a/escanear
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compilar a binario standalone
|
## Compilar a binario standalone
|
||||||
@@ -56,7 +91,7 @@ python3 -m venv .venv
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Genera `dist/gitswarm`, un único ejecutable autocontenido (lleva Python + rich dentro). Para instalarlo en tu PATH:
|
Genera `dist/gitswarm`, un único ejecutable autocontenido (lleva Python + rich + readchar dentro). Para instalarlo en tu PATH:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp dist/gitswarm ~/.local/bin/
|
cp dist/gitswarm ~/.local/bin/
|
||||||
@@ -65,8 +100,7 @@ cp dist/gitswarm ~/.local/bin/
|
|||||||
## Ficheros
|
## Ficheros
|
||||||
|
|
||||||
- `gitswarm.py` — script principal
|
- `gitswarm.py` — script principal
|
||||||
- `gitswarm` — wrapper bash que invoca el venv local (para desarrollo)
|
- `requirements.txt` — dependencias Python (`rich`, `readchar`)
|
||||||
- `requirements.txt` — dependencias Python (`rich`)
|
|
||||||
- `build.sh` — compila el binario con Nuitka
|
- `build.sh` — compila el binario con Nuitka
|
||||||
- `.venv/` — virtualenv local (no en git)
|
- `.venv/` — virtualenv local (no en git)
|
||||||
- `dist/` — binarios compilados (no en git)
|
- `dist/` — binarios compilados (no en git)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ echo "[build] compilando (esto puede tardar 1-2 min)…"
|
|||||||
--remove-output \
|
--remove-output \
|
||||||
--lto=yes \
|
--lto=yes \
|
||||||
--include-package=rich \
|
--include-package=rich \
|
||||||
|
--include-package=readchar \
|
||||||
gitswarm.py
|
gitswarm.py
|
||||||
|
|
||||||
echo "[build] empaquetando release ${RELEASE_NAME}.tar.gz…"
|
echo "[build] empaquetando release ${RELEASE_NAME}.tar.gz…"
|
||||||
|
|||||||
+473
-123
@@ -1,26 +1,115 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""gitswarm — lista y actualiza repos git de primer nivel en una carpeta."""
|
"""gitswarm — TUI para listar y actualizar repos git de primer nivel en una carpeta."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import shlex
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rich.console import Console
|
import readchar
|
||||||
|
from rich.console import Console, Group
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn,
|
||||||
|
MofNCompleteColumn,
|
||||||
|
Progress,
|
||||||
|
SpinnerColumn,
|
||||||
|
TextColumn,
|
||||||
|
TimeElapsedColumn,
|
||||||
|
)
|
||||||
from rich.prompt import Prompt
|
from rich.prompt import Prompt
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.2.0"
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
# --- env anti-bloqueo para git ----------------------------------------------
|
||||||
|
|
||||||
|
NONINTERACTIVE_ENV = {
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_ASKPASS": "/bin/true",
|
||||||
|
"SSH_ASKPASS": "/bin/true",
|
||||||
|
# SSH: no preguntar, time out rápido si el host no responde
|
||||||
|
"GIT_SSH_COMMAND": (
|
||||||
|
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "
|
||||||
|
"-o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=2"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Config de git pasada con `-c` para acotar timeouts de red en HTTP/HTTPS.
|
||||||
|
# lowSpeedTime=10s + lowSpeedLimit=1KB/s aborta si no llegan datos en 10s.
|
||||||
|
NETWORK_GIT_CONFIG = [
|
||||||
|
"-c", "http.lowSpeedLimit=1000",
|
||||||
|
"-c", "http.lowSpeedTime=10",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _git_env() -> dict[str, str]:
|
||||||
|
return {**os.environ, **NONINTERACTIVE_ENV}
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(repo: Path, *args: str, timeout: int = 30, network: bool = False) -> tuple[int, str, str]:
|
||||||
|
cmd = ["git"]
|
||||||
|
if network:
|
||||||
|
cmd.extend(NETWORK_GIT_CONFIG)
|
||||||
|
cmd.extend(args)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env=_git_env(),
|
||||||
|
)
|
||||||
|
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_auth_error(msg: str) -> bool:
|
||||||
|
m = msg.lower()
|
||||||
|
return any(
|
||||||
|
token in m
|
||||||
|
for token in (
|
||||||
|
"authentication failed",
|
||||||
|
"could not read username",
|
||||||
|
"could not read password",
|
||||||
|
"permission denied (publickey)",
|
||||||
|
"host key verification failed",
|
||||||
|
"terminal prompts disabled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_network_error(msg: str) -> bool:
|
||||||
|
m = msg.lower()
|
||||||
|
return any(
|
||||||
|
token in m
|
||||||
|
for token in (
|
||||||
|
"could not resolve host",
|
||||||
|
"name or service not known",
|
||||||
|
"connection refused",
|
||||||
|
"connection timed out",
|
||||||
|
"operation timed out",
|
||||||
|
"network is unreachable",
|
||||||
|
"no route to host",
|
||||||
|
"unable to access",
|
||||||
|
"failed to connect",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- modelo -----------------------------------------------------------------
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RepoStatus:
|
class RepoStatus:
|
||||||
name: str
|
name: str
|
||||||
@@ -31,19 +120,13 @@ class RepoStatus:
|
|||||||
ahead: int = 0
|
ahead: int = 0
|
||||||
behind: int = 0
|
behind: int = 0
|
||||||
has_upstream: bool = True
|
has_upstream: bool = True
|
||||||
|
auth_required: bool = False
|
||||||
|
unreachable: bool = False
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
dirty_files: list[str] = field(default_factory=list)
|
dirty_files: list[str] = field(default_factory=list)
|
||||||
|
selected: bool = False
|
||||||
|
busy: bool = False
|
||||||
def run_git(repo: Path, *args: str, timeout: int = 30) -> tuple[int, str, str]:
|
last_action: str = ""
|
||||||
result = subprocess.run(
|
|
||||||
["git", *args],
|
|
||||||
cwd=repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def is_git_repo(path: Path) -> bool:
|
def is_git_repo(path: Path) -> bool:
|
||||||
@@ -69,7 +152,19 @@ def gather_status(path: Path, fetch: bool = True) -> RepoStatus:
|
|||||||
s.dirty_files = out.splitlines()
|
s.dirty_files = out.splitlines()
|
||||||
|
|
||||||
if fetch:
|
if fetch:
|
||||||
run_git(path, "fetch", "--quiet", "--all", timeout=120)
|
try:
|
||||||
|
rc, _, ferr = run_git(
|
||||||
|
path, "fetch", "--quiet", "--all",
|
||||||
|
timeout=30, network=True,
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
if looks_like_auth_error(ferr):
|
||||||
|
s.auth_required = True
|
||||||
|
elif looks_like_network_error(ferr):
|
||||||
|
s.unreachable = True
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# El fetch superó 30s: lo damos por host inalcanzable
|
||||||
|
s.unreachable = True
|
||||||
|
|
||||||
rc, out, _ = run_git(
|
rc, out, _ = run_git(
|
||||||
path, "rev-list", "--left-right", "--count", "@{u}...HEAD"
|
path, "rev-list", "--left-right", "--count", "@{u}...HEAD"
|
||||||
@@ -100,15 +195,60 @@ def scan(base: Path, fetch: bool = True) -> list[RepoStatus]:
|
|||||||
return []
|
return []
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return []
|
return []
|
||||||
msg = f"[cyan]Analizando {len(candidates)} repo(s){' (con fetch)' if fetch else ''}…[/cyan]"
|
results: dict[Path, RepoStatus] = {}
|
||||||
with console.status(msg, spinner="dots"):
|
label = "Analizando" + (" (con fetch)" if fetch else "")
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[cyan]{task.description}[/cyan]"),
|
||||||
|
BarColumn(),
|
||||||
|
MofNCompleteColumn(),
|
||||||
|
TextColumn("[dim]{task.fields[current]}[/dim]"),
|
||||||
|
TimeElapsedColumn(),
|
||||||
|
console=console,
|
||||||
|
transient=True,
|
||||||
|
) as progress:
|
||||||
|
task = progress.add_task(label, total=len(candidates), current="")
|
||||||
with ThreadPoolExecutor(max_workers=min(8, len(candidates))) as ex:
|
with ThreadPoolExecutor(max_workers=min(8, len(candidates))) as ex:
|
||||||
return list(ex.map(lambda p: gather_status(p, fetch=fetch), candidates))
|
futures = {ex.submit(gather_status, p, fetch): p for p in candidates}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
p = futures[fut]
|
||||||
|
results[p] = fut.result()
|
||||||
|
progress.update(task, advance=1, current=p.name)
|
||||||
|
return [results[p] for p in candidates]
|
||||||
|
|
||||||
|
|
||||||
|
# --- render -----------------------------------------------------------------
|
||||||
|
|
||||||
|
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
|
|
||||||
|
def spinner_frame() -> str:
|
||||||
|
return SPINNER_FRAMES[int(time.monotonic() * 10) % len(SPINNER_FRAMES)]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_page(cursor: int, total: int) -> tuple[int, int, int, int, int]:
|
||||||
|
"""Devuelve (start, end, page_idx, total_pages, page_size) para la ventana visible."""
|
||||||
|
h = max(10, console.size.height)
|
||||||
|
# Márgenes: título + cabecera + bordes + leyenda + status + colchón
|
||||||
|
page_size = max(5, h - 8)
|
||||||
|
if total == 0:
|
||||||
|
return 0, 0, 0, 1, page_size
|
||||||
|
page = cursor // page_size
|
||||||
|
start = page * page_size
|
||||||
|
end = min(start + page_size, total)
|
||||||
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
return start, end, page, total_pages, page_size
|
||||||
|
|
||||||
|
|
||||||
def status_cell(s: RepoStatus) -> Text:
|
def status_cell(s: RepoStatus) -> Text:
|
||||||
|
if s.busy:
|
||||||
|
return Text(f"{spinner_frame()} trabajando…", style="bold blue")
|
||||||
if s.error:
|
if s.error:
|
||||||
return Text(f"ERROR: {s.error}", style="bold red")
|
return Text(f"ERROR: {s.error}", style="bold red")
|
||||||
|
if s.auth_required:
|
||||||
|
return Text("🔒 auth", style="bold yellow")
|
||||||
|
if s.unreachable:
|
||||||
|
return Text("🔌 sin red", style="bold yellow")
|
||||||
if not s.has_upstream:
|
if not s.has_upstream:
|
||||||
return Text("sin upstream", style="yellow")
|
return Text("sin upstream", style="yellow")
|
||||||
if s.behind == 0 and s.ahead == 0:
|
if s.behind == 0 and s.ahead == 0:
|
||||||
@@ -121,153 +261,357 @@ def status_cell(s: RepoStatus) -> Text:
|
|||||||
return Text(" ").join(parts)
|
return Text(" ").join(parts)
|
||||||
|
|
||||||
|
|
||||||
def render_table(repos: list[RepoStatus]) -> Table:
|
def progress_bar(done: int, total: int, width: int = 30) -> Text:
|
||||||
|
if total <= 0:
|
||||||
|
return Text("")
|
||||||
|
filled = int(width * done / total)
|
||||||
|
pct = int(100 * done / total)
|
||||||
|
bar = Text()
|
||||||
|
bar.append("█" * filled, style="bold green")
|
||||||
|
bar.append("░" * (width - filled), style="dim")
|
||||||
|
return Text.assemble(
|
||||||
|
Text("["),
|
||||||
|
bar,
|
||||||
|
Text(f"] {done}/{total} ({pct}%)", style="bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render(
|
||||||
|
repos: list[RepoStatus],
|
||||||
|
cursor: int,
|
||||||
|
base: Path,
|
||||||
|
status_msg: str = "",
|
||||||
|
progress: tuple[int, int] | None = None,
|
||||||
|
) -> Group:
|
||||||
|
start, end, page_idx, total_pages, _ = compute_page(cursor, len(repos))
|
||||||
|
page_label = f" — página {page_idx + 1}/{total_pages}" if total_pages > 1 else ""
|
||||||
|
marked = sum(1 for r in repos if r.selected)
|
||||||
|
marked_label = f" · [bold cyan]{marked} marcado(s)[/bold cyan]" if marked else ""
|
||||||
table = Table(
|
table = Table(
|
||||||
show_header=True,
|
show_header=True,
|
||||||
header_style="bold magenta",
|
header_style="bold magenta",
|
||||||
title="Repositorios git",
|
title=f"[bold]gitswarm[/bold] — [bold]{base}[/bold][dim]{page_label}[/dim]{marked_label}",
|
||||||
title_style="bold white",
|
title_style="white",
|
||||||
expand=True,
|
expand=True,
|
||||||
)
|
)
|
||||||
table.add_column("#", justify="right", style="dim")
|
table.add_column(" ", width=1, no_wrap=True)
|
||||||
|
table.add_column("#", justify="right", style="dim", width=4)
|
||||||
table.add_column("Repo", style="bold", no_wrap=True, overflow="ellipsis")
|
table.add_column("Repo", style="bold", no_wrap=True, overflow="ellipsis")
|
||||||
table.add_column("Rama", style="cyan", no_wrap=True, overflow="ellipsis")
|
table.add_column("Rama", style="cyan", no_wrap=True, overflow="ellipsis")
|
||||||
table.add_column("Estado", no_wrap=True)
|
table.add_column("Estado", no_wrap=True)
|
||||||
table.add_column("Local", no_wrap=True)
|
table.add_column("Local", no_wrap=True)
|
||||||
table.add_column("Último commit", no_wrap=True, overflow="ellipsis", ratio=1)
|
table.add_column("Último commit", no_wrap=True, overflow="ellipsis", ratio=1)
|
||||||
for i, s in enumerate(repos, 1):
|
|
||||||
|
for i in range(start, end):
|
||||||
|
s = repos[i]
|
||||||
|
caret = Text("▶", style="bold yellow") if i == cursor else Text(" ")
|
||||||
|
if s.selected:
|
||||||
|
mark = Text("☑", style="bold cyan")
|
||||||
|
else:
|
||||||
|
mark = Text("○", style="dim") if s.behind else Text(" ")
|
||||||
|
# Repo: cursor + marca + nombre
|
||||||
|
name = Text(s.name)
|
||||||
|
if i == cursor:
|
||||||
|
name.stylize("bold underline")
|
||||||
|
repo_cell = Text.assemble(mark, " ", name)
|
||||||
|
|
||||||
dirty = (
|
dirty = (
|
||||||
Text(f"● {len(s.dirty_files)} cambio(s)", style="bold yellow")
|
Text(f"● {len(s.dirty_files)}", style="bold yellow")
|
||||||
if s.dirty
|
if s.dirty
|
||||||
else Text("limpio", style="dim green")
|
else Text("limpio", style="dim green")
|
||||||
)
|
)
|
||||||
|
last = s.last_action or s.last_commit
|
||||||
|
last_style = "italic dim" if s.last_action else ""
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
caret,
|
||||||
s.name,
|
str(i + 1),
|
||||||
|
repo_cell,
|
||||||
s.branch,
|
s.branch,
|
||||||
status_cell(s),
|
status_cell(s),
|
||||||
dirty,
|
dirty,
|
||||||
s.last_commit,
|
Text(last, style=last_style) if last_style else Text(last),
|
||||||
)
|
)
|
||||||
return table
|
|
||||||
|
|
||||||
|
legend = Text.assemble(
|
||||||
|
("↑↓ j/k", "bold cyan"), " mover ",
|
||||||
|
("←→ h/l", "bold cyan"), " página ",
|
||||||
|
("Space", "bold cyan"), " marcar ",
|
||||||
|
("a/n", "bold cyan"), " todos/ninguno ",
|
||||||
|
("Enter", "bold green"), " update ",
|
||||||
|
("s", "bold cyan"), " status ",
|
||||||
|
("c", "bold cyan"), " commit ",
|
||||||
|
("r", "bold cyan"), " refresh ",
|
||||||
|
("?", "bold cyan"), " ayuda ",
|
||||||
|
("q", "bold cyan"), " salir",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
status = Text(status_msg, style="dim italic") if status_msg else Text("")
|
||||||
|
if progress is not None:
|
||||||
|
done, total = progress
|
||||||
|
bar = Text.assemble(
|
||||||
|
Text("Progreso ", style="bold cyan"),
|
||||||
|
progress_bar(done, total),
|
||||||
|
)
|
||||||
|
return Group(table, legend, bar, status)
|
||||||
|
return Group(table, legend, status)
|
||||||
|
|
||||||
|
|
||||||
|
# --- acciones ---------------------------------------------------------------
|
||||||
|
|
||||||
def update_repo(s: RepoStatus) -> tuple[bool, str]:
|
def update_repo(s: RepoStatus) -> tuple[bool, str]:
|
||||||
if s.error:
|
if s.error:
|
||||||
return False, f"no se puede actualizar (error previo: {s.error})"
|
return False, f"error previo: {s.error}"
|
||||||
if not s.has_upstream:
|
if not s.has_upstream:
|
||||||
return False, "no tiene upstream configurado"
|
return False, "sin upstream"
|
||||||
if s.dirty:
|
if s.dirty:
|
||||||
return False, "tiene cambios locales sin commitear, abortado"
|
return False, "cambios locales sin commitear, abortado"
|
||||||
if s.behind == 0:
|
if s.behind == 0:
|
||||||
return True, "ya estaba al día"
|
return True, "ya estaba al día"
|
||||||
rc, out, err = run_git(s.path, "pull", "--ff-only", "--quiet", timeout=180)
|
try:
|
||||||
|
rc, out, err = run_git(s.path, "pull", "--ff-only", "--quiet", timeout=90, network=True)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "🔌 timeout (host no responde)"
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False, (err or out or "git pull --ff-only falló").splitlines()[0]
|
msg = (err or out or "git pull --ff-only falló").splitlines()[0]
|
||||||
|
if looks_like_auth_error(err):
|
||||||
|
return False, "🔒 necesita credenciales"
|
||||||
|
if looks_like_network_error(err):
|
||||||
|
return False, "🔌 host inalcanzable"
|
||||||
|
return False, msg
|
||||||
return True, f"actualizado (+{s.behind} commit(s))"
|
return True, f"actualizado (+{s.behind} commit(s))"
|
||||||
|
|
||||||
|
|
||||||
def find_repo(repos: list[RepoStatus], token: str) -> RepoStatus | None:
|
def run_update_queue(repos: list[RepoStatus], live: Live, cursor: int, base: Path) -> None:
|
||||||
if token.isdigit():
|
pending = [r for r in repos if r.selected and r.behind > 0 and not r.dirty and r.has_upstream and not r.error]
|
||||||
idx = int(token) - 1
|
if not pending:
|
||||||
return repos[idx] if 0 <= idx < len(repos) else None
|
live.update(render(repos, cursor, base, "Nada que actualizar (marca algún repo behind con Space)."), refresh=True)
|
||||||
exact = [r for r in repos if r.name == token]
|
|
||||||
if exact:
|
|
||||||
return exact[0]
|
|
||||||
partial = [r for r in repos if token.lower() in r.name.lower()]
|
|
||||||
if len(partial) == 1:
|
|
||||||
return partial[0]
|
|
||||||
if len(partial) > 1:
|
|
||||||
names = ", ".join(r.name for r in partial)
|
|
||||||
console.print(f"[yellow]'{token}' es ambiguo: {names}[/yellow]")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_update(repos: list[RepoStatus], args: list[str]) -> None:
|
|
||||||
if not args:
|
|
||||||
console.print("[red]Uso: update <repo|nº|all>[/red]")
|
|
||||||
return
|
return
|
||||||
if args == ["all"]:
|
for r in pending:
|
||||||
targets = list(repos)
|
r.busy = True
|
||||||
else:
|
r.selected = False
|
||||||
targets = []
|
total = len(pending)
|
||||||
for token in args:
|
|
||||||
r = find_repo(repos, token)
|
stop = threading.Event()
|
||||||
if r is None:
|
state = {"ok": 0, "done": 0}
|
||||||
console.print(f"[red]✗ repo no encontrado: {token}[/red]")
|
|
||||||
continue
|
def animate() -> None:
|
||||||
targets.append(r)
|
while not stop.is_set():
|
||||||
if not targets:
|
in_progress = total - state["done"]
|
||||||
return
|
msg = f"Actualizando · {in_progress} en curso · {state['ok']} ok"
|
||||||
for r in targets:
|
live.update(
|
||||||
with console.status(f"[cyan]actualizando {r.name}…[/cyan]", spinner="dots"):
|
render(repos, cursor, base, msg, progress=(state["done"], total)),
|
||||||
ok, msg = update_repo(r)
|
refresh=True,
|
||||||
icon = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
)
|
||||||
console.print(f" {icon} [bold]{r.name}[/bold]: {msg}")
|
stop.wait(0.1)
|
||||||
idx = repos.index(r)
|
|
||||||
repos[idx] = gather_status(r.path, fetch=False)
|
anim = threading.Thread(target=animate, daemon=True)
|
||||||
|
anim.start()
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(4, total)) as ex:
|
||||||
|
futures = {ex.submit(update_repo, r): r for r in pending}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
r = futures[fut]
|
||||||
|
ok, msg = fut.result()
|
||||||
|
r.busy = False
|
||||||
|
r.last_action = ("✓ " if ok else "✗ ") + msg
|
||||||
|
if ok:
|
||||||
|
state["ok"] += 1
|
||||||
|
state["done"] += 1
|
||||||
|
# Re-leer ahead/behind sin fetch (el pull ya lo bajó)
|
||||||
|
fresh = gather_status(r.path, fetch=False)
|
||||||
|
fresh.selected = False
|
||||||
|
fresh.last_action = r.last_action
|
||||||
|
idx = repos.index(r)
|
||||||
|
repos[idx] = fresh
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
anim.join()
|
||||||
|
|
||||||
|
live.update(render(repos, cursor, base, f"Listo: {state['ok']}/{total} actualizados."), refresh=True)
|
||||||
|
|
||||||
|
|
||||||
HELP = """[bold]Comandos:[/bold]
|
def show_status(s: RepoStatus, live: Live) -> None:
|
||||||
[cyan]list[/cyan] / [cyan]ls[/cyan] Vuelve a mostrar la tabla
|
"""Suspende el Live y muestra `git status` del repo."""
|
||||||
[cyan]refresh[/cyan] / [cyan]r[/cyan] Re-escanea haciendo git fetch
|
live.stop()
|
||||||
[cyan]update <repo>[/cyan] git pull --ff-only en ese repo (acepta nº, nombre o trozo)
|
try:
|
||||||
[cyan]update all[/cyan] Actualiza todos los que estén behind
|
console.clear()
|
||||||
[cyan]version[/cyan] / [cyan]v[/cyan] Muestra la versión
|
console.rule(f"[bold]{s.name}[/bold] — git status")
|
||||||
[cyan]help[/cyan] / [cyan]?[/cyan] Esta ayuda
|
try:
|
||||||
[cyan]quit[/cyan] / [cyan]q[/cyan] Salir (también Ctrl-D / Ctrl-C)
|
result = subprocess.run(
|
||||||
|
["git", "-c", "color.status=always", "status"],
|
||||||
|
cwd=s.path,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env=_git_env(),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
console.print(f"[red]git status devolvió {result.returncode}[/red]")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
console.print("[red]timeout ejecutando git status[/red]")
|
||||||
|
console.rule("[dim]pulsa cualquier tecla para volver[/dim]")
|
||||||
|
readchar.readkey()
|
||||||
|
finally:
|
||||||
|
live.start(refresh=True)
|
||||||
|
|
||||||
|
|
||||||
|
def do_commit(s: RepoStatus, live: Live) -> str:
|
||||||
|
"""Suspende el Live, hace add -A y commit. Devuelve mensaje de resultado."""
|
||||||
|
if s.error:
|
||||||
|
return f"✗ error previo: {s.error}"
|
||||||
|
if s.branch == "(detached)":
|
||||||
|
return "✗ HEAD detached, no se puede commitear"
|
||||||
|
if not s.dirty:
|
||||||
|
return "nada que commitear"
|
||||||
|
|
||||||
|
live.stop()
|
||||||
|
msg_result = ""
|
||||||
|
try:
|
||||||
|
console.clear()
|
||||||
|
console.rule(f"[bold]{s.name}[/bold] — commit")
|
||||||
|
# Resumen de lo que se va a stagear
|
||||||
|
added = modified = deleted = 0
|
||||||
|
for line in s.dirty_files:
|
||||||
|
code = line[:2]
|
||||||
|
if "?" in code:
|
||||||
|
added += 1
|
||||||
|
elif "D" in code:
|
||||||
|
deleted += 1
|
||||||
|
else:
|
||||||
|
modified += 1
|
||||||
|
console.print(
|
||||||
|
f"Se va a hacer [bold]git add -A[/bold] e incluir: "
|
||||||
|
f"[green]+{added} nuevos[/green], [yellow]~{modified} modificados[/yellow], "
|
||||||
|
f"[red]-{deleted} borrados[/red]"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cm = Prompt.ask("[bold]Mensaje de commit[/bold] (vacío = cancelar)", default="").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
cm = ""
|
||||||
|
if not cm:
|
||||||
|
msg_result = "commit cancelado"
|
||||||
|
else:
|
||||||
|
rc, _, err = run_git(s.path, "add", "-A", timeout=60)
|
||||||
|
if rc != 0:
|
||||||
|
msg_result = f"✗ git add falló: {(err or '').splitlines()[0] if err else 'sin detalle'}"
|
||||||
|
else:
|
||||||
|
rc, out, err = run_git(s.path, "commit", "-m", cm, timeout=60)
|
||||||
|
if rc != 0:
|
||||||
|
line = (err or out or "commit falló").splitlines()[0]
|
||||||
|
msg_result = f"✗ commit falló: {line}"
|
||||||
|
else:
|
||||||
|
msg_result = "✓ commit creado"
|
||||||
|
console.rule("[dim]pulsa cualquier tecla para volver[/dim]")
|
||||||
|
readchar.readkey()
|
||||||
|
finally:
|
||||||
|
live.start(refresh=True)
|
||||||
|
return msg_result
|
||||||
|
|
||||||
|
|
||||||
|
# --- ayuda ------------------------------------------------------------------
|
||||||
|
|
||||||
|
HELP_TEXT = """[bold]gitswarm — atajos de teclado[/bold]
|
||||||
|
|
||||||
|
[cyan]↑ / ↓ / j / k[/cyan] mover el cursor
|
||||||
|
[cyan]← / → / h / l[/cyan] cambiar de página
|
||||||
|
[cyan]g / G[/cyan] ir al inicio / final
|
||||||
|
[cyan]Space[/cyan] marcar/desmarcar el repo bajo el cursor
|
||||||
|
[cyan]a[/cyan] marcar todos los que estén behind
|
||||||
|
[cyan]n[/cyan] desmarcar todos
|
||||||
|
[cyan]Enter[/cyan] ejecuta [bold]git pull --ff-only[/bold] en los marcados
|
||||||
|
[cyan]s[/cyan] ver [bold]git status[/bold] del repo bajo cursor
|
||||||
|
[cyan]c[/cyan] commit en el repo bajo cursor (git add -A + mensaje)
|
||||||
|
[cyan]r[/cyan] refrescar (re-fetch de todos)
|
||||||
|
[cyan]?[/cyan] esta ayuda
|
||||||
|
[cyan]q[/cyan] salir (también Ctrl-C / Ctrl-D)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def repl(base: Path) -> None:
|
def show_help(live: Live) -> None:
|
||||||
console.print(
|
live.stop()
|
||||||
f"[dim]gitswarm v{__version__} — escaneando[/dim] [bold]{base}[/bold]"
|
try:
|
||||||
)
|
console.clear()
|
||||||
repos = scan(base, fetch=True)
|
console.print(HELP_TEXT)
|
||||||
|
console.rule("[dim]pulsa cualquier tecla para volver[/dim]")
|
||||||
|
readchar.readkey()
|
||||||
|
finally:
|
||||||
|
live.start(refresh=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- TUI loop ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def tui(repos: list[RepoStatus], base: Path) -> None:
|
||||||
if not repos:
|
if not repos:
|
||||||
console.print(f"[yellow]No se han encontrado repos git en {base}[/yellow]")
|
console.print(f"[yellow]No se han encontrado repos git en {base}[/yellow]")
|
||||||
return
|
return
|
||||||
console.print(render_table(repos))
|
cursor = 0
|
||||||
console.print("[dim]Escribe 'help' para ver los comandos.[/dim]")
|
status_msg = ""
|
||||||
while True:
|
with Live(render(repos, cursor, base, status_msg), console=console, screen=False, auto_refresh=False) as live:
|
||||||
try:
|
while True:
|
||||||
raw = Prompt.ask("\n[bold blue]gitswarm[/bold blue]").strip()
|
live.update(render(repos, cursor, base, status_msg), refresh=True)
|
||||||
except (EOFError, KeyboardInterrupt):
|
try:
|
||||||
console.print()
|
key = readchar.readkey()
|
||||||
return
|
except KeyboardInterrupt:
|
||||||
if not raw:
|
return
|
||||||
continue
|
status_msg = ""
|
||||||
try:
|
if key in ("q", "Q", readchar.key.CTRL_C, readchar.key.CTRL_D):
|
||||||
parts = shlex.split(raw)
|
return
|
||||||
except ValueError as e:
|
elif key in (readchar.key.UP, "k"):
|
||||||
console.print(f"[red]Comando mal formado: {e}[/red]")
|
cursor = (cursor - 1) % len(repos)
|
||||||
continue
|
elif key in (readchar.key.DOWN, "j"):
|
||||||
cmd, *args = parts
|
cursor = (cursor + 1) % len(repos)
|
||||||
cmd = cmd.lower()
|
elif key in (readchar.key.LEFT, readchar.key.PAGE_UP, "h"):
|
||||||
if cmd in ("quit", "exit", "q"):
|
_, _, page_idx, total_pages, page_size = compute_page(cursor, len(repos))
|
||||||
return
|
new_page = (page_idx - 1) % total_pages
|
||||||
elif cmd in ("help", "?", "h"):
|
cursor = min(new_page * page_size, len(repos) - 1)
|
||||||
console.print(HELP)
|
elif key in (readchar.key.RIGHT, readchar.key.PAGE_DOWN, "l"):
|
||||||
elif cmd in ("list", "ls"):
|
_, _, page_idx, total_pages, page_size = compute_page(cursor, len(repos))
|
||||||
console.print(render_table(repos))
|
new_page = (page_idx + 1) % total_pages
|
||||||
elif cmd in ("version", "v"):
|
cursor = min(new_page * page_size, len(repos) - 1)
|
||||||
console.print(f"gitswarm v{__version__}")
|
elif key in (readchar.key.HOME, "g"):
|
||||||
elif cmd in ("refresh", "r"):
|
cursor = 0
|
||||||
repos = scan(base, fetch=True)
|
elif key in (readchar.key.END, "G"):
|
||||||
console.print(render_table(repos))
|
cursor = len(repos) - 1
|
||||||
elif cmd == "update":
|
elif key == " ":
|
||||||
cmd_update(repos, args)
|
r = repos[cursor]
|
||||||
console.print(render_table(repos))
|
if r.behind > 0 and not r.dirty and r.has_upstream and not r.error:
|
||||||
else:
|
r.selected = not r.selected
|
||||||
console.print(
|
else:
|
||||||
f"[red]Comando desconocido: {cmd}[/red] (prueba [cyan]help[/cyan])"
|
status_msg = "ese repo no se puede actualizar (limpio, sin upstream, dirty o con error)"
|
||||||
)
|
elif key == "a":
|
||||||
|
for r in repos:
|
||||||
|
if r.behind > 0 and not r.dirty and r.has_upstream and not r.error:
|
||||||
|
r.selected = True
|
||||||
|
elif key == "n":
|
||||||
|
for r in repos:
|
||||||
|
r.selected = False
|
||||||
|
elif key in (readchar.key.ENTER, "\r", "\n"):
|
||||||
|
run_update_queue(repos, live, cursor, base)
|
||||||
|
elif key in ("s", "S"):
|
||||||
|
show_status(repos[cursor], live)
|
||||||
|
elif key in ("c", "C"):
|
||||||
|
msg = do_commit(repos[cursor], live)
|
||||||
|
# refresca el estado del repo tras el commit
|
||||||
|
fresh = gather_status(repos[cursor].path, fetch=False)
|
||||||
|
fresh.last_action = msg
|
||||||
|
repos[cursor] = fresh
|
||||||
|
elif key in ("r", "R"):
|
||||||
|
status_msg = "refrescando…"
|
||||||
|
live.update(render(repos, cursor, base, status_msg), refresh=True)
|
||||||
|
new_repos = scan(base, fetch=True)
|
||||||
|
if new_repos:
|
||||||
|
repos[:] = new_repos
|
||||||
|
cursor = min(cursor, len(repos) - 1)
|
||||||
|
status_msg = f"refrescado: {len(repos)} repo(s)"
|
||||||
|
elif key == "?":
|
||||||
|
show_help(live)
|
||||||
|
|
||||||
|
|
||||||
|
# --- entrypoint -------------------------------------------------------------
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="gitswarm",
|
prog="gitswarm",
|
||||||
description="Lista y actualiza repos git de primer nivel en una carpeta.",
|
description="TUI para listar y actualizar repos git de primer nivel en una carpeta.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v",
|
"-v",
|
||||||
@@ -280,7 +624,13 @@ def main() -> int:
|
|||||||
if not args.path.is_dir():
|
if not args.path.is_dir():
|
||||||
console.print(f"[red]No es un directorio válido: {args.path}[/red]")
|
console.print(f"[red]No es un directorio válido: {args.path}[/red]")
|
||||||
return 1
|
return 1
|
||||||
repl(args.path.resolve())
|
base = args.path.resolve()
|
||||||
|
console.print(f"[dim]gitswarm v{__version__} — escaneando[/dim] [bold]{base}[/bold]")
|
||||||
|
repos = scan(base, fetch=True)
|
||||||
|
try:
|
||||||
|
tui(repos, base)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
rich>=13.0
|
rich>=13.0
|
||||||
zstandard>=0.22
|
readchar>=4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user