aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/common.py4
-rw-r--r--app/selfupdate.py180
-rw-r--r--app/tests/test_backends_common.py8
-rw-r--r--app/tests/test_hub.py159
-rw-r--r--app/tests/test_selfupdate.py241
-rw-r--r--app/ui/hub.py70
6 files changed, 654 insertions, 8 deletions
diff --git a/app/backends/common.py b/app/backends/common.py
index 811f13a..b5d6576 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -631,12 +631,12 @@ def git_update(checkout: Path, *, emit=None, cancel=None) -> int:
fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel)
if fetch_rc != 0:
return fetch_rc
- branch = _origin_default_branch(checkout)
+ 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:
+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",
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
diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py
index 68a5543..71f0c17 100644
--- a/app/tests/test_backends_common.py
+++ b/app/tests/test_backends_common.py
@@ -161,7 +161,7 @@ class GitUpdateTests(unittest.TestCase):
"""Patch run_console_subprocess (fetch/reset) and the branch probe."""
run = mock.patch.object(common, "run_console_subprocess",
return_value=fetch_rc).start()
- mock.patch.object(common, "_origin_default_branch",
+ mock.patch.object(common, "origin_default_branch",
return_value=branch).start()
return run
@@ -207,20 +207,20 @@ class OriginDefaultBranchTests(unittest.TestCase):
stdout=b"refs/remotes/origin/master\n")
with mock.patch.object(common, "run_console_subprocess_quiet",
return_value=proc):
- self.assertEqual(common._origin_default_branch(
+ self.assertEqual(common.origin_default_branch(
common.Path("/co")), "master")
def test_missing_ref_falls_back_to_main(self):
proc = mock.Mock(returncode=128, stdout=b"")
with mock.patch.object(common, "run_console_subprocess_quiet",
return_value=proc):
- self.assertEqual(common._origin_default_branch(
+ self.assertEqual(common.origin_default_branch(
common.Path("/co")), "main")
def test_unstartable_probe_falls_back_to_main(self):
with mock.patch.object(common, "run_console_subprocess_quiet",
return_value=None):
- self.assertEqual(common._origin_default_branch(
+ self.assertEqual(common.origin_default_branch(
common.Path("/co")), "main")
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 485a37f..228a103 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -394,6 +394,14 @@ class SubmenuStatusTableTests(unittest.TestCase):
servers, showing running/stopped inline instead of the table.
"""
+ def setUp(self):
+ # These tests concern the backend actions; the host's git-checkout
+ # state (which gates the "Update Generator" entry) must not leak in.
+ patcher = patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=False)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
def _capture_menu(self, captured):
def fake_menu(stdscr, title, options, **kwargs):
captured["title"] = title
@@ -4102,6 +4110,157 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
# freshly detected status table.
self.assertEqual(titles, ["Configure Backends", "Configure Backends"])
+ def test_configure_menu_offers_update_generator_for_git_checkouts(self):
+ def options_for(git_checkout):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return tui.Wizard.BACK
+
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub, "REGISTRY", [info]), \
+ patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=git_checkout):
+ hub._Hub(None).screen_configure()
+ return [opt[0] for opt in captured["options"]]
+
+ self.assertIn("Update Generator", options_for(True))
+ self.assertNotIn("Update Generator", options_for(False))
+
+ def test_selecting_update_generator_runs_the_action_and_reshows(self):
+ titles = []
+ ran = []
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ titles.append(title)
+ return "update_self" if len(titles) == 1 else tui.Wizard.BACK
+
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True)]
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub, "REGISTRY", [info]), \
+ patch.object(hub, "_update_self_action",
+ side_effect=lambda scr: ran.append(True)):
+ result = hub._Hub(None).screen_configure()
+ self.assertIs(result, tui.Wizard.BACK)
+ self.assertEqual(ran, [True])
+ # An inline action: the same menu re-shows after the update.
+ self.assertEqual(titles, ["Configure Backends", "Configure Backends"])
+
+ def test_update_self_action_flashes_err_without_git_checkout(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=False), \
+ patch.object(hub.taskview, "run_steps") as mk_run, \
+ patch_flash:
+ hub._update_self_action(None)
+ mk_run.assert_not_called()
+ self.assertEqual(len(flashes), 1)
+ self.assertEqual(flashes[0][1], "err")
+ self.assertIn("not a git checkout", flashes[0][0])
+
+ def test_update_self_action_declined_confirm_aborts_before_running(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=True), \
+ patch.object(hub.selfupdate, "current_commit",
+ return_value="aaa"), \
+ patch.object(hub.selfupdate, "modified_tracked_files",
+ return_value=["app/converter/config.py"]), \
+ patch.object(hub.tui, "confirm",
+ return_value=False) as mk_confirm, \
+ patch.object(hub.taskview, "run_steps") as mk_run, \
+ patch_flash:
+ hub._update_self_action(None)
+ mk_run.assert_not_called()
+ self.assertEqual(flashes, [])
+ # The confirm names the files that will be preserved.
+ body = mk_confirm.call_args[1]["body"]
+ self.assertIn("app/converter/config.py", body)
+
+ def test_update_self_action_runs_step_and_flashes_update(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=True), \
+ patch.object(hub.selfupdate, "current_commit",
+ side_effect=["aaa", "bbb"]), \
+ patch.object(hub.selfupdate, "modified_tracked_files",
+ return_value=[]), \
+ patch.object(hub.taskview, "run_steps",
+ return_value=0) as mk_run, \
+ patch.object(hub.selfupdate, "update_generator",
+ return_value=0) as mk_update, \
+ patch_flash:
+ hub._update_self_action(None)
+ mk_run.assert_called_once()
+ self.assertEqual(mk_run.call_args[0][0], None)
+ self.assertEqual(mk_run.call_args[0][1], "Update Generator")
+ steps = mk_run.call_args[0][2]
+ self.assertEqual([step.title for step in steps],
+ ["Fetch and reset the checkout"])
+
+ def emit(line):
+ pass
+
+ steps[0].work(emit, "CANCEL")
+ mk_update.assert_called_once_with(emit=emit, cancel="CANCEL")
+ self.assertEqual(len(flashes), 1)
+ text, kind = flashes[0]
+ self.assertEqual(kind, "ok")
+ self.assertIn("aaa", text)
+ self.assertIn("bbb", text)
+ self.assertIn("Restart to apply", text)
+
+ def test_update_self_action_flashes_already_up_to_date(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=True), \
+ patch.object(hub.selfupdate, "current_commit",
+ side_effect=["aaa", "aaa"]), \
+ patch.object(hub.selfupdate, "modified_tracked_files",
+ return_value=[]), \
+ patch.object(hub.taskview, "run_steps", return_value=0), \
+ patch_flash:
+ hub._update_self_action(None)
+ self.assertEqual(flashes,
+ [("The generator is already up to date (aaa).",
+ "ok")])
+
+ def test_update_self_action_flashes_warn_when_cancelled(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=True), \
+ patch.object(hub.selfupdate, "current_commit",
+ side_effect=["aaa", "aaa"]), \
+ patch.object(hub.selfupdate, "modified_tracked_files",
+ return_value=[]), \
+ patch.object(hub.taskview, "run_steps", return_value=130), \
+ patch_flash:
+ hub._update_self_action(None)
+ self.assertEqual(flashes[-1][1], "warn")
+ self.assertIn("cancelled", flashes[-1][0])
+
+ def test_update_self_action_flashes_err_when_failed(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.selfupdate, "is_git_checkout",
+ return_value=True), \
+ patch.object(hub.selfupdate, "current_commit",
+ side_effect=["aaa", "aaa"]), \
+ patch.object(hub.selfupdate, "modified_tracked_files",
+ return_value=[]), \
+ patch.object(hub.taskview, "run_steps", return_value=1), \
+ patch_flash:
+ hub._update_self_action(None)
+ self.assertEqual(flashes[-1][1], "err")
+ self.assertIn("did not complete", flashes[-1][0])
+
def test_pick_backend_install_lists_uninstalled_only(self):
captured = {}
diff --git a/app/tests/test_selfupdate.py b/app/tests/test_selfupdate.py
new file mode 100644
index 0000000..bf2f6b3
--- /dev/null
+++ b/app/tests/test_selfupdate.py
@@ -0,0 +1,241 @@
+"""Tests for the generator's self-update (selfupdate.py).
+
+The update is a fetch + conditional hard reset of the generator's own
+checkout; its defining property is preservation: locally modified tracked
+files (app/converter/config.py above all) are snapshotted before the reset
+and written back afterwards, so an update never overwrites user config.
+These tests simulate the reset's effect on the working tree via the
+subprocess mocks' side effects and assert what survives.
+"""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import selfupdate
+
+
+def _proc(returncode=0, stdout=b""):
+ proc = mock.Mock()
+ proc.returncode = returncode
+ proc.stdout = stdout
+ return proc
+
+
+class IsGitCheckoutTests(unittest.TestCase):
+ def test_git_directory_and_worktree_file_count(self):
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ self.assertFalse(selfupdate.is_git_checkout(root))
+ (root / ".git").mkdir()
+ self.assertTrue(selfupdate.is_git_checkout(root))
+ (root / ".git").rmdir()
+ (root / ".git").write_text("gitdir: /elsewhere\n",
+ encoding="utf-8")
+ self.assertTrue(selfupdate.is_git_checkout(root))
+
+ def test_defaults_to_the_install_root(self):
+ # The repo running the tests is a git checkout.
+ self.assertTrue(selfupdate.is_git_checkout())
+
+
+class CurrentCommitTests(unittest.TestCase):
+ def test_short_sha_is_parsed(self):
+ proc = _proc(0, b"abc1234\n")
+ with mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ return_value=proc):
+ self.assertEqual(selfupdate.current_commit(Path("/co")),
+ "abc1234")
+
+ def test_git_failure_returns_none(self):
+ proc = _proc(128, b"")
+ with mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ return_value=proc):
+ self.assertIsNone(selfupdate.current_commit(Path("/co")))
+ with mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ return_value=None):
+ self.assertIsNone(selfupdate.current_commit(Path("/co")))
+
+
+class ModifiedTrackedFilesTests(unittest.TestCase):
+ def test_porcelain_lines_are_parsed(self):
+ # --untracked-files=no: git never reports "??" entries here.
+ proc = _proc(0, b" M app/converter/config.py\n"
+ b"M staged.py\n"
+ b" D deleted.py\n"
+ b"R old.py -> new.py\n")
+ with mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ return_value=proc):
+ self.assertEqual(
+ selfupdate.modified_tracked_files(Path("/co")),
+ ["app/converter/config.py", "staged.py", "deleted.py",
+ "new.py"])
+
+ def test_git_failure_returns_empty(self):
+ proc = _proc(128, b"")
+ with mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ return_value=proc):
+ self.assertEqual(selfupdate.modified_tracked_files(Path("/co")),
+ [])
+
+
+class UpdateGeneratorTests(unittest.TestCase):
+ """update_generator against a fake checkout in a temp directory."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.root = Path(self._tmp.name)
+ self.config = self.root / "app" / "converter" / "config.py"
+ self.config.parent.mkdir(parents=True)
+ self.config.write_bytes(b"# user settings\n")
+ self.emitted = []
+
+ def tearDown(self):
+ mock.patch.stopall()
+ self._tmp.cleanup()
+
+ def _patch_git(self, *, fetch_rc=0, reset_rc=0, head=b"aaa\n",
+ remote=b"bbb\n", status=b" M app/converter/config.py\n",
+ on_reset=None):
+ """Patch the subprocess layer; returns the run_console mock.
+
+ run_console_subprocess serves fetch (first call) and reset (second);
+ ON_RESET, when given, runs before the reset's return code so a test
+ can simulate the working-tree damage a real reset does (rewriting or
+ deleting files).
+ """
+ run = mock.patch.object(
+ selfupdate.common, "run_console_subprocess",
+ side_effect=self._run_side_effect(fetch_rc, reset_rc,
+ on_reset)).start()
+
+ def quiet(argv, **kwargs):
+ if "status" in argv:
+ return _proc(0, status)
+ if "rev-parse" in argv:
+ ref = argv[argv.index("rev-parse") + 2]
+ return _proc(0, remote if ref.startswith("origin/")
+ else head)
+ return _proc(0, b"")
+
+ mock.patch.object(selfupdate.common,
+ "run_console_subprocess_quiet",
+ side_effect=quiet).start()
+ mock.patch.object(selfupdate.common, "origin_default_branch",
+ return_value="main").start()
+ return run
+
+ @staticmethod
+ def _run_side_effect(fetch_rc, reset_rc, on_reset):
+ def run(argv, **kwargs):
+ if "fetch" in argv:
+ return fetch_rc
+ if "reset" in argv:
+ if on_reset is not None:
+ on_reset()
+ return reset_rc
+ raise AssertionError(f"unexpected subprocess call: {argv}")
+ return run
+
+ # -- up to date / fetch failure: the tree must never be touched ------
+
+ def test_already_up_to_date_skips_the_reset(self):
+ run = self._patch_git(head=b"bbb\n", remote=b"bbb\n")
+ rc = selfupdate.update_generator(root=self.root,
+ emit=self.emitted.append)
+ self.assertEqual(rc, 0)
+ # Only the fetch ran — a no-op update cannot discard anything.
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(self.config.read_bytes(), b"# user settings\n")
+ self.assertTrue(any("already up to date" in line
+ for line in self.emitted))
+
+ def test_fetch_failure_short_circuits(self):
+ run = self._patch_git(fetch_rc=128)
+ rc = selfupdate.update_generator(root=self.root)
+ self.assertEqual(rc, 128)
+ self.assertEqual(run.call_count, 1)
+ self.assertEqual(self.config.read_bytes(), b"# user settings\n")
+
+ def test_console_mode_prints_instead_of_emitting(self):
+ with mock.patch("builtins.print") as mk_print:
+ self._patch_git()
+ selfupdate.update_generator(root=self.root)
+ self.assertTrue(mk_print.called)
+
+ # -- the reset path ---------------------------------------------------
+
+ def test_updates_and_preserves_the_modified_file(self):
+ run = self._patch_git(on_reset=lambda: self.config.write_bytes(
+ b"# upstream settings\n"))
+ rc = selfupdate.update_generator(root=self.root,
+ emit=self.emitted.append)
+ self.assertEqual(rc, 0)
+ # Fetch first, then the reset to origin's default branch.
+ self.assertEqual(
+ run.call_args_list[1][0][0],
+ ["git", "-C", str(self.root), "reset", "--hard",
+ "origin/main"])
+ # The user's config survived the reset verbatim...
+ self.assertEqual(self.config.read_bytes(), b"# user settings\n")
+ # ...and the skip is reported.
+ self.assertTrue(any("Kept your local app/converter/config.py"
+ in line for line in self.emitted))
+
+ def test_streaming_adds_progress_and_passes_emit(self):
+ run = self._patch_git()
+ selfupdate.update_generator(root=self.root, emit=self.emitted.append)
+ fetch_argv = run.call_args_list[0][0][0]
+ self.assertEqual(fetch_argv[:4],
+ ["git", "-C", str(self.root), "fetch"])
+ self.assertIn("--progress", fetch_argv)
+ self.assertEqual(run.call_args_list[0][1]["emit"],
+ self.emitted.append)
+ self.assertEqual(run.call_args_list[1][1]["emit"],
+ self.emitted.append)
+
+ def test_reset_failure_still_restores_the_snapshot(self):
+ self._patch_git(reset_rc=1, on_reset=lambda: self.config.write_bytes(
+ b"# upstream settings\n"))
+ rc = selfupdate.update_generator(root=self.root)
+ self.assertEqual(rc, 1)
+ self.assertEqual(self.config.read_bytes(), b"# user settings\n")
+
+ def test_staged_added_file_is_restored_after_the_reset_removes_it(self):
+ added = self.root / "new_config.py"
+ added.write_bytes(b"# user additions\n")
+ def damage():
+ added.unlink()
+ self._patch_git(status=b"A new_config.py\n", on_reset=damage)
+ rc = selfupdate.update_generator(root=self.root)
+ self.assertEqual(rc, 0)
+ self.assertEqual(added.read_bytes(), b"# user additions\n")
+
+ def test_deleted_tracked_file_is_not_resurrected(self):
+ # A deletion is not a modification with content: the reset's
+ # restore of the file stands (it shows up in the confirm instead).
+ self._patch_git(status=b" D gone.py\n")
+ rc = selfupdate.update_generator(root=self.root)
+ self.assertEqual(rc, 0)
+ self.assertFalse((self.root / "gone.py").exists())
+
+ def test_identical_upstream_version_is_not_reported_as_kept(self):
+ # Upstream's new file content equals what the user already has:
+ # the restore is a no-op and says nothing.
+ self._patch_git(on_reset=lambda: self.config.write_bytes(
+ b"# user settings\n"))
+ rc = selfupdate.update_generator(root=self.root,
+ emit=self.emitted.append)
+ self.assertEqual(rc, 0)
+ self.assertFalse(any("Kept your local" in line
+ for line in self.emitted))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/ui/hub.py b/app/ui/hub.py
index c003b16..67680db 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -30,6 +30,7 @@ from pathlib import Path
from typing import Callable, Optional, Tuple
import logging_kit
+import selfupdate
from backends import (
REGISTRY,
@@ -170,7 +171,9 @@ class _Hub:
backend (qwen offers its per-model weight (un)installer there),
start/stop the installed backends' local servers, install (backends
with nothing on disk), update (every installed backend refreshed to
- the latest upstream version in one task-view run), and uninstall.
+ the latest upstream version in one task-view run), update the
+ generator itself (git checkout moved to the remote's HEAD, keeping
+ locally modified files such as config.py), and uninstall.
Selecting one pushes the next screen; Esc pops back to the main
menu. The Build action downloads any missing models alongside the
build (a split view), so it heals a configured-but-unbuilt backend
@@ -222,6 +225,8 @@ class _Hub:
options.append(("Install Backend", "install"))
if any(_updatable(info, by_key) for info in REGISTRY):
options.append(("Update Backends", "update"))
+ if selfupdate.is_git_checkout():
+ options.append(("Update Generator", "update_self"))
if any(_uninstallable(info, by_key) for info in REGISTRY):
options.append(("Uninstall Backend", "uninstall"))
@@ -229,7 +234,8 @@ class _Hub:
self.stdscr, "Configure Backends", options,
back_value=tui.Wizard.BACK,
help_lines=["Install, update, configure, or remove a TTS "
- "backend."],
+ "backend. 'Update Generator' refreshes this "
+ "tool's own checkout instead."],
table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if choice is tui.Wizard.BACK:
@@ -242,6 +248,10 @@ class _Hub:
_update_backends_action(self.stdscr)
invalidate_detect_cache()
continue # an inline action: re-show this same menu
+ if choice == "update_self":
+ _update_self_action(self.stdscr)
+ invalidate_detect_cache()
+ continue # an inline action: re-show this same menu
if choice == "uninstall":
return self.screen_uninstall
if choice == "download_models":
@@ -759,6 +769,62 @@ def _update_backends_action(stdscr) -> None:
"Backends' to retry.", "err")
+def _update_self_action(stdscr) -> None:
+ """Run the "Update Generator" action inside the TUI.
+
+ Moves the generator's own git checkout to the remote's default-branch
+ HEAD (fetch + hard reset — the same flow the backend checkouts use).
+ User state cannot be overwritten: everything untracked (app/envs,
+ input/output/voices, the backend checkouts, logs) is outside the
+ reset's reach, and locally modified tracked files — the user-editable
+ app/converter/config.py above all — are snapshotted before the reset
+ and written back afterwards (a kept file whose upstream version
+ changed is reported for a manual merge). A dirty checkout confirms
+ first (declining, Esc included, aborts before anything runs); the run
+ itself streams in the task view like the other inline actions. After:
+ a successful run flashes the moved commit range (or "already up to
+ date") plus the restart reminder — the fresh code loads on relaunch,
+ where requirements re-install runs automatically — while cancel and
+ failure flash their usual warn/err. The menu re-shows either way.
+ """
+ if not selfupdate.is_git_checkout():
+ tui.flash(stdscr, "This install is not a git checkout — update by "
+ "re-cloning the repository.", "err")
+ return
+ before = selfupdate.current_commit()
+ modified = selfupdate.modified_tracked_files()
+ if modified:
+ body = ["These locally modified files are kept as they are:",
+ ""]
+ body += modified[:6]
+ if len(modified) > 6:
+ body.append(f"...and {len(modified) - 6} more")
+ if tui.confirm(stdscr, "Update the generator?", body=body,
+ default=False, cancel_value=False) is not True:
+ return
+
+ def work(emit, cancel):
+ return selfupdate.update_generator(emit=emit, cancel=cancel)
+
+ rc = taskview.run_steps(
+ stdscr, "Update Generator",
+ [taskview.TaskStep("Fetch and reset the checkout", work)])
+ after = selfupdate.current_commit()
+ if rc == 130:
+ tui.flash(stdscr, "Update cancelled — re-run 'Update Generator' "
+ "any time.", "warn")
+ elif rc:
+ tui.flash(stdscr, "The generator update did not complete — see the "
+ "log above and retry.", "err")
+ elif before is not None and before == after:
+ tui.flash(stdscr, f"The generator is already up to date ({before}).",
+ "ok")
+ else:
+ tui.flash(stdscr, f"Generator updated {before or '?'} → {after}. "
+ "Restart to apply — requirements re-install runs "
+ "automatically on next launch.", "ok")
+
+
def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
"""Map a backend's state to (status_text, status_kind, name_kind).