From cbfb0e035d4e343b09c721de02656c01472b49ce Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 20 Aug 2026 12:23:56 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --- first_time_install.sh | 0 scripts/install/install_service.sh | 0 scripts/install/install_web_service.sh | 0 start_display.sh | 0 stop_display.sh | 0 test/test_git_pull_resolution.py | 40 ++++++++++++++++++++++++-- web_interface/blueprints/api_v3.py | 13 +++++++-- 7 files changed, 48 insertions(+), 5 deletions(-) mode change 100644 => 100755 first_time_install.sh mode change 100644 => 100755 scripts/install/install_service.sh mode change 100644 => 100755 scripts/install/install_web_service.sh mode change 100644 => 100755 start_display.sh mode change 100644 => 100755 stop_display.sh diff --git a/first_time_install.sh b/first_time_install.sh old mode 100644 new mode 100755 diff --git a/scripts/install/install_service.sh b/scripts/install/install_service.sh old mode 100644 new mode 100755 diff --git a/scripts/install/install_web_service.sh b/scripts/install/install_web_service.sh old mode 100644 new mode 100755 diff --git a/start_display.sh b/start_display.sh old mode 100644 new mode 100755 diff --git a/stop_display.sh b/stop_display.sh old mode 100644 new mode 100755 diff --git a/test/test_git_pull_resolution.py b/test/test_git_pull_resolution.py index d5668142..4d235df5 100644 --- a/test/test_git_pull_resolution.py +++ b/test/test_git_pull_resolution.py @@ -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") diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 984637e8..c1d2c03b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -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/`` 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, )