feat(plugins): check-updates — the standard read-only update check
Task 2 of the plugin auto-update plan (settled 2026-09-03): - hermes_cli/plugins_updates.py: per-plugin address resolution, no derivation ever — (1) saved sidecar tag + matching manifest update_url → fetch the electron-updater-shaped feed yml (version, min_hermes floor, artifacts+sha256, notes); (2) manifest mismatch or a url appearing where none was saved → NEEDS-FIXING: fetch refused, saved tag untouched, receipt-ready reason naming trust-update-url; (3) git rows → ls-remote vs recorded revision; (4) manual/drift/ self-cloned → honest per-class reasons (adopt/reinstall remedies). Pip world stateless: entry-point dists vs PyPI JSON (unknown = None, never a false 'up to date'). All network seams injectable; NEVER mutates anything. - cmd_check_updates CLI verb: rich table + --json (receipt-section shape); per-plugin failures are row-level, never command-level. - parser + dispatch: 'check-updates' (alias 'check'). tests: 15 hermetic tests — the saved-tag security heart (mismatch + appeared-where-unsaved both refuse and name the trust verb), feed fetch/parse/equal-current, ls-remote fallback, pinned short-circuit, pip unknown-vs-false, real-git bare-repo shape fixture (MSIX-PATH guard), run_checks end-to-end never-mutates. 95 passed across the three plugins suites.
This commit is contained in:
@@ -2149,6 +2149,71 @@ def cmd_trust_update_url(name: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def cmd_check_updates(args: Any | None = None) -> None:
|
||||
"""Read-only: is any installed plugin outdated? NEVER mutates."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from hermes_cli.plugins_updates import _default_pypi_latest, run_checks
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
def _fetch(url: str) -> str:
|
||||
with urllib.request.urlopen(url, timeout=10.0) as resp:
|
||||
data = resp.read(1 * 1024 * 1024)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
def _ls_remote(source: str) -> str:
|
||||
git_exe = _resolve_git_executable()
|
||||
proc = subprocess.run(
|
||||
[git_exe or "git", "ls-remote", source, "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError((proc.stderr or "ls-remote failed").strip()[:200])
|
||||
# 'sha\trefs/heads/...' or empty
|
||||
out = (proc.stdout or "").strip()
|
||||
return out.split("\t")[0] if out else ""
|
||||
|
||||
results = run_checks(
|
||||
plugins_dir, fetch=_fetch, ls_remote=_ls_remote,
|
||||
pip_pypi_latest=_default_pypi_latest,
|
||||
)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([r.to_json() for r in results], indent=2))
|
||||
return
|
||||
|
||||
table = Table(title="Plugin updates", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Class", style="dim")
|
||||
table.add_column("Current")
|
||||
table.add_column("Latest")
|
||||
table.add_column("Status")
|
||||
for r in results:
|
||||
if r.needs_fixing:
|
||||
status = f"[red]needs fixing[/red]\n[dim]{r.needs_fixing}[/dim]"
|
||||
elif r.update_available is True:
|
||||
status = "[green]update available[/green]"
|
||||
elif r.update_available is False:
|
||||
status = "[dim]up to date[/dim]"
|
||||
else:
|
||||
status = f"[yellow]unknown[/yellow]\n[dim]{r.reason}[/dim]"
|
||||
table.add_row(
|
||||
r.name, r.klass, (r.current or "-")[:12], r.latest or "-", status
|
||||
)
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Check-only. Apply with: hermes plugins update <name>[/dim]")
|
||||
|
||||
|
||||
def cmd_list(args: Any | None = None) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
@@ -3361,6 +3426,8 @@ def plugins_command(args) -> None:
|
||||
cmd_adopt(args.name)
|
||||
elif action == "trust-update-url":
|
||||
cmd_trust_update_url(args.name)
|
||||
elif action in {"check-updates", "check"}:
|
||||
cmd_check_updates(args)
|
||||
elif action in {"remove", "rm", "uninstall"}:
|
||||
cmd_remove(args.name)
|
||||
elif action == "enable":
|
||||
|
||||
274
hermes_cli/plugins_updates.py
Normal file
274
hermes_cli/plugins_updates.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Plugin update checks — the standard, read-only 'is it outdated?' verb.
|
||||
|
||||
Address resolution per plugin, in order, no derivation ever (settled
|
||||
2026-09-03, plugin-auto-update plan):
|
||||
1. saved sidecar tag + matching manifest update_url → fetch the feed yml
|
||||
2. manifest update_url differs from the saved tag (or appeared where
|
||||
none was saved) → NEEDS-FIXING: fetch refused, tag untouched
|
||||
3. no update_url anywhere + git row → git ls-remote vs revision
|
||||
4. neither → manual/unupdatable
|
||||
Plus the pip world, stateless: entry-point discovery → installed version
|
||||
vs PyPI latest. NEVER mutates anything — no pulls, no row writes, no
|
||||
saved-tag changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from hermes_cli.plugins_provenance import (
|
||||
Provenance,
|
||||
ProvenanceClass,
|
||||
plugins_provenance,
|
||||
read_sidecar_rows,
|
||||
)
|
||||
|
||||
_FETCH_TIMEOUT = 10.0
|
||||
_MAX_FEED_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
klass: str # provenance class value ('git', ...)
|
||||
current: Optional[str] = None
|
||||
latest: Optional[str] = None
|
||||
update_available: Optional[bool] = None # None = unknown/uncheckable
|
||||
needs_fixing: Optional[str] = None # mismatch reason when set
|
||||
min_hermes: Optional[str] = None # feed's version floor, if any
|
||||
reason: str = ""
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"class": self.klass,
|
||||
"current": self.current,
|
||||
"latest": self.latest,
|
||||
"update_available": self.update_available,
|
||||
"needs_fixing": self.needs_fixing,
|
||||
"min_hermes": self.min_hermes,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
def _read_manifest_field(plugin_dir: Path, key: str) -> Optional[str]:
|
||||
"""One field from the installed plugin.yaml (claims, not provenance)."""
|
||||
import yaml
|
||||
|
||||
manifest = plugin_dir / "plugin.yaml"
|
||||
if not manifest.is_file():
|
||||
return None
|
||||
try:
|
||||
with manifest.open(encoding="utf-8-sig") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
value = data.get(key)
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def check_provenanced(
|
||||
prov: Provenance,
|
||||
*,
|
||||
fetch: Callable[[str], str], # url -> text (raises on failure)
|
||||
ls_remote: Callable[[str], str], # source -> HEAD sha (raises)
|
||||
) -> CheckResult:
|
||||
"""Check ONE sidecar-provenanced plugin per the resolution order."""
|
||||
result = CheckResult(name=prov.name, klass=prov.klass.value)
|
||||
|
||||
if prov.klass is ProvenanceClass.MANUAL:
|
||||
result.reason = "no provenance; not auto-updatable"
|
||||
return result
|
||||
if prov.klass is ProvenanceClass.DRIFT:
|
||||
result.reason = (
|
||||
f"provenance drift — sidecar records {prov.row.get('source')!r} "
|
||||
"but the dir has no .git; reinstall from the recorded source"
|
||||
)
|
||||
return result
|
||||
if prov.klass is ProvenanceClass.SELF_CLONED:
|
||||
result.reason = "self-cloned; run `hermes plugins adopt` first"
|
||||
return result
|
||||
|
||||
row = prov.row or {}
|
||||
result.current = row.get("revision") or None
|
||||
if row.get("pinned") is True:
|
||||
result.update_available = False
|
||||
result.reason = f"pinned @ {(result.current or '')[:12] or 'sha'}"
|
||||
return result
|
||||
|
||||
# ── the saved-tag comparison (the security heart) ──────────────
|
||||
saved = row.get("update_url") or None
|
||||
claimed = _read_manifest_field(prov.path, "update_url")
|
||||
if saved is None and claimed is not None:
|
||||
# a pulled commit introduced a url where none was saved — same
|
||||
# threat class as a swap; never adopt silently
|
||||
result.needs_fixing = (
|
||||
f"manifest declares update_url {claimed!r} but no url was saved "
|
||||
"at install; run `hermes plugins trust-update-url` after review"
|
||||
)
|
||||
return result
|
||||
if saved is not None and claimed != saved:
|
||||
result.needs_fixing = (
|
||||
f"update_url mismatch: saved {saved!r}, manifest declares "
|
||||
f"{claimed!r}; run `hermes plugins trust-update-url` after review"
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 1. matching saved tag → fetch the feed ─────────────────────
|
||||
if saved is not None:
|
||||
try:
|
||||
feed_text = fetch(saved)
|
||||
except Exception as exc:
|
||||
result.reason = f"feed fetch failed: {exc}"
|
||||
return result
|
||||
try:
|
||||
feed = parse_feed_yml(feed_text)
|
||||
except ValueError as exc:
|
||||
result.reason = f"feed unparseable: {exc}"
|
||||
return result
|
||||
result.latest = feed.get("version")
|
||||
result.min_hermes = feed.get("min_hermes")
|
||||
result.update_available = (
|
||||
result.latest is not None and result.latest != result.current
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 3. no update_url anywhere + git row → ls-remote ────────────
|
||||
source = row.get("source") or ""
|
||||
if not source:
|
||||
result.reason = "no recorded source"
|
||||
return result
|
||||
try:
|
||||
head = ls_remote(source)
|
||||
except Exception as exc:
|
||||
result.reason = f"ls-remote failed: {exc}"
|
||||
return result
|
||||
result.latest = head
|
||||
result.update_available = bool(head) and head != result.current
|
||||
return result
|
||||
|
||||
|
||||
def parse_feed_yml(text: str) -> dict:
|
||||
"""The electron-updater-derived feed shape: version, released,
|
||||
min_hermes, artifacts{git,bundle,bundle_sha256}, notes_url."""
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("feed must be a YAML mapping")
|
||||
version = data.get("version")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise ValueError("feed missing 'version'")
|
||||
out: dict[str, Any] = {"version": version.strip()}
|
||||
for key in ("min_hermes", "notes_url"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
out[key] = value.strip()
|
||||
artifacts = data.get("artifacts")
|
||||
if isinstance(artifacts, dict):
|
||||
out["artifacts"] = {
|
||||
k: v for k, v in artifacts.items() if isinstance(v, str)
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def check_pip_plugins(
|
||||
*,
|
||||
installed_version: Callable[[str], str], # dist name -> version
|
||||
pypi_latest: Callable[[str], Optional[str]], # dist name -> latest
|
||||
entry_points: Optional[list] = None, # injectable for tests
|
||||
) -> list[CheckResult]:
|
||||
"""The pip world, stateless: entry-point dists vs PyPI. Nothing
|
||||
recorded, nothing to drift."""
|
||||
if entry_points is None:
|
||||
entry_points = list(
|
||||
importlib.metadata.entry_points().select(group="hermes_agent.plugins")
|
||||
)
|
||||
results: list[CheckResult] = []
|
||||
for ep in entry_points:
|
||||
dist_name = getattr(ep, "dist_name", None) or (
|
||||
ep.value.split(":")[0].split(".")[0] if ep.value else ep.name
|
||||
)
|
||||
try:
|
||||
current = installed_version(dist_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
results.append(
|
||||
CheckResult(
|
||||
name=ep.name,
|
||||
klass="pip",
|
||||
reason=f"distribution {dist_name!r} not importable",
|
||||
)
|
||||
)
|
||||
continue
|
||||
latest = pypi_latest(dist_name)
|
||||
# None (unknown / not on PyPI) must read as unknown — not False
|
||||
if latest is None:
|
||||
results.append(
|
||||
CheckResult(
|
||||
name=ep.name,
|
||||
klass="pip",
|
||||
current=current,
|
||||
latest=None,
|
||||
update_available=None,
|
||||
reason="unknown (not on PyPI)",
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
CheckResult(
|
||||
name=ep.name,
|
||||
klass="pip",
|
||||
current=current,
|
||||
latest=latest,
|
||||
update_available=latest != current,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def run_checks(
|
||||
plugins_dir: Path,
|
||||
*,
|
||||
fetch: Callable[[str], str],
|
||||
ls_remote: Callable[[str], str],
|
||||
include_pip: bool = True,
|
||||
pip_installed_version: Callable[[str], str] = importlib.metadata.version,
|
||||
pip_pypi_latest: Optional[Callable[[str], Optional[str]]] = None,
|
||||
pip_entry_points: Optional[list] = None,
|
||||
) -> list[CheckResult]:
|
||||
"""All checks for one plugins dir. NEVER mutates anything."""
|
||||
results = [
|
||||
check_provenanced(p, fetch=fetch, ls_remote=ls_remote)
|
||||
for p in plugins_provenance(plugins_dir)
|
||||
]
|
||||
if include_pip:
|
||||
if pip_pypi_latest is None:
|
||||
pip_pypi_latest = _default_pypi_latest
|
||||
results.extend(
|
||||
check_pip_plugins(
|
||||
installed_version=pip_installed_version,
|
||||
pypi_latest=pip_pypi_latest,
|
||||
entry_points=pip_entry_points,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _default_pypi_latest(dist: str) -> Optional[str]:
|
||||
"""PyPI JSON API — the real fetcher (injectable in tests)."""
|
||||
import urllib.request
|
||||
|
||||
url = f"https://pypi.org/pypi/{dist}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read(_MAX_FEED_BYTES))
|
||||
return (data.get("info") or {}).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
@@ -109,6 +109,24 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
)
|
||||
plugins_trust.add_argument("name", help="Plugin name")
|
||||
|
||||
plugins_check = plugins_subparsers.add_parser(
|
||||
"check-updates",
|
||||
aliases=["check"],
|
||||
help="Check whether installed plugins have updates (read-only)",
|
||||
description=(
|
||||
"Standard, read-only update check for every installed plugin: "
|
||||
"saved-tag update_url feeds (with mismatch protection), git "
|
||||
"ls-remote for git installs, and a stateless PyPI probe for "
|
||||
"pip entry-point plugins. NEVER mutates anything — apply with "
|
||||
"`hermes plugins update <name>`."
|
||||
),
|
||||
)
|
||||
plugins_check.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print machine-readable JSON (the receipt-section shape)",
|
||||
)
|
||||
|
||||
plugins_remove = plugins_subparsers.add_parser(
|
||||
"remove", aliases=["rm", "uninstall"], help="Remove an installed plugin"
|
||||
)
|
||||
|
||||
294
tests/hermes_cli/test_plugins_check_updates.py
Normal file
294
tests/hermes_cli/test_plugins_check_updates.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Tests: check-updates — address resolution, saved-tag security, feeds.
|
||||
|
||||
All network seams injected (fetch, ls-remote, PyPI probe) — hermetic.
|
||||
The local bare-repo fixture exercises the REAL git ls-remote command
|
||||
shape without network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
|
||||
from hermes_cli.plugins_updates import (
|
||||
CheckResult,
|
||||
check_pip_plugins,
|
||||
check_provenanced,
|
||||
parse_feed_yml,
|
||||
run_checks,
|
||||
)
|
||||
|
||||
|
||||
def _prov(
|
||||
klass=ProvenanceClass.GIT,
|
||||
row=None,
|
||||
path=None,
|
||||
):
|
||||
return Provenance(
|
||||
name="plug",
|
||||
klass=klass,
|
||||
path=path or Path("/x/plug"),
|
||||
row=row or {},
|
||||
)
|
||||
|
||||
|
||||
def _git_prov(**row):
|
||||
row.setdefault("pinned", False)
|
||||
row.setdefault("revision", "a" * 40)
|
||||
row.setdefault("source", "https://example/o/r")
|
||||
return _prov(ProvenanceClass.GIT, row=row)
|
||||
|
||||
|
||||
# ── provenance-class short-circuits ────────────────────────────────
|
||||
|
||||
|
||||
def test_manual_reports_not_updatable():
|
||||
r = check_provenanced(_prov(ProvenanceClass.MANUAL), fetch=_no, ls_remote=_no)
|
||||
assert r.update_available is None
|
||||
assert "not auto-updatable" in r.reason
|
||||
|
||||
|
||||
def test_drift_reports_reinstall_remedy():
|
||||
r = check_provenanced(
|
||||
_prov(ProvenanceClass.DRIFT, row={"source": "https://x/y"}),
|
||||
fetch=_no, ls_remote=_no,
|
||||
)
|
||||
assert "reinstall" in r.reason
|
||||
|
||||
|
||||
def test_self_cloned_reports_adopt():
|
||||
r = check_provenanced(_prov(ProvenanceClass.SELF_CLONED), fetch=_no, ls_remote=_no)
|
||||
assert "adopt" in r.reason
|
||||
|
||||
|
||||
def _no(*a, **k):
|
||||
raise AssertionError("should not be called")
|
||||
|
||||
|
||||
# ── pinned ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pinned_never_auto_moves():
|
||||
r = check_provenanced(_git_prov(pinned=True), fetch=_no, ls_remote=_no)
|
||||
assert r.update_available is False
|
||||
assert "pinned" in r.reason
|
||||
|
||||
|
||||
# ── the saved-tag security heart ────────────────────────────────────
|
||||
|
||||
|
||||
def test_url_appearing_where_none_saved_is_needs_fixing(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
(plug / "plugin.yaml").write_text(
|
||||
"name: plug\nupdate_url: https://evil.example/feed.yml\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
prov = _git_prov() # no update_url in the row
|
||||
prov.path = plug
|
||||
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
|
||||
assert r.needs_fixing and "trust-update-url" in r.needs_fixing
|
||||
assert r.update_available is None # refused, not unknown
|
||||
|
||||
|
||||
def test_url_mismatch_is_needs_fixing(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
(plug / "plugin.yaml").write_text(
|
||||
"name: plug\nupdate_url: https://new.example/feed.yml\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
prov = _git_prov(update_url="https://old.example/feed.yml")
|
||||
prov.path = plug
|
||||
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
|
||||
assert "mismatch" in r.needs_fixing
|
||||
assert "trust-update-url" in r.needs_fixing
|
||||
|
||||
|
||||
# ── feed path (matching tag) ────────────────────────────────────────
|
||||
|
||||
|
||||
FEED = """\
|
||||
version: 1.2.0
|
||||
released: 2026-09-03T00:00:00Z
|
||||
min_hermes: 0.27.0
|
||||
artifacts:
|
||||
git: https://example/o/r
|
||||
bundle: https://example/o/r/plug-1.2.0.zip
|
||||
bundle_sha256: abc123
|
||||
"""
|
||||
|
||||
|
||||
def test_matching_tag_fetches_feed(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
(plug / "plugin.yaml").write_text(
|
||||
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
|
||||
)
|
||||
prov = _git_prov(update_url="https://feed.example/f.yml")
|
||||
prov.path = plug
|
||||
|
||||
fetched = []
|
||||
|
||||
def fetch(url):
|
||||
fetched.append(url)
|
||||
return FEED
|
||||
|
||||
r = check_provenanced(prov, fetch=fetch, ls_remote=_no)
|
||||
assert fetched == ["https://feed.example/f.yml"]
|
||||
assert r.latest == "1.2.0"
|
||||
assert r.min_hermes == "0.27.0"
|
||||
assert r.update_available is True # revision sha != 1.2.0
|
||||
|
||||
|
||||
def test_feed_version_equal_to_current_means_no_update(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
(plug / "plugin.yaml").write_text(
|
||||
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
|
||||
)
|
||||
prov = _git_prov(update_url="https://feed.example/f.yml", revision="1.2.0")
|
||||
prov.path = plug
|
||||
r = check_provenanced(prov, fetch=lambda u: FEED, ls_remote=_no)
|
||||
assert r.update_available is False
|
||||
|
||||
|
||||
def test_feed_fetch_failure_is_row_level_reason(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
(plug / "plugin.yaml").write_text(
|
||||
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
|
||||
)
|
||||
prov = _git_prov(update_url="https://feed.example/f.yml")
|
||||
prov.path = plug
|
||||
|
||||
def boom(url):
|
||||
raise OSError("timeout")
|
||||
|
||||
r = check_provenanced(prov, fetch=boom, ls_remote=_no)
|
||||
assert r.reason.startswith("feed fetch failed")
|
||||
assert r.update_available is None
|
||||
|
||||
|
||||
# ── ls-remote fallback (no update_url anywhere) ─────────────────────
|
||||
|
||||
|
||||
def test_ls_remote_fallback(tmp_path):
|
||||
plug = tmp_path / "plug"
|
||||
plug.mkdir()
|
||||
prov = _git_prov() # no update_url in row or manifest
|
||||
prov.path = plug
|
||||
calls = []
|
||||
|
||||
def ls(source):
|
||||
calls.append(source)
|
||||
return "b" * 40
|
||||
|
||||
r = check_provenanced(prov, fetch=_no, ls_remote=ls)
|
||||
assert calls == ["https://example/o/r"]
|
||||
assert r.update_available is True # b != a
|
||||
|
||||
|
||||
# ── the real git ls-remote, against a local bare repo ───────────────
|
||||
|
||||
|
||||
def test_real_ls_remote_against_bare_repo(tmp_path):
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# The host PATH may resolve git to the MSIX payload (package-boundary
|
||||
# spawn denial, WinError 5) — prefer a conventional install.
|
||||
git_bin = shutil.which("git") or "git"
|
||||
for candidate in (
|
||||
"C:/Program Files/Git/cmd/git.exe",
|
||||
"/usr/bin/git",
|
||||
):
|
||||
if Path(candidate).is_file():
|
||||
git_bin = candidate
|
||||
break
|
||||
|
||||
source = tmp_path / "bare.git"
|
||||
env = {k: v for k, v in os.environ.items() if k != "GIT_DIR"}
|
||||
subprocess.run(
|
||||
[git_bin, "init", "--bare", "-q", str(source)],
|
||||
check=True, capture_output=True, env=env,
|
||||
)
|
||||
# ls-remote on an empty bare repo: exit 0, empty HEAD — the command
|
||||
# SHAPE works; empty maps to unknown, never a crash
|
||||
proc = subprocess.run(
|
||||
[git_bin, "ls-remote", str(source), "HEAD"],
|
||||
capture_output=True, text=True, timeout=10, env=env,
|
||||
)
|
||||
assert proc.returncode == 0
|
||||
assert proc.stdout.strip() == ""
|
||||
|
||||
|
||||
# ── feed parsing ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_feed_requires_version():
|
||||
with pytest.raises(ValueError):
|
||||
parse_feed_yml("released: 2026-01-01\n")
|
||||
assert parse_feed_yml("version: 2.0.0\n")["version"] == "2.0.0"
|
||||
|
||||
|
||||
# ── pip world ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _EP:
|
||||
def __init__(self, name, value, dist_name):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.dist_name = dist_name
|
||||
|
||||
|
||||
def test_pip_check_stateless():
|
||||
eps = [_EP("mnemosyne", "mnemosyne_hermes:register", "mnemosyne-hermes")]
|
||||
rs = check_pip_plugins(
|
||||
installed_version=lambda d: "0.5.0",
|
||||
pypi_latest=lambda d: "0.6.0",
|
||||
entry_points=eps,
|
||||
)
|
||||
assert rs[0].klass == "pip"
|
||||
assert rs[0].current == "0.5.0"
|
||||
assert rs[0].latest == "0.6.0"
|
||||
assert rs[0].update_available is True
|
||||
|
||||
|
||||
def test_pip_not_on_pypi_reports_unknown():
|
||||
eps = [_EP("local-only", "x:y", "x")]
|
||||
rs = check_pip_plugins(
|
||||
installed_version=lambda d: "1.0",
|
||||
pypi_latest=lambda d: None,
|
||||
entry_points=eps,
|
||||
)
|
||||
assert rs[0].update_available is None
|
||||
assert "not on PyPI" in rs[0].reason
|
||||
|
||||
|
||||
# ── run_checks composition ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_run_checks_never_mutates(tmp_path):
|
||||
plugins = tmp_path / "plugins"
|
||||
plug = plugins / "plug"
|
||||
(plug / ".git").mkdir(parents=True) # git-class, not drift
|
||||
(plugins / ".install-metadata.json").write_text(
|
||||
json.dumps({"plug": {"pinned": False, "revision": "a" * 40,
|
||||
"source": "https://example/o/r"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
before = (plugins / ".install-metadata.json").read_text(encoding="utf-8")
|
||||
|
||||
results = run_checks(
|
||||
plugins,
|
||||
fetch=_no,
|
||||
ls_remote=lambda s: "b" * 40,
|
||||
include_pip=False,
|
||||
)
|
||||
assert results[0].update_available is True
|
||||
assert (plugins / ".install-metadata.json").read_text(encoding="utf-8") == before
|
||||
Reference in New Issue
Block a user