diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 32bf9d370e..615800d9c3 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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 diff --git a/tests/hermes_cli/test_update_fetch_timeout.py b/tests/hermes_cli/test_update_fetch_timeout.py new file mode 100644 index 0000000000..6ac59af396 --- /dev/null +++ b/tests/hermes_cli/test_update_fetch_timeout.py @@ -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")