Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 cbfb0e035d fix(web): stop the installer chmod stripping exec bits on every update
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
2026-08-20 13:53:50 -04:00
9 changed files with 55 additions and 140 deletions
Regular → Executable
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
+37 -3
View File
@@ -58,7 +58,7 @@ def repos(tmp_path):
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 args == ['git', 'pull', '--rebase', '--autostash']
assert note == ''
@@ -73,7 +73,7 @@ def test_branch_without_upstream_falls_back_to_origin_branch(repos):
args, note, error = resolve_pull_command(str(repos))
assert error is None
assert args == ['git', 'pull', '--rebase', 'origin', 'audit']
assert args == ['git', 'pull', '--rebase', '--autostash', 'origin', 'audit']
assert 'audit' in note
@@ -155,7 +155,7 @@ def test_switching_attaches_tracking_so_pull_needs_no_fallback(repos):
args, note, error = resolve_pull_command(str(repos))
assert error is None
assert args == ['git', 'pull', '--rebase']
assert args == ['git', 'pull', '--rebase', '--autostash']
assert note == ''
@@ -200,3 +200,37 @@ def test_stash_option_lets_the_switch_through_and_keeps_the_work(repos):
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")
-85
View File
@@ -1,85 +0,0 @@
"""A check that could not run must not be reported as "up to date".
check-update returned update_available=False whenever git failed. The banner
is the only route to the update button, so a checkout git refuses to touch
looked exactly like a current one -- permanently, and with nothing for the
user to act on. The usual cause is an install performed as root, after which
every git command fails with "detected dubious ownership".
"""
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import api_v3 as mod # noqa: E402
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
DUBIOUS = ("fatal: detected dubious ownership in repository at "
"'/home/pi/LEDMatrix'\nTo add an exception for this directory, call:\n"
"\tgit config --global --add safe.directory /home/pi/LEDMatrix\n")
@pytest.fixture
def client():
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
mod._update_check_cache['result'] = None
mod._update_check_cache['ts'] = 0
return app.test_client()
def _fetch_fails(stderr: bytes):
def fake_run(args, **kwargs):
if args[:2] == ['git', 'fetch']:
return subprocess.CompletedProcess(args, 1, stdout=b'', stderr=stderr)
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
return fake_run
class TestFailedCheckIsNotSilence:
def test_dubious_ownership_is_reported_not_swallowed(self, client):
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
data = client.get('/api/v3/system/check-update').get_json()
assert data['check_failed'] is True, (
"a git failure was reported as a successful 'no update' check")
assert data['update_available'] is False
def test_the_message_tells_the_user_what_to_do(self, client):
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
data = client.get('/api/v3/system/check-update').get_json()
assert 'chown' in data['error'], (
"dubious ownership is unactionable without the fix command")
assert 'root' in data['error']
def test_an_ordinary_git_failure_still_surfaces(self, client):
with patch.object(mod.subprocess, 'run',
_fetch_fails(b'fatal: some other git problem\n')):
data = client.get('/api/v3/system/check-update').get_json()
assert data['check_failed'] is True
assert 'some other git problem' in data['error']
def test_offline_reads_as_offline(self, client):
with patch.object(mod.subprocess, 'run',
_fetch_fails(b'fatal: could not resolve host: github.com\n')):
data = client.get('/api/v3/system/check-update').get_json()
assert 'Could not reach GitHub' in data['error']
class TestSuccessPathUnchanged:
def test_up_to_date_carries_no_failure_flag(self, client):
def fake_run(args, **kwargs):
if args[:2] == ['git', 'fetch']:
return subprocess.CompletedProcess(args, 0, stdout=b'', stderr=b'')
if args[:2] == ['git', 'rev-parse']:
return subprocess.CompletedProcess(args, 0, stdout='abc123\n', stderr='')
return subprocess.CompletedProcess(args, 0, stdout='0\n', stderr='')
with patch.object(mod.subprocess, 'run', fake_run):
data = client.get('/api/v3/system/check-update').get_json()
assert data['update_available'] is False
assert not data.get('check_failed'), "a healthy check must not look like a failure"
+16 -36
View File
@@ -1657,13 +1657,22 @@ def resolve_pull_command(project_dir):
backup, or following an install guide that names one. The update button
then reports a failure the user cannot act on.
``--autostash`` is passed for the same reason. Rebase refuses to start
when any tracked file is modified, and on these installs something always
is: first_time_install.sh chmods five scripts that git tracked as 644, so
every machine that ran the installer carries five permanent mode changes
and the update button reports "cannot pull with rebase: You have unstaged
changes". Those modes are corrected in this commit, but a user cannot pull
the correction while the pull is what is blocked, and any other local edit
would reproduce it anyway. Autostash reapplies the changes afterwards.
Returns ``(args, note, error)``. When ``origin/<branch>`` exists the pull
is made explicit against it, so the update proceeds and the branch is
given tracking information afterwards.
"""
upstream = _git_upstream(project_dir)
if upstream:
return ['git', 'pull', '--rebase'], '', None
return ['git', 'pull', '--rebase', '--autostash'], '', None
branch = _git_current_branch(project_dir)
if not branch:
@@ -1673,7 +1682,7 @@ def resolve_pull_command(project_dir):
)
if _git_remote_branch_exists(project_dir, branch):
return (
['git', 'pull', '--rebase', 'origin', branch],
['git', 'pull', '--rebase', '--autostash', 'origin', branch],
f"Branch '{branch}' had no upstream; pulled from origin/{branch} and set it as the upstream.",
None,
)
@@ -1821,33 +1830,6 @@ def get_system_version():
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
def _update_check_failed(detail: str) -> Dict[str, Any]:
"""A check that could not run is not the same as being up to date.
Reporting update_available=False on a git failure hides the banner, and
the banner is the only route to the update button -- so a checkout git
refuses to touch looks exactly like a current one, permanently. The most
common cause is an install performed as root: git then reports "dubious
ownership" and every command fails, including the fetch here.
"""
return {'update_available': False, 'remote_sha': 'unknown',
'commits_behind': 0, 'check_failed': True, 'error': detail}
def _describe_git_failure(stderr: str) -> str:
"""Turn git's stderr into something the user can act on."""
text = (stderr or '').strip()
if 'dubious ownership' in text or 'detected dubious ownership' in text:
return ("This checkout is owned by a different user than the one "
"running the web interface, so git refuses to use it. It is "
"usually the result of installing as root. Fix the ownership "
"and the update will work: sudo chown -R $USER:$USER "
+ str(PROJECT_ROOT))
if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower():
return "Could not reach GitHub to check for updates."
return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed")
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
@@ -1863,13 +1845,12 @@ def check_for_update():
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode, stderr)
failed = _update_check_failed(_describe_git_failure(stderr))
_update_check_cache['result'] = failed
fetch_result.returncode,
fetch_result.stderr.decode(errors='replace').strip())
_update_check_cache['result'] = _safe
_update_check_cache['ts'] = now
return jsonify(failed)
return jsonify(_safe)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
@@ -1897,8 +1878,7 @@ def check_for_update():
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
return jsonify(_safe)
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
+2 -16
View File
@@ -1107,29 +1107,15 @@
fetch('/api/v3/system/check-update')
.then(function(r) { return r.json(); })
.then(function(data) {
var banner = document.getElementById('update-banner');
var btn = document.getElementById('update-banner-btn');
if (data.check_failed) {
// A check that could not run is not the same as being up
// to date. Hiding the banner here made a checkout git
// refuses to touch look permanently current, with no
// route to the update button and nothing to act on.
document.getElementById('update-banner-text').textContent =
data.error || 'Could not check for updates.';
if (btn) btn.style.display = 'none';
banner.style.display = '';
return;
}
if (btn) btn.style.display = '';
if (data.update_available && getDismissedSha() !== data.remote_sha) {
var n = data.commits_behind || 0;
var msg = 'A new LEDMatrix update is available';
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
document.getElementById('update-banner-text').textContent = msg;
banner.style.display = '';
document.getElementById('update-banner').style.display = '';
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
} else {
banner.style.display = 'none';
document.getElementById('update-banner').style.display = 'none';
}
})
.catch(function() {});