"""Is a newer version published? Notify, never install. Omarchy checks itself for updates (`omarchy-update-available` looks at its own checkout and its package) but nothing checks plugins, so a user can sit on an old Nexthop indefinitely without ever being told. This closes that gap the smallest way it can be closed. **This module never updates anything.** It answers one question — is the installed checkout behind its origin — and the answer becomes a quiet glyph in the panel naming the command the user can run. Updating stays where Omarchy put it: `omarchy plugin update`, which shows the diff and asks. A plugin that fetched and ran new code would be self-modifying code inside a system that deliberately gates updates behind human review, and it would reopen a security review that took four rounds to clear. How it stays cheap and read-only: * `git ls-remote` asks the remote for its HEAD without writing a single byte into the user's checkout — no fetch, no new objects, no refs touched. It takes well under a second and needs no credentials. * Whether we are *behind* rather than merely *different* is then decided from objects we already have, so a developer checkout that is ahead of origin is never nagged. * Egress is to the repository the user installed from and nowhere else. It carries nothing about them or their network. It is still egress, so it is disclosed and `updateCheck` turns it off. The one piece of untrusted input here is the commit id the remote hands back, and it goes on to be an argument to another `git` call — so it is validated against the exact 40-hex shape before it reaches a subprocess, the same doctrine `vet_target()` applies to URLs we did not choose. """ import os import re import shutil import subprocess import threading from pathlib import Path # A git object id and nothing else. This is the guard that matters: the value # arrives from the network and is then passed as an argument to git. RE_SHA = re.compile(r"^[0-9a-f]{40}$") # A release is a rare event, and the check exists to catch the user who would # otherwise never look. Daily is generous. CHECK_INTERVAL_S = 24 * 3600 # Not at startup: the daemon restarts with the shell, and a check on every # restart would be noise for no benefit. Nothing is lost by waiting. FIRST_CHECK_DELAY_S = 300 # The remote may be slow, unreachable, or a captive portal that answers # everything. None of those may stall the daemon. GIT_TIMEOUT_S = 20 # `ls-remote` prints one short line per ref; this is far past HEAD alone. MAX_LS_REMOTE_BYTES = 64 * 1024 def verdict(local: str, remote: str, have_remote: bool, head_before_remote: bool, remote_before_head: bool) -> str: """Where the checkout stands relative to origin. Pure, so it is testable. * `current` — the same commit. * `behind` — origin is strictly ahead of us: an update. * `ahead` — we are strictly ahead of origin: a dev checkout. * `diverged` — neither contains the other. * `unknown` — we could not tell, and say so rather than guessing. Both ancestry directions are needed, and the reason is the likeliest case of all: `omarchy plugin update` fetches before it shows its diff, so a user who looked and said "not now" already *holds* origin's commit while still being behind it. Deciding "behind" from "we have never seen that object" alone would show that user nothing. """ if not local or not remote: return "unknown" if local == remote: return "current" if not have_remote: # We do not hold origin's commit at all, so it is newer than anything # we know about. return "behind" if head_before_remote: return "behind" if remote_before_head: return "ahead" return "diverged" class UpdateWatch: """Asks origin, on a slow cadence, whether this checkout is behind. The result lives in memory only. A daemon restart forgets it and waits `FIRST_CHECK_DELAY_S` before asking again, which is why no fifth state file was added for this. """ def __init__(self, repo: Path = None, enabled: bool = True, spawn=None): # Derived from this file, not from the working directory: the daemon # can be started from anywhere. self.repo = Path(repo) if repo else Path(__file__).resolve().parent.parent self.enabled = enabled self.state = "unknown" self.checked_ts = None self._next = None # How a check is run. Off the loop by default: `ls-remote` may sit # at its 20 s timeout on exactly the flaky network where the # outage watch matters, and the loop must not wait for it. Tests # pass a synchronous spawn so the verdict lands within the tick. self._spawn = spawn or self._in_thread self._lock = threading.Lock() self._inflight = False self._pending = None # a verdict awaiting the next tick @staticmethod def _in_thread(fn): threading.Thread(target=fn, name="update-check", daemon=True).start() def _git(self, *args, capture: bool = True): """One git call. Fixed argv, no shell, bounded, always timed out.""" env = dict(os.environ) # A credential prompt on a private or moved remote would otherwise # block until the timeout every single time. env["GIT_TERMINAL_PROMPT"] = "0" env.pop("GIT_ASKPASS", None) env.pop("SSH_ASKPASS", None) # `git -C