fix(update): bound network git in hermes update and prune shallow grafts on apply

- _git_run(network=True) now carries a 300s timeout; a dead-stalled fetch
  (HTTP/2 to GitHub on some networks, black-holed proxy) becomes a failed run
  whose stderr names the stall instead of an update pinned on
  'Fetching updates...' forever (#93759, #95777). Local git stays unbounded.
- The apply path prunes stale .git/shallow grafts alongside its existing
  lock/tmp_pack cleanup, so installs that already accumulated grafts from
  past depth-1 checks (#105951: 57 entries) heal on their next update, not
  only on --check.
This commit is contained in:
Teknium
2026-09-11 01:40:27 -07:00
parent 5bfa389e8a
commit 9ce7547faf
2 changed files with 71 additions and 5 deletions
+28 -5
View File
@@ -147,13 +147,30 @@ def _record_update_step(step: str, ok: bool, detail: str = "") -> None:
record_step(step, ok, detail)
# A fetch whose transport dead-stalls (HTTP/2 to GitHub on some networks, a black-holed proxy)
# otherwise leaves `hermes update` on "Fetching updates..." forever (#93759, #95777). Five
# minutes is generous for a scoped single-branch fetch and still ends in a real error.
NETWORK_GIT_TIMEOUT_SECONDS = 300
def _git_run(git_cmd, args, cwd=None, *, check=False, network=False):
"""Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the
terminal prompt so an HTTP 401 fails fast instead of hanging."""
return subprocess.run(
git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True,
text=True, encoding="utf-8", errors="replace", check=check,
**(_no_prompt_git_kwargs() if network else {}))
terminal prompt so an HTTP 401 fails fast instead of hanging, and bounds the wait."""
try:
return subprocess.run(
git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True,
text=True, encoding="utf-8", errors="replace", check=check,
**({"timeout": NETWORK_GIT_TIMEOUT_SECONDS, **_no_prompt_git_kwargs()} if network else {}))
except subprocess.TimeoutExpired as exc:
# subprocess.run already killed the child; the checkout stays consistent because
# fetch writes to tmp_pack_* and only renames on success. Report as a failed run
# so every caller's existing stderr path prints one clear line.
result = subprocess.CompletedProcess(
exc.cmd, 124, stdout="",
stderr=f"git {args[0]} timed out after {NETWORK_GIT_TIMEOUT_SECONDS}s with no response from the remote")
if check:
raise subprocess.CalledProcessError(124, exc.cmd, output="", stderr=result.stderr) from exc
return result
def _capture_head_sha(git_cmd, cwd) -> str | None:
@@ -1321,6 +1338,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
swept = clear_stale_tmp_packs(_m().PROJECT_ROOT)
if swept:
print(" (removed %d aborted-fetch pack temp file(s))" % len(swept))
# Shallow installer checkouts collect one `.git/shallow` graft per past depth-1 fetch
# (#105951); stale grafts break merge-base and push this run into the divergence path.
from hermes_cli.gitlock import prune_stale_shallow_grafts
pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT)
if pruned:
print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)")
# Surface autostashes left by earlier updates (--keep-stash, failed restores).
# Surface autostash entries left behind by earlier updates (#63717 problem 6) — parked --keep-stash
@@ -0,0 +1,43 @@
"""A dead-stalled network fetch ends `hermes update` with an error, never a hang (#93759, #95777).
`_git_run(network=True)` bounds the wait; a `TimeoutExpired` becomes a failed
CompletedProcess whose stderr names the stall, so every caller's existing
fetch-failure path prints one clear line. Local git (network=False) is unbounded.
"""
import subprocess
from unittest.mock import MagicMock, patch
import hermes_cli.update_cmd as update_cmd
def _timeout(cmd, **kwargs):
if "timeout" in kwargs:
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
return MagicMock(returncode=0, stdout="ok", stderr="")
def test_network_fetch_stall_becomes_a_failed_run_with_a_named_cause(monkeypatch):
monkeypatch.setattr(update_cmd, "_m", lambda: MagicMock(PROJECT_ROOT="/repo"))
with patch.object(update_cmd.subprocess, "run", side_effect=_timeout) as run:
result = update_cmd._git_run(["git"], ["fetch", "origin", "main"], network=True)
assert result.returncode != 0
assert "timed out" in result.stderr and "fetch" in result.stderr
assert run.call_args.kwargs["timeout"] == update_cmd.NETWORK_GIT_TIMEOUT_SECONDS
# The no-prompt guard still rides along with the bound.
assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
def test_local_git_stays_unbounded_and_check_true_raises(monkeypatch):
monkeypatch.setattr(update_cmd, "_m", lambda: MagicMock(PROJECT_ROOT="/repo"))
with patch.object(update_cmd.subprocess, "run", side_effect=_timeout) as run:
assert update_cmd._git_run(["git"], ["rev-parse", "HEAD"]).returncode == 0
assert "timeout" not in run.call_args.kwargs
try:
update_cmd._git_run(["git"], ["fetch", "origin", "main"], network=True, check=True)
except subprocess.CalledProcessError as exc:
assert exc.returncode == 124
else:
raise AssertionError("check=True must raise on a timed-out fetch")