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 <other>` updates FETCH_HEAD but never
creates origin/<other>, 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.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 20:11:16 -05:00
parent f1af61354e
commit 4c602f2e05
3 changed files with 183 additions and 5 deletions

View file

@ -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 <other>` then
# updates FETCH_HEAD but never creates origin/<other>, 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

View file

@ -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 <b>`
# updates FETCH_HEAD but never creates origin/<b>, so other branches are
# invisible to `git branch -r` and `gh pr checkout`, and `hermes update
# --branch <b>` 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"

View file

@ -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 <other>`` updates FETCH_HEAD but never
creates ``origin/<other>``, so other branches are invisible to ``git branch
-r`` / ``gh pr checkout`` and ``hermes update --branch <other>`` 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"