aboutsummaryrefslogtreecommitdiff
path: root/app/backends/common.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/common.py')
-rw-r--r--app/backends/common.py114
1 files changed, 108 insertions, 6 deletions
diff --git a/app/backends/common.py b/app/backends/common.py
index 098dfb2..4edfd61 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -496,22 +496,124 @@ def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int:
emit=emit, cancel=cancel)
+def git_head(checkout: Path) -> Optional[str]:
+ """CHECKOUT's current HEAD commit sha, or None when it is not a repo."""
+ proc = run_console_subprocess_quiet(["git", "-C", str(checkout),
+ "rev-parse", "HEAD"])
+ if proc is None or proc.returncode != 0:
+ return None
+ return proc.stdout.decode("utf-8", errors="replace").strip() or None
+
+
+def git_commit_time(checkout: Path) -> Optional[int]:
+ """CHECKOUT's HEAD commit time as a unix timestamp, or None.
+
+ Uses the *committer* time (``%ct``): a rebase or cherry-pick rewrites
+ it to when the rewrite happened, so a force-pushed or rebased branch
+ always looks newer than binaries built from the pre-rewrite sources.
+ None (not a repo, probe failed) leaves the decision to the caller.
+ """
+ proc = run_console_subprocess_quiet(["git", "-C", str(checkout),
+ "show", "-s", "--format=%ct",
+ "HEAD"])
+ if proc is None or proc.returncode != 0:
+ return None
+ try:
+ return int(proc.stdout.decode("ascii", errors="replace").strip())
+ except ValueError:
+ return None
+
+
+def git_update(checkout: Path, *, emit=None, cancel=None) -> int:
+ """Update CHECKOUT to its remote's HEAD: fetch, then hard reset.
+
+ The backend checkouts are read-only working copies of upstream repos —
+ all state that matters (models, build trees, server.json, voices.json)
+ is untracked and survives the reset, while local edits the installers
+ made (the vendored-ggml patch in the audio.cpp checkout) are meant to
+ be re-applied by the caller afterwards. ``git reset --hard`` is used
+ instead of ``git pull`` because a pull merges against the working tree
+ and would conflict on exactly those re-applied-by-design edits.
+
+ The branch reset to is the remote's default (``refs/remotes/origin/
+ HEAD``), falling back to ``main`` when the symbolic ref is missing (a
+ bare-ish mirror or a restrictive server). EMIT/CANCEL behave like
+ git_clone's (fetch runs with --progress so the task view sees updates).
+ Returns the exit code of the first failing step (0 when the checkout
+ now matches origin's HEAD).
+ """
+ if emit is None:
+ print(f"[INFO] Updating git checkout {checkout}...")
+ else:
+ emit(f"[INFO] Updating git checkout {checkout}...")
+ fetch_argv = ["git", "-C", str(checkout), "fetch"]
+ reset_argv = ["git", "-C", str(checkout), "reset", "--hard"]
+ 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.
+ fetch_argv.append("--progress")
+ fetch_argv.append("origin")
+ fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel)
+ if fetch_rc != 0:
+ return fetch_rc
+ branch = _origin_default_branch(checkout)
+ return run_console_subprocess(reset_argv + [f"origin/{branch}"],
+ emit=emit, cancel=cancel)
+
+
+def _origin_default_branch(checkout: Path) -> str:
+ """The remote's default branch name for CHECKOUT ("main" as fallback)."""
+ proc = run_console_subprocess_quiet(
+ ["git", "-C", str(checkout), "symbolic-ref",
+ "refs/remotes/origin/HEAD"])
+ if proc is not None and proc.returncode == 0:
+ ref = proc.stdout.decode("utf-8", errors="replace").strip()
+ # refs/remotes/origin/HEAD -> refs/remotes/origin/main
+ name = ref.rpartition("/")[2]
+ if name:
+ return name
+ return "main"
+
+
+def run_console_subprocess_quiet(argv: List[str],
+ cwd: Optional[Path] = None):
+ """Run ARGV silently and return the completed result.
+
+ Unlike run_console_subprocess (which streams or returns only an exit
+ code) this captures stdout and needs the process object itself, for the
+ small git probes (rev-parse, symbolic-ref) whose *output* matters and
+ whose failure is a normal, non-fatal outcome. Returns None when the
+ process could not be started.
+ """
+ import subprocess
+ try:
+ return subprocess.run(
+ argv, capture_output=True,
+ cwd=str(cwd) if cwd is not None else None, check=False)
+ except OSError:
+ return None
+
+
def pip_install(packages: List[str], *, emit=None, cancel=None,
- env_dir: Optional[Path] = None) -> int:
+ env_dir: Optional[Path] = None,
+ upgrade: bool = False) -> int:
"""pip install PACKAGES into a managed venv. Returns exit code.
Delegates to ``backends.envs.pip_install`` so backend TTS packages are
installed into their dedicated tool-managed environments (``envs/tts``
default; ``envs/qwen`` / ``envs/faster`` via ENV_DIR) rather than into
whatever interpreter happens to be running the wizard — and never two
- conflicting stacks into the same env. With EMIT given (the in-TUI task
- view) pip runs with its output streamed into EMIT; CANCEL aborts it.
- The import is local to avoid a circular import (envs imports this
- module).
+ conflicting stacks into the same env. With UPGRADE pip runs with
+ ``-U`` (the backend update action's freshness check: pip only installs
+ when a newer version resolves, else reports "already satisfied").
+ With EMIT given (the in-TUI task view) pip runs with its output streamed
+ into EMIT; CANCEL aborts it. The import is local to avoid a circular
+ import (envs imports this module).
"""
from backends import envs
return envs.pip_install(packages, emit=emit, cancel=cancel,
- env_dir=env_dir)
+ env_dir=env_dir, upgrade=upgrade)
def pip_uninstall(packages: List[str], *, emit=None,