#!/usr/bin/env python3 """Self-update for the audiobook generator's own git checkout. The generator ships as a plain ``git clone`` with no tags or releases, so updating means moving the checkout to the remote's default-branch HEAD — the same fetch + hard-reset flow the backend checkouts use (``backends.common.git_update``). Everything stateful lives in untracked, gitignored paths (``app/envs/``, ``input/``, ``output/``, ``voices/``, the backend checkouts, logs), which a hard reset never touches. The one tracked file users are expected to edit — ``app/converter/ config.py`` — gets explicit protection: every locally modified tracked file is snapshotted before the reset and written back afterwards, so an update can never silently overwrite user configuration with upstream's new version. A kept file whose upstream version changed is reported, so new upstream options can be merged by hand. Run via the hub's Configure Backends > Update tts-audiobook-generator action; the module is stdlib-only like ``backends.common`` / ``backends.envs``. """ from pathlib import Path from typing import Dict, List, Optional from backends import common from backends.envs import TTS_ROOT def _root(root: Optional[Path]) -> Path: return Path(root) if root is not None else TTS_ROOT def is_git_checkout(root: Optional[Path] = None) -> bool: """True when ROOT (the install by default) is a git working copy. ``.git`` is a directory for a normal clone and a file for a linked worktree or submodule checkout — both count; a zip download or a copy without git history does not. """ return (_root(root) / ".git").exists() def current_commit(root: Optional[Path] = None) -> Optional[str]: """The checked-out commit's short SHA, or None when git cannot answer.""" proc = common.run_console_subprocess_quiet( ["git", "-C", str(_root(root)), "rev-parse", "--short", "HEAD"]) if proc is None or proc.returncode != 0: return None sha = proc.stdout.decode("ascii", errors="replace").strip() return sha or None def modified_tracked_files(root: Optional[Path] = None) -> List[str]: """Tracked files with local changes (staged or unstaged), repo-relative. Untracked files are excluded (``--untracked-files=no``): they are invisible to ``git reset --hard`` anyway. Deletions are included — the reset restores a deleted tracked file, so they show up in the update confirm as state the reset will undo. An empty list also results when git itself fails (no checkout), so callers must gate on is_git_checkout for the error case. """ proc = common.run_console_subprocess_quiet( ["git", "-C", str(_root(root)), "status", "--porcelain", "--untracked-files=no"]) if proc is None or proc.returncode != 0: return [] files: List[str] = [] for line in proc.stdout.decode("utf-8", errors="replace").splitlines(): # Porcelain: "XYpath"; staged renames read "old -> new". if len(line) < 4: continue path = line[3:] if " -> " in path: path = path.rsplit(" -> ", 1)[1] path = path.strip().strip('"') if path and path not in files: files.append(path) return files def _say(message: str, emit=None) -> None: if emit is None: print(message) else: emit(message) def _snapshot(rel_paths: List[str], root: Path) -> Dict[str, bytes]: """Read every existing file in REL_PATHS (missing ones — deletions — are skipped: the reset restores those by itself).""" snapshot: Dict[str, bytes] = {} for rel in rel_paths: path = root / rel try: if path.is_file(): snapshot[rel] = path.read_bytes() except OSError: continue return snapshot def _restore(snapshot: Dict[str, bytes], root: Path) -> List[str]: """Write the user's file contents back over the reset's result. A file whose post-reset content already matches the snapshot (the user's version is what upstream now ships) is left untouched, so only genuinely skipped upstream changes are reported. Returns the repo-relative paths that were actually written. """ restored: List[str] = [] for rel, data in snapshot.items(): path = root / rel try: if path.is_file() and path.read_bytes() == data: continue path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) except OSError: continue restored.append(rel) return restored def _rev_parse(root: Path, ref: str) -> Optional[str]: proc = common.run_console_subprocess_quiet( ["git", "-C", str(root), "rev-parse", "--short", ref]) if proc is None or proc.returncode != 0: return None sha = proc.stdout.decode("ascii", errors="replace").strip() return sha or None def update_generator(*, emit=None, cancel=None, root: Optional[Path] = None) -> int: """Update the generator's own checkout to the remote's default branch. Fetch, then — only when the remote moved — hard-reset to ``origin/`` (an up-to-date checkout skips the reset entirely, so a no-op update can never discard anything). Locally modified tracked files are snapshotted first and written back after the reset attempt (even a cancelled or failed one — the tree may be mid-reset when CANCEL fires), which is what keeps user config intact. EMIT/CANCEL behave like git_update's. Returns the exit code of the first failing step (0 when the checkout now matches the remote, or already did). """ base = _root(root) snapshot = _snapshot(modified_tracked_files(base), base) _say("[INFO] Fetching the latest generator code...", emit) fetch_argv = ["git", "-C", str(base), "fetch"] if emit is not None: # --progress makes git report percentage updates even though stderr # is piped (it normally only does so on a terminal), feeding the # task view — same as git_update. fetch_argv.append("--progress") fetch_argv.append("origin") fetch_rc = common.run_console_subprocess(fetch_argv, emit=emit, cancel=cancel) if fetch_rc != 0: _restore(snapshot, base) return fetch_rc branch = common.origin_default_branch(base) remote = _rev_parse(base, f"origin/{branch}") if remote is not None and _rev_parse(base, "HEAD") == remote: _say(f"[INFO] tts-audiobook-generator is already up to date " f"({remote}).", emit) _restore(snapshot, base) return 0 _say(f"[INFO] Updating the checkout to origin/{branch} " f"({remote or 'unknown commit'})...", emit) reset_rc = common.run_console_subprocess( ["git", "-C", str(base), "reset", "--hard", f"origin/{branch}"], emit=emit, cancel=cancel) for rel in _restore(snapshot, base): _say(f"[OK] Kept your local {rel} (upstream's changes to it were " "skipped — merge new options by hand if needed).", emit) if not reset_rc: # The fresh code loads on relaunch (where requirements re-install # runs automatically) — the console says so because the hub no # longer flashes after the task view's own summary. _say("[OK] Restart to apply the update — requirements re-install " "runs automatically on next launch.", emit) return reset_rc