Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/git@mkpo.li
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mkpoli
8 changes: 5 additions & 3 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5009,22 +5009,25 @@ def _clear_bytecode_cache(root: Path) -> int:
_format_venv_python_holders_message,
_gateway_prompt,
_get_origin_url,
_get_stored_origin_url,
_has_upstream_remote,
_install_psutil_android_compat,
_invalidate_update_cache,
_is_android_python,
_is_fork,
_is_noncanonical_origin,
_log_only_write,
_mark_skip_upstream_prompt,
_npm_bin_exists,
_npm_lockfile_changed,
_npm_manifest_paths,
_npm_manifests_digest,
_origin_is_official_repo,
_pause_windows_gateways_for_update,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_fts_optimize_available_notice,
_print_stash_cleanup_guidance,
_push_main_to_origin,
_record_npm_lockfile_hash,
_refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies,
Expand All @@ -5039,7 +5042,6 @@ def _clear_bytecode_cache(root: Path) -> int:
_should_skip_upstream_prompt,
_stash_apply_failed_only_on_existing_untracked,
_stash_local_changes_if_needed,
_sync_fork_with_upstream,
_sync_with_upstream_if_needed,
_update_node_dependencies,
_update_via_zip,
Expand All @@ -5056,7 +5058,7 @@ def _clear_bytecode_cache(root: Path) -> int:
_write_update_planned_stop_marker,
_UPDATE_RUNTIME_RELOAD_MODULES,
_UPDATE_CRITICAL_FILES,
OFFICIAL_REPO_URLS,
OFFICIAL_REPO_IDENTITY,
OFFICIAL_REPO_URL,
SKIP_UPSTREAM_PROMPT_FILE,
_PRE_UPDATE_SNAPSHOT_KEEP,
Expand Down
171 changes: 127 additions & 44 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,12 +1134,10 @@ def _discard_stashed_changes(
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
return True

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",
}
# Lowercased host/owner/repo identity of the official repository, compared
# against the parsed origin URL so every spelling (scp-like, ssh://,
# https://, any letter case) matches.
OFFICIAL_REPO_IDENTITY = "github.com/nousresearch/hermes-agent"

OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"

Expand All @@ -1160,21 +1158,89 @@ def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
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:
def _get_stored_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
"""Read remote.origin.url exactly as stored in .git/config.

Unlike ``git remote get-url`` (used by ``_get_origin_url``), this does
NOT apply ``url.<base>.insteadOf`` rewrites, so it reflects the URL the
remote was actually configured with — the canonical value on installs
where the user's git config rewrites GitHub URLs to an SSH alias,
mirror, or proxy at transport time.
"""
try:
result = subprocess.run(
git_cmd + ["config", "--get", "remote.origin.url"],
cwd=cwd,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if result.returncode == 0:
value = result.stdout.strip()
return value or None
except Exception:
pass
return None

def _origin_is_official_repo(origin_url: str) -> bool:
"""Return True if *origin_url* addresses the official repository.

Parses the URL into a lowercased ``host/owner/repo`` identity and
compares it exactly, so the scp-like (``git@github.com:owner/repo``),
``ssh://`` and ``https://`` spellings all match, regardless of letter
case, trailing ``.git``, user component or numeric port. Extra path
segments (``.../hermes-agent/tree/main``), non-git transport schemes
(``file://``, ``ftp://``), and non-numeric ports do not match — the
identity must be exactly the official repo over a git transport.

Hosts that are not literally ``github.com`` — SSH config aliases such as
``gh-work:owner/repo``, mirrors, proxies, and the effective URLs produced
by ``url.<base>.insteadOf`` rewrites — return False: they cannot be
resolved to a physical host from here, so they are conservatively treated
as non-canonical.
"""
url = origin_url.strip().rstrip("/")
if url.lower().endswith(".git"):
url = url[:-4]

if "://" in url:
# https://github.com/owner/repo, ssh://git@github.com[:port]/owner/repo
scheme, rest = url.split("://", 1)
if scheme.lower() not in {"https", "http", "ssh", "git"}:
return False
host, sep, path = rest.partition("/")
if not sep:
return False
elif ":" in url:
# scp-like: git@github.com:owner/repo, gh-work:owner/repo
host, path = url.split(":", 1)
else:
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:

host = host.rsplit("@", 1)[-1] # drop any user component
if ":" in host:
host, port = host.rsplit(":", 1)
if port and not port.isdigit():
return False
return True

segments = [s for s in path.split("/") if s]
if len(segments) != 2:
return False

identity = f"{host}/{segments[0]}/{segments[1]}".casefold()
return identity == OFFICIAL_REPO_IDENTITY

def _is_noncanonical_origin(origin_url: Optional[str]) -> bool:
"""Check if the origin remote is not the official repository.

Returns True for anything that does not parse as the official repo under
a recognized URL spelling: genuine forks, mirrors, and origins reached
through SSH aliases or ``url.insteadOf`` rewrites. The update flow uses
this as "origin is not confirmed canonical" — the host behind an aliased
URL cannot be verified from here, so this is never proof of forkhood.
"""
if not origin_url:
return False
return not _origin_is_official_repo(origin_url)

def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Check if an 'upstream' remote already exists."""
Expand Down Expand Up @@ -1232,8 +1298,8 @@ def _mark_skip_upstream_prompt():
except Exception:
pass

def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
"""Attempt to push updated main to origin (sync fork).
def _push_main_to_origin(git_cmd: list[str], cwd: Path) -> bool:
"""Attempt to push the updated local main to origin.

Returns True if push succeeded, False otherwise.
"""
Expand All @@ -1249,13 +1315,12 @@ def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
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.
"""Check if origin 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
- Try to push the updated main back to origin if possible
"""
has_upstream = _has_upstream_remote(git_cmd, cwd)

Expand All @@ -1266,7 +1331,7 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:

# Ask user if they want to add upstream
print()
print("ℹ Your fork is not tracking the official Hermes repository.")
print("ℹ This origin is not the canonical Hermes repository URL.")
print(" This means you may miss updates from NousResearch/hermes-agent.")
print()
try:
Expand Down Expand Up @@ -1323,20 +1388,20 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:
# 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(f"ℹ Origin 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 is not ahead, origin is up to date
if upstream_ahead == 0:
print(" ✓ Fork is up to date with upstream")
print(" ✓ Origin 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(f"→ Origin is {upstream_ahead} commit(s) behind upstream")
print("→ Pulling from upstream...")

try:
Expand All @@ -1353,15 +1418,22 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:

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")
# Try to push the updated main back to origin. For a fork on GitHub this
# fast-forwards its main; for a read-only mirror or an aliased URL of the
# official repo the push fails harmlessly — the local repo is already
# updated.
print("→ Syncing origin with upstream...")
if _push_main_to_origin(git_cmd, cwd):
print(" ✓ Origin synced with upstream")
else:
print(" ℹ Got updates from upstream but couldn't push to origin.")
print(
" No write access? Origin may also be a read-only mirror or an"
)
print(
" ℹ Got updates from upstream but couldn't push to fork (no write access?)"
" aliased URL of the official repository (SSH alias / url.insteadOf)."
)
print(" Your local repo is updated, but your fork on GitHub may be behind.")
print(" Your local repo is updated, but origin's main may be behind.")

def _invalidate_update_cache():
"""Delete the update-check cache for ALL profiles so no banner
Expand Down Expand Up @@ -3163,7 +3235,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
capture_output=True,
)

# Build git command once — reused for fork detection and the update itself.
# Build git command once — reused for origin classification and the update itself.
git_cmd = ["git"]
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
Expand All @@ -3177,12 +3249,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
# lockfile churn) update with a clean tree.
_discard_lockfile_churn(git_cmd, _m().PROJECT_ROOT)

# Detect if we're updating from a fork (before any branch logic)
# Classify the origin before any branch logic.
# `git remote get-url` applies url.<base>.insteadOf rewrites, so also
# read the stored, unrewritten remote.origin.url: an origin counts as
# the official repo if EITHER spelling parses as official. Users who
# bind a specific SSH identity or proxy via insteadOf keep a canonical
# stored URL and must not be treated as a fork; genuine forks and
# mirrors are non-canonical under both readings.
origin_url = _m()._get_origin_url(git_cmd, _m().PROJECT_ROOT)
is_fork = _is_fork(origin_url)
stored_origin_url = _m()._get_stored_origin_url(git_cmd, _m().PROJECT_ROOT)
noncanonical_origin = _is_noncanonical_origin(origin_url) and (
stored_origin_url is None or _is_noncanonical_origin(stored_origin_url)
)

if is_fork:
print("⚠ Updating from fork:")
if noncanonical_origin:
print(
"⚠ Updating from a non-canonical origin (fork, mirror, or rewritten URL):"
)
print(f" {origin_url}")
print()

Expand Down Expand Up @@ -3306,8 +3389,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
if commit_count == 0:
_invalidate_update_cache()

# Even if origin is up to date, the fork may be behind upstream
if is_fork and branch == "main":
# Even if we're up to date with origin, origin may be behind upstream
if noncanonical_origin and branch == "main":
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)

# Restore stash and switch back to original branch if we moved
Expand Down Expand Up @@ -3531,8 +3614,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
_m()._record_bytecode_fingerprint()

# Fork upstream sync logic (only for main branch on forks)
if is_fork and branch == "main":
# Upstream sync for non-canonical origins (main branch only)
if noncanonical_origin and branch == "main":
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)

# Reinstall Python dependencies. Prefer .[all], but if one optional extra
Expand Down
Loading