diff options
Diffstat (limited to 'app/selfupdate.py')
| -rw-r--r-- | app/selfupdate.py | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/app/selfupdate.py b/app/selfupdate.py new file mode 100644 index 0000000..239db92 --- /dev/null +++ b/app/selfupdate.py @@ -0,0 +1,180 @@ +#!/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 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: "XY<space>path"; 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/<default-branch>`` (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] The generator is already up to date ({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) + return reset_rc |
