mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-21 02:19:07 +00:00
git tracks five scripts as mode 644 that first_time_install.sh then chmods to
755 (start_display.sh, stop_display.sh, the two install_*_service.sh, and
one-shot-install.sh does the same to first_time_install.sh). With
core.fileMode true, the default on Linux, git reports all five as modified
from then on, in files the user never touched.
The update button stashes local changes before pulling, so it is not blocked
by this. But it never pops that stash -- stash pop and stash apply appear
nowhere in the update flow -- so the mode change is stashed away and left
there, and the files revert:
=== file modes after the update button's stash ===
664 first_time_install.sh <- installer had made these 755
664 start_display.sh
664 stop_display.sh
664 scripts/install/install_service.sh
So every web-UI update silently strips the executable bit from the installer's
own scripts, and leaves a stash entry holding the difference. start_display.sh
and stop_display.sh stop working from the shell afterwards.
A manual `git pull --rebase` over SSH fails outright, since nothing stashes for
it: "cannot pull with rebase: You have unstaged changes". That is the likely
source of the reports, since plenty of people update that way.
Tracking the five as 755 -- what they should always have been, as the
installer chmodding them attests -- removes the spurious mode change
entirely: nothing to stash, nothing stripped, no stash entry, and manual
pulls work.
The pull also passes --autostash, for the case the code explicitly tolerates:
when the stash fails it logs a warning and pulls anyway, and that pull is what
then fails. Autostash also pops what it stashes, which the manual stash does
not.
Note that `git add -A` after `git update-index --chmod=+x` silently reverts
the index to the on-disk mode, so the modes here were set by chmodding the
files themselves.
Regression test asserts the five stay tracked executable; reverting any one
of them fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
237 lines
9.2 KiB
Python
237 lines
9.2 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', '--autostash']
|
|
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', '--autostash', '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', '--autostash']
|
|
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
|
|
|
|
|
|
class TestInstallerDoesNotBlockTheUpdateButton:
|
|
"""first_time_install.sh chmods scripts that git tracked as 644.
|
|
|
|
With core.fileMode true -- the default on Linux -- that leaves five
|
|
permanently modified tracked files on every machine that ran the
|
|
installer, and `git pull --rebase` refuses to start:
|
|
|
|
error: cannot pull with rebase: You have unstaged changes.
|
|
|
|
Tracking them as executable makes the installer's chmod a no-op.
|
|
"""
|
|
|
|
CHMODDED = [
|
|
'first_time_install.sh',
|
|
'start_display.sh',
|
|
'stop_display.sh',
|
|
'scripts/install/install_service.sh',
|
|
'scripts/install/install_web_service.sh',
|
|
]
|
|
|
|
def test_scripts_the_installer_chmods_are_tracked_executable(self):
|
|
import subprocess
|
|
from pathlib import Path
|
|
root = Path(__file__).resolve().parent.parent
|
|
out = subprocess.run(['git', 'ls-files', '-s', *self.CHMODDED],
|
|
capture_output=True, text=True, cwd=str(root)).stdout
|
|
modes = {line.split()[3]: line.split()[0] for line in out.strip().split('\n') if line}
|
|
non_exec = sorted(f for f, m in modes.items() if m != '100755')
|
|
assert not non_exec, (
|
|
f"{non_exec} are chmodded by the installer but tracked non-executable, "
|
|
"so every install leaves the working tree dirty and the update "
|
|
"button cannot pull")
|