Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .lazy-refresh-incomplete
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
started=1785343166.9711895
pid=2093896
116 changes: 77 additions & 39 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11026,15 +11026,33 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
depth_args = ["--depth", "1"] if is_shallow else []

if branch == "main":
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
# Probe locally (~6 ms) whether an 'upstream' remote exists at all
# before spending a network fetch on it. Non-fork installs have no
# 'upstream' remote, and the old flow burned a failed network attempt
# (~0.3-1 s) on every --check before falling back to origin.
has_upstream_remote = (
subprocess.run(
git_cmd + ["remote", "get-url", "upstream"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
).returncode
== 0
)
if fetch_result.returncode != 0:
# Fallback to origin if upstream doesn't exist
fetch_result = None
if has_upstream_remote:
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if fetch_result is not None and fetch_result.returncode == 0:
upstream_exists = True
compare_branch = f"upstream/{branch}"
else:
# No upstream remote, or the upstream fetch failed — use origin.
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch"] + depth_args + ["origin", branch],
Expand All @@ -11044,9 +11062,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
)
upstream_exists = False
compare_branch = f"origin/{branch}"
else:
upstream_exists = True
compare_branch = f"upstream/{branch}"
else:
# Non-default branch: compare against origin/<branch> directly.
print("→ Fetching from origin...")
Expand Down Expand Up @@ -12547,8 +12562,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
# the bad commit and the fix landing).
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
try:
# Merge the ref we already fetched above (→ Fetching updates...)
# instead of `git pull`, which performs a SECOND network fetch of
# the same branch (~0.5-1.5 s of redundant round-trip per update).
# `merge --ff-only origin/<branch>` is byte-identical in effect to
# `pull --ff-only origin <branch>` given the fresh tracking ref;
# the divergence fallback below is unchanged.
pull_result = subprocess.run(
git_cmd + ["pull", "--ff-only", "origin", branch],
git_cmd + ["merge", "--ff-only", f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
Expand Down Expand Up @@ -12781,34 +12802,51 @@ def _cmd_update_impl(args, gateway_mode: bool):
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
if (desktop_dir / "package.json").exists() and _resolve_node_runtime_npm() and has_desktop_app:
print("→ Checking if desktop app needs rebuilding...")
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the (very loud) Electron/vite build output into
# update.log instead of streaming it to the terminal. On the rare
# nonzero exit, retry once after waiting again for the venv — this
# covers a still-settling rebuild window the first wait didn't fully
# catch — then surface the captured tail so the failure is
# debuggable.
#
# Start the build subprocess with the Hermes-managed Node on PATH:
# when `hermes update` runs inside the desktop updater chain
# (Desktop → hermes-setup → hermes update), the shell PATH
# customizations are lost, so a bare-PATH child would fail with
# `node: not found` before cmd_gui can self-heal.
from hermes_constants import with_hermes_node_path

_build_env = with_hermes_node_path()
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
else:
# Consult the content-hash stamp IN-PROCESS first. The spawned
# `hermes desktop --build-only` subprocess re-imports the whole
# CLI stack (~1-3 s) just to reach the same _desktop_build_needed
# check; when the stamp already says "up to date" we can skip the
# spawn entirely. The update path never passes --source, so the
# subprocess would run with source_mode=False — mirror that here.
# Any error in the pre-check falls through to the subprocess.
_skip_desktop_build = False
try:
_skip_desktop_build = not _desktop_build_needed(
desktop_dir, PROJECT_ROOT, source_mode=False
)
except Exception:
_skip_desktop_build = False
if _skip_desktop_build:
print(" ✓ Desktop app up to date")
else:
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the (very loud) Electron/vite build output into
# update.log instead of streaming it to the terminal. On the rare
# nonzero exit, retry once after waiting again for the venv — this
# covers a still-settling rebuild window the first wait didn't fully
# catch — then surface the captured tail so the failure is
# debuggable.
#
# Start the build subprocess with the Hermes-managed Node on PATH:
# when `hermes update` runs inside the desktop updater chain
# (Desktop → hermes-setup → hermes update), the shell PATH
# customizations are lost, so a bare-PATH child would fail with
# `node: not found` before cmd_gui can self-heal.
from hermes_constants import with_hermes_node_path

_build_env = with_hermes_node_path()
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
build_result = _run_logged_subprocess(_desktop_build_cmd, cwd=PROJECT_ROOT, env=_build_env)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
else:
print(" ✓ Desktop app up to date")

print()
print("✓ Code updated!")
Expand Down
87 changes: 68 additions & 19 deletions hermes_cli/managed_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,41 +274,90 @@ def ensure_uv(
return _UvResult(result)


def _uv_self_update_is_fresh(now: float | None = None) -> bool:
"""Return True when ``uv self update`` ran recently enough to skip.

uv releases roughly weekly while many users run ``hermes update`` daily;
re-running a blocking network self-update on every invocation is waste
and, offline, an unbounded hang risk. A stamp file under HERMES_HOME
caches the last successful self-update time.
"""
try:
from hermes_constants import get_hermes_home

stamp = get_hermes_home() / "cache" / ".uv_self_update_stamp"
age = (now if now is not None else time.time()) - stamp.stat().st_mtime
return 0 <= age < UV_SELF_UPDATE_INTERVAL_SECONDS
except Exception:
return False


def _touch_uv_self_update_stamp() -> None:
try:
from hermes_constants import get_hermes_home

stamp = get_hermes_home() / "cache" / ".uv_self_update_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.touch()
except OSError:
pass


# uv ships releases ~weekly; refresh the managed binary at most this often.
UV_SELF_UPDATE_INTERVAL_SECONDS = 7 * 24 * 3600
# `uv self update` is a network call; unbounded it can hang forever on a
# blackholed connection (no default timeout in uv's downloader path).
UV_SELF_UPDATE_TIMEOUT_SECONDS = 60


def update_managed_uv(
*,
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
force: bool = False,
) -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.

Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path when uv is available and ``None`` otherwise.
A self-update failure is non-fatal because the old version still works.
``repair_observer``, when provided, receives the runtime repair result.

The network self-update is skipped when it succeeded within the last
``UV_SELF_UPDATE_INTERVAL_SECONDS`` (7 days) unless ``force=True``; the
vulnerable-runtime repair probe below ALWAYS runs — CVE-driven runtime
repair must never be gated behind the freshness stamp.
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None

result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug(
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
)
if force or not _uv_self_update_is_fresh():
try:
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
timeout=UV_SELF_UPDATE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
logger.debug("uv self update timed out after %ss", UV_SELF_UPDATE_TIMEOUT_SECONDS)
result = None
if result is not None and result.returncode == 0:
_touch_uv_self_update_stamp()
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
elif result is not None:
# Non-fatal — old uv still works fine.
logger.debug(
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
)

# Keep this hook inside the long-standing API. During an update, main.py is
# already imported from the old checkout, then ``git pull`` replaces this
Expand Down
25 changes: 13 additions & 12 deletions tests/hermes_cli/test_cmd_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,11 @@ def test_update_falls_back_to_main_when_branch_not_on_remote(
assert "origin/main" in rev_list_cmds[0]
assert "origin/fix/stoicneko" not in rev_list_cmds[0]

# pull should use main, not fix/stoicneko
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
# the ff-only merge should target origin/main, not the feature branch
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert len(merge_cmds) == 1
assert "origin/main" in merge_cmds[0]
assert "fix/stoicneko" not in merge_cmds[0]

@patch("shutil.which", return_value=None)
@patch("subprocess.run")
Expand All @@ -381,9 +382,9 @@ def test_update_uses_current_branch_when_on_remote(
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]

pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert len(merge_cmds) == 1
assert "origin/main" in merge_cmds[0]

@patch("shutil.which", return_value=None)
@patch("subprocess.run")
Expand All @@ -408,9 +409,9 @@ def test_update_already_up_to_date(
assert update_observer.__self__ is ensure_observer.__self__
assert update_observer.__self__ == []

# Should NOT have called pull
# Should NOT have advanced the checkout (no pull, no ff-only merge)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
pull_cmds = [c for c in commands if "pull" in c]
pull_cmds = [c for c in commands if "pull" in c or "merge --ff-only" in c]
assert len(pull_cmds) == 0

@patch("shutil.which", return_value=None)
Expand Down Expand Up @@ -824,9 +825,9 @@ def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, cap
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds

# pull must target bb/gui
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
# the ff-only merge must target origin/bb/gui
merge_cmds = [c for c in commands if "merge --ff-only" in c]
assert any("origin/bb/gui" in c and "origin/main" not in c for c in merge_cmds), merge_cmds

@patch("shutil.which", return_value=None)
@patch("subprocess.run")
Expand Down
Loading
Loading