"""Operaciones git + refresco de metadata desde Gitea. Todo lo que toca disco/red vive aquí; los workers (QThread) lo invocan en segundo plano. Las funciones aceptan un callback ``log(str)`` opcional para emitir progreso a la UI. """ from __future__ import annotations import datetime as _dt import json import os import shutil import subprocess import urllib.error import urllib.request from collections.abc import Callable from pathlib import Path from .config import Game from .metadata import GameMeta, icon_path, load_meta, save_meta from .paths import game_dir, metadata_dir, repo_dir LogFn = Callable[[str], None] _HTTP_TIMEOUT = 15 def _noop(_: str) -> None: pass def _auth_args(token: str) -> list[str]: """Args -c para autenticar git ante Gitea con un token, sin tocar .git/config.""" if not token: return [] return ["-c", f"http.extraHeader=Authorization: token {token}"] def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str: """Ejecuta git capturando salida; lanza RuntimeError si falla. Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch de repos privados) y la redacta en el log para no filtrarla. """ cmd = ["git", *_auth_args(token), *args] def emit(line: str) -> None: log(line.replace(token, "***") if token else line) # Echo del comando: nunca mostramos los args -c con el token. emit("$ " + " ".join(["git", *args])) # GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido # en vez de colgarse esperando credenciales (no hay terminal en la GUI). proc = subprocess.run( cmd, cwd=str(cwd) if cwd else None, capture_output=True, text=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) if proc.stdout: emit(proc.stdout.rstrip()) if proc.stderr: emit(proc.stderr.rstrip()) if proc.returncode != 0: raise RuntimeError(f"git {' '.join(args)} falló (código {proc.returncode})") return proc.stdout.strip() def is_installed(root: Path, game: Game) -> bool: """Un juego está instalado si su clone existe y tiene un .git.""" return (repo_dir(root, game.id) / ".git").exists() def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> bool: """Hace fetch y devuelve True si el clone local está por detrás del remoto. No modifica el árbol de trabajo: solo cuenta los commits de origin/ que no están en HEAD. Devuelve False si el juego no está descargado. """ repo = repo_dir(root, game.id) if not (repo / ".git").exists(): return False _run_git(["fetch", "origin", "--prune"], repo, log, token=token) target = ( load_meta(root, game.id).default_branch or _detect_origin_head(repo, log) or "HEAD" ) try: behind = _run_git( ["rev-list", "--count", f"HEAD..origin/{target}"], repo, log ) except RuntimeError: return False return int(behind or "0") > 0 def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None: """Esborra la descàrrega local del joc (clon + metadata), sense tocar el TOML.""" target = game_dir(root, game.id) if not target.exists(): log(f"{game.name}: no hi ha res a esborrar") return log(f"Esborrant la descàrrega local de {game.name}…") shutil.rmtree(target, ignore_errors=True) def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> GameMeta: """Clona (si no existe) o trae el remoto forzado (descartando cambios locales). Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve. """ repo = repo_dir(root, game.id) repo.parent.mkdir(parents=True, exist_ok=True) branch = _fetch_default_branch(game, log, token) if (repo / ".git").exists(): log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…") _run_git(["fetch", "origin", "--prune"], repo, log, token=token) target = branch or _detect_origin_head(repo, log) or "HEAD" _run_git(["reset", "--hard", f"origin/{target}"], repo, log) _run_git(["clean", "-fd"], repo, log) else: log(f"Clonant {game.name}…") if repo.exists(): # carpeta a medias sin .git: limpiarla shutil.rmtree(repo, ignore_errors=True) _run_git(["clone", game.clone_url, str(repo)], None, log, token=token) return refresh_metadata(root, game, branch, log, token) def refresh_metadata( root: Path, game: Game, branch: str | None = None, log: LogFn = _noop, token: str = "", ) -> GameMeta: """Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea.""" repo = repo_dir(root, game.id) meta = load_meta(root, game.id) # Descripción + rama por defecto desde la API de Gitea (best-effort). api = _fetch_gitea_info(game, log, token) if api is not None: meta.description = api.get("description", meta.description) or meta.description meta.default_branch = api.get("default_branch", meta.default_branch) elif branch: meta.default_branch = branch # Versión vía version_cmd dentro del repo (best-effort). meta.version = _read_version(game, repo, log) or meta.version meta.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds") # Copiar icono desde el repo a la cache. _copy_icon(root, game, repo, log) save_meta(root, game.id, meta) return meta def _copy_icon(root: Path, game: Game, repo: Path, log: LogFn) -> None: src = repo / game.icon_rel if not src.exists(): log(f"(sense icona a {game.icon_rel})") return metadata_dir(root, game.id).mkdir(parents=True, exist_ok=True) try: shutil.copyfile(src, icon_path(root, game.id)) log(f"Icona actualitzada des de {game.icon_rel}") except OSError as exc: log(f"No s'ha pogut copiar la icona: {exc}") def _read_version(game: Game, repo: Path, log: LogFn) -> str: if not game.version_cmd: return "" try: proc = subprocess.run( game.version_cmd, cwd=str(repo), shell=True, capture_output=True, text=True, timeout=20, ) except (OSError, subprocess.SubprocessError) as exc: log(f"version_cmd ha fallat: {exc}") return "" out = (proc.stdout or "").strip() return out.splitlines()[0] if out else "" def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None: if not game.info_url: return None headers = {"Accept": "application/json"} if token: headers["Authorization"] = f"token {token}" try: req = urllib.request.Request(game.info_url, headers=headers) with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: return json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc: log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}") return None def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None: info = _fetch_gitea_info(game, log, token) if info: return info.get("default_branch") return None def _detect_origin_head(repo: Path, log: LogFn) -> str | None: """Fallback: deduce la rama por defecto desde origin/HEAD.""" try: ref = _run_git( ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repo, log ) except RuntimeError: return None # ref tiene forma "origin/main" return ref.split("/", 1)[1] if "/" in ref else ref or None