The cell fakes subprocess.run in a bare tmp dir; the upstream ff now refuses when its recovery marker cannot be written (37051f2be31), so the fixture creates the .git directory the marker lives in. No assertion changed.
168 lines
7.5 KiB
Python
168 lines
7.5 KiB
Python
"""Fetch-failure classification for `hermes update` / `hermes update --check`.
|
|
|
|
A GitHub-side HTTP 429 (rate limit / outage) used to be reported as the
|
|
generic "Failed to fetch updates from origin." — or worse, matched the
|
|
"unable to access" branch and got called a local network error. The
|
|
classifier must call out rate limiting / outages explicitly, and the raw
|
|
stderr line must always be printed alongside the diagnosis.
|
|
"""
|
|
|
|
from hermes_cli import update_cmd
|
|
|
|
|
|
RATE_LIMIT_STDERR = (
|
|
"error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429\n"
|
|
"fatal: expected flush after ref listing"
|
|
)
|
|
CURL_429_STDERR = (
|
|
"fatal: unable to access 'https://github.com/NousResearch/hermes-agent.git/':"
|
|
" The requested URL returned error: 429"
|
|
)
|
|
|
|
|
|
class TestClassifyFetchFailure:
|
|
def test_http_429_rpc_failure_reports_rate_limit(self):
|
|
msg = update_cmd._classify_fetch_failure(RATE_LIMIT_STDERR)
|
|
assert "rate limiting" in msg
|
|
assert "try again in 5 minutes" in msg
|
|
|
|
def test_curl_unable_to_access_429_is_rate_limit_not_network(self):
|
|
# "unable to access" also appears here — 429 must win.
|
|
msg = update_cmd._classify_fetch_failure(CURL_429_STDERR)
|
|
assert "rate limiting" in msg
|
|
assert "Network error" not in msg
|
|
|
|
def test_rate_limit_phrase_without_code(self):
|
|
msg = update_cmd._classify_fetch_failure("fatal: GitHub rate limit exceeded")
|
|
assert "rate limiting" in msg
|
|
|
|
def test_5xx_reports_outage(self):
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"fatal: unable to access 'https://github.com/x.git/':"
|
|
" The requested URL returned error: 503"
|
|
)
|
|
assert "outage" in msg
|
|
assert "githubstatus.com" in msg
|
|
|
|
def test_dns_failure_reports_network_error(self):
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"fatal: unable to access 'https://github.com/x.git/':"
|
|
" Could not resolve host: github.com"
|
|
)
|
|
assert msg.startswith("✗ Network error")
|
|
|
|
def test_username_prompt_401_reports_github_not_user_credentials(self):
|
|
# What GitHub's HTTP 401 looks like once the terminal prompt is
|
|
# disabled — must NOT be blamed on the user's credentials.
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"fatal: could not read Username for 'https://github.com':"
|
|
" terminal prompts disabled"
|
|
)
|
|
assert "GitHub" in msg and "outage" in msg
|
|
assert "check your git credentials" not in msg
|
|
|
|
def test_auth_failure(self):
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"fatal: Authentication failed for 'https://github.com/x.git/'"
|
|
)
|
|
assert "Authentication failed" in msg
|
|
|
|
def test_ssh_publickey_denial_reports_ssh_auth_not_generic(self):
|
|
# git wraps OpenSSH's own rejection as "Could not read from remote
|
|
# repository" — never "Authentication failed" — so this needs its
|
|
# own rule ahead of the generic fallback (#82169).
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"git@github.com: Permission denied (publickey).\n"
|
|
"fatal: Could not read from remote repository."
|
|
)
|
|
assert "SSH authentication failed" in msg
|
|
assert "https://github.com/NousResearch/hermes-agent.git" in msg
|
|
|
|
def test_ssh_host_key_failure_reports_ssh_auth(self):
|
|
msg = update_cmd._classify_fetch_failure(
|
|
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n"
|
|
"Host key verification failed.\n"
|
|
"fatal: Could not read from remote repository."
|
|
)
|
|
assert "SSH authentication failed" in msg
|
|
|
|
def test_unknown_falls_back_to_generic(self):
|
|
msg = update_cmd._classify_fetch_failure("fatal: something novel")
|
|
assert msg == "✗ Failed to fetch updates from origin."
|
|
|
|
|
|
class TestPrintFetchFailure:
|
|
def test_prints_diagnosis_and_first_raw_line(self, capsys):
|
|
update_cmd._print_fetch_failure(RATE_LIMIT_STDERR)
|
|
out = capsys.readouterr().out
|
|
assert "rate limiting" in out
|
|
assert "HTTP 429" in out
|
|
# raw first stderr line preserved for diagnosability
|
|
assert "error: RPC failed" in out
|
|
|
|
def test_empty_stderr_prints_only_diagnosis(self, capsys):
|
|
update_cmd._print_fetch_failure("")
|
|
out = capsys.readouterr().out.strip().splitlines()
|
|
assert out == ["✗ Failed to fetch updates from origin."]
|
|
|
|
|
|
def test_update_network_git_calls_never_prompt_for_credentials():
|
|
"""Every `git fetch`/`pull`/`push` in the updater runs with prompts disabled.
|
|
|
|
Live incident (Sep 2026): a GitHub-side 401 made `hermes update` sit on
|
|
``Username for 'https://github.com':`` instead of failing with a diagnosis.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
|
|
kw = update_cmd._no_prompt_git_kwargs()
|
|
assert kw["stdin"] is subprocess.DEVNULL
|
|
assert kw["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
|
# Only the prompt is disabled — credential helpers / askpass stay
|
|
# configured so a private-fork origin still authenticates.
|
|
assert "GIT_CONFIG_COUNT" not in kw["env"] or kw["env"]["GIT_CONFIG_COUNT"] == os.environ.get("GIT_CONFIG_COUNT")
|
|
|
|
|
|
def test_update_and_upstream_network_calls_disable_terminal_prompts(monkeypatch, tmp_path):
|
|
"""Exercise origin fetch and fork fetch/merge/push, not their source spelling."""
|
|
import subprocess
|
|
from hermes_cli import update_cmd_git
|
|
|
|
monkeypatch.setenv("GIT_TERMINAL_PROMPT", "1")
|
|
monkeypatch.setenv("GCM_INTERACTIVE", "Always")
|
|
monkeypatch.setenv("GIT_ASKPASS", "fixture-askpass")
|
|
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
|
|
monkeypatch.setenv("GIT_CONFIG_KEY_0", "credential.helper")
|
|
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "fixture-helper")
|
|
monkeypatch.setattr(update_cmd, "_has_upstream_remote", lambda *a: True)
|
|
upstream = "c" * 40
|
|
monkeypatch.setattr(update_cmd, "_count_commits_between",
|
|
lambda git, cwd, base, head: 2 if head == upstream else 0)
|
|
calls = []
|
|
|
|
def run(cmd, **kwargs):
|
|
calls.append((cmd[1:], kwargs))
|
|
return subprocess.CompletedProcess(cmd, 0, upstream if "rev-parse" in cmd else "", "")
|
|
|
|
monkeypatch.setattr(subprocess, "run", run)
|
|
(tmp_path / ".git").mkdir() # the fork ff's recovery marker lives there: no marker, no move
|
|
update_cmd._git_run(["git"], ["fetch", "origin", "main"], cwd=tmp_path, network=True, check=True)
|
|
assert update_cmd_git._sync_with_upstream_if_needed(["git"], tmp_path, assume_yes=True)
|
|
from hermes_cli.update_custody import git_subcommand
|
|
|
|
# The fork push runs only after the update is validated (_push_synced_fork), never inside the sync.
|
|
update_cmd_git._sync_fork_with_upstream(["git"], tmp_path)
|
|
# The fork's fast-forward is local (`merge --ff-only` after the fetch): no credential helper ever
|
|
# runs under the checkout lock fd a mutator inherits (R2).
|
|
network = [(args, kwargs) for args, kwargs in calls if git_subcommand(args) in {"fetch", "pull", "push", "merge"}]
|
|
assert [git_subcommand(args) for args, _ in network] == ["fetch", "fetch", "merge", "push"]
|
|
assert network[2][0][-1] == upstream # the counted commit, not a name a local branch can shadow
|
|
for args, kwargs in network:
|
|
assert kwargs["stdin"] is subprocess.DEVNULL, args
|
|
env = kwargs["env"]
|
|
assert env["GIT_TERMINAL_PROMPT"] == "0", args
|
|
assert env["GCM_INTERACTIVE"] == "Never", args
|
|
assert env["GIT_ASKPASS"] == "fixture-askpass", args
|
|
assert env["GIT_CONFIG_COUNT"] == "1", args
|
|
assert env["GIT_CONFIG_VALUE_0"] == "fixture-helper", args
|