mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
perf(cli): skip npm install during update when lockfile is unchanged (#17268)
(cherry picked from commit 8fb6d5e910)
(cherry picked from commit 27474007b9463d7ce19d981a24aeeac552e79f48)
This commit is contained in:
parent
fd461b58ca
commit
aa56243a89
2 changed files with 132 additions and 1 deletions
|
|
@ -8126,6 +8126,39 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
|||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _npm_lockfile_changed(hermes_root: Path) -> bool:
|
||||
lockfile = PROJECT_ROOT / "package-lock.json"
|
||||
if not lockfile.exists():
|
||||
return True
|
||||
# Also check that node_modules exists; a matching hash with missing
|
||||
# node_modules means the cache was recorded by another checkout.
|
||||
if not (PROJECT_ROOT / "node_modules").is_dir():
|
||||
return True
|
||||
try:
|
||||
current = hashlib.sha256(lockfile.read_bytes()).hexdigest()
|
||||
# Key the cache by PROJECT_ROOT so parallel worktrees don't collide.
|
||||
cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]
|
||||
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
if not cache_file.exists():
|
||||
return True
|
||||
return cache_file.read_text(encoding="utf-8").strip() != current
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
|
||||
lockfile = PROJECT_ROOT / "package-lock.json"
|
||||
if not lockfile.exists():
|
||||
return
|
||||
try:
|
||||
digest = hashlib.sha256(lockfile.read_bytes()).hexdigest()
|
||||
cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]
|
||||
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
cache_file.write_text(digest, encoding="utf-8")
|
||||
except OSError:
|
||||
logger.debug("Could not write npm lockfile hash cache")
|
||||
|
||||
|
||||
def _update_node_dependencies() -> None:
|
||||
from hermes_constants import find_node_executable, with_hermes_node_path
|
||||
|
||||
|
|
@ -8136,6 +8169,13 @@ def _update_node_dependencies() -> None:
|
|||
if not (PROJECT_ROOT / "package.json").exists():
|
||||
return
|
||||
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
hermes_root = get_default_hermes_root()
|
||||
if not _npm_lockfile_changed(hermes_root):
|
||||
logger.info("npm lockfile unchanged, skipping npm install")
|
||||
return
|
||||
|
||||
# With a single workspace lockfile the root install would cover ALL
|
||||
# workspaces — but apps/desktop pulls in Electron as a devDependency,
|
||||
# and its postinstall downloads a ~200MB binary. Most users don't
|
||||
|
|
@ -8180,6 +8220,7 @@ def _update_node_dependencies() -> None:
|
|||
env=nixos_env,
|
||||
)
|
||||
if ws_result.returncode == 0:
|
||||
_record_npm_lockfile_hash(hermes_root)
|
||||
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
|
||||
else:
|
||||
print(" ⚠ npm workspace install failed")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Tests for cmd_update — branch fallback when remote branch doesn't exist."""
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
|
@ -70,6 +71,92 @@ def _patch_managed_uv(request):
|
|||
yield
|
||||
|
||||
|
||||
class TestCmdUpdateNpmLockfileCache:
|
||||
@staticmethod
|
||||
def _cache_file(hermes_root, project_root):
|
||||
cache_key = hashlib.sha256(str(project_root).encode()).hexdigest()[:12]
|
||||
return hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
|
||||
def test_npm_lockfile_changed_no_cache(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
|
||||
assert hm._npm_lockfile_changed(tmp_path) is True
|
||||
|
||||
def test_npm_lockfile_changed_matching(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
content = b'{"lockfileVersion": 3}'
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_bytes(content)
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
self._cache_file(tmp_path, tmp_path).write_text(digest)
|
||||
|
||||
assert hm._npm_lockfile_changed(tmp_path) is False
|
||||
|
||||
def test_npm_lockfile_changed_mismatch(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
self._cache_file(tmp_path, tmp_path).write_text("old-digest")
|
||||
|
||||
assert hm._npm_lockfile_changed(tmp_path) is True
|
||||
|
||||
def test_npm_lockfile_changed_missing_node_modules(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
content = b'{"lockfileVersion": 3}'
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
self._cache_file(tmp_path, tmp_path).write_text(digest)
|
||||
# node_modules missing: should report changed even though hash matches
|
||||
|
||||
assert hm._npm_lockfile_changed(tmp_path) is True
|
||||
|
||||
def test_record_npm_lockfile_hash(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
content = b'{"lockfileVersion": 3}'
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_bytes(content)
|
||||
|
||||
hm._record_npm_lockfile_hash(tmp_path)
|
||||
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
assert self._cache_file(tmp_path, tmp_path).read_text() == expected
|
||||
|
||||
def test_npm_lockfile_changed_cache_read_error(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
# Make cache file a directory to cause OSError on read
|
||||
self._cache_file(tmp_path, tmp_path).mkdir(parents=True)
|
||||
|
||||
assert hm._npm_lockfile_changed(tmp_path) is True
|
||||
|
||||
def test_update_skips_npm_when_lockfile_unchanged(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch.object(hm, "_npm_lockfile_changed", return_value=False), \
|
||||
patch("subprocess.run") as mock_run:
|
||||
hm._update_node_dependencies()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestCmdUpdatePip:
|
||||
"""Regression tests for pip-install update flows."""
|
||||
|
||||
|
|
@ -246,7 +333,10 @@ class TestCmdUpdateBranchFallback:
|
|||
), patch.object(hm, "_sync_with_upstream_if_needed") as sync_mock:
|
||||
cmd_update(mock_args)
|
||||
|
||||
sync_mock.assert_called_once_with(["git"], PROJECT_ROOT)
|
||||
expected_git_cmd = (
|
||||
["git", "-c", "windows.appendAtomically=false"] if hm._is_windows() else ["git"]
|
||||
)
|
||||
sync_mock.assert_called_once_with(expected_git_cmd, PROJECT_ROOT)
|
||||
captured = capsys.readouterr()
|
||||
assert "Already up to date!" in captured.out
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue