From 74d665e6ee7f38c336cc2b8b47cac2acb1fe17b8 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 26 May 2026 16:31:14 -0400 Subject: [PATCH 1/2] fix(update): switch to main before pull to stop upgrade loop on dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: user on the `dev` branch upgrading from v2.10.0 to v2.10.2 saw the "Upgrade now? [y/N]" prompt fire on every restart. Accepting it ran the install pipeline successfully but the next launch re-prompted the same way. Root cause: release tags in this repo live on main-side merge commits (e.g. v2.10.2 is on the `Merge branch 'dev' into main` commit). That commit is *downstream* of `dev`, so walking back from dev's HEAD, setuptools-scm finds only v2.10.0 and reports e.g. `2.10.0.post1.dev9`. After hate_crack/__init__.py strips the post/dev suffix, `__version__` is `"2.10.0"` again. check_for_updates() sees `2.10.2 > 2.10.0` and re-prompts forever. `git pull` on dev is a no-op because dev is fully synced — the tag just isn't on the branch. Fix: _run_upgrade() now detects the current branch via `git symbolic-ref --short HEAD`. If it's not `main`, refuse to clobber uncommitted work (git status --porcelain), then `git checkout main` before running the existing pull/install pipeline. The release-tagged commit is now on HEAD, setuptools-scm regenerates a clean version, and the loop ends. Safety guards: - Uncommitted changes: bail with manual-steps message (exit 1) — no git checkout attempted. - main checked out in another worktree: surface git's stderr and bail with manual instructions. - Detached HEAD (symbolic-ref returncode != 0): skip the switch and let the existing flow run — no regression for users on a tag. - Already on main: skip the switch block; behavior unchanged. Tests (tests/test_version_check.py): - test_run_upgrade_switches_from_dev_to_main_then_upgrades - test_run_upgrade_bails_when_non_main_branch_is_dirty - test_run_upgrade_bails_when_checkout_main_fails - test_run_upgrade_skips_switch_when_already_on_main - test_run_upgrade_skips_switch_on_detached_head Four pre-existing tests updated to thread an extra branch_proc mock through the new symbolic-ref subprocess call. Co-Authored-By: Claude Opus 4.7 (1M context) --- hate_crack/main.py | 41 +++++++++ tests/test_version_check.py | 167 +++++++++++++++++++++++++++++++++--- 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index e631957..a388c7b 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -980,6 +980,47 @@ def _run_upgrade(): raise SystemExit(1) repo_root = git_root_result.stdout.strip() + # Release tags live on main-side merge commits, so pulling on `dev` or + # any feature branch won't move HEAD onto the new tag — setuptools-scm + # then regenerates the version as e.g. 2.10.0.postN.devM and the update + # checker re-fires on next start, looping forever. Switch to main first. + branch_result = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + ) + branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "" + + if branch and branch != "main": + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if status.stdout.strip(): + print( + f"\n Cannot auto-upgrade: uncommitted changes on '{branch}'." + "\n Commit or stash them, then re-run." + "\n Or upgrade manually: git checkout main && git pull && make install\n" + ) + raise SystemExit(1) + + print(f"\n Switching from '{branch}' to 'main' to pick up the release tag...") + checkout = subprocess.run( + ["git", "checkout", "main"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if checkout.returncode != 0: + print( + f"\n Failed to switch to main:\n {checkout.stderr.strip()}\n" + "\n Upgrade manually: git checkout main && git pull && make install\n" + ) + raise SystemExit(1) + import shutil uv = shutil.which("uv") or os.path.expanduser("~/.local/bin/uv") diff --git a/tests/test_version_check.py b/tests/test_version_check.py index b7e616e..3e315ad 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,7 +1,6 @@ """Tests for the startup version check feature.""" -import json -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest @@ -144,22 +143,26 @@ class TestCheckForUpdates: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + branch_proc = MagicMock() + branch_proc.returncode = 0 + branch_proc.stdout = "main\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] + "subprocess.run", side_effect=[git_root_proc, branch_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 mock_run.call_count == 3 + make_cmd = mock_run.call_args_list[2][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" + assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo" output = capsys.readouterr().out assert "Upgrade complete" in output @@ -172,13 +175,17 @@ class TestCheckForUpdates: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + branch_proc = MagicMock() + branch_proc.returncode = 0 + branch_proc.stdout = "main\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] + "subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc] ), patch("shutil.which", return_value="/usr/local/bin/uv"), patch( "os.path.isfile", return_value=True ), pytest.raises( @@ -221,19 +228,24 @@ class TestRunUpgrade: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + branch_proc = MagicMock() + branch_proc.returncode = 0 + branch_proc.stdout = "main\n" + make_proc = MagicMock() make_proc.returncode = 0 - with patch("subprocess.run", side_effect=[git_root_proc, make_proc]) as mock_run, \ - pytest.raises(SystemExit) as exc: + with patch( + "subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc] + ) as mock_run, pytest.raises(SystemExit) as exc: hc_module._run_upgrade() assert exc.value.code == 0 - assert mock_run.call_count == 2 - make_cmd = mock_run.call_args_list[1][0][0] + assert mock_run.call_count == 3 + make_cmd = mock_run.call_args_list[2][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" + assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo" output = capsys.readouterr().out assert "Upgrade complete" in output @@ -242,10 +254,16 @@ class TestRunUpgrade: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + branch_proc = MagicMock() + branch_proc.returncode = 0 + branch_proc.stdout = "main\n" + make_proc = MagicMock() make_proc.returncode = 1 - with patch("subprocess.run", side_effect=[git_root_proc, make_proc]), pytest.raises(SystemExit) as exc: + with patch( + "subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc] + ), pytest.raises(SystemExit) as exc: hc_module._run_upgrade() assert exc.value.code == 1 @@ -278,3 +296,126 @@ class TestRunUpgrade: hc_module.check_for_updates() mock_run.assert_not_called() + + # ------------------------------------------------------------------ + # Branch-switch behavior: when the user runs the upgrade from a + # non-main checkout, release tags aren't reachable from HEAD and + # the upgrade no-ops (loops). _run_upgrade() must switch to main + # first, with safety guards. + # ------------------------------------------------------------------ + + def test_run_upgrade_switches_from_dev_to_main_then_upgrades( + self, hc_module, capsys + ): + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + branch_proc = MagicMock(returncode=0, stdout="dev\n") + status_proc = MagicMock(returncode=0, stdout="") + checkout_proc = MagicMock(returncode=0, stdout="", stderr="") + make_proc = MagicMock(returncode=0) + + with patch( + "subprocess.run", + side_effect=[ + git_root_proc, + branch_proc, + status_proc, + checkout_proc, + make_proc, + ], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 0 + assert mock_run.call_count == 5 + # Third call is the checkout to main. + checkout_call = mock_run.call_args_list[3] + assert checkout_call[0][0] == ["git", "checkout", "main"] + # Final call is the shell upgrade. + upgrade_cmd = mock_run.call_args_list[4][0][0] + assert "git pull" in upgrade_cmd + assert "git fetch --tags" in upgrade_cmd + output = capsys.readouterr().out + assert "Switching from 'dev' to 'main'" in output + + def test_run_upgrade_bails_when_non_main_branch_is_dirty( + self, hc_module, capsys + ): + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + branch_proc = MagicMock(returncode=0, stdout="feat/x\n") + status_proc = MagicMock(returncode=0, stdout=" M hate_crack/main.py\n") + + with patch( + "subprocess.run", + side_effect=[git_root_proc, branch_proc, status_proc], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 1 + # No checkout, no upgrade command should fire after the bail. + assert mock_run.call_count == 3 + all_call_args = [c[0][0] for c in mock_run.call_args_list] + assert ["git", "checkout", "main"] not in all_call_args + output = capsys.readouterr().out + assert "uncommitted changes" in output + assert "feat/x" in output + + def test_run_upgrade_bails_when_checkout_main_fails(self, hc_module, capsys): + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + branch_proc = MagicMock(returncode=0, stdout="dev\n") + status_proc = MagicMock(returncode=0, stdout="") + checkout_proc = MagicMock( + returncode=1, + stdout="", + stderr="error: 'main' is already checked out at '/other/wt'\n", + ) + + with patch( + "subprocess.run", + side_effect=[git_root_proc, branch_proc, status_proc, checkout_proc], + ), pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 1 + output = capsys.readouterr().out + assert "Failed to switch to main" in output + assert "already checked out" in output + + def test_run_upgrade_skips_switch_when_already_on_main( + self, hc_module, capsys + ): + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + branch_proc = MagicMock(returncode=0, stdout="main\n") + make_proc = MagicMock(returncode=0) + + with patch( + "subprocess.run", + side_effect=[git_root_proc, branch_proc, make_proc], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 0 + # Only rev-parse + symbolic-ref + upgrade shell. No checkout. + assert mock_run.call_count == 3 + all_call_args = [c[0][0] for c in mock_run.call_args_list] + assert ["git", "checkout", "main"] not in all_call_args + output = capsys.readouterr().out + assert "Switching from" not in output + + def test_run_upgrade_skips_switch_on_detached_head(self, hc_module, capsys): + """Detached HEAD: symbolic-ref returns non-zero. We should not + attempt a branch switch (there's nothing to switch from) — let + the existing upgrade flow proceed.""" + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + branch_proc = MagicMock(returncode=1, stdout="", stderr="fatal: ref HEAD is not a symbolic ref\n") + make_proc = MagicMock(returncode=0) + + with patch( + "subprocess.run", + side_effect=[git_root_proc, branch_proc, make_proc], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 0 + assert mock_run.call_count == 3 + all_call_args = [c[0][0] for c in mock_run.call_args_list] + assert ["git", "checkout", "main"] not in all_call_args From 32ca52af77db18dc0b75776d90ca0c82846ea4b5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 26 May 2026 16:31:22 -0400 Subject: [PATCH 2/2] docs: add v2.10.3 version history entry Covers the auto-upgrade-loop fix shipping in this patch. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 44a5e95..ab3d820 100644 --- a/README.md +++ b/README.md @@ -912,6 +912,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.10.3 + - Auto-upgrade no longer loops infinitely when invoked from a non-main branch (e.g. `dev`). Release tags live on main-side merge commits, so `git pull` on `dev` was a no-op and setuptools-scm kept regenerating the version as `X.Y.Z.postN.devM` — the update check then re-fired forever. `_run_upgrade()` now switches to `main` before pulling, with safety guards: refuses to clobber uncommitted work, surfaces clear errors when `main` is checked out in another worktree, and leaves detached-HEAD checkouts untouched + Version 2.10.2 - Fingerprint Attack no longer launches hashcat against empty wordlists when no candidates exist; prints a "no candidates to expand" message and skips the attack (plus the secondary hybrid pass that previously fired six wasted hashcat sessions) - Forced `LC_ALL=C` on every `sort -u` subprocess (fingerprint expander pipeline, `_write_field_sorted_unique`, LM-to-NT combinator dedupe) — fixes "sort: Illegal byte sequence" on macOS when cracked passwords contain non-UTF-8 bytes, which was silently emptying the fingerprint candidate list