Merge pull request #5188 — resolve git under macOS launchd (#5184, @rodboev)

Release v0.51.737 — resolve git under macOS launchd (#5184, @rodboev)
This commit is contained in:
nesquena-hermes
2026-06-28 21:39:59 -07:00
committed by GitHub
4 changed files with 154 additions and 6 deletions
+1
View File
@@ -28,6 +28,7 @@
### Fixed
- **The version badge and in-app updater now work on macOS when the WebUI is launched by launchd.** A launchd-started process inherits a minimal `PATH` that doesn't include git's location (e.g. Homebrew), so `api/updates.py` running a bare `git` failed — breaking both the displayed version and the update checker on those installs. Git is now resolved once in `_run_git()`: normal `PATH` lookup first (byte-identical on Linux/CI and every non-miss), falling back to `/usr/bin/git` only on macOS when `PATH` lookup misses; a `.git`-absent install keeps its existing `no_git` path and the missing-git diagnostic is preserved when no executable exists. Thanks @rodboev. (#5184, fixes #5175)
- **Settings search now finds the busy-input-mode setting by its option text and description, not just its label.** The Settings search index was built from each field's *label* only, so searching for natural terms like "queue", "interrupt", "steer", "message", or "default" didn't surface the busy-input-mode control even though those words describe exactly what it does. The index now also collects each field's option text, description text, and optional per-field supplemental search terms (kept a strict superset of the old label-only match, so existing results are unaffected), and the busy-input field carries supplemental terms so it's discoverable by those words. Thanks @rodboev. (#5163, fixes #5148)
- **`scripts/test.sh` now works on Windows Git Bash by accepting the Windows `.venv` layout.** The documented local test entrypoint created a valid `.venv` on Windows but then threw it away because it hardcoded the POSIX interpreter path (`.venv/bin/python`); it now resolves both the POSIX path and the Windows `.venv/Scripts/python.exe` in the one place used for venv create/reuse, dependency install, and the final pytest run (byte-identical behavior on POSIX/CI), unblocking Windows contributors running the documented flow. Thanks @rodboev. (#5166, fixes #5152)
- **Updating the Agent from the WebUI now restarts the gateway, so model switching works immediately afterward instead of bricking on a stale-module guard.** The in-app updater ("Update Now" → agent target) pulled new Agent code to disk and purged `__pycache__` but only re-exec'd the WebUI process — the Agent gateway kept running the old in-memory code, so the next model switch tripped the stale-module guard (*"This gateway is running code from … restart the gateway: `hermes gateway restart`"*) and the user had to restart it by hand. A successful agent update now invokes a shared `restart_active_profile_gateway()` helper (extracted from the existing health-restart path, same `hermes gateway restart` CLI delegation with its in-flight-run drain) — but only after all git operations succeed, and only for the `agent` target; a `busy`/`failed` update never restarts, and a `webui`-only update is unchanged. Thanks @rodboev. (#5181, fixes #5156)
+14 -1
View File
@@ -15,6 +15,7 @@ import os
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
@@ -187,9 +188,12 @@ def _run_git(args, cwd, timeout=10):
On failure, returns stderr (or stdout as fallback) so callers can
surface actionable git error messages instead of empty strings.
"""
git_executable = _resolve_git_executable()
if not git_executable:
return 'git executable not found', False
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
[git_executable] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
encoding='utf-8', errors='replace',
)
@@ -210,6 +214,15 @@ def _run_git(args, cwd, timeout=10):
return f'git failed to start: {exc}', False
def _resolve_git_executable():
git_executable = shutil.which('git')
if git_executable:
return git_executable
if sys.platform == 'darwin' and os.path.exists('/usr/bin/git'):
return '/usr/bin/git'
return None
def _dirty_suffix(path: Path, timeout=1) -> str:
"""Return a best-effort ``-dirty`` suffix without blocking version display."""
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
+129
View File
@@ -0,0 +1,129 @@
import sys
from unittest.mock import MagicMock, patch
import api.updates as updates
def test_run_git_uses_which_result_when_available(tmp_path):
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch.object(updates.subprocess, 'run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout='v0.51.999\n', stderr='')
out, ok = updates._run_git(['describe', '--tags'], tmp_path)
assert ok is True
assert out == 'v0.51.999'
assert mock_run.call_args.args[0][0] == 'C:/Tools/git.exe'
def test_run_git_falls_back_to_usr_bin_git_on_darwin(tmp_path):
def fake_run(cmd, **kwargs):
assert cmd[0] == '/usr/bin/git'
return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='')
with patch.object(updates.shutil, 'which', return_value=None), \
patch.object(sys, 'platform', 'darwin'), \
patch.object(updates.os.path, 'exists', return_value=True), \
patch.object(updates.subprocess, 'run', side_effect=fake_run):
out, ok = updates._run_git(['describe', '--tags'], tmp_path)
assert ok is True
assert out == 'v0.51.999'
def test_run_git_returns_not_found_when_no_executable(tmp_path):
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd[0])
if cmd[0] == '/usr/bin/git':
raise AssertionError('non-macOS path miss must not use /usr/bin/git')
raise FileNotFoundError
with patch.object(updates.shutil, 'which', return_value=None), \
patch.object(sys, 'platform', 'linux'), \
patch.object(updates.os.path, 'exists', return_value=True), \
patch.object(updates.subprocess, 'run', side_effect=fake_run):
out, ok = updates._run_git(['status'], tmp_path)
assert ok is False
assert out == 'git executable not found'
assert '/usr/bin/git' not in calls
def test_run_git_returns_not_found_when_usr_bin_git_absent_on_darwin(tmp_path):
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd[0])
raise FileNotFoundError
with patch.object(updates.shutil, 'which', return_value=None), \
patch.object(sys, 'platform', 'darwin'), \
patch.object(updates.os.path, 'exists', return_value=False), \
patch.object(updates.subprocess, 'run', side_effect=fake_run):
out, ok = updates._run_git(['status'], tmp_path)
assert ok is False
assert out == 'git executable not found'
assert '/usr/bin/git' not in calls
def test_detect_webui_version_recovers_via_launchd_fallback(tmp_path):
def fake_run(cmd, **kwargs):
assert cmd[0] == '/usr/bin/git'
if cmd[1:] == ['describe', '--tags', '--always']:
return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='')
if cmd[1:] == ['diff-index', '--quiet', 'HEAD', '--']:
return MagicMock(returncode=0, stdout='', stderr='')
raise AssertionError(f'unexpected git args: {cmd[1:]!r}')
with patch.object(updates.shutil, 'which', return_value=None), \
patch.object(sys, 'platform', 'darwin'), \
patch.object(updates.os.path, 'exists', return_value=True), \
patch.object(updates, 'REPO_ROOT', tmp_path), \
patch.object(updates.subprocess, 'run', side_effect=fake_run):
version = updates._detect_webui_version()
assert version == 'v0.51.999'
def test_check_repo_does_not_report_git_not_found_via_launchd_fallback(tmp_path):
(tmp_path / '.git').mkdir()
def fake_run(cmd, **kwargs):
assert cmd[0] == '/usr/bin/git'
git_args = cmd[1:]
if git_args == ['fetch', 'origin', '--tags', '--force']:
return MagicMock(returncode=0, stdout='', stderr='')
if git_args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return MagicMock(returncode=0, stdout='', stderr='')
if git_args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return MagicMock(returncode=0, stdout='origin/master\n', stderr='')
if git_args == ['rev-list', '--count', 'HEAD..origin/master']:
return MagicMock(returncode=0, stdout='0\n', stderr='')
if git_args == ['merge-base', 'HEAD', 'origin/master']:
return MagicMock(returncode=0, stdout='abcdef1234567890\n', stderr='')
if git_args == ['rev-parse', '--short', 'abcdef1234567890']:
return MagicMock(returncode=0, stdout='abcdef1\n', stderr='')
if git_args == ['rev-parse', '--short', 'origin/master']:
return MagicMock(returncode=0, stdout='fedcba9\n', stderr='')
if git_args == ['remote', 'get-url', 'origin']:
return MagicMock(
returncode=0,
stdout='https://github.com/nesquena/hermes-webui.git\n',
stderr='',
)
if git_args == ['diff-index', '--quiet', 'HEAD', '--']:
return MagicMock(returncode=0, stdout='', stderr='')
raise AssertionError(f'unexpected git args: {git_args!r}')
with patch.object(updates.shutil, 'which', return_value=None), \
patch.object(sys, 'platform', 'darwin'), \
patch.object(updates.os.path, 'exists', return_value=True), \
patch.object(updates.subprocess, 'run', side_effect=fake_run):
info = updates._check_repo(tmp_path, 'webui')
assert info['behind'] == 0
assert info['dirty'] is False
assert 'error' not in info
+10 -5
View File
@@ -288,7 +288,8 @@ def test_update_cache_is_scoped_by_agent_inclusion(tmp_path):
def test_run_git_returns_stderr_on_failure(tmp_path):
"""When a git command fails, _run_git should return stderr (not empty string)."""
with patch('subprocess.run') as mock_run:
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=1,
stdout='',
@@ -302,7 +303,8 @@ def test_run_git_returns_stderr_on_failure(tmp_path):
def test_run_git_returns_stdout_when_no_stderr(tmp_path):
"""If stderr is empty on failure, fall back to stdout."""
with patch('subprocess.run') as mock_run:
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=128,
stdout='Already up to date.',
@@ -316,7 +318,8 @@ def test_run_git_returns_stdout_when_no_stderr(tmp_path):
def test_run_git_returns_exit_code_when_no_output(tmp_path):
"""If both stdout and stderr are empty, report the exit code."""
with patch('subprocess.run') as mock_run:
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=1,
stdout='',
@@ -330,7 +333,8 @@ def test_run_git_returns_exit_code_when_no_output(tmp_path):
def test_run_git_uses_utf8_replacement_for_windows_console_output(tmp_path):
"""Git output can contain Unicode even when Windows' active code page cannot."""
with patch('subprocess.run') as mock_run:
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout='v0.51.184\n', stderr=None)
out, ok = updates._run_git(['describe', '--tags'], tmp_path)
@@ -344,7 +348,8 @@ def test_run_git_uses_utf8_replacement_for_windows_console_output(tmp_path):
def test_run_git_handles_missing_stdout_after_decode_thread_failure(tmp_path):
"""A subprocess reader failure must not make version detection crash on import."""
with patch('subprocess.run') as mock_run:
with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \
patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout=None, stderr=None)
out, ok = updates._run_git(['diff', '--binary', 'HEAD', '--'], tmp_path)