Files
hermes-agent/tests/hermes_cli/test_update_upstream_prompt_noninteractive.py
yoniebans be284cf5c0 fix(update): report when the official repo was not checked on the up-to-date path
Review follow-up on #97052 (helix4u): a fork with no upstream remote whose HEAD matches origin/main used to print plain "Already up to date!" under --yes even though official main was never consulted, so an unattended stale fork looked current. _sync_with_upstream_if_needed now returns whether the official upstream was actually checked, and the commit_count == 0 completion line says "Up to date with your fork (official repo not checked)." when it was not. Skip-as-decline semantics are unchanged: no prompt, no remote mutation, no decline marker. Caller-level regression test added for the fork + no-upstream + --yes + HEAD==origin/main path; helper tests now pin the return contract.
2026-08-28 13:33:56 -04:00

125 lines
4.7 KiB
Python

"""The fork-upstream prompt must never block a non-interactive update (#60240).
`_sync_with_upstream_if_needed` asks "Add official repo as 'upstream' remote?"
on fork checkouts with no upstream remote. In unattended contexts (CI, cron,
the desktop updater hand-off) stdin is open but nobody answers, so a bare
``input()`` blocks forever. These tests pin the gate: under ``assume_yes`` or
a non-TTY stdio pair the prompt is skipped as a decline WITHOUT persisting the
skip marker or touching git remotes, and both update call sites forward the
interaction state.
"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli import update_cmd
@pytest.fixture
def fork_without_upstream(tmp_path):
with patch.object(
update_cmd, "_has_upstream_remote", return_value=False
), patch.object(
update_cmd, "_should_skip_upstream_prompt", return_value=False
), patch.object(
update_cmd, "_add_upstream_remote", return_value=True
) as add_remote, patch.object(
update_cmd, "_mark_skip_upstream_prompt"
) as mark_skip, patch("builtins.input") as stdin_input:
yield SimpleNamespace(
cwd=tmp_path,
add_remote=add_remote,
mark_skip=mark_skip,
stdin_input=stdin_input,
)
def _tty(stdin: bool, stdout: bool):
return (
patch.object(update_cmd.sys.stdin, "isatty", return_value=stdin),
patch.object(update_cmd.sys.stdout, "isatty", return_value=stdout),
)
class TestUpstreamPromptNonInteractive:
def test_assume_yes_skips_prompt_without_touching_remotes(
self, fork_without_upstream, capsys
):
p_in, p_out = _tty(True, True)
with p_in, p_out:
checked = update_cmd._sync_with_upstream_if_needed(
["git"], fork_without_upstream.cwd, assume_yes=True
)
assert checked is False
fork_without_upstream.stdin_input.assert_not_called()
fork_without_upstream.add_remote.assert_not_called()
fork_without_upstream.mark_skip.assert_not_called()
assert "Skipping upstream setup" in capsys.readouterr().out
@pytest.mark.parametrize(
"stdin_tty,stdout_tty", [(False, False), (False, True), (True, False)]
)
def test_non_tty_skips_prompt_without_persisting_decline(
self, fork_without_upstream, capsys, stdin_tty, stdout_tty
):
p_in, p_out = _tty(stdin_tty, stdout_tty)
with p_in, p_out:
checked = update_cmd._sync_with_upstream_if_needed(
["git"], fork_without_upstream.cwd
)
assert checked is False
fork_without_upstream.stdin_input.assert_not_called()
fork_without_upstream.add_remote.assert_not_called()
fork_without_upstream.mark_skip.assert_not_called()
assert "Skipping upstream setup" in capsys.readouterr().out
def test_gateway_prompt_routes_through_input_fn(self, fork_without_upstream):
prompts = []
def gw_input(prompt, default=""):
prompts.append((prompt, default))
return "n"
p_in, p_out = _tty(False, False)
with p_in, p_out:
update_cmd._sync_with_upstream_if_needed(
["git"], fork_without_upstream.cwd, input_fn=gw_input
)
assert prompts == [("Add official repo as 'upstream' remote? [y/N]", "n")]
fork_without_upstream.stdin_input.assert_not_called()
fork_without_upstream.add_remote.assert_not_called()
fork_without_upstream.mark_skip.assert_called_once_with()
def test_interactive_decline_still_persists_marker(self, fork_without_upstream):
fork_without_upstream.stdin_input.return_value = "n"
p_in, p_out = _tty(True, True)
with p_in, p_out:
update_cmd._sync_with_upstream_if_needed(
["git"], fork_without_upstream.cwd
)
fork_without_upstream.stdin_input.assert_called_once()
fork_without_upstream.add_remote.assert_not_called()
fork_without_upstream.mark_skip.assert_called_once_with()
def test_interactive_accept_adds_upstream(self, fork_without_upstream):
fork_without_upstream.stdin_input.return_value = "y"
with patch.object(
update_cmd, "_count_commits_between", return_value=-1
), patch.object(update_cmd.subprocess, "run"):
p_in, p_out = _tty(True, True)
with p_in, p_out:
update_cmd._sync_with_upstream_if_needed(
["git"], fork_without_upstream.cwd
)
fork_without_upstream.add_remote.assert_called_once_with(
["git"], fork_without_upstream.cwd
)
fork_without_upstream.mark_skip.assert_not_called()