Skip to content

Commit 9d4b3e5

Browse files
fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage NousResearch#3489) (NousResearch#3492)
* fix: harden `hermes update` against diverged history, non-main branches, and gateway edge cases The self-update command (`hermes update` / gateway `/update`) could fail or silently corrupt state in several scenarios: 1. **Diverged history** — `git pull --ff-only` aborts with a cryptic subprocess error when upstream has force-pushed or rebased. Now falls back to `git reset --hard origin/main` since local changes are already stashed. 2. **User on a feature branch / detached HEAD** — the old code would either clobber the feature branch HEAD to point at origin/main, or silently pull against a non-existent remote branch. Now auto-checkouts main before pulling, with a clear warning. 3. **Fetch failures** — network or auth errors produced raw subprocess tracebacks. Now shows user-friendly messages ("Network error", "Authentication failed") with actionable hints. 4. **reset --hard failure** — if the fallback reset itself fails (disk full, permissions), the old code would still attempt stash restore on a broken working tree. Now skips restore and tells the user their changes are safe in stash. 5. **Gateway /update stash conflicts** — non-interactive mode (Telegram `/update`) called sys.exit(1) when stash restore had conflicts, making the entire update report as failed even though the code update itself succeeded. Now treats stash conflicts as non-fatal in non-interactive mode (returns False instead of exiting). * fix: restore stash and branch on 'already up to date' early return The PR moved stash creation before the commit-count check (needed for the branch-switching feature), but the 'already up to date' early return didn't restore the stash or switch back to the original branch — leaving the user stranded on main with changes trapped in a stash. Now the early-return path restores the stash and checks out the original branch when applicable. --------- Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
1 parent 6ed9740 commit 9d4b3e5

2 files changed

Lines changed: 338 additions & 31 deletions

File tree

hermes_cli/main.py

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2632,7 +2632,12 @@ def _restore_stashed_changes(
26322632
print("Resolve conflicts manually, then run: git stash drop")
26332633

26342634
print(f"Restore your changes with: git stash apply {stash_ref}")
2635-
sys.exit(1)
2635+
# In non-interactive mode (gateway /update), don't abort — the code
2636+
# update itself succeeded, only the stash restore had conflicts.
2637+
# Aborting would report the entire update as failed.
2638+
if prompt_user:
2639+
sys.exit(1)
2640+
return False
26362641

26372642
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
26382643
if stash_selector is None:
@@ -2706,62 +2711,130 @@ def cmd_update(args):
27062711

27072712
# Fetch and pull
27082713
try:
2709-
print("→ Fetching updates...")
27102714
git_cmd = ["git"]
27112715
if sys.platform == "win32":
27122716
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
2713-
2714-
subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
2715-
2716-
# Get current branch
2717+
2718+
print("→ Fetching updates...")
2719+
fetch_result = subprocess.run(
2720+
git_cmd + ["fetch", "origin"],
2721+
cwd=PROJECT_ROOT,
2722+
capture_output=True,
2723+
text=True,
2724+
)
2725+
if fetch_result.returncode != 0:
2726+
stderr = fetch_result.stderr.strip()
2727+
if "Could not resolve host" in stderr or "unable to access" in stderr:
2728+
print("✗ Network error — cannot reach the remote repository.")
2729+
print(f" {stderr.splitlines()[0]}" if stderr else "")
2730+
elif "Authentication failed" in stderr or "could not read Username" in stderr:
2731+
print("✗ Authentication failed — check your git credentials or SSH key.")
2732+
else:
2733+
print(f"✗ Failed to fetch updates from origin.")
2734+
if stderr:
2735+
print(f" {stderr.splitlines()[0]}")
2736+
sys.exit(1)
2737+
2738+
# Get current branch (returns literal "HEAD" when detached)
27172739
result = subprocess.run(
27182740
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
27192741
cwd=PROJECT_ROOT,
27202742
capture_output=True,
27212743
text=True,
2722-
check=True
2744+
check=True,
27232745
)
2724-
branch = result.stdout.strip()
2746+
current_branch = result.stdout.strip()
27252747

2726-
# Fall back to main if the current branch doesn't exist on the remote
2727-
verify = subprocess.run(
2728-
git_cmd + ["rev-parse", "--verify", f"origin/{branch}"],
2729-
cwd=PROJECT_ROOT, capture_output=True, text=True,
2730-
)
2731-
if verify.returncode != 0:
2732-
branch = "main"
2748+
# Always update against main
2749+
branch = "main"
2750+
2751+
# If user is on a non-main branch or detached HEAD, switch to main
2752+
if current_branch != "main":
2753+
label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'"
2754+
print(f" ⚠ Currently on {label} — switching to main for update...")
2755+
# Stash before checkout so uncommitted work isn't lost
2756+
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
2757+
subprocess.run(
2758+
git_cmd + ["checkout", "main"],
2759+
cwd=PROJECT_ROOT,
2760+
capture_output=True,
2761+
text=True,
2762+
check=True,
2763+
)
2764+
else:
2765+
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
2766+
2767+
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
27332768

27342769
# Check if there are updates
27352770
result = subprocess.run(
27362771
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
27372772
cwd=PROJECT_ROOT,
27382773
capture_output=True,
27392774
text=True,
2740-
check=True
2775+
check=True,
27412776
)
27422777
commit_count = int(result.stdout.strip())
2743-
2778+
27442779
if commit_count == 0:
27452780
_invalidate_update_cache()
2781+
# Restore stash and switch back to original branch if we moved
2782+
if auto_stash_ref is not None:
2783+
_restore_stashed_changes(
2784+
git_cmd, PROJECT_ROOT, auto_stash_ref,
2785+
prompt_user=prompt_for_restore,
2786+
)
2787+
if current_branch not in ("main", "HEAD"):
2788+
subprocess.run(
2789+
git_cmd + ["checkout", current_branch],
2790+
cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
2791+
)
27462792
print("✓ Already up to date!")
27472793
return
2748-
2749-
print(f"→ Found {commit_count} new commit(s)")
27502794

2751-
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
2752-
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
2795+
print(f"→ Found {commit_count} new commit(s)")
27532796

27542797
print("→ Pulling updates...")
2798+
update_succeeded = False
27552799
try:
2756-
subprocess.run(git_cmd + ["pull", "--ff-only", "origin", branch], cwd=PROJECT_ROOT, check=True)
2800+
pull_result = subprocess.run(
2801+
git_cmd + ["pull", "--ff-only", "origin", branch],
2802+
cwd=PROJECT_ROOT,
2803+
capture_output=True,
2804+
text=True,
2805+
)
2806+
if pull_result.returncode != 0:
2807+
# ff-only failed — local and remote have diverged (e.g. upstream
2808+
# force-pushed or rebase). Since local changes are already
2809+
# stashed, reset to match the remote exactly.
2810+
print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...")
2811+
reset_result = subprocess.run(
2812+
git_cmd + ["reset", "--hard", f"origin/{branch}"],
2813+
cwd=PROJECT_ROOT,
2814+
capture_output=True,
2815+
text=True,
2816+
)
2817+
if reset_result.returncode != 0:
2818+
print(f"✗ Failed to reset to origin/{branch}.")
2819+
if reset_result.stderr.strip():
2820+
print(f" {reset_result.stderr.strip()}")
2821+
print(" Try manually: git fetch origin && git reset --hard origin/main")
2822+
sys.exit(1)
2823+
update_succeeded = True
27572824
finally:
27582825
if auto_stash_ref is not None:
2759-
_restore_stashed_changes(
2760-
git_cmd,
2761-
PROJECT_ROOT,
2762-
auto_stash_ref,
2763-
prompt_user=prompt_for_restore,
2764-
)
2826+
# Don't attempt stash restore if the code update itself failed —
2827+
# working tree is in an unknown state.
2828+
if not update_succeeded:
2829+
print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})")
2830+
print(f" Restore manually with: git stash apply")
2831+
else:
2832+
_restore_stashed_changes(
2833+
git_cmd,
2834+
PROJECT_ROOT,
2835+
auto_stash_ref,
2836+
prompt_user=prompt_for_restore,
2837+
)
27652838

27662839
_invalidate_update_cache()
27672840

0 commit comments

Comments
 (0)