_git_origin_url spawned a bare 'git' while every other plugin git call goes through _resolve_git_executable(); from a service with a minimal PATH (or Windows without Git on PATH) the probe failed where install and update succeed. No git at all now goes straight to the .git/config parse.
127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
"""Tests: plugin provenance reconciliation — the 2x2.
|
|
|
|
Row-presence says "hermes installed this"; `.git`-presence cross-checks
|
|
it. The reconciliation IS the disambiguation (settled 2026-09-03,
|
|
plugin-auto-update plan Task 1):
|
|
row+git = git install (updatable via the recorded source)
|
|
neither = manual drop (not auto-updatable, listed as such)
|
|
git only = self-cloned (adoptable: origin URL ready)
|
|
row only = provenance drift (flagged with a reinstall remedy)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.plugins_provenance import (
|
|
Provenance,
|
|
ProvenanceClass,
|
|
plugins_provenance,
|
|
)
|
|
|
|
|
|
def _write_sidecar(home: Path, rows: dict) -> None:
|
|
plugins = home / "plugins"
|
|
plugins.mkdir(parents=True, exist_ok=True)
|
|
(plugins / ".install-metadata.json").write_text(
|
|
json.dumps(rows, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _fake_git_dir(plugin_dir: Path, origin_url: str = "https://example/o/r") -> None:
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
git = plugin_dir / ".git"
|
|
git.mkdir()
|
|
# a real-enough git dir: remote config the adopt path reads
|
|
(git / "config").write_text(
|
|
f'[remote "origin"]\n\turl = {origin_url}\n', encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _manual_dir(plugin_dir: Path) -> None:
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
(plugin_dir / "plugin.yaml").write_text("name: x\n", encoding="utf-8")
|
|
|
|
|
|
def test_git_install_row_and_git(tmp_path):
|
|
_write_sidecar(tmp_path, {"plug": {"pinned": False, "revision": "a" * 40, "source": "https://example/o/r"}})
|
|
_fake_git_dir(tmp_path / "plugins" / "plug")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
assert len(prov) == 1
|
|
assert prov[0].klass is ProvenanceClass.GIT
|
|
assert prov[0].row is not None and prov[0].row["revision"] == "a" * 40
|
|
|
|
|
|
def test_manual_drop_neither(tmp_path):
|
|
_write_sidecar(tmp_path, {})
|
|
_manual_dir(tmp_path / "plugins" / "handmade")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
assert prov[0].klass is ProvenanceClass.MANUAL
|
|
|
|
|
|
def test_self_cloned_git_without_row(tmp_path):
|
|
_write_sidecar(tmp_path, {})
|
|
_fake_git_dir(tmp_path / "plugins" / "selfcloned", "https://example/m/r")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
assert prov[0].klass is ProvenanceClass.SELF_CLONED
|
|
assert prov[0].origin_url == "https://example/m/r"
|
|
|
|
|
|
def test_drift_row_without_git(tmp_path):
|
|
_write_sidecar(tmp_path, {"ghost": {"pinned": False, "revision": "b" * 40, "source": "https://example/g/r"}})
|
|
_manual_dir(tmp_path / "plugins" / "ghost")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
assert prov[0].klass is ProvenanceClass.DRIFT
|
|
assert prov[0].row["source"] == "https://example/g/r"
|
|
|
|
|
|
def test_missing_sidecar_is_all_manual_or_selfcloned(tmp_path):
|
|
# no sidecar at all: everything classifies from the dir alone
|
|
_fake_git_dir(tmp_path / "plugins" / "cloned")
|
|
_manual_dir(tmp_path / "plugins" / "dropped")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
classes = {p.name: p.klass for p in prov}
|
|
assert classes["cloned"] is ProvenanceClass.SELF_CLONED
|
|
assert classes["dropped"] is ProvenanceClass.MANUAL
|
|
|
|
|
|
def test_update_url_tag_round_trips(tmp_path):
|
|
# a saved update_url tag rides the row; the manifest is claims
|
|
_write_sidecar(
|
|
tmp_path,
|
|
{"plug": {"pinned": False, "revision": "c" * 40, "source": "https://example/o/r",
|
|
"update_url": "https://example/feed.yml"}},
|
|
)
|
|
_fake_git_dir(tmp_path / "plugins" / "plug")
|
|
prov = plugins_provenance(tmp_path / "plugins")
|
|
assert prov[0].saved_update_url == "https://example/feed.yml"
|
|
|
|
def test_origin_url_uses_the_resolved_git_and_survives_no_git_at_all(tmp_path, monkeypatch):
|
|
"""Provenance runs from the gateway service, where PATH can be minimal: the probe must use the
|
|
same resolved git as install/update, and with no git anywhere the .git/config parse still
|
|
yields the origin (never a bare ``git`` spawn that raises or picks up a stray binary)."""
|
|
import subprocess
|
|
from hermes_cli import plugins_cmd, plugins_provenance
|
|
|
|
plugin = tmp_path / "plugins" / "p"
|
|
plugin.mkdir(parents=True)
|
|
_fake_git_dir(plugin, "https://example/from-config")
|
|
spawned = []
|
|
real_run = subprocess.run
|
|
|
|
def record(argv, *a, **k):
|
|
spawned.append(argv[0])
|
|
return real_run(argv, *a, **k)
|
|
|
|
monkeypatch.setattr(subprocess, "run", record)
|
|
monkeypatch.setattr(plugins_cmd, "_resolve_git_executable", lambda: None)
|
|
assert plugins_provenance._git_origin_url(plugin) == "https://example/from-config"
|
|
assert spawned == []
|
|
|
|
monkeypatch.setattr(plugins_cmd, "_resolve_git_executable", lambda: "/nonexistent/resolved-git")
|
|
assert plugins_provenance._git_origin_url(plugin) == "https://example/from-config"
|
|
assert spawned == ["/nonexistent/resolved-git"]
|