mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 04:08:06 +00:00
Reported from a pi whose checkout sat on a local branch:
git pull failed (returncode=1): There is no tracking information for
the current branch. Please specify which branch you want to rebase
against.
The Tools tab reported that as "Update failed; check logs for details",
which tells the user nothing they can act on, and the underlying git
message never reached the UI at all.
A branch with no upstream is easy to end up on — checking one out by
name, restoring a backup, or following a guide that names a branch — and
until now it left the update button permanently broken with no way out
except SSH.
resolve_pull_command() now decides how to pull:
- upstream set -> git pull --rebase, as before
- no upstream, origin/<branch> -> git pull --rebase origin <branch>,
then attach tracking so the next update is a plain pull
- no upstream, no remote branch -> an error naming the branch and
pointing at Switch branch
- detached HEAD -> says so, rather than failing obscurely
That resolution happens BEFORE the stash. Previously the handler stashed
local changes and then discovered it could not pull, putting the user's
work away for an update that was never going to run.
Failures now surface git's own message instead of "check logs".
Adds a branch picker to the Tools tab, backed by GET
/system/git-branches (local + remote-only) and a checkout_branch action.
Switching attaches tracking, so Pull Latest works afterwards. Branch
names are validated against a strict pattern before reaching a subprocess
argument list.
Local edits block a checkout, as they should. Rather than a truncated
one-line error, the response carries git's full list of blocking files
and a can_retry_with_stash flag; the UI then offers "Stash and switch" as
an explicit choice. Stashing is never done unasked — putting someone's
edits away without consent is worse than refusing the switch.
Verified on the pi that produced the report: on its untracked 'audit'
branch the update now returns the actionable message, git-info reports
upstream='' and can_pull=false, and an injected branch name is rejected.
27 tests build real git repositories and cover each path, including the
stash route that could not be exercised safely on the device.
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
"""Guard: the update button works on branches without tracking information.
|
|
|
|
`git pull --rebase` fails outright on a branch with no upstream:
|
|
|
|
There is no tracking information for the current branch.
|
|
Please specify which branch you want to rebase against.
|
|
|
|
That is easy to land on — checking out a branch by name, restoring a
|
|
backup, or following a guide that names one — and the Tools tab reported it
|
|
as a bare "Update failed; check logs for details", which the user cannot act
|
|
on. resolve_pull_command() falls back to an explicit `origin <branch>` pull
|
|
when that remote branch exists, and returns an actionable message when it
|
|
does not.
|
|
|
|
These tests build real git repositories in a temp dir, so they exercise git's
|
|
actual behaviour rather than a mock of it.
|
|
"""
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from web_interface.blueprints.api_v3 import (
|
|
checkout_branch,
|
|
is_valid_branch_name,
|
|
resolve_pull_command,
|
|
)
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
subprocess.run(['git', '--version'], capture_output=True).returncode != 0,
|
|
reason='git not available',
|
|
)
|
|
|
|
|
|
def _git(*args, cwd):
|
|
return subprocess.run(['git', *args], cwd=str(cwd),
|
|
capture_output=True, text=True, check=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def repos(tmp_path):
|
|
"""An 'origin' repo with a main branch, and a clone of it."""
|
|
origin = tmp_path / 'origin'
|
|
origin.mkdir()
|
|
_git('init', '--initial-branch=main', '--bare', cwd=origin)
|
|
|
|
work = tmp_path / 'work'
|
|
_git('clone', str(origin), str(work), cwd=tmp_path)
|
|
_git('config', 'user.email', 'test@example.com', cwd=work)
|
|
_git('config', 'user.name', 'Test', cwd=work)
|
|
(work / 'README.md').write_text('hello\n')
|
|
_git('add', 'README.md', cwd=work)
|
|
_git('commit', '-m', 'initial', cwd=work)
|
|
_git('push', '-u', 'origin', 'main', cwd=work)
|
|
return work
|
|
|
|
|
|
def test_branch_with_upstream_uses_a_plain_pull(repos):
|
|
args, note, error = resolve_pull_command(str(repos))
|
|
assert error is None
|
|
assert args == ['git', 'pull', '--rebase']
|
|
assert note == ''
|
|
|
|
|
|
def test_branch_without_upstream_falls_back_to_origin_branch(repos):
|
|
"""The reported bug: a local branch that also exists on origin."""
|
|
_git('push', 'origin', 'main:audit', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
# A branch created this way has no tracking information.
|
|
_git('checkout', '-b', 'audit', cwd=repos)
|
|
assert subprocess.run(['git', 'rev-parse', '--abbrev-ref', '@{u}'],
|
|
cwd=str(repos), capture_output=True).returncode != 0
|
|
|
|
args, note, error = resolve_pull_command(str(repos))
|
|
assert error is None
|
|
assert args == ['git', 'pull', '--rebase', 'origin', 'audit']
|
|
assert 'audit' in note
|
|
|
|
|
|
def test_pull_fallback_actually_succeeds(repos):
|
|
"""The fallback command must work, not merely look right."""
|
|
_git('push', 'origin', 'main:audit', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
_git('checkout', '-b', 'audit', cwd=repos)
|
|
|
|
args, _, error = resolve_pull_command(str(repos))
|
|
assert error is None
|
|
done = subprocess.run(args, cwd=str(repos), capture_output=True, text=True)
|
|
assert done.returncode == 0, done.stderr
|
|
|
|
|
|
def test_local_only_branch_reports_an_actionable_message(repos):
|
|
"""No upstream and no origin/<branch>: say so, don't just fail."""
|
|
_git('checkout', '-b', 'local-experiment', cwd=repos)
|
|
args, _, error = resolve_pull_command(str(repos))
|
|
assert args is None
|
|
assert error and 'local-experiment' in error
|
|
assert 'no origin/local-experiment' in error
|
|
|
|
|
|
def test_detached_head_reports_an_actionable_message(repos):
|
|
head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=str(repos),
|
|
capture_output=True, text=True).stdout.strip()
|
|
_git('checkout', head, cwd=repos)
|
|
args, _, error = resolve_pull_command(str(repos))
|
|
assert args is None
|
|
assert error and 'detached HEAD' in error
|
|
|
|
|
|
def test_missing_directory_does_not_raise(tmp_path):
|
|
"""A bad path must return an error, not blow up the request."""
|
|
args, _, error = resolve_pull_command(str(tmp_path / 'nope'))
|
|
assert args is None
|
|
assert error
|
|
|
|
|
|
# ── branch switching ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize('name', [
|
|
'main', 'audit', 'feat/thing', 'release-1.2', 'a_b.c',
|
|
])
|
|
def test_valid_branch_names_accepted(name):
|
|
assert is_valid_branch_name(name)
|
|
|
|
|
|
@pytest.mark.parametrize('name', [
|
|
'', ' ', 'a b', 'a;rm -rf /', '--upload-pack=evil', '-x',
|
|
'a..b', 'a\nb', 'x' * 201, 'branch$(whoami)', '../escape',
|
|
])
|
|
def test_unsafe_branch_names_rejected(name):
|
|
"""The value reaches a subprocess argument list, so refuse the exotic."""
|
|
assert not is_valid_branch_name(name)
|
|
|
|
|
|
def test_switch_to_remote_only_branch_creates_it_with_tracking(repos):
|
|
_git('push', 'origin', 'main:release', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
|
|
payload, code = checkout_branch(str(repos), 'release')
|
|
assert code == 200 and payload['status'] == 'success', payload
|
|
|
|
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'release'
|
|
upstream = subprocess.run(['git', 'rev-parse', '--abbrev-ref', '@{u}'],
|
|
cwd=str(repos), capture_output=True, text=True)
|
|
assert upstream.stdout.strip() == 'origin/release'
|
|
|
|
|
|
def test_switching_attaches_tracking_so_pull_needs_no_fallback(repos):
|
|
"""The whole point: after switching, a plain `git pull` works."""
|
|
_git('push', 'origin', 'main:audit', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
payload, _ = checkout_branch(str(repos), 'audit')
|
|
assert payload['status'] == 'success'
|
|
|
|
args, note, error = resolve_pull_command(str(repos))
|
|
assert error is None
|
|
assert args == ['git', 'pull', '--rebase']
|
|
assert note == ''
|
|
|
|
|
|
def test_unknown_branch_is_reported_not_created(repos):
|
|
payload, code = checkout_branch(str(repos), 'does-not-exist')
|
|
assert code == 404
|
|
assert 'does-not-exist' in payload['message']
|
|
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'main'
|
|
|
|
|
|
def test_local_edits_block_the_switch_and_name_the_files(repos):
|
|
_git('push', 'origin', 'main:other', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
_git('checkout', '-b', 'other', 'origin/other', cwd=repos)
|
|
(repos / 'README.md').write_text('changed on other\n')
|
|
_git('add', 'README.md', cwd=repos)
|
|
_git('commit', '-m', 'diverge', cwd=repos)
|
|
_git('checkout', 'main', cwd=repos)
|
|
(repos / 'README.md').write_text('uncommitted local edit\n')
|
|
|
|
payload, code = checkout_branch(str(repos), 'other')
|
|
assert code == 200 and payload['status'] == 'error'
|
|
assert payload['can_retry_with_stash'] is True
|
|
assert 'README.md' in payload['detail']
|
|
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'main'
|
|
|
|
|
|
def test_stash_option_lets_the_switch_through_and_keeps_the_work(repos):
|
|
"""stash=True must switch *and* leave the edit recoverable."""
|
|
_git('push', 'origin', 'main:other', cwd=repos)
|
|
_git('fetch', 'origin', cwd=repos)
|
|
_git('checkout', '-b', 'other', 'origin/other', cwd=repos)
|
|
(repos / 'README.md').write_text('changed on other\n')
|
|
_git('add', 'README.md', cwd=repos)
|
|
_git('commit', '-m', 'diverge', cwd=repos)
|
|
_git('checkout', 'main', cwd=repos)
|
|
(repos / 'README.md').write_text('uncommitted local edit\n')
|
|
|
|
payload, code = checkout_branch(str(repos), 'other', stash=True)
|
|
assert code == 200 and payload['status'] == 'success', payload
|
|
assert 'stashed' in payload['message']
|
|
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'other'
|
|
# The edit is not lost — it is on the stash.
|
|
assert 'switch to other' in _git('stash', 'list', cwd=repos).stdout
|