Files
hermes-agent/hermes_cli/plugins_cmd_git.py
ethernet 1e76db800c refactor(cli): split plugins_cmd.py into topical siblings
plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move
each verb family into a plugins_cmd_<topic>.py sibling: git (install
metadata + git plumbing), install, update (plus adopt / trust-update-url /
check-updates), remove, capabilities, toggle (composite UI) and listing.
The facade keeps the shared primitives, enable/disable selection,
discovery and the dispatch table, and re-exports the names other modules,
tests and the old-updater surface import (978 lines now).

Siblings never import the facade at module level; they read facade names
through _pc() at call time, so monkeypatching plugins_cmd.<name> still
intercepts calls made from a sibling. No behaviour change. Tests that
imported three sibling-only helpers now import them from the defining
module, and two subprocess.run patches target subprocess directly.
2026-09-24 11:50:29 -04:00

369 lines
18 KiB
Python

"""Plugin install metadata (``.install-metadata.json`` source/revision/pin records) and the git plumbing
behind clone, exact-revision checkout, credential scrubbing and the autostashing ``git pull``.
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is
imported late here, never at module level).
"""
from __future__ import annotations
import json
import re
import subprocess
import threading
import urllib.parse
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, Optional
from hermes_cli._subprocess_compat import noninteractive_git_env
from hermes_constants import get_hermes_home
from utils import atomic_write_text
def _pc():
"""The facade, read at call time: tests patch ``plugins_cmd.<name>`` and sibling calls must see it."""
from hermes_cli import plugins_cmd
return plugins_cmd
_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$")
def _install_metadata_path() -> Path:
return get_hermes_home() / "plugins" / ".install-metadata.json"
def _read_install_metadata() -> dict[str, dict[str, object]]:
"""Read profile-local, non-secret plugin source metadata from disk."""
path = _install_metadata_path()
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise _pc().PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc
if not isinstance(value, dict):
raise _pc().PluginOperationError("Plugin install metadata must be a JSON object.")
return value
def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
"""Atomically replace the profile-local plugin install metadata sidecar."""
path = _install_metadata_path()
atomic_write_text(
path, json.dumps(metadata, indent=2, sort_keys=True) + "\n", tmp_prefix=f"{path.name}.tmp-")
_INSTALL_METADATA_LOCK_HOLDER = threading.local()
@contextmanager
def _install_metadata_lock():
"""Serialize read-modify-write of the sidecar across threads and processes. Installs overlap (the
Desktop install card runs its rows a second apart); each held a snapshot read before its clone, so
the later write dropped the earlier plugin's record."""
from hermes_cli.auth import _file_lock
path = _install_metadata_path()
with _file_lock(path.with_name(f"{path.name}.lock"), _INSTALL_METADATA_LOCK_HOLDER, 10.0,
"Timed out waiting for the plugin install metadata lock"):
yield
def _update_install_record(name: str, update: Callable[[Optional[dict]], Optional[dict]]) -> None:
"""Rewrite one plugin's record in the CURRENT sidecar, under the lock. *update* maps the current
record (None when absent) to the new one (None removes it); every other record is re-read here,
never carried over from a caller's earlier snapshot."""
with _install_metadata_lock():
metadata = _pc()._read_install_metadata()
record = update(metadata.get(name))
if record is None:
if name not in metadata:
return
del metadata[name]
else:
metadata[name] = record
_pc()._write_install_metadata(metadata)
def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]:
"""Full SHA a ``--ref`` install of *name* is pinned to, else ``None``."""
entry = (metadata if metadata is not None else _pc()._read_install_metadata()).get(name)
if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str):
return entry["revision"]
return None
def _pin_annotation(name: str, metadata: dict) -> Optional[str]:
sha = pinned_revision(name, metadata)
return f"git pinned@{sha[:8]}" if sha else None
def _normalize_exact_revision(ref: str) -> str:
"""Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError."""
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
raise _pc().PluginOperationError("--ref must be a full 40-character commit SHA.")
return ref.lower()
def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str:
"""Diagnosable Git output without echoing embedded credentials."""
from agent.redact import redact_sensitive_text
error = (result.stderr or result.stdout or "").strip()
if source_url:
error = error.replace(source_url, _scrub_git_url(source_url))
return redact_sensitive_text(error)
def _git_or_raise(
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "",
auth_url: str = "",
) -> subprocess.CompletedProcess:
"""Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error)."""
result = _pc()._run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url)
if result.returncode != 0:
raise _pc().PluginOperationError(failure_prefix + _safe_git_error(result, source_url))
return result
def _git_head_revision(repo: Path, git_exe: str) -> str:
return _git_or_raise(
git_exe, repo, "rev-parse", "HEAD", timeout=15,
failure_prefix="Could not determine installed Git revision:\n",
).stdout.strip().lower()
def _git_resolve_commit(repo: Path, git_exe: str, revision: str) -> str:
"""The COMMIT a revision names, peeling annotated tags.
A catalog pin is 40 hex, but that does not make it a commit: a tag object
has a sha of its own, and a pin recorded as `git rev-parse <tag>` names the
tag object, not the commit it points at. Git detaches at the commit, so
comparing HEAD against the tag object's sha refuses a correct checkout
(and the catalog installer then cannot install that entry at all). Peeling
first keeps the guard — HEAD must still BE that commit — while admitting
the pins authors actually publish. Returns `revision` unchanged when it
resolves to nothing, so the mismatch guard below still fires.
"""
try:
result = _pc()._run_plugin_git(
git_exe, repo, "rev-parse", "--verify", "--quiet", f"{revision}^{{commit}}", timeout=15,
)
except (OSError, subprocess.TimeoutExpired):
return revision
resolved = result.stdout.strip().lower()
return resolved if result.returncode == 0 and resolved else revision
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD. The checkout is
a network verb too: in a partial (subdirectory) clone it downloads the file contents."""
timeout = _pc()._clone_timeout_seconds()
for verb, args, failure_prefix in (
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
):
try:
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
auth_url=source_url, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise _pc().PluginOperationError(
f"Git {verb} of commit '{revision}' timed out after {timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from exc
actual = _pc()._git_head_revision(repo, git_exe)
if actual != _git_resolve_commit(repo, git_exe, revision):
raise _pc().PluginOperationError(
f"Checked-out revision '{actual}' does not match requested commit '{revision}'.")
def _scrub_git_url(git_url: str) -> str:
"""Strip credentials and query/fragment data from an HTTP Git URL."""
parsed = urllib.parse.urlsplit(git_url)
if parsed.scheme in {"http", "https"} and parsed.hostname:
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
if parsed.port is not None:
host = f"{host}:{parsed.port}"
return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "", ""))
return git_url
def _canonical_source(git_url: str, subdir: Optional[str]) -> str:
scrubbed = _scrub_git_url(git_url)
return f"{scrubbed}#{subdir}" if subdir else scrubbed
def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None:
"""Ensure credentials used for cloning do not survive in ``.git/config``."""
scrubbed = _scrub_git_url(git_url)
if scrubbed != git_url:
_git_or_raise(
git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15,
failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url)
def _restrict_checkout_to_subdir(repo: Path, git_exe: str, subdir: str) -> None:
"""Sparse-check-out only *subdir*. Written as the classic ``info/sparse-checkout`` file
rather than ``git sparse-checkout set`` so older Git clients work too."""
_git_or_raise(git_exe, repo, "config", "core.sparseCheckout", "true", timeout=15,
failure_prefix="Could not enable sparse checkout:\n")
pattern_file = repo / ".git" / "info" / "sparse-checkout"
pattern_file.parent.mkdir(parents=True, exist_ok=True)
escaped = re.sub(r"([\\*?\[])", r"\\\1", subdir.strip("/"))
pattern_file.write_text(f"/{escaped}/\n", encoding="utf-8")
def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str],
subdir: Optional[str] = None) -> str:
"""Shallow-clone *git_url* into *tmp_clone* (detached at *revision* when given), scrub any
credentials from the recorded origin, and return the installed HEAD SHA.
A *subdir* install is a blobless clone with a sparse checkout of that subdirectory: a plugin
living in a monorepo (Hindsight: 170 MB at depth 1, 2 MB for its plugin folder) otherwise
downloads every file in the repository, which times out on slow connections."""
git_exe = _pc()._resolve_git_executable()
if not git_exe:
raise _pc().PluginOperationError("git is not installed or not in PATH.")
clone_timeout = _pc()._clone_timeout_seconds()
partial = ["--filter=blob:none"] if subdir else []
no_checkout = ["--no-checkout"] if revision or subdir else []
clone_args = ["clone", "--depth", "1", *partial, *no_checkout, git_url, str(tmp_clone)]
try:
result = _pc()._run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url,
timeout=clone_timeout)
except FileNotFoundError as e:
raise _pc().PluginOperationError("git is not installed or not in PATH.") from e
except subprocess.TimeoutExpired as e:
raise _pc().PluginOperationError(f"Git clone timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e
if result.returncode != 0:
raise _pc().PluginOperationError(_pc()._clone_failure_message(git_url, _safe_git_error(result, git_url)))
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
if subdir:
_restrict_checkout_to_subdir(tmp_clone, git_exe, subdir)
if revision:
_checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url)
elif subdir:
try:
_git_or_raise(git_exe, tmp_clone, "checkout", "HEAD", timeout=clone_timeout, source_url=git_url,
auth_url=git_url, failure_prefix="Git checkout of the plugin subdirectory failed:\n")
except subprocess.TimeoutExpired as e:
raise _pc().PluginOperationError(
f"Git checkout timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e
return _pc()._git_head_revision(tmp_clone, git_exe)
def _run_plugin_git(
git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "",
) -> subprocess.CompletedProcess:
"""Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote
a network verb talks to; it runs anonymously first and a stored user credential for that host
is attached only when the remote refuses anonymous access (private repos)."""
from hermes_cli.git_credentials import run_git_with_credential_fallback
return run_git_with_credential_fallback(
[git_exe, *args], auth_url, env=noninteractive_git_env(), capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=timeout, cwd=str(target))
def _stash_ref(git_exe: str, target: Path) -> str:
"""Current ``refs/stash`` commit, or empty string when no stash exists."""
probe = _pc()._run_plugin_git(git_exe, target, "rev-parse", "--verify", "refs/stash")
return probe.stdout.strip() if probe.returncode == 0 else ""
def _reapply_stash(git_exe: str, target: Path, stash_sha: str) -> bool:
"""``stash apply`` the autostash commit *stash_sha*; drop it on a clean apply. False when it
applied with errors or left unmerged paths (the stash entry is kept in that case).
Git is addressed by the stash's commit sha, never a ``stash@{N}`` selector: on native Windows
the MSYS runtime re-parses git.exe's argv and strips the braces, so ``stash@{0}`` reaches git
as ``stash@0`` and both the apply and the drop fail (#87542)."""
restore = _pc()._run_plugin_git(git_exe, target, "stash", "apply", stash_sha)
unmerged = _pc()._run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U")
if restore.returncode != 0 or unmerged.stdout.strip():
return False
# `stash drop` only takes a selector; a bare `drop` targets the newest entry, so drop
# positionally only while the newest entry is still our autostash.
if _stash_ref(git_exe, target) == stash_sha:
_pc()._run_plugin_git(git_exe, target, "stash", "drop")
return True
def _autostash_dirty_tree(git_exe: str, target: Path) -> tuple[str, str]:
"""Stash local edits before a pull. Returns ``(stash_sha, error)``; *stash_sha* is empty when
the tree was clean, and a non-empty error means the tree is dirty but nothing was saved, so
the pull must not run."""
status = _pc()._run_plugin_git(git_exe, target, "status", "--porcelain", "-z")
if status.returncode != 0 or not status.stdout.strip():
return "", ""
# `git add -N` entries make `git stash push` fail outright (see update_cmd_stash), so promote them
# to real staged adds first; the checkout's own local edits are otherwise unstashable.
from hermes_cli.update_cmd_stash import _intent_to_add_paths
intent_to_add = _intent_to_add_paths(status.stdout)
if intent_to_add:
_pc()._run_plugin_git(git_exe, target, "add", "--", *intent_to_add)
pre_stash = _stash_ref(git_exe, target)
push = _pc()._run_plugin_git(
git_exe, target, "stash", "push", "--include-untracked", "-m", "hermes-plugin-update-autostash")
post_stash = _stash_ref(git_exe, target)
if not post_stash or post_stash == pre_stash:
err = _safe_git_error(push)
return "", (
"Local changes in the plugin checkout could not be "
"stashed; update aborted before touching the checkout."
+ (f"\n{err}" if err else ""))
if push.returncode != 0:
# Saved-but-couldn't-clean (undeletable untracked files): the stash entry is complete;
# reset tracked mods so the pull isn't blocked by a still-dirty tree.
_pc()._run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
return post_stash, ""
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
"""``git pull --ff-only`` a plugin checkout, autostashing local edits (users patch installed
plugins in place, and a plain ff-only pull would then refuse forever).
Users tweak installed plugins in place (config constants, small patches), and a plain ``pull --ff-only``
then aborts with "Your local changes ... would be overwritten by merge" — making the plugin permanently
un-updatable until they hand-run git. Same UX class Factory Droid fixed in v0.188 ("Updating a plugin
marketplace now succeeds when its checkout has local changes"), and the same autostash approach ``hermes
update`` already uses for the main checkout (PR #70161).
"""
git_exe = _pc()._resolve_git_executable()
if not git_exe:
return False, "git is not installed or not in PATH."
try:
stash_sha, err = _autostash_dirty_tree(git_exe, target)
if err:
return False, err
origin = _pc()._run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15)
result = _pc()._run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip())
if result.returncode != 0:
err = _safe_git_error(result) or "git pull failed."
if not stash_sha:
return False, err
# Put the user's edits back before reporting the failure.
if _reapply_stash(git_exe, target, stash_sha):
note = "Local changes were restored."
else:
note = "Local changes are preserved in git stash (restore with: git stash pop)."
return False, f"{err}\n{note}"
pulled = result.stdout.strip()
if not stash_sha:
return True, pulled
if _reapply_stash(git_exe, target, stash_sha):
return True, pulled + "\nLocal changes were re-applied on top of the update."
# Conflicted re-apply: leave the plugin importable on the updated
# revision; the user's edits stay safe in the stash entry.
_pc()._run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
return True, pulled + (
"\n⚠ Local changes in this plugin conflicted with the update and "
"were NOT re-applied. They are preserved in git stash — inspect "
"with `git stash show -p` and re-apply with "
f"`git stash pop` inside {target}.")
except FileNotFoundError:
return False, "git is not installed or not in PATH."
except subprocess.TimeoutExpired:
return False, "Git operation timed out after 60 seconds."