Merge branch 'bugfix/auto-update-branch-switch' into dev

This commit is contained in:
Justin Bollinger
2026-05-26 16:48:48 -04:00
3 changed files with 198 additions and 13 deletions
+3
View File
@@ -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
+41
View File
@@ -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")
+154 -13
View File
@@ -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