From 9fba3f7bf695672ef8812c597312e36deb6b2c4b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 17:45:36 -0400 Subject: [PATCH] feat: add upgrade prompt to version update check When a newer release is found, prompt the user to upgrade in-place. Resolves the git repo root via `git rev-parse --show-toplevel` before running `git pull && make clean && make && make install`, so the upgrade works correctly whether hate_crack is run from source or installed into site-packages. Ctrl-C and EOF at the prompt continue normally. --- hate_crack/main.py | 34 +++++++++++ tests/test_version_check.py | 113 +++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 5d3e541..9c8c51b 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -836,6 +836,40 @@ def check_for_updates(): f"\n Update available: {latest} (current: {local_base})." f"\n See https://github.com/trustedsec/hate_crack/releases\n" ) + try: + answer = input(" Upgrade now? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return + if answer == "y": + import subprocess + + print() + # Find the actual git repo root - _repo_root may point to + # site-packages when installed rather than the source checkout. + git_root_result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=_repo_root, + capture_output=True, + text=True, + ) + if git_root_result.returncode != 0: + print( + "\n Could not find a git repository to upgrade from." + "\n Run manually: git pull && make clean && make && make install\n" + ) + raise SystemExit(1) + repo_root = git_root_result.stdout.strip() + result = subprocess.run( + "git pull && make clean && make && make install", + shell=True, + cwd=repo_root, + ) + if result.returncode == 0: + print("\n Upgrade complete. Please restart hate_crack.\n") + else: + print("\n Upgrade failed. Check the output above for errors.\n") + raise SystemExit(0) except Exception: pass diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 68b22fb..ce06f34 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,7 +1,7 @@ """Tests for the startup version check feature.""" import json -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -27,7 +27,7 @@ class TestCheckForUpdates: with patch.object(hc_module, "requests") as mock_requests, patch.object( hc_module, "REQUESTS_AVAILABLE", True - ): + ), patch("builtins.input", return_value="n"): mock_requests.get.return_value = mock_resp hc_module.check_for_updates() @@ -99,7 +99,7 @@ class TestCheckForUpdates: with patch.object(hc_module, "requests") as mock_requests, patch.object( hc_module, "REQUESTS_AVAILABLE", True - ): + ), patch("builtins.input", return_value="n"): mock_requests.get.return_value = mock_resp hc_module.check_for_updates() @@ -119,3 +119,110 @@ class TestCheckForUpdates: output = capsys.readouterr().out assert "Update available" not in output + + def test_upgrade_declined_does_not_run_make(self, hc_module): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ), patch("builtins.input", return_value="n"), patch( + "subprocess.run" + ) as mock_run: + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + mock_run.assert_not_called() + + def test_upgrade_accepted_runs_make_and_exits(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + git_root_proc = MagicMock() + git_root_proc.returncode = 0 + git_root_proc.stdout = "/fake/repo\n" + + make_proc = MagicMock() + make_proc.returncode = 0 + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ), patch("builtins.input", return_value="y"), patch( + "subprocess.run", side_effect=[git_root_proc, make_proc] + ) as mock_run, pytest.raises( + SystemExit + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + assert mock_run.call_count == 2 + make_cmd = mock_run.call_args_list[1][0][0] + assert "git pull" in make_cmd + assert "make install" in make_cmd + assert mock_run.call_args_list[1][1]["cwd"] == "/fake/repo" + output = capsys.readouterr().out + assert "Upgrade complete" in output + + def test_upgrade_failure_prints_error(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + git_root_proc = MagicMock() + git_root_proc.returncode = 0 + git_root_proc.stdout = "/fake/repo\n" + + make_proc = MagicMock() + make_proc.returncode = 1 + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ), patch("builtins.input", return_value="y"), patch( + "subprocess.run", side_effect=[git_root_proc, make_proc] + ), pytest.raises( + SystemExit + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Upgrade failed" in output + + def test_upgrade_no_git_repo_prints_manual_instructions(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + git_root_proc = MagicMock() + git_root_proc.returncode = 128 + git_root_proc.stdout = "" + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ), patch("builtins.input", return_value="y"), patch( + "subprocess.run", return_value=git_root_proc + ), pytest.raises( + SystemExit + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Run manually" in output + + def test_upgrade_prompt_ctrl_c_continues(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ), patch("builtins.input", side_effect=KeyboardInterrupt), patch( + "subprocess.run" + ) as mock_run: + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + mock_run.assert_not_called()