From e88116256c481a81662b849d919100e63e8ff299 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 7 Jun 2026 12:48:19 -0500 Subject: [PATCH] fix(update): scope git fetch to target branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `git fetch origin` (and `git fetch upstream`) pulls every ref. The repo carries thousands of auto-generated branches, so on any non-single-branch checkout the installer's update path and `hermes update` spend minutes downloading the full branch list — long enough to stall the desktop installer or trip the follow-up `git pull --ff-only`. Scope every update-path fetch to the branch we actually compare/merge against: - scripts/install.sh: collapse the remote to single-branch and fetch only $BRANCH on the "existing install, updating" path. - hermes_cli/main.py: fetch the resolved branch in the apply path, the --check path (upstream + origin), and the fork upstream-sync. Tracking-ref updates still happen via git's opportunistic refspec, so the later origin/ rev-parse/rev-list checks are unaffected. Tests assert the apply-path fetch is branch-scoped and never bare. --- hermes_cli/main.py | 30 ++++++++++++++--------- scripts/install.ps1 | 2 +- scripts/install.sh | 7 +++++- tests/hermes_cli/test_update_autostash.py | 21 ++++++++++++++-- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4e5f9e6527..38331e02bf 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6224,12 +6224,14 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: _mark_skip_upstream_prompt() return - # Fetch upstream + # Fetch upstream main only. This sync compares upstream/main with + # origin/main, so there's no reason to pull every upstream ref — and a bare + # fetch drags in thousands of auto-generated branches. print() print("→ Fetching upstream...") try: subprocess.run( - git_cmd + ["fetch", "upstream", "--quiet"], + git_cmd + ["fetch", "upstream", "main", "--quiet"], cwd=cwd, capture_output=True, check=True, @@ -7464,14 +7466,16 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - # Fetch both origin and upstream; prefer upstream as the canonical reference. + # Fetch only the branch we compare against; prefer upstream as the canonical + # reference. A bare `git fetch ` pulls every ref, and this repo has + # thousands of auto-generated branches, so scope the fetch to . # Note: upstream/ may not exist for non-main branches (a fork's # bb/gui has no upstream counterpart), so when the caller picks a # non-default branch we skip the upstream probe and use origin directly. if branch == "main": print("→ Fetching from upstream...") fetch_result = subprocess.run( - git_cmd + ["fetch", "upstream"], + git_cmd + ["fetch", "upstream", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -7480,7 +7484,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Fallback to origin if upstream doesn't exist print("→ Fetching from origin...") fetch_result = subprocess.run( - git_cmd + ["fetch", "origin"], + git_cmd + ["fetch", "origin", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -7494,7 +7498,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Non-default branch: compare against origin/ directly. print("→ Fetching from origin...") fetch_result = subprocess.run( - git_cmd + ["fetch", "origin"], + git_cmd + ["fetch", "origin", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -8002,9 +8006,16 @@ def _cmd_update_impl(args, gateway_mode: bool): # Fetch and pull try: + # Resolve the target branch up front so the fetch can be scoped to it. + # A bare `git fetch origin` pulls every ref, and this repo carries + # thousands of auto-generated branches — an unscoped fetch can stall for + # minutes on a non-single-branch checkout. Fetch only what we update + # against. + branch = _resolve_update_branch(args) + print("→ Fetching updates...") fetch_result = subprocess.run( - git_cmd + ["fetch", "origin"], + git_cmd + ["fetch", "origin", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -8036,11 +8047,6 @@ def _cmd_update_impl(args, gateway_mode: bool): ) current_branch = result.stdout.strip() - # Determine the target branch. Default is "main" (the long-standing - # CLI behavior); --branch overrides for callers that want to update - # against a non-default channel. - branch = _resolve_update_branch(args) - # If user is on a different branch than the update target, switch # to the target. When the target is "main" this is the historical # "always update against main" behavior; for any other target it's diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3965435c1c..ab116b6699 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1130,7 +1130,7 @@ function Install-Repository { git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName" if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" } } - git -c windows.appendAtomically=false fetch origin + git -c windows.appendAtomically=false fetch origin $Branch if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } # Precedence: Commit > Tag > Branch. Commit and Tag check # out as detached HEAD intentionally -- they're meant to be diff --git a/scripts/install.sh b/scripts/install.sh index db3ae5b8bb..88e1239956 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1118,7 +1118,12 @@ clone_repo() { autostash_ref="stash@{0}" fi - git fetch origin + # Fetch only the target branch. A bare `git fetch origin` pulls + # every ref, and this repo carries thousands of auto-generated + # branches — on a non-single-branch checkout that turns each update + # into a multi-minute download that can stall the installer. + git remote set-branches origin "$BRANCH" 2>/dev/null || true + git fetch origin "$BRANCH" git checkout "$BRANCH" git pull --ff-only origin "$BRANCH" diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 8457784c78..a6db6c669d 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -350,7 +350,7 @@ def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypa def fake_run(cmd, **kwargs): recorded.append(cmd) - if cmd == ["git", "fetch", "origin"]: + if cmd == ["git", "fetch", "origin", "main"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: return SimpleNamespace(stdout="main\n", stderr="", returncode=0) @@ -399,7 +399,7 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): def fake_run(cmd, **kwargs): recorded.append(cmd) - if cmd == ["git", "fetch", "origin"]: + if cmd == ["git", "fetch", "origin", "main"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: return SimpleNamespace(stdout="main\n", stderr="", returncode=0) @@ -630,6 +630,23 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): assert len(checkout_calls) == 0 +def test_cmd_update_fetch_is_scoped_to_target_branch(monkeypatch, tmp_path): + """The update fetch must name the target branch. A bare `git fetch origin` + pulls every ref, and this repo has thousands of auto-generated branches, so + an unscoped fetch can stall for minutes on a non-single-branch checkout.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + fetch_calls = [c for c in recorded if "fetch" in c] + assert fetch_calls == [["git", "fetch", "origin", "main"]] + assert ["git", "fetch", "origin"] not in recorded + + # --------------------------------------------------------------------------- # Fetch failure — friendly error messages # ---------------------------------------------------------------------------