From 6c7b31dbb7ac944b69c6a140c862b9f5f86a8d83 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 2 Jul 2026 10:42:42 -0400 Subject: [PATCH] fix(update): repair master->main tracking in self-updater Old clones made before the default-branch rename sit on a local `master` whose upstream still points at the deleted refs/heads/master, so both a bare `git pull` and `_run_upgrade()`'s `git checkout main` failed on stale clones. The updater now fetches origin first, checks out main via `git checkout -B main origin/main`, repairs the upstream to origin/main, and pulls explicitly with `git pull origin main` so it never consults the dangling branch.*.merge config. Existing dirty-branch and detached-HEAD guards are unchanged. Adds tests covering the renamed-clone migration and the fetch-failure bail. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 + hate_crack/main.py | 45 +++++++++- tests/test_version_check.py | 168 +++++++++++++++++++++++++++++------- 3 files changed, 183 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 833d615..c7db24c 100644 --- a/README.md +++ b/README.md @@ -937,6 +937,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.10.7 + - Auto-upgrade (`hate_crack --update` / the in-menu upgrade) now survives the historical `master` → `main` default-branch rename. Old clones made before the rename sit on a local `master` whose upstream (`branch.master.merge`) still points at the now-deleted `refs/heads/master`, so a bare `git pull` failed with "Your configuration specifies to merge with the ref 'refs/heads/master' from the remote, but no such ref was fetched" — and `_run_upgrade()`'s `git checkout main` also failed on stale clones that had never fetched `origin/main`. The updater now fetches from `origin` *before* switching branches, checks out `main` via `git checkout -B main origin/main` (creating/resetting it from the remote regardless of local state), repairs the upstream with `git branch --set-upstream-to=origin/main main` so future manual `git pull`s work too, and pulls explicitly with `git pull origin main` so it never consults the dangling `branch.*.merge` config. Existing safety guards (dirty-branch bail, detached-HEAD skip) are unchanged + Version 2.10.6 - Fixed the Hashview integration calling API routes that don't exist in Hashview (verified against v0.8.3-dev), which 404'd as soon as a customer ID was entered ("Could not list hashfiles"). The customer→hashfile listing relied on a phantom `GET /v1/hashfiles` list-all route; it now enumerates via the real `GET /v1/hashfiles/hash_type/` endpoint where available — the download flow sweeps common hashcat modes to display a customer's uploaded hashfiles. That listing route only exists on Hashview builds from 2026-06-08+ (the `v0.8.3-dev` branch); on `main`/older servers there is no hashfile-listing API at all, so the flow now degrades gracefully to entering the hashfile ID directly (looked up in the Hashview web UI) and resolving its type via `GET /v1/getHashType/`. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `start_job` uses `POST`. Hashview exposes no stop-job route, so `stop_job` now raises with guidance to use `delete_job`; and no bulk cracked-hash export exists, so the best-effort "found" merge degrades gracefully - The Hashview CLI now honours `HASHVIEW_URL` / `HASHVIEW_API_KEY` environment variables as overrides for the `config.json` values, so the client can be pointed at a different Hashview instance (e.g. a local dev stack) without editing the persisted config diff --git a/hate_crack/main.py b/hate_crack/main.py index 20b67b6..d1268c7 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -986,10 +986,33 @@ def _run_upgrade(): raise SystemExit(1) repo_root = git_root_result.stdout.strip() + # Fetch first so origin/main is present even on a stale clone that has + # never been fetched since the default branch was renamed master -> main. + # Without this, `git checkout main` on a master-only clone fails because + # there's no origin/main ref to auto-create a tracking branch from. + fetch_result = subprocess.run( + ["git", "fetch", "--tags", "origin"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if fetch_result.returncode != 0: + print( + f"\n Failed to fetch from origin:\n {fetch_result.stderr.strip()}\n" + "\n Upgrade manually: git fetch --tags && git checkout main && git pull origin main && make install\n" + ) + raise SystemExit(1) + # 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. + # + # Old clones made before the default branch was renamed master -> main + # sit on a local `master` whose upstream (branch.master.merge) still + # points at the now-deleted refs/heads/master. A bare `git pull` then + # fails with "no such ref was fetched". We migrate such clones to a + # local `main` tracking origin/main so future manual pulls also work. branch_result = subprocess.run( ["git", "symbolic-ref", "--short", "HEAD"], cwd=repo_root, @@ -1009,13 +1032,16 @@ def _run_upgrade(): 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" + "\n Or upgrade manually: git checkout main && git pull origin main && 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"], + # -B creates/resets a local `main` pointing at origin/main so this + # works whether or not a local `main` already exists (e.g. a stale + # master-only clone that has never had a main branch). + ["git", "checkout", "-B", "main", "origin/main"], cwd=repo_root, capture_output=True, text=True, @@ -1023,20 +1049,31 @@ def _run_upgrade(): 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" + "\n Upgrade manually: git checkout main && git pull origin main && make install\n" ) raise SystemExit(1) + # Repair the upstream so a later manual `git pull` consults + # origin/main rather than a dangling branch.master.merge ref. + subprocess.run( + ["git", "branch", "--set-upstream-to=origin/main", "main"], + cwd=repo_root, + capture_output=True, + text=True, + ) + import shutil uv = shutil.which("uv") or os.path.expanduser("~/.local/bin/uv") result = subprocess.run( + # `git pull origin main` is explicit so it never consults the possibly + # broken branch..merge config on a renamed clone. # git fetch --tags ensures new release tags are visible to setuptools-scm. # make install handles system deps and the CLI shim. # uv sync --reinstall-package forces setuptools-scm to regenerate the # version from the new tag so the version number updates correctly. - f"git pull && git fetch --tags && make install && {uv} sync --reinstall-package hate_crack", + f"git pull origin main && git fetch --tags && make install && {uv} sync --reinstall-package hate_crack", shell=True, cwd=repo_root, ) diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 3e315ad..d19d8bc 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -143,6 +143,9 @@ class TestCheckForUpdates: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + fetch_proc = MagicMock() + fetch_proc.returncode = 0 + branch_proc = MagicMock() branch_proc.returncode = 0 branch_proc.stdout = "main\n" @@ -153,16 +156,17 @@ class TestCheckForUpdates: 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, branch_proc, make_proc] + "subprocess.run", + side_effect=[git_root_proc, fetch_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 == 3 - make_cmd = mock_run.call_args_list[2][0][0] - assert "git pull" in make_cmd + assert mock_run.call_count == 4 + make_cmd = mock_run.call_args_list[3][0][0] + assert "git pull origin main" in make_cmd assert "make install" in make_cmd - assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo" + assert mock_run.call_args_list[3][1]["cwd"] == "/fake/repo" output = capsys.readouterr().out assert "Upgrade complete" in output @@ -175,6 +179,9 @@ class TestCheckForUpdates: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + fetch_proc = MagicMock() + fetch_proc.returncode = 0 + branch_proc = MagicMock() branch_proc.returncode = 0 branch_proc.stdout = "main\n" @@ -185,7 +192,8 @@ class TestCheckForUpdates: 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, branch_proc, make_proc] + "subprocess.run", + side_effect=[git_root_proc, fetch_proc, branch_proc, make_proc], ), patch("shutil.which", return_value="/usr/local/bin/uv"), patch( "os.path.isfile", return_value=True ), pytest.raises( @@ -228,6 +236,9 @@ class TestRunUpgrade: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + fetch_proc = MagicMock() + fetch_proc.returncode = 0 + branch_proc = MagicMock() branch_proc.returncode = 0 branch_proc.stdout = "main\n" @@ -236,16 +247,19 @@ class TestRunUpgrade: make_proc.returncode = 0 with patch( - "subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc] + "subprocess.run", + side_effect=[git_root_proc, fetch_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 - make_cmd = mock_run.call_args_list[2][0][0] - assert "git pull" in make_cmd + assert mock_run.call_count == 4 + # The fetch happens before the branch inspection. + assert mock_run.call_args_list[1][0][0] == ["git", "fetch", "--tags", "origin"] + make_cmd = mock_run.call_args_list[3][0][0] + assert "git pull origin main" in make_cmd assert "make install" in make_cmd - assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo" + assert mock_run.call_args_list[3][1]["cwd"] == "/fake/repo" output = capsys.readouterr().out assert "Upgrade complete" in output @@ -254,6 +268,9 @@ class TestRunUpgrade: git_root_proc.returncode = 0 git_root_proc.stdout = "/fake/repo\n" + fetch_proc = MagicMock() + fetch_proc.returncode = 0 + branch_proc = MagicMock() branch_proc.returncode = 0 branch_proc.stdout = "main\n" @@ -262,7 +279,8 @@ class TestRunUpgrade: make_proc.returncode = 1 with patch( - "subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc] + "subprocess.run", + side_effect=[git_root_proc, fetch_proc, branch_proc, make_proc], ), pytest.raises(SystemExit) as exc: hc_module._run_upgrade() @@ -308,59 +326,143 @@ class TestRunUpgrade: self, hc_module, capsys ): git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") branch_proc = MagicMock(returncode=0, stdout="dev\n") status_proc = MagicMock(returncode=0, stdout="") checkout_proc = MagicMock(returncode=0, stdout="", stderr="") + upstream_proc = MagicMock(returncode=0, stdout="", stderr="") make_proc = MagicMock(returncode=0) with patch( "subprocess.run", side_effect=[ git_root_proc, + fetch_proc, branch_proc, status_proc, checkout_proc, + upstream_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"] + assert mock_run.call_count == 7 + # Fetch happens before the checkout. + assert mock_run.call_args_list[1][0][0] == ["git", "fetch", "--tags", "origin"] + # The checkout creates/resets main from origin/main. + checkout_call = mock_run.call_args_list[4] + assert checkout_call[0][0] == ["git", "checkout", "-B", "main", "origin/main"] + # Upstream is repaired to origin/main. + assert mock_run.call_args_list[5][0][0] == [ + "git", + "branch", + "--set-upstream-to=origin/main", + "main", + ] # Final call is the shell upgrade. - upgrade_cmd = mock_run.call_args_list[4][0][0] - assert "git pull" in upgrade_cmd + upgrade_cmd = mock_run.call_args_list[6][0][0] + assert "git pull origin main" 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_migrates_master_only_renamed_clone( + self, hc_module, capsys + ): + """An old clone still sitting on `master` (default branch renamed + master -> main upstream) must be migrated: fetch BEFORE checkout, + checkout/create `main` from origin/main, set upstream to origin/main, + and the final pull must be `git pull origin main` (never bare).""" + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") + branch_proc = MagicMock(returncode=0, stdout="master\n") + status_proc = MagicMock(returncode=0, stdout="") + checkout_proc = MagicMock(returncode=0, stdout="", stderr="") + upstream_proc = MagicMock(returncode=0, stdout="", stderr="") + make_proc = MagicMock(returncode=0) + + with patch( + "subprocess.run", + side_effect=[ + git_root_proc, + fetch_proc, + branch_proc, + status_proc, + checkout_proc, + upstream_proc, + make_proc, + ], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 0 + calls = [c[0][0] for c in mock_run.call_args_list] + + # A fetch must occur before any checkout. + fetch_idx = calls.index(["git", "fetch", "--tags", "origin"]) + checkout_idx = calls.index(["git", "checkout", "-B", "main", "origin/main"]) + assert fetch_idx < checkout_idx + + # Upstream repaired to origin/main after the checkout. + upstream_idx = calls.index( + ["git", "branch", "--set-upstream-to=origin/main", "main"] + ) + assert checkout_idx < upstream_idx + + # Final pull is explicit, not a bare `git pull`. + upgrade_cmd = mock_run.call_args_list[-1][0][0] + assert "git pull origin main" in upgrade_cmd + assert "make install" in upgrade_cmd + + output = capsys.readouterr().out + assert "Switching from 'master' to 'main'" in output + + def test_run_upgrade_bails_when_fetch_fails(self, hc_module, capsys): + git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + fetch_proc = MagicMock( + returncode=1, stdout="", stderr="fatal: unable to access origin\n" + ) + + with patch( + "subprocess.run", + side_effect=[git_root_proc, fetch_proc], + ) as mock_run, pytest.raises(SystemExit) as exc: + hc_module._run_upgrade() + + assert exc.value.code == 1 + # No branch inspection / checkout after the fetch failure. + assert mock_run.call_count == 2 + output = capsys.readouterr().out + assert "Failed to fetch from origin" 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") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") 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], + side_effect=[git_root_proc, fetch_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 + assert mock_run.call_count == 4 all_call_args = [c[0][0] for c in mock_run.call_args_list] - assert ["git", "checkout", "main"] not in all_call_args + assert ["git", "checkout", "-B", "main", "origin/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") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") branch_proc = MagicMock(returncode=0, stdout="dev\n") status_proc = MagicMock(returncode=0, stdout="") checkout_proc = MagicMock( @@ -371,7 +473,13 @@ class TestRunUpgrade: with patch( "subprocess.run", - side_effect=[git_root_proc, branch_proc, status_proc, checkout_proc], + side_effect=[ + git_root_proc, + fetch_proc, + branch_proc, + status_proc, + checkout_proc, + ], ), pytest.raises(SystemExit) as exc: hc_module._run_upgrade() @@ -384,20 +492,21 @@ class TestRunUpgrade: self, hc_module, capsys ): git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") 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], + side_effect=[git_root_proc, fetch_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 + # rev-parse + fetch + symbolic-ref + upgrade shell. No checkout. + assert mock_run.call_count == 4 all_call_args = [c[0][0] for c in mock_run.call_args_list] - assert ["git", "checkout", "main"] not in all_call_args + assert ["git", "checkout", "-B", "main", "origin/main"] not in all_call_args output = capsys.readouterr().out assert "Switching from" not in output @@ -406,16 +515,17 @@ class TestRunUpgrade: 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") + fetch_proc = MagicMock(returncode=0, stdout="", stderr="") 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], + side_effect=[git_root_proc, fetch_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 + assert mock_run.call_count == 4 all_call_args = [c[0][0] for c in mock_run.call_args_list] - assert ["git", "checkout", "main"] not in all_call_args + assert ["git", "checkout", "-B", "main", "origin/main"] not in all_call_args