diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9323fe378b..a53694bb8a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2849,6 +2849,231 @@ def _restore_stashed_changes( print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") return True +# ========================================================================= +# Fork detection and upstream management for `hermes update` +# ========================================================================= + +OFFICIAL_REPO_URLS = { + "https://github.com/NousResearch/hermes-agent.git", + "git@github.com:NousResearch/hermes-agent.git", + "https://github.com/NousResearch/hermes-agent", + "git@github.com:NousResearch/hermes-agent", +} +OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" +SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" + + +def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: + """Get the URL of the origin remote, or None if not set.""" + try: + result = subprocess.run( + git_cmd + ["remote", "get-url", "origin"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None + + +def _is_fork(origin_url: Optional[str]) -> bool: + """Check if the origin remote points to a fork (not the official repo).""" + if not origin_url: + return False + # Normalize URL for comparison (strip trailing .git if present) + normalized = origin_url.rstrip("/") + if normalized.endswith(".git"): + normalized = normalized[:-4] + for official in OFFICIAL_REPO_URLS: + official_normalized = official.rstrip("/") + if official_normalized.endswith(".git"): + official_normalized = official_normalized[:-4] + if normalized == official_normalized: + return False + return True + + +def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: + """Check if an 'upstream' remote already exists.""" + try: + result = subprocess.run( + git_cmd + ["remote", "get-url", "upstream"], + cwd=cwd, + capture_output=True, + text=True, + ) + return result.returncode == 0 + except Exception: + return False + + +def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: + """Add the official repo as the 'upstream' remote. Returns True on success.""" + try: + result = subprocess.run( + git_cmd + ["remote", "add", "upstream", OFFICIAL_REPO_URL], + cwd=cwd, + capture_output=True, + text=True, + ) + return result.returncode == 0 + except Exception: + return False + + +def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int: + """Count commits on `head` that are not on `base`. Returns -1 on error.""" + try: + result = subprocess.run( + git_cmd + ["rev-list", "--count", f"{base}..{head}"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return int(result.stdout.strip()) + except Exception: + pass + return -1 + + +def _should_skip_upstream_prompt() -> bool: + """Check if user previously declined to add upstream.""" + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + return (hermes_home / SKIP_UPSTREAM_PROMPT_FILE).exists() + + +def _mark_skip_upstream_prompt(): + """Create marker file to skip future upstream prompts.""" + try: + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + (hermes_home / SKIP_UPSTREAM_PROMPT_FILE).touch() + except Exception: + pass + + +def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool: + """Attempt to push updated main to origin (sync fork). + + Returns True if push succeeded, False otherwise. + """ + try: + result = subprocess.run( + git_cmd + ["push", "origin", "main", "--force-with-lease"], + cwd=cwd, + capture_output=True, + text=True, + ) + return result.returncode == 0 + except Exception: + return False + + +def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: + """Check if fork is behind upstream and sync if safe. + + This implements the fork upstream sync logic: + - If upstream remote doesn't exist, ask user if they want to add it + - Compare origin/main with upstream/main + - If origin/main is strictly behind upstream/main, pull from upstream + - Try to sync fork back to origin if possible + """ + has_upstream = _has_upstream_remote(git_cmd, cwd) + + if not has_upstream: + # Check if user previously declined + if _should_skip_upstream_prompt(): + return + + # Ask user if they want to add upstream + print() + print("ℹ Your fork is not tracking the official Hermes repository.") + print(" This means you may miss updates from NousResearch/hermes-agent.") + print() + try: + response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + response = "n" + + if response in ("", "y", "yes"): + print("→ Adding upstream remote...") + if _add_upstream_remote(git_cmd, cwd): + print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git") + has_upstream = True + else: + print(" ✗ Failed to add upstream remote. Skipping upstream sync.") + return + else: + print(" Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later.") + _mark_skip_upstream_prompt() + return + + # Fetch upstream + print() + print("→ Fetching upstream...") + try: + subprocess.run( + git_cmd + ["fetch", "upstream", "--quiet"], + cwd=cwd, + capture_output=True, + check=True, + ) + except subprocess.CalledProcessError: + print(" ✗ Failed to fetch upstream. Skipping upstream sync.") + return + + # Compare origin/main with upstream/main + origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") + upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main") + + if origin_ahead < 0 or upstream_ahead < 0: + print(" ✗ Could not compare branches. Skipping upstream sync.") + return + + # If origin/main has commits not on upstream, don't trample + if origin_ahead > 0: + print() + print(f"ℹ Your fork has {origin_ahead} commit(s) not on upstream.") + print(" Skipping upstream sync to preserve your changes.") + print(" If you want to merge upstream changes, run:") + print(" git pull upstream main") + return + + # If upstream is not ahead, fork is up to date + if upstream_ahead == 0: + print(" ✓ Fork is up to date with upstream") + return + + # origin/main is strictly behind upstream/main (can fast-forward) + print() + print(f"→ Fork is {upstream_ahead} commit(s) behind upstream") + print("→ Pulling from upstream...") + + try: + subprocess.run( + git_cmd + ["pull", "--ff-only", "upstream", "main"], + cwd=cwd, + check=True, + ) + except subprocess.CalledProcessError: + print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.") + return + + print(" ✓ Updated from upstream") + + # Try to sync fork back to origin + print("→ Syncing fork...") + if _sync_fork_with_upstream(git_cmd, cwd): + print(" ✓ Fork synced with upstream") + else: + print(" ℹ Got updates from upstream but couldn't push to fork (no write access?)") + print(" Your local repo is updated, but your fork on GitHub may be behind.") + + def _invalidate_update_cache(): """Delete the update-check cache for ALL profiles so no banner reports a stale "commits behind" count after a successful update. @@ -2985,6 +3210,19 @@ def cmd_update(args): cwd=PROJECT_ROOT, check=False, capture_output=True ) + # Detect if we're updating from a fork (before any branch logic) + git_cmd_base = ["git"] + if sys.platform == "win32": + git_cmd_base = ["git", "-c", "windows.appendAtomically=false"] + + origin_url = _get_origin_url(git_cmd_base, PROJECT_ROOT) + is_fork = _is_fork(origin_url) + + if is_fork: + print("⚠ Updating from fork:") + print(f" {origin_url}") + print() + if use_zip_update: # ZIP-based update for Windows when git is broken _update_via_zip(args) @@ -3125,6 +3363,10 @@ def cmd_update(args): removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") + + # Fork upstream sync logic (only for main branch on forks) + if is_fork and branch == "main": + _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) # Reinstall Python dependencies. Prefer .[all], but if one optional extra # breaks on this machine, keep base deps and reinstall the remaining extras