Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d22e3885af | |||
| f35ce3c5dd | |||
| f49512b72b | |||
| 0f471bee65 | |||
| 8c1ce90f60 | |||
| 226c80ddf7 |
@@ -18,9 +18,9 @@ Una vez dentro de la lista:
|
|||||||
|
|
||||||
| Tecla | Acción |
|
| Tecla | Acción |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `↑` / `↓` / `j` / `k` | mover cursor |
|
| `↑` / `↓` / `j` / `k` | mover cursor una fila |
|
||||||
| `PgUp` / `PgDn` | saltar 10 |
|
| `←` / `→` / `h` / `l` / `PgUp` / `PgDn` | pasar de página |
|
||||||
| `g` / `G` | inicio / final |
|
| `g` / `Home` · `G` / `End` | inicio / final de la lista |
|
||||||
| `Space` | marcar / desmarcar el repo bajo el cursor |
|
| `Space` | marcar / desmarcar el repo bajo el cursor |
|
||||||
| `a` | marcar todos los que aún no estén en local |
|
| `a` | marcar todos los que aún no estén en local |
|
||||||
| `n` | desmarcar todo |
|
| `n` | desmarcar todo |
|
||||||
@@ -28,6 +28,8 @@ Una vez dentro de la lista:
|
|||||||
| `r` | refrescar (vuelve a consultar al servidor) |
|
| `r` | refrescar (vuelve a consultar al servidor) |
|
||||||
| `q` / `Ctrl-C` | salir |
|
| `q` / `Ctrl-C` | salir |
|
||||||
|
|
||||||
|
La lista se pagina automáticamente según la altura del terminal, así que con miles de repos sigue respondiendo al instante.
|
||||||
|
|
||||||
Iconos de estado:
|
Iconos de estado:
|
||||||
|
|
||||||
- `○ remoto` — está en el servidor, no clonado localmente
|
- `○ remoto` — está en el servidor, no clonado localmente
|
||||||
|
|||||||
+144
-50
@@ -9,6 +9,8 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -23,7 +25,7 @@ from rich.live import Live
|
|||||||
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.0.5"
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -72,6 +74,21 @@ class RepoEntry:
|
|||||||
|
|
||||||
# --- config -----------------------------------------------------------------
|
# --- config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_default_config() -> int:
|
||||||
|
if CONFIG_PATH.exists():
|
||||||
|
console.print(f"[yellow]Ya existe[/yellow] [bold]{CONFIG_PATH}[/bold] — no se sobreescribe.")
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(CONFIG_EXAMPLE)
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"[red]No se pudo escribir {CONFIG_PATH}: {e}[/red]")
|
||||||
|
return 1
|
||||||
|
console.print(f"[green]Config creada en[/green] [bold]{CONFIG_PATH}[/bold]")
|
||||||
|
console.print("[dim]Edítala y descomenta token / default_owner / clone_protocol si los necesitas.[/dim]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> Config:
|
def load_config() -> Config:
|
||||||
if not CONFIG_PATH.exists():
|
if not CONFIG_PATH.exists():
|
||||||
console.print(f"[red]No se ha encontrado la configuración en[/red] [bold]{CONFIG_PATH}[/bold]")
|
console.print(f"[red]No se ha encontrado la configuración en[/red] [bold]{CONFIG_PATH}[/bold]")
|
||||||
@@ -194,8 +211,9 @@ def scan_local(base: Path) -> dict[str, Path]:
|
|||||||
url = local_remote_url(p)
|
url = local_remote_url(p)
|
||||||
if url:
|
if url:
|
||||||
found[normalize_url(url)] = p
|
found[normalize_url(url)] = p
|
||||||
# Siempre indexamos también por nombre como fallback
|
else:
|
||||||
found.setdefault(f"name::{p.name}", p)
|
# Sense origin → emparellem pel nom (per a repos locals sense remote)
|
||||||
|
found[f"name::{p.name}"] = p
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
@@ -242,11 +260,34 @@ def build_entries(remote_repos: list[RemoteRepo], local_index: dict[str, Path])
|
|||||||
|
|
||||||
# --- render -----------------------------------------------------------------
|
# --- 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]:
|
||||||
|
"""Calcula (start, end, page_idx, total_pages, page_size) per a la finestra visible."""
|
||||||
|
h = max(10, console.size.height)
|
||||||
|
# Marges: 1 títol + 1 separador títol + 1 cabecera + 2 vores + 1 llegenda + 1 status + 1 buffer
|
||||||
|
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 render(entries: list[RepoEntry], cursor: int, base: Path, owner: str, status_msg: str = "") -> Group:
|
def render(entries: list[RepoEntry], cursor: int, base: Path, owner: str, status_msg: str = "") -> Group:
|
||||||
|
start, end, page_idx, total_pages, _ = compute_page(cursor, len(entries))
|
||||||
|
page_label = f" — pàgina {page_idx + 1}/{total_pages}" if total_pages > 1 else ""
|
||||||
table = Table(
|
table = Table(
|
||||||
show_header=True,
|
show_header=True,
|
||||||
header_style="bold magenta",
|
header_style="bold magenta",
|
||||||
title=f"[bold]repoman[/bold] — [cyan]{owner}[/cyan] → [bold]{base}[/bold]",
|
title=f"[bold]repoman[/bold] — [cyan]{owner}[/cyan] → [bold]{base}[/bold][dim]{page_label}[/dim]",
|
||||||
title_style="white",
|
title_style="white",
|
||||||
expand=True,
|
expand=True,
|
||||||
)
|
)
|
||||||
@@ -255,44 +296,51 @@ def render(entries: list[RepoEntry], cursor: int, base: Path, owner: str, status
|
|||||||
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("Descripción", no_wrap=True, overflow="ellipsis", ratio=1)
|
table.add_column("Descripción", no_wrap=True, overflow="ellipsis", ratio=1)
|
||||||
|
|
||||||
for i, e in enumerate(entries):
|
for i in range(start, end):
|
||||||
|
e = entries[i]
|
||||||
# Cursor
|
# Cursor
|
||||||
caret = Text("▶", style="bold yellow") if i == cursor else Text(" ")
|
caret = Text("▶", style="bold yellow") if i == cursor else Text(" ")
|
||||||
|
|
||||||
# Estado
|
# Estat
|
||||||
if e.cloning:
|
if e.cloning:
|
||||||
state = Text("⟳ clonando…", style="bold blue")
|
state = Text(f"{spinner_frame()} clonant…", style="bold blue")
|
||||||
elif e.cloned_ok is True:
|
elif e.cloned_ok is True:
|
||||||
state = Text("✓ clonado", style="bold green")
|
state = Text("✓ clonat", style="bold green")
|
||||||
elif e.cloned_ok is False:
|
elif e.cloned_ok is False:
|
||||||
state = Text(f"✗ {e.error[:11]}", style="bold red")
|
state = Text("✗ error", style="bold red")
|
||||||
elif e.local_path is not None and e.remote is None:
|
elif e.local_path is not None and e.remote is None:
|
||||||
state = Text("◇ sólo local", style="dim yellow")
|
state = Text("◇ només local", style="dim yellow")
|
||||||
elif e.local_path is not None:
|
elif e.local_path is not None:
|
||||||
state = Text("● en local", style="green")
|
state = Text("● en local", style="green")
|
||||||
elif e.selected:
|
elif e.selected:
|
||||||
state = Text("☑ marcado", style="bold cyan")
|
state = Text("☑ marcat", style="bold cyan")
|
||||||
else:
|
else:
|
||||||
state = Text("○ remoto", style="dim")
|
state = Text("○ remot", style="dim")
|
||||||
|
|
||||||
# Nombre con marca de privado
|
# Nom amb marca de privat
|
||||||
name = Text(e.name)
|
name = Text(e.name)
|
||||||
if e.remote and e.remote.private:
|
if e.remote and e.remote.private:
|
||||||
name = Text("🔒 ", style="yellow") + name
|
name = Text("🔒 ", style="yellow") + name
|
||||||
if i == cursor:
|
if i == cursor:
|
||||||
name.stylize("bold underline")
|
name.stylize("bold underline")
|
||||||
|
|
||||||
desc = (e.remote.description if e.remote else "(no está en el servidor)") or "—"
|
# Descripció: motiu de l'error si n'hi ha, si no la del repo
|
||||||
|
if e.cloned_ok is False and e.error:
|
||||||
|
desc = Text(e.error, style="red")
|
||||||
|
else:
|
||||||
|
desc_text = (e.remote.description if e.remote else "(no està al servidor)") or "—"
|
||||||
|
desc = Text(desc_text)
|
||||||
table.add_row(caret, state, name, desc)
|
table.add_row(caret, state, name, desc)
|
||||||
|
|
||||||
legend = Text.assemble(
|
legend = Text.assemble(
|
||||||
("↑/↓ j/k", "bold cyan"), " mover ",
|
("↑/↓ j/k", "bold cyan"), " moure ",
|
||||||
("Space", "bold cyan"), " marcar/desmarcar ",
|
("←/→ h/l", "bold cyan"), " pàgina ",
|
||||||
("a", "bold cyan"), " marcar todos remotos ",
|
("Space", "bold cyan"), " marcar ",
|
||||||
("n", "bold cyan"), " ninguno ",
|
("a", "bold cyan"), " tots ",
|
||||||
("Enter", "bold green"), " clonar marcados ",
|
("n", "bold cyan"), " cap ",
|
||||||
("r", "bold cyan"), " refrescar ",
|
("Enter", "bold green"), " clona ",
|
||||||
("q", "bold cyan"), " salir",
|
("r", "bold cyan"), " refresca ",
|
||||||
|
("q", "bold cyan"), " surt",
|
||||||
style="dim",
|
style="dim",
|
||||||
)
|
)
|
||||||
status = Text(status_msg, style="dim italic") if status_msg else Text("")
|
status = Text(status_msg, style="dim italic") if status_msg else Text("")
|
||||||
@@ -303,54 +351,85 @@ def render(entries: list[RepoEntry], cursor: int, base: Path, owner: str, status
|
|||||||
|
|
||||||
def clone_one(entry: RepoEntry, base: Path, cfg: Config) -> tuple[RepoEntry, bool, str]:
|
def clone_one(entry: RepoEntry, base: Path, cfg: Config) -> tuple[RepoEntry, bool, str]:
|
||||||
if entry.remote is None:
|
if entry.remote is None:
|
||||||
return entry, False, "sin info remota"
|
return entry, False, "sense info remota"
|
||||||
url = entry.remote.ssh_url if cfg.clone_protocol == "ssh" else entry.remote.clone_url
|
url = entry.remote.ssh_url if cfg.clone_protocol == "ssh" else entry.remote.clone_url
|
||||||
if not url:
|
if not url:
|
||||||
return entry, False, "URL vacía"
|
return entry, False, "URL buida"
|
||||||
target = base / entry.name
|
target = base / entry.name
|
||||||
if target.exists():
|
if target.exists():
|
||||||
return entry, False, "ya existe en local"
|
return entry, False, "ja existeix en local"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"GIT_TERMINAL_PROMPT": "0", # mai demanar credencials interactivament
|
||||||
|
"GIT_ASKPASS": "/bin/true", # cap helper interactiu
|
||||||
|
"SSH_ASKPASS": "/bin/true",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "clone", "--quiet", url, str(target)],
|
["git", "clone", "--quiet", url, str(target)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
err = (result.stderr or result.stdout or "git clone falló").strip().splitlines()
|
# Netegem la carpeta parcial si git la va crear
|
||||||
return entry, False, err[-1][:80] if err else "error"
|
if target.exists():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
err_lines = (result.stderr or result.stdout or "git clone ha fallat").strip().splitlines()
|
||||||
|
err_msg = " ".join(l.strip() for l in err_lines if l.strip())
|
||||||
|
return entry, False, err_msg or "error desconegut"
|
||||||
return entry, True, "ok"
|
return entry, True, "ok"
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return entry, False, "timeout"
|
if target.exists():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
return entry, False, "timeout (>10 min)"
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return entry, False, "git no encontrado"
|
return entry, False, "git no trobat"
|
||||||
|
|
||||||
|
|
||||||
def run_clone_queue(entries: list[RepoEntry], base: Path, cfg: Config, live: Live, cursor: int, owner: str) -> int:
|
def run_clone_queue(entries: list[RepoEntry], base: Path, cfg: Config, live: Live, cursor: int, owner: str) -> int:
|
||||||
pending = [e for e in entries if e.selected and e.local_path is None and e.remote is not None]
|
pending = [e for e in entries if e.selected and e.local_path is None and e.remote is not None]
|
||||||
if not pending:
|
if not pending:
|
||||||
live.update(render(entries, cursor, base, owner, "Nada marcado para clonar."))
|
live.update(render(entries, cursor, base, owner, "Res marcat per clonar."), refresh=True)
|
||||||
return 0
|
return 0
|
||||||
for e in pending:
|
for e in pending:
|
||||||
e.cloning = True
|
e.cloning = True
|
||||||
e.selected = False
|
e.selected = False
|
||||||
live.update(render(entries, cursor, base, owner, f"Clonando {len(pending)} repo(s)…"))
|
total = len(pending)
|
||||||
ok = 0
|
|
||||||
max_workers = min(4, len(pending))
|
stop = threading.Event()
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
state = {"ok": 0, "done": 0}
|
||||||
futures = {ex.submit(clone_one, e, base, cfg): e for e in pending}
|
|
||||||
for fut in as_completed(futures):
|
def animate() -> None:
|
||||||
entry, success, msg = fut.result()
|
while not stop.is_set():
|
||||||
entry.cloning = False
|
in_progress = total - state["done"]
|
||||||
entry.cloned_ok = success
|
msg = f"Clonant {in_progress} en curs · {state['done']}/{total} acabats"
|
||||||
entry.error = "" if success else msg
|
live.update(render(entries, cursor, base, owner, msg), refresh=True)
|
||||||
if success:
|
stop.wait(0.1)
|
||||||
entry.local_path = base / entry.name
|
|
||||||
ok += 1
|
anim = threading.Thread(target=animate, daemon=True)
|
||||||
live.update(render(entries, cursor, base, owner, f"Clonando… {ok}/{len(pending)} listos"))
|
anim.start()
|
||||||
live.update(render(entries, cursor, base, owner, f"Hecho: {ok}/{len(pending)} clonados."))
|
try:
|
||||||
return ok
|
max_workers = min(4, total)
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
futures = {ex.submit(clone_one, e, base, cfg): e for e in pending}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
entry, success, msg = fut.result()
|
||||||
|
entry.cloning = False
|
||||||
|
entry.cloned_ok = success
|
||||||
|
entry.error = "" if success else msg
|
||||||
|
if success:
|
||||||
|
entry.local_path = base / entry.name
|
||||||
|
state["ok"] += 1
|
||||||
|
state["done"] += 1
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
anim.join()
|
||||||
|
|
||||||
|
live.update(render(entries, cursor, base, owner, f"Fet: {state['ok']}/{total} clonats."), refresh=True)
|
||||||
|
return state["ok"]
|
||||||
|
|
||||||
|
|
||||||
# --- TUI loop ---------------------------------------------------------------
|
# --- TUI loop ---------------------------------------------------------------
|
||||||
@@ -375,10 +454,14 @@ def tui(entries: list[RepoEntry], base: Path, cfg: Config, owner: str) -> None:
|
|||||||
cursor = (cursor - 1) % len(entries)
|
cursor = (cursor - 1) % len(entries)
|
||||||
elif key in (readchar.key.DOWN, "j"):
|
elif key in (readchar.key.DOWN, "j"):
|
||||||
cursor = (cursor + 1) % len(entries)
|
cursor = (cursor + 1) % len(entries)
|
||||||
elif key in (readchar.key.PAGE_UP,):
|
elif key in (readchar.key.LEFT, readchar.key.PAGE_UP, "h"):
|
||||||
cursor = max(0, cursor - 10)
|
_, _, page_idx, total_pages, page_size = compute_page(cursor, len(entries))
|
||||||
elif key in (readchar.key.PAGE_DOWN,):
|
new_page = (page_idx - 1) % total_pages
|
||||||
cursor = min(len(entries) - 1, cursor + 10)
|
cursor = min(new_page * page_size, len(entries) - 1)
|
||||||
|
elif key in (readchar.key.RIGHT, readchar.key.PAGE_DOWN, "l"):
|
||||||
|
_, _, page_idx, total_pages, page_size = compute_page(cursor, len(entries))
|
||||||
|
new_page = (page_idx + 1) % total_pages
|
||||||
|
cursor = min(new_page * page_size, len(entries) - 1)
|
||||||
elif key in (readchar.key.HOME, "g"):
|
elif key in (readchar.key.HOME, "g"):
|
||||||
cursor = 0
|
cursor = 0
|
||||||
elif key in (readchar.key.END, "G"):
|
elif key in (readchar.key.END, "G"):
|
||||||
@@ -421,10 +504,21 @@ def main() -> int:
|
|||||||
description="Lista repos en un servidor Gitea y clona los que faltan en local.",
|
description="Lista repos en un servidor Gitea y clona los que faltan en local.",
|
||||||
)
|
)
|
||||||
parser.add_argument("-v", "--version", action="version", version=f"repoman v{__version__}")
|
parser.add_argument("-v", "--version", action="version", version=f"repoman v{__version__}")
|
||||||
parser.add_argument("path", type=Path, help="Carpeta local donde se clonarán los repos")
|
parser.add_argument(
|
||||||
|
"--create-default-config",
|
||||||
|
action="store_true",
|
||||||
|
help=f"Crea un fichero de config por defecto en {CONFIG_PATH} y sale",
|
||||||
|
)
|
||||||
|
parser.add_argument("path", type=Path, nargs="?", help="Carpeta local donde se clonarán los repos")
|
||||||
parser.add_argument("owner", nargs="?", help="Usuario u organización en Gitea (opcional si default_owner está en la config)")
|
parser.add_argument("owner", nargs="?", help="Usuario u organización en Gitea (opcional si default_owner está en la config)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.create_default_config:
|
||||||
|
return create_default_config()
|
||||||
|
|
||||||
|
if args.path is None:
|
||||||
|
parser.error("falta el argumento path")
|
||||||
|
|
||||||
if shutil.which("git") is None:
|
if shutil.which("git") is None:
|
||||||
console.print("[red]git no está instalado o no se encuentra en el PATH[/red]")
|
console.print("[red]git no está instalado o no se encuentra en el PATH[/red]")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
Reference in New Issue
Block a user