From 9c6648c406223a9126cf352c3ab689692ead2093 Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Thu, 29 Oct 2026 22:07:29 -0500 Subject: [PATCH] feat(updater): offer updates from main, not tagged releases A git checkout is now compared with GitHub's main branch at launch; if main is ahead, the dialog lists the new commits and Update now runs git pull --ff-only. A diverged or dirty checkout is refused, never merged. A pull that changes pyproject.toml or uv.lock says to re-run the installer. Prebuilt apps have no checkout and still check Releases. --- docs-site/getting-started/installation.md | 6 +- src/glider/_version.py | 3 +- src/glider/updater.py | 168 +++++++++++++++++++--- tests/unit/test_updater.py | 129 +++++++++++++++++ 4 files changed, 288 insertions(+), 18 deletions(-) diff --git a/docs-site/getting-started/installation.md b/docs-site/getting-started/installation.md index e0a44fd1..756dc06f 100644 --- a/docs-site/getting-started/installation.md +++ b/docs-site/getting-started/installation.md @@ -213,7 +213,11 @@ Video recording relies on **FFmpeg**. Install it once: === "From source" - Re-run the [one-command installer](#one-command) with the same extras, or + GLIDER compares your copy with the `main` branch on GitHub at launch and + offers to pull any new changes (**Help → Check for Updates…**). If an + update changes GLIDER's dependencies, it says so: re-run the installer. + + Or re-run the [one-command installer](#one-command) with the same extras, or by hand: ```bash diff --git a/src/glider/_version.py b/src/glider/_version.py index 88bf00a1..e268ac04 100644 --- a/src/glider/_version.py +++ b/src/glider/_version.py @@ -5,7 +5,8 @@ ``pyproject.toml``'s ``[tool.setuptools.dynamic]`` section. Bump the value here when cutting a release — CI reads it via ``setuptools`` -dynamic resolution and the in-app updater compares it against GitHub Releases. +dynamic resolution. The in-app updater compares it against GitHub Releases only +for a prebuilt app; a git checkout is compared with ``main`` instead. """ __version__ = "1.0.0" diff --git a/src/glider/updater.py b/src/glider/updater.py index 10b92d08..375fdcc3 100644 --- a/src/glider/updater.py +++ b/src/glider/updater.py @@ -1,13 +1,16 @@ """In-app update checker. -On application startup we run a single, cheap, best-effort check against the -GitHub Releases API. If a newer tagged release exists, a modeless dialog tells -the user about it and offers three actions: - -* **Download** — open the release page in the system browser. +On application startup we run a single, cheap, best-effort check against +GitHub. A git checkout (what the installers make) is compared with ``main``: +if ``main`` has commits the checkout lacks, the dialog offers to ``git pull`` +them. A prebuilt app has no checkout, so it falls back to GitHub Releases and +offers to open the newer release's page. Either way the dialog has three +actions: + +* **Update now** / **Download** — pull ``main``, or open the release page. * **Later** — dismiss; check again next launch. -* **Don't ask again** — suppress future prompts *for this specific version*. - Next release resets the prompt. +* **Don't ask again** — suppress future prompts *for this specific version* + (a release tag, or the commit ``main`` is at). The next one resets it. The check is strictly non-blocking: it runs on a :class:`QThread`, has a 5-second timeout, and silently swallows all network errors. The user must never see a @@ -29,6 +32,8 @@ from __future__ import annotations import logging +import subprocess +from pathlib import Path from typing import NamedTuple import httpx @@ -47,6 +52,12 @@ REPO = "glider" RELEASES_API = f"https://api.github.com/repos/{OWNER}/{REPO}/releases/latest" RELEASES_PAGE = f"https://github.com/{OWNER}/{REPO}/releases/latest" +BRANCH = "main" +COMPARE_API = f"https://api.github.com/repos/{OWNER}/{REPO}/compare/{{base}}...{BRANCH}" + +# src/glider/updater.py -> the checkout. Only a checkout if it holds ``.git`` +# (a directory, or a file in a worktree); a prebuilt app has neither. +REPO_DIR = Path(__file__).resolve().parents[2] # How long we're willing to block a worker thread on the GitHub API before # giving up. Five seconds is long enough for a slow home/lab connection and @@ -64,6 +75,8 @@ class UpdateInfo(NamedTuple): latest_version: str release_url: str release_notes: str + # Commits ``main`` is ahead of the checkout; None for a release check. + commits_behind: int | None = None # --- Pure helpers (testable without Qt) -------------------------------------- @@ -133,6 +146,107 @@ def fetch_latest_release( ) +def local_commit(repo_dir: Path = REPO_DIR) -> str | None: + """The checkout's HEAD commit, or None when this isn't a git checkout.""" + if not (repo_dir / ".git").exists(): + return None + try: + out = subprocess.run( + ["git", "-C", str(repo_dir), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=REQUEST_TIMEOUT_SECONDS, + check=True, + ) + except Exception: + logger.debug("Could not read the checkout's commit", exc_info=True) + return None + return out.stdout.strip() or None + + +def fetch_main_update( + base: str, + timeout: float = REQUEST_TIMEOUT_SECONDS, + *, + api_url: str = COMPARE_API, +) -> UpdateInfo | None: + """How far GitHub's ``main`` is ahead of commit ``base``. + + Returns None on any failure, including a ``base`` GitHub has never seen + (an unpushed local commit), which GitHub answers with a 404. + """ + try: + response = httpx.get( + api_url.format(base=base), + timeout=timeout, + headers={"Accept": "application/vnd.github+json"}, + ) + response.raise_for_status() + data = response.json() + behind = int(data["ahead_by"]) # main is ahead of base by this many + commits = data.get("commits") or [] + head = commits[-1]["sha"] if commits else base + except Exception: + logger.debug("Update check failed", exc_info=True) + return None + + # Newest first, subject lines only — the dialog shows a handful. + subjects = [(c.get("commit", {}).get("message") or "").splitlines()[0:1] for c in commits] + notes = "\n".join(f"• {s[0]}" for s in reversed(subjects) if s) + return UpdateInfo( + latest_version=head, + release_url=data.get("html_url") or f"https://github.com/{OWNER}/{REPO}", + release_notes=notes, + commits_behind=behind, + ) + + +def fetch_update() -> UpdateInfo | None: + """Compare a checkout with ``main``; anything else with the latest release.""" + base = local_commit() + return fetch_main_update(base) if base else fetch_latest_release() + + +def pull_main(repo_dir: Path = REPO_DIR) -> tuple[bool, str]: + """``git pull --ff-only`` the checkout. Returns (ok, message for the user). + + Fast-forward only, so local changes or a diverged branch make it refuse + rather than merge — the user's checkout is never left mid-merge. + """ + try: + before = local_commit(repo_dir) + out = subprocess.run( + ["git", "-C", str(repo_dir), "pull", "--ff-only", "origin", BRANCH], + capture_output=True, + text=True, + timeout=120, + ) + except Exception as exc: + return False, str(exc) + if out.returncode != 0: + return False, (out.stderr or out.stdout).strip() + changed = subprocess.run( + ["git", "-C", str(repo_dir), "diff", "--name-only", f"{before}..HEAD"], + capture_output=True, + text=True, + ).stdout.split() + # ponytail: dependencies are only flagged, not installed — reinstalling + # needs the extras the installer was run with, which we don't record. + if "pyproject.toml" in changed or "uv.lock" in changed: + return True, ( + "GLIDER was updated, and its dependencies changed.\n" + "Run the installer again (with the same options) before restarting." + ) + return True, "GLIDER was updated. Restart it to use the new version." + + +def update_available(info: UpdateInfo, current_version: str) -> bool: + """Whether ``info`` describes something newer than what is running.""" + if info.commits_behind is not None: + return info.commits_behind > 0 + return is_newer(info.latest_version, current_version) + + # --- Qt plumbing ------------------------------------------------------------- @@ -147,7 +261,7 @@ class _UpdateFetcher(QThread): result = pyqtSignal(object) # emits UpdateInfo | None def run(self) -> None: # pragma: no cover — thin wrapper; logic in fetch_latest_release - self.result.emit(fetch_latest_release()) + self.result.emit(fetch_update()) class UpdateChecker(QObject): @@ -215,12 +329,17 @@ def _on_result(self, info: UpdateInfo | None) -> None: ) return - if not is_newer(info.latest_version, self._current_version): + if not update_available(info, self._current_version): if not self._silent: + latest = ( + f"has everything on {BRANCH}" + if info.commits_behind is not None + else "is the latest release" + ) QMessageBox.information( self._parent_widget, "Check for Updates", - f"You're up to date — GLIDER {self._current_version} " "is the latest release.", + f"You're up to date — GLIDER {self._current_version} {latest}.", ) return @@ -240,17 +359,24 @@ def _show_prompt(self, info: UpdateInfo) -> None: box = QMessageBox(self._parent_widget) box.setWindowTitle("GLIDER Update Available") box.setIcon(QMessageBox.Icon.Information) - box.setText( - f"GLIDER {info.latest_version} is available.\n" - f"You have version {self._current_version}." - ) + from_main = info.commits_behind is not None + if from_main: + n = info.commits_behind + box.setText(f"{n} new change{'s' if n != 1 else ''} on {BRANCH} since your copy.") + else: + box.setText( + f"GLIDER {info.latest_version} is available.\n" + f"You have version {self._current_version}." + ) if info.release_notes: # Trim to a handful of lines — full release notes can be very # long and we don't want the dialog to grow without bound. snippet_lines = info.release_notes.splitlines()[:8] box.setInformativeText("\n".join(snippet_lines)) - download_btn = box.addButton("Download", QMessageBox.ButtonRole.AcceptRole) + download_btn = box.addButton( + "Update now" if from_main else "Download", QMessageBox.ButtonRole.AcceptRole + ) later_btn = box.addButton("Later", QMessageBox.ButtonRole.RejectRole) skip_btn = box.addButton("Don't ask again", QMessageBox.ButtonRole.DestructiveRole) box.setDefaultButton(download_btn) @@ -258,7 +384,17 @@ def _show_prompt(self, info: UpdateInfo) -> None: box.exec() clicked = box.clickedButton() - if clicked is download_btn: + if clicked is download_btn and from_main: + ok, message = pull_main() + if ok: + QMessageBox.information(self._parent_widget, "GLIDER Updated", message) + else: + QMessageBox.warning( + self._parent_widget, + "Update Failed", + f"Could not pull {BRANCH}:\n\n{message}", + ) + elif clicked is download_btn: QDesktopServices.openUrl(QUrl(info.release_url)) elif clicked is skip_btn: self._settings.setValue(SKIPPED_VERSION_KEY, info.latest_version) diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index d90cb86a..5a954424 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -20,8 +20,12 @@ UpdateChecker, UpdateInfo, fetch_latest_release, + fetch_main_update, + fetch_update, is_newer, + local_commit, parse_version, + pull_main, ) # --- Pure logic -------------------------------------------------------------- @@ -134,6 +138,114 @@ def test_missing_tag_returns_none(self) -> None: assert fetch_latest_release() is None +class TestFetchMainUpdate: + def test_counts_commits_main_is_ahead(self) -> None: + payload = { + "ahead_by": 2, + "html_url": "https://github.com/LaingLab/glider/compare/abc...main", + "commits": [ + {"sha": "c1", "commit": {"message": "feat: one\n\nbody"}}, + {"sha": "c2", "commit": {"message": "fix: two"}}, + ], + } + with patch("glider.updater.httpx.get", return_value=_make_response(200, payload)) as get: + info = fetch_main_update("abc") + assert "compare/abc...main" in get.call_args.args[0] + assert info is not None + assert info.commits_behind == 2 + assert info.latest_version == "c2" # the commit main is at + assert info.release_notes == "• fix: two\n• feat: one" # newest first, subjects + + def test_up_to_date(self) -> None: + payload = {"ahead_by": 0, "commits": []} + with patch("glider.updater.httpx.get", return_value=_make_response(200, payload)): + info = fetch_main_update("abc") + assert info is not None and info.commits_behind == 0 + + def test_unknown_commit_returns_none(self) -> None: + # An unpushed local commit: GitHub 404s the compare. + with patch("glider.updater.httpx.get", return_value=_make_response(404)): + assert fetch_main_update("abc") is None + + +class TestCheckoutDetection: + def test_not_a_checkout(self, tmp_path) -> None: + assert local_commit(tmp_path) is None + + def test_this_checkout_has_a_commit(self) -> None: + # The suite runs from a checkout, so the real one resolves. + assert local_commit() is not None + + def test_prebuilt_app_falls_back_to_releases(self) -> None: + with ( + patch("glider.updater.local_commit", return_value=None), + patch("glider.updater.fetch_latest_release", return_value="release") as rel, + ): + assert fetch_update() == "release" + rel.assert_called_once() + + def test_checkout_compares_with_main(self) -> None: + with ( + patch("glider.updater.local_commit", return_value="abc"), + patch("glider.updater.fetch_main_update", return_value="main") as main, + ): + assert fetch_update() == "main" + main.assert_called_once_with("abc") + + +def _git(repo, *args) -> str: + import subprocess + + return subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=True + ).stdout.strip() + + +class TestPullMain: + """Real git against a local "origin" — no network.""" + + @pytest.fixture + def clone(self, tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + _git(origin, "init", "-q", "-b", "main") + _git(origin, "config", "user.email", "t@t") + _git(origin, "config", "user.name", "t") + (origin / "a.txt").write_text("1") + _git(origin, "add", ".") + _git(origin, "commit", "-qm", "one") + _git(tmp_path, "clone", "-q", str(origin), "clone") + return origin, tmp_path / "clone" + + def _commit(self, origin, name, text) -> None: + (origin / name).write_text(text) + _git(origin, "add", ".") + _git(origin, "commit", "-qm", name) + + def test_fast_forwards(self, clone) -> None: + origin, local = clone + self._commit(origin, "a.txt", "2") + ok, message = pull_main(local) + assert ok and "Restart" in message + assert (local / "a.txt").read_text() == "2" + + def test_flags_dependency_changes(self, clone) -> None: + origin, local = clone + self._commit(origin, "pyproject.toml", "[project]") + ok, message = pull_main(local) + assert ok and "installer" in message + + def test_refuses_to_merge_a_diverged_checkout(self, clone) -> None: + origin, local = clone + self._commit(origin, "a.txt", "2") + _git(local, "config", "user.email", "t@t") + _git(local, "config", "user.name", "t") + self._commit(local, "b.txt", "local work") + ok, _ = pull_main(local) + assert not ok + assert (local / "a.txt").read_text() == "1" # untouched + + # --- Qt controller ----------------------------------------------------------- @@ -245,6 +357,23 @@ def test_skipped_version_suppresses_only_that_version( assert len(shown) == 1 + def test_checkout_behind_main_prompts(self, qapp, isolated_settings, monkeypatch) -> None: + checker = UpdateChecker(parent=None, current_version="1.0.0", settings=isolated_settings) + shown = [] + monkeypatch.setattr(UpdateChecker, "_show_prompt", lambda self, info: shown.append(info)) + checker._silent = True + # Same version string: only the commit count matters for a checkout. + checker._on_result(UpdateInfo("c2", "https://x", "", commits_behind=3)) + assert len(shown) == 1 + + def test_checkout_level_with_main_is_quiet(self, qapp, isolated_settings, monkeypatch) -> None: + checker = UpdateChecker(parent=None, current_version="1.0.0", settings=isolated_settings) + shown = [] + monkeypatch.setattr(UpdateChecker, "_show_prompt", lambda self, info: shown.append(info)) + checker._silent = True + checker._on_result(UpdateInfo("c2", "https://x", "", commits_behind=0)) + assert shown == [] + def test_network_failure_silent_mode_stays_quiet( self, qapp, isolated_settings, monkeypatch ) -> None: