fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)

The stash/restore cycle in the update path was observed to clobber
freshly-pulled source files (apps/desktop/ deletion -> Vite
'[UNRESOLVED_ENTRY] Cannot resolve entry module index.html'). On a
managed clone the user never edits the source tree, so any 'dirty' state
is pure git artifact (CRLF renormalization, npm lockfile churn, files
left behind when a directory was deleted upstream such as
apps/bootstrap-installer/). Stashing that and re-applying it after a pull
is fragile and unnecessary.

- hermes update (hermes_cli/main.py): on a non-fork (managed) clone,
  discard working-tree dirt via reset --hard HEAD + clean -fd instead of
  stash/apply. Forks keep the stash machinery so intentional edits
  survive. Also pin core.autocrlf=false on Windows so the dirt is never
  created (mirrors install.ps1 #38239).
- install.sh: replace the update-path stash/restore dance with a hard
  reset to origin/<branch>; the installer is a managed-only entry point.
- install.sh + install.ps1 desktop stage: prefer 'npm ci' (wipes and
  reinstalls node_modules from the lockfile) over bare 'npm install',
  which can report 'up to date' against a stale marker while node_modules
  is empty -- leaving tsc unresolved so 'npm run pack' fails.

Tests: managed clone cleans instead of stashing; fork still stashes;
existing stash tests force the stash path explicitly.
This commit is contained in:
Teknium
2026-06-03 16:40:13 -07:00
committed by GitHub
parent 1927ff217e
commit 8a19884bf3
4 changed files with 208 additions and 47 deletions
+82
View File
@@ -597,6 +597,10 @@ def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatc
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: "abc123deadbeef",
)
# Force the stash path (not the managed-clone clean path) so this test
# exercises stash restore. A real fork, or a clone where the managed
# clean fails, falls through to stash.
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
@@ -635,6 +639,81 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
assert len(checkout_calls) == 0
def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path):
"""On a non-fork (managed) clone, working-tree dirt is discarded via
_clean_managed_worktree, NOT preserved via stash/restore.
The stash/restore cycle has clobbered freshly-pulled source files
(apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A managed clone
has nothing the user authored, so the correct move is to throw the
git-artifact dirt away and pull cleanly.
"""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
# Official origin → not a fork.
monkeypatch.setattr(
hermes_main, "_get_origin_url",
lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git",
)
clean_calls = []
monkeypatch.setattr(
hermes_main, "_clean_managed_worktree",
lambda *a, **kw: clean_calls.append(1) or True,
)
stash_calls = []
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: stash_calls.append(1) or "shouldnotbeused",
)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",
lambda *a, **kw: restore_calls.append(1) or True,
)
side_effect, _ = _make_update_side_effect(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
# Managed clean path used; stash path never touched.
assert len(clean_calls) == 1
assert len(stash_calls) == 0
assert len(restore_calls) == 0
def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path):
"""A fork (non-official origin) keeps the stash machinery so the user's
intentional local edits survive the update."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(
hermes_main, "_get_origin_url",
lambda *a, **kw: "https://github.com/someuser/hermes-agent.git",
)
clean_calls = []
monkeypatch.setattr(
hermes_main, "_clean_managed_worktree",
lambda *a, **kw: clean_calls.append(1) or True,
)
stash_calls = []
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: stash_calls.append(1) or "abc123",
)
monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True)
monkeypatch.setattr(hermes_main, "_sync_with_upstream_if_needed", lambda *a, **kw: None)
side_effect, _ = _make_update_side_effect(commit_count="0")
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
hermes_main.cmd_update(SimpleNamespace())
# Fork: stash path used, managed clean NOT used.
assert len(stash_calls) == 1
assert len(clean_calls) == 0
# ---------------------------------------------------------------------------
# Fetch failure — friendly error messages
# ---------------------------------------------------------------------------
@@ -685,6 +764,9 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path,
hermes_main, "_stash_local_changes_if_needed",
lambda *a, **kw: "abc123deadbeef",
)
# Force the stash path so this test exercises the reset-failure handling
# of the stash branch (not the managed-clone clean path).
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
restore_calls = []
monkeypatch.setattr(
hermes_main, "_restore_stashed_changes",