From 4c602f2e0591677e31b30b78e5c4aeb9d175121f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 20:11:16 -0500 Subject: [PATCH] fix(install): stop pinning managed checkouts to a single branch `git clone --depth 1 --branch main` implies --single-branch, so every managed install ends up with remote.origin.fetch pinned to main. On such a checkout `git fetch origin ` updates FETCH_HEAD but never creates origin/, leaving other branches invisible to `git branch -r` and `gh pr checkout`. install.sh made it stickier by re-narrowing the refspec on every run, undoing manual fixes. Restore the wildcard in both installers. It costs no bandwidth: naming the branch is what makes a fetch cheap, and every fetch we issue does. --- scripts/install.ps1 | 7 + scripts/install.sh | 27 ++- ...test_managed_checkout_branch_visibility.py | 154 ++++++++++++++++++ 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 tests/test_managed_checkout_branch_visibility.py diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e02acfff881..0480fdcc7ec 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1761,6 +1761,13 @@ function Install-Repository { # next `hermes update` checkout aborts on a "dirty" tree the user never # touched (see the update path above). git -c windows.appendAtomically=false config core.autocrlf false 2>$null + # `clone --depth 1 --branch $Branch` implies --single-branch, which pins + # remote.origin.fetch to that one branch: `git fetch origin ` then + # updates FETCH_HEAD but never creates origin/, so other branches + # are invisible to `git branch -r` / `gh pr checkout` and `hermes update + # --branch` fails. Restore the wildcard -- every fetch we issue names its + # branch, so nothing extra gets downloaded. + git -c windows.appendAtomically=false remote set-branches origin '*' 2>$null # Post-clone pin: when a clone (or ZIP-fallback init) just landed us on # $Branch's tip, honour the higher-precedence $Commit / $Tag by checking diff --git a/scripts/install.sh b/scripts/install.sh index df8739d62cd..16bc2a3645a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -305,6 +305,22 @@ EOF log_info "Discarded npm lockfile churn (${dirty_count} file(s))" } +# `git clone --depth 1 --branch main` implies --single-branch, which pins +# remote.origin.fetch to main alone. On such a checkout `git fetch origin ` +# updates FETCH_HEAD but never creates origin/, so other branches are +# invisible to `git branch -r` and `gh pr checkout`, and `hermes update +# --branch ` fails with "does not exist locally or on origin". +# +# Restoring the wildcard costs nothing: every fetch the installer and the CLI +# issue names its branch, and git only walks the full refspec on a bare +# `git fetch`. Also heals checkouts an older installer already narrowed. +widen_remote_branches() { + local repo="${1:-$INSTALL_DIR}" + [ -n "$repo" ] && [ -d "$repo/.git" ] || return 0 + command -v git >/dev/null 2>&1 || return 0 + git -C "$repo" remote set-branches origin '*' 2>/dev/null || true +} + emit_manifest() { # Stage-Desktop is included only with --include-desktop, mirroring # install.ps1: the signed bootstrap installer (Hermes-Setup) passes it so @@ -1210,11 +1226,11 @@ clone_repo() { autostash_ref="stash@{0}" fi - # 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 + widen_remote_branches "$INSTALL_DIR" + # Naming the branch is what keeps the fetch cheap: a bare + # `git fetch origin` pulls every ref, and this repo carries + # thousands of branches, which turns each update into a + # multi-minute download that can stall the installer. git fetch origin "$BRANCH" git checkout "$BRANCH" # Managed installs should follow origin/$BRANCH exactly. If the @@ -1304,6 +1320,7 @@ EOF exit 1 fi fi + widen_remote_branches "$INSTALL_DIR" fi cd "$INSTALL_DIR" diff --git a/tests/test_managed_checkout_branch_visibility.py b/tests/test_managed_checkout_branch_visibility.py new file mode 100644 index 00000000000..2c02c1804aa --- /dev/null +++ b/tests/test_managed_checkout_branch_visibility.py @@ -0,0 +1,154 @@ +"""Regression: managed installs must be able to see the repo's other branches. + +``git clone --depth 1 --branch main`` implies ``--single-branch``, which pins +``remote.origin.fetch`` to ``+refs/heads/main:refs/remotes/origin/main``. On +such a checkout ``git fetch origin `` updates FETCH_HEAD but never +creates ``origin/``, so other branches are invisible to ``git branch +-r`` / ``gh pr checkout`` and ``hermes update --branch `` fails with +"does not exist locally or on origin". + +The installer restores the wildcard refspec. That only costs bandwidth if +something issues a bare ``git fetch``, so the startup update check has to keep +naming its branch. +""" + +from __future__ import annotations + +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="needs git and bash", +) + + +def _git(cwd: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_upstream(tmp_path: Path) -> Path: + """A bare remote with `main` plus a couple of side branches.""" + upstream = tmp_path / "upstream.git" + seed = tmp_path / "seed" + _git(tmp_path, "init", "--bare", "-b", "main", str(upstream)) + _git(tmp_path, "clone", upstream.as_uri(), str(seed)) + (seed / "a.txt").write_text("one\n") + _git(seed, "add", "a.txt") + _git(seed, "commit", "-m", "init") + for branch in ("feat-1", "feat-2"): + _git(seed, "checkout", "-b", branch, "main") + (seed / f"{branch}.txt").write_text(branch) + _git(seed, "add", ".") + _git(seed, "commit", "-m", branch) + _git(seed, "checkout", "main") + _git(seed, "push", "--all", "origin") + return upstream + + +def _narrow_clone(tmp_path: Path, upstream: Path) -> Path: + """Clone exactly the way the installer does: shallow and single-branch.""" + checkout = tmp_path / "checkout" + _git(tmp_path, "clone", "--depth", "1", "--branch", "main", + upstream.as_uri(), str(checkout)) + assert _git(checkout, "config", "--get-all", "remote.origin.fetch") == ( + "+refs/heads/main:refs/remotes/origin/main" + ) + return checkout + + +def _widen(checkout: Path) -> None: + """Run install.sh's widen_remote_branches() against a real checkout.""" + match = re.search(r"widen_remote_branches\(\) \{.*?\n\}", INSTALL_SH.read_text(), + re.DOTALL) + assert match is not None, "widen_remote_branches() not found in install.sh" + script = f"{match.group(0)}\nwiden_remote_branches {shlex.quote(str(checkout))}\n" + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_narrow_clone_cannot_resolve_other_branches(tmp_path: Path) -> None: + """The premise: without the fix, fetching a branch by name is not enough.""" + checkout = _narrow_clone(tmp_path, _make_upstream(tmp_path)) + + _git(checkout, "fetch", "origin", "feat-1") + assert _git(checkout, "branch", "-r").split() == ["origin/main"] + + # This is the exact pair of commands `hermes update --branch feat-1` runs. + for args in (("checkout", "feat-1"), ("checkout", "-B", "feat-1", "origin/feat-1")): + assert subprocess.run(["git", *args], cwd=checkout, + capture_output=True).returncode != 0 + + +def test_widening_makes_other_branches_resolvable(tmp_path: Path) -> None: + checkout = _narrow_clone(tmp_path, _make_upstream(tmp_path)) + _widen(checkout) + + assert _git(checkout, "config", "--get-all", "remote.origin.fetch") == ( + "+refs/heads/*:refs/remotes/origin/*" + ) + _git(checkout, "fetch", "--depth", "1", "origin", "feat-1") + assert _git(checkout, "branch", "-r").split() == ["origin/feat-1", "origin/main"] + _git(checkout, "checkout", "-B", "feat-1", "origin/feat-1") + assert _git(checkout, "rev-parse", "--abbrev-ref", "HEAD") == "feat-1" + + +def test_widening_downloads_nothing_by_itself(tmp_path: Path) -> None: + """Rewriting the refspec must not pull refs the user didn't ask for.""" + checkout = _narrow_clone(tmp_path, _make_upstream(tmp_path)) + before = _git(checkout, "rev-list", "--all", "--objects", "--count") + + _widen(checkout) + + assert _git(checkout, "branch", "-r").split() == ["origin/main"] + assert _git(checkout, "rev-list", "--all", "--objects", "--count") == before + + +def test_widening_is_idempotent(tmp_path: Path) -> None: + """Re-running the installer over an already-widened checkout is a no-op.""" + checkout = _narrow_clone(tmp_path, _make_upstream(tmp_path)) + _widen(checkout) + _widen(checkout) + + assert _git(checkout, "config", "--get-all", "remote.origin.fetch") == ( + "+refs/heads/*:refs/remotes/origin/*" + ) + + +def test_update_check_stays_scoped_on_a_widened_checkout(tmp_path: Path) -> None: + """The startup banner check must not pull every branch once it can see them. + + A bare ``git fetch origin`` on a widened checkout would drag in every + branch — hundreds of MB on the real repo, behind a 10s timeout, on every + launch. Naming main keeps the transfer to one ref. + """ + import hermes_cli.banner as banner + + upstream = _make_upstream(tmp_path) + checkout = _narrow_clone(tmp_path, upstream) + _widen(checkout) + + # Move main forward so the check has something to report. + seed = tmp_path / "seed" + (seed / "a.txt").write_text("two\n") + _git(seed, "commit", "-am", "advance main") + _git(seed, "push", "origin", "main") + + assert banner._check_via_local_git(checkout) == banner.UPDATE_AVAILABLE_NO_COUNT + assert _git(checkout, "branch", "-r").split() == ["origin/main"] + assert _git(checkout, "rev-parse", "--is-shallow-repository") == "true"