fix(update): require managed marker before destructive clean

This commit is contained in:
helix4u 2026-06-04 22:33:16 -06:00 committed by Teknium
parent ad69d3edc7
commit c8e80cd0bf
2 changed files with 86 additions and 27 deletions

View file

@ -8149,21 +8149,21 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool:
"""Discard working-tree dirt on a managed (non-fork) clone.
"""Discard working-tree dirt on an explicitly managed checkout.
On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or
~/.hermes/hermes-agent) the user never edits the source tree, so any
"dirty" state is pure git artifact: CRLF renormalization, npm lockfile
churn, or files left behind when a directory was deleted upstream (e.g.
apps/bootstrap-installer/). Stashing that dirt and re-applying it after a
pull is actively dangerous the stash/restore cycle has been observed to
clobber freshly-pulled source files (apps/desktop/ deletion
On a Desktop/bootstrap-managed install the user never edits the source
tree, so any "dirty" state is pure git artifact: CRLF renormalization, npm
lockfile churn, or files left behind when a directory was deleted upstream
(e.g. apps/bootstrap-installer/). Stashing that dirt and re-applying it
after a pull is actively dangerous the stash/restore cycle has been
observed to clobber freshly-pulled source files (apps/desktop/ deletion
"[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
For a managed clone the correct move is to throw the dirt away with
``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's
update path), NOT preserve it. Forks keep the stash machinery because
their local edits are intentional.
For an explicitly managed checkout the correct move is to throw the dirt
away with ``git reset --hard HEAD`` + ``git clean -fd`` (mirroring
install.ps1's update path), NOT preserve it. Ordinary source checkouts,
including upstream-origin checkouts, keep the stash machinery because
their local edits may be intentional.
Returns True if the tree was cleaned (or was already clean), False on
a git failure (caller should fall back to the stash path).
@ -8353,6 +8353,7 @@ OFFICIAL_REPO_URLS = {
}
OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
MANAGED_CHECKOUT_MARKERS = (".hermes-bootstrap-complete",)
def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
@ -8388,6 +8389,19 @@ def _is_fork(origin_url: Optional[str]) -> bool:
return True
def _is_managed_update_checkout(origin_url: Optional[str], cwd: Path) -> bool:
"""Return True when this official checkout is safe to clean destructively.
The destructive clean path is only safe for checkouts Hermes explicitly
owns. An official ``origin`` alone is not enough proof: contributors can
also work from upstream-origin source checkouts with intentional local
files.
"""
if not origin_url or _is_fork(origin_url):
return False
return any((cwd / marker).is_file() for marker in MANAGED_CHECKOUT_MARKERS)
def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Check if an 'upstream' remote already exists."""
try:
@ -10227,9 +10241,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
# lockfile churn) update with a clean tree.
_discard_lockfile_churn(git_cmd, PROJECT_ROOT)
# Detect if we're updating from a fork (before any branch logic)
# Detect if we're updating from a fork, and whether this official-origin
# checkout has an explicit Hermes-owned marker that makes destructive
# worktree cleanup safe.
origin_url = _get_origin_url(git_cmd, PROJECT_ROOT)
is_fork = _is_fork(origin_url)
is_managed_checkout = _is_managed_update_checkout(origin_url, PROJECT_ROOT)
if is_fork:
print("⚠ Updating from fork:")
@ -10295,11 +10312,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
else f"branch '{current_branch}'"
)
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
# Stash before checkout so uncommitted work isn't lost — but on a
# managed (non-fork) clone there's nothing to preserve, so discard
# git-artifact dirt instead (a dirty tree would otherwise block the
# checkout). Forks keep the stash so their edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
# Stash before checkout so uncommitted work isn't lost — but on an
# explicitly managed checkout there's nothing to preserve, so
# discard git-artifact dirt instead (a dirty tree would otherwise
# block the checkout). Other checkouts keep the stash so their
# edits survive.
if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
@ -10336,13 +10354,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f" {track_result.stderr.strip().splitlines()[0]}")
sys.exit(1)
else:
# On a managed (non-fork) clone the user never edits the source
# tree, so any dirt is git artifact (CRLF, lockfile churn,
# On an explicitly managed checkout the user never edits the
# source tree, so any dirt is git artifact (CRLF, lockfile churn,
# upstream-deleted dirs). Throw it away rather than stash/restore
# it — the stash/restore cycle has clobbered freshly-pulled source
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall
# through to the stash path so their intentional edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Other
# checkouts fall through to the stash path so their intentional
# edits survive.
if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)