aboutsummaryrefslogtreecommitdiff
path: root/app/selfupdate.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/selfupdate.py')
-rw-r--r--app/selfupdate.py180
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