upgrade: report git branch via --show-current so a stale release tag can't break upgrades

git rev-parse --abbrev-ref HEAD reports "heads/release" when a stale
tag named release exists alongside the release branch (tags outrank
heads in git ref resolution).  check_for_update then looked up the
nonexistent origin/heads/release and permanently reported no updates
available, and the Controller UI pre-filled "heads/release" as the
upgrade branch.

Use git branch --show-current everywhere instead; it never
disambiguates.  A detached HEAD (empty output) now defaults to the
release branch for upgrades; a genuine git failure still returns None.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 15:59:11 -06:00
parent 9e8050bd48
commit ab5f416b3f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
5 changed files with 94 additions and 15 deletions

View file

@ -78,17 +78,20 @@ def get_current_branch() -> Optional[str]:
Get the current git branch of /opt/wrolpi.
Returns:
Branch name, or None if unable to determine
Branch name ('release' on a detached HEAD), or None if unable to determine
"""
try:
# Not `rev-parse --abbrev-ref HEAD`: it reports "heads/release" when a stale
# `release` tag exists alongside the branch, breaking origin/{branch} lookups.
result = subprocess.run(
["git", "-C", "/opt/wrolpi", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "-C", "/opt/wrolpi", "branch", "--show-current"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
# A detached HEAD prints nothing; upgrades come from `release` by default.
return result.stdout.strip() or "release"
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
return None

View file

@ -7,6 +7,7 @@ from unittest import mock
from controller.lib.scripts import (
AVAILABLE_SCRIPTS,
get_current_branch,
get_script_output,
get_script_status,
list_available_scripts,
@ -14,6 +15,44 @@ from controller.lib.scripts import (
)
class TestGetCurrentBranch:
"""Tests for get_current_branch function."""
def test_returns_branch_name(self):
"""Should return the branch name from `git branch --show-current`.
`git rev-parse --abbrev-ref HEAD` must not be used: it reports "heads/release" when a
stale `release` tag exists alongside the `release` branch.
"""
mock_result = mock.MagicMock()
mock_result.returncode = 0
mock_result.stdout = "release\n"
with mock.patch("subprocess.run", return_value=mock_result) as mock_run:
assert get_current_branch() == "release"
cmd = mock_run.call_args[0][0]
assert "--show-current" in cmd
assert "--abbrev-ref" not in cmd
def test_detached_head_defaults_to_release(self):
"""A detached HEAD (empty output) should default to 'release'."""
mock_result = mock.MagicMock()
mock_result.returncode = 0
mock_result.stdout = "\n"
with mock.patch("subprocess.run", return_value=mock_result):
assert get_current_branch() == "release"
def test_git_failure_returns_none(self):
"""A failing git command should return None."""
mock_result = mock.MagicMock()
mock_result.returncode = 128
mock_result.stdout = ""
with mock.patch("subprocess.run", return_value=mock_result):
assert get_current_branch() is None
class TestAvailableScripts:
"""Tests for AVAILABLE_SCRIPTS constant."""