diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 57dbaaf7c2dd8..df9c9255b254f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7834,7 +7834,8 @@ def _update_via_zip(args): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError("venv rebuild failed; aborting update before dependency install") pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -10036,7 +10037,8 @@ def _cmd_update_impl(args, gateway_mode: bool): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError("venv rebuild failed; aborting update before dependency install") pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index b38893f713f94..3ff91e894546b 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -111,13 +111,17 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b shutil.rmtree(venv_dir, ignore_errors=True) result = subprocess.run( - [uv_bin, "venv", str(venv_dir), "--python", python_version], + [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"], capture_output=True, text=True, check=False, ) if result.returncode == 0: venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" + if not venv_python.exists(): + logger.warning("venv rebuild reported success but %s is missing", venv_python) + print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}") + return False py_ver = subprocess.run( [str(venv_python), "--version"], capture_output=True, diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 89f3d8badaec4..59d060175f6f7 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -114,8 +114,10 @@ def test_removes_old_venv_and_creates_new(self, tmp_path): (venv_dir / "old_file").write_text("stale") uv_bin = str(tmp_path / "bin" / "uv") + commands = [] def fake_run(cmd, **kwargs): + commands.append(cmd) m = MagicMock(returncode=0) if cmd[1] == "venv": # Simulate uv creating the venv dir @@ -133,6 +135,19 @@ def fake_run(cmd, **kwargs): result = rebuild_venv(uv_bin, venv_dir) assert result is True mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True) + assert commands[0] == [uv_bin, "venv", str(venv_dir), "--python", "3.11", "--clear"] + + def test_rebuild_success_without_python_returns_false(self, tmp_path): + venv_dir = tmp_path / "venv" + uv_bin = str(tmp_path / "bin" / "uv") + + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ + patch("hermes_cli.managed_uv.shutil.rmtree"): + mock_run.return_value = MagicMock(returncode=0, stdout="") + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + assert result is False + assert mock_run.call_count == 1 def test_rebuild_failure_returns_false(self, tmp_path): venv_dir = tmp_path / "venv" diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 5a255a05211f6..b9a9c7e554210 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -423,6 +423,37 @@ def fake_run(cmd, **kwargs): assert ".[all]" in install_cmds[0] +def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path): + """A failed fresh managed-uv venv rebuild must not continue into pip install.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False) + + recorded = [] + + def fake_run(cmd, **kwargs): + recorded.append(cmd) + if cmd == ["git", "fetch", "origin"]: + return SimpleNamespace(stdout="", stderr="", returncode=0) + if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: + return SimpleNamespace(stdout="main\n", stderr="", returncode=0) + if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]: + return SimpleNamespace(stdout="1\n", stderr="", returncode=0) + if cmd == ["git", "pull", "--ff-only", "origin", "main"]: + return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \ + patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \ + pytest.raises(RuntimeError, match="venv rebuild failed"): + hermes_main.cmd_update(SimpleNamespace()) + + install_cmds = [c for c in recorded if "pip" in c and "install" in c] + assert install_cmds == [] + + def test_install_with_optional_fallback_honors_custom_group(monkeypatch): """Termux update path should target .[termux-all] when requested.""" calls = []