Compare commits

...

1 Commits

Author SHA1 Message Date
emozilla
2950c6fa2e preserve shallow clones and show correct update values for them 2026-06-06 00:41:54 -04:00
4 changed files with 454 additions and 58 deletions

View File

@@ -1243,7 +1243,23 @@ async function checkUpdates() {
}
branch = await resolveHealedBranch(updateRoot, branch)
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
// Installer checkouts are shallow (`git clone --depth 1`, PR #39423). On a
// shallow clone a plain `git fetch` unshallows the repo — dragging in the
// entire history — and `rev-list HEAD..origin/<branch> --count` then reports
// a huge bogus "behind" number. Detect shallow up front and (a) fetch with
// --depth 1 to preserve the boundary, (b) compare tip SHAs instead of
// counting. Full clones (developers, pre-#39423 installs) keep the exact
// count path unchanged.
const shallowProbe = await runGit(['rev-parse', '--is-shallow-repository'], { cwd: updateRoot })
const isShallow = shallowProbe.code === 0
? shallowProbe.stdout.trim() === 'true'
: fileExists(path.join(gitDir, 'shallow')) // older git fallback
const fetchArgs = isShallow
? ['fetch', '--depth', '1', '--quiet', 'origin', branch]
: ['fetch', '--quiet', 'origin', branch]
const fetched = await runGit(fetchArgs, { cwd: updateRoot })
if (fetched.code !== 0) {
return {
supported: true,
@@ -1256,6 +1272,38 @@ async function checkUpdates() {
}
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
if (isShallow) {
// No history to count across the shallow boundary. `fetch origin <branch>`
// updated FETCH_HEAD; origin/<branch> may not be a tracking ref in a
// `clone --depth 1`, so prefer FETCH_HEAD and fall back to origin/<branch>.
const [currentSha, fetchHeadSha, originSha, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', 'FETCH_HEAD']).catch(() => ''),
git(['rev-parse', `origin/${branch}`]).catch(() => ''),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD'])
])
const targetSha = fetchHeadSha || originSha
// Can't enumerate commits across a shallow boundary; surface presence only.
const behind = targetSha && currentSha && targetSha !== currentSha ? 1 : 0
return {
supported: true,
branch,
currentBranch,
behind,
behindExact: false,
shallow: true,
currentSha,
targetSha,
commits: [],
dirty: dirtyStr.length > 0,
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', `origin/${branch}`]),
@@ -1272,6 +1320,8 @@ async function checkUpdates() {
branch,
currentBranch,
behind,
behindExact: true,
shallow: false,
currentSha,
targetSha,
commits,

View File

@@ -144,17 +144,117 @@ def _check_via_rev(local_rev: str) -> Optional[int]:
return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
def _is_shallow_clone(repo_dir: Path) -> bool:
"""Return True if ``repo_dir`` is a shallow git clone.
Installer-created checkouts are shallow (``git clone --depth 1``, see
PR #39423). A shallow clone has no usable history before the grafted
boundary, so commit-distance math (``rev-list --count A..B``) is
meaningless and an ordinary ``git fetch`` would *unshallow* the repo —
pulling the entire history and making the count explode. Callers use
this to branch into a tip-comparison path instead.
Defaults to False (treat as full clone) on any error, so developer
checkouts and pre-#39423 full installs keep the exact count path.
"""
try:
result = subprocess.run(
["git", "rev-parse", "--is-shallow-repository"],
capture_output=True, text=True, timeout=5,
cwd=str(repo_dir),
)
except Exception:
return False
if result.returncode != 0:
# Older git (<2.15) lacks --is-shallow-repository; the presence of a
# `.git/shallow` file is the portable fallback signal.
return (repo_dir / ".git" / "shallow").exists()
return result.stdout.strip() == "true"
def _git_rev(repo_dir: Path, rev: str) -> Optional[str]:
"""Resolve ``rev`` to a full commit SHA in ``repo_dir``, or None."""
try:
result = subprocess.run(
["git", "rev-parse", rev],
capture_output=True, text=True, timeout=5,
cwd=str(repo_dir),
)
except Exception:
return None
if result.returncode != 0:
return None
value = (result.stdout or "").strip()
return value or None
def _ls_remote_main(repo_dir: Path) -> Optional[str]:
"""Return origin's ``refs/heads/main`` tip SHA via ls-remote, or None.
Authoritative upstream-tip lookup that does NOT depend on local tracking
refs — the reliable way to learn the remote head of a shallow clone,
where ``origin/main`` may be missing (tag-pinned clone) or stale.
"""
try:
result = subprocess.run(
["git", "ls-remote", "origin", "refs/heads/main"],
capture_output=True, text=True, timeout=10,
cwd=str(repo_dir),
)
except Exception:
return None
if result.returncode != 0 or not result.stdout:
return None
return result.stdout.split()[0] or None
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
"""Count commits behind origin/main in a local checkout."""
"""Report whether a local checkout is behind origin/main.
Supports both shapes of install:
* **Shallow clone** (installer, ``--depth 1``): a plain ``git fetch``
would unshallow the repo and a ``HEAD..origin/main`` count would be
bogus. So we fetch shallow (``--depth 1``) to keep the boundary, then
compare the local HEAD SHA against the freshly-fetched ``origin/main``
tip. Equal → ``0`` (up to date); different → ``UPDATE_AVAILABLE_NO_COUNT``
(behind, but an exact count is impossible across a shallow boundary).
* **Full clone** (developer checkout, or installs from before #39423):
ordinary fetch + exact ``rev-list --count HEAD..origin/main``.
"""
shallow = _is_shallow_clone(repo_dir)
fetch_cmd = ["git", "fetch", "origin", "main", "--quiet"]
if shallow:
# Keep the clone shallow — otherwise the fetch drags in the entire
# history and the count below explodes (see #39423 fallout).
fetch_cmd = ["git", "fetch", "--depth", "1", "origin", "main", "--quiet"]
try:
subprocess.run(
["git", "fetch", "origin", "--quiet"],
fetch_cmd,
capture_output=True, timeout=10,
cwd=str(repo_dir),
)
except Exception:
pass # Offline or timeout — use stale refs, that's fine
if shallow:
# No usable history to count across the shallow boundary — compare
# tip SHAs instead. Resolving the upstream tip from local tracking
# refs is unreliable on a `clone --depth 1` (origin/main may be a
# detached fetch, a tag-pinned clone has no origin/main at all), so
# ask the remote authoritatively via ls-remote — the same approach
# _check_via_rev() uses for nix builds — and only fall back to
# FETCH_HEAD / origin/main if the remote probe fails (offline).
local = _git_rev(repo_dir, "HEAD")
upstream = _ls_remote_main(repo_dir)
if not upstream:
upstream = _git_rev(repo_dir, "FETCH_HEAD") or _git_rev(repo_dir, "origin/main")
if not local or not upstream:
return None
return 0 if local == upstream else UPDATE_AVAILABLE_NO_COUNT
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],
@@ -358,18 +458,25 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
return None
ahead = 0
try:
result = subprocess.run(
["git", "rev-list", "--count", "origin/main..HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(repo_dir),
)
if result.returncode == 0:
ahead = int((result.stdout or "0").strip() or "0")
except Exception:
ahead = 0
# On a shallow clone there is no history before the grafted boundary, so
# `origin/main..HEAD` would be bogus (and any fetch that populated
# origin/main already happened via the shallow-preserving path elsewhere).
# A `--depth 1` install is pinned to a single commit, so "carried commits"
# is definitionally zero — skip the count and report ahead=0. Full clones
# (developers, pre-#39423 installs) keep the exact count.
if not _is_shallow_clone(repo_dir):
try:
result = subprocess.run(
["git", "rev-list", "--count", "origin/main..HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(repo_dir),
)
if result.returncode == 0:
ahead = int((result.stdout or "0").strip() or "0")
except Exception:
ahead = 0
return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}

View File

@@ -6761,6 +6761,54 @@ def _capture_head_sha(git_cmd, cwd) -> str | None:
return None
def _is_shallow_clone(git_cmd, cwd) -> bool:
"""Return True if ``cwd`` is a shallow git clone.
Installer checkouts are shallow (``git clone --depth 1``, see PR #39423).
On a shallow clone an ordinary ``git fetch`` un-shallows the repo —
pulling the entire history and defeating the installer's bandwidth
savings — and commit-distance math (``rev-list --count A..B``) across the
grafted boundary is bogus. ``hermes update`` uses this to fetch with
``--depth 1`` and reset to the fetched tip instead of counting + ff-merging.
Defaults to False (treat as full clone) on any error, preserving the
historical behavior for developer checkouts and pre-#39423 full installs.
"""
try:
result = subprocess.run(
git_cmd + ["rev-parse", "--is-shallow-repository"],
cwd=cwd,
capture_output=True,
text=True,
)
except OSError:
return False
if result.returncode != 0:
# Older git (<2.15) lacks --is-shallow-repository; fall back to the
# presence of a `.git/shallow` file, the portable shallow signal.
try:
return (Path(cwd) / ".git" / "shallow").exists()
except OSError:
return False
return result.stdout.strip() == "true"
def _git_rev(git_cmd, cwd, rev: str) -> str | None:
"""Resolve ``rev`` to a commit SHA in ``cwd``, or None."""
try:
result = subprocess.run(
git_cmd + ["rev-parse", rev],
cwd=cwd,
capture_output=True,
text=True,
)
except OSError:
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]:
"""Compile each file in ``_UPDATE_CRITICAL_FILES`` to catch SyntaxErrors.
@@ -9799,11 +9847,29 @@ 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.
# Note: upstream/<branch> 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":
# Installer checkouts are shallow (`git clone --depth 1`, PR #39423).
# A plain fetch would un-shallow the repo and the `rev-list --count` below
# would be bogus across the grafted boundary. On a shallow clone we fetch
# `--depth 1` straight from origin (a shallow install has no `upstream`
# remote) and compare tip SHAs instead of counting. Full clones keep the
# historical upstream-preferred fetch + exact count path.
is_shallow = _is_shallow_clone(git_cmd, PROJECT_ROOT)
if is_shallow:
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "--depth", "1", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
upstream_exists = False
compare_branch = f"origin/{branch}"
elif branch == "main":
# Fetch both origin and upstream; prefer upstream as the canonical reference.
# Note: upstream/<branch> 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.
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "upstream"],
@@ -9863,17 +9929,32 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
sys.exit(1)
rev_result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=True,
)
behind = int(rev_result.stdout.strip())
if is_shallow:
# No history to count across the shallow boundary — compare tip SHAs.
local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD")
target_sha = (
_git_rev(git_cmd, PROJECT_ROOT, compare_branch)
or _git_rev(git_cmd, PROJECT_ROOT, "FETCH_HEAD")
)
behind = 0 if (local_sha and target_sha and local_sha == target_sha) else 1
else:
rev_result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=True,
)
behind = int(rev_result.stdout.strip())
if behind == 0:
print("✓ Already up to date.")
elif is_shallow:
# Exact count is unknowable on a shallow clone — report availability.
print(f"⚕ Update available on {compare_branch}.")
from hermes_cli.config import recommended_update_command
print(f" Run '{recommended_update_command()}' to install.")
else:
commits_word = "commit" if behind == 1 else "commits"
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
@@ -10337,9 +10418,20 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Fetch and pull
try:
# Installer checkouts are shallow (`git clone --depth 1`, PR #39423).
# A plain `git fetch` would un-shallow the repo (negating the
# installer's bandwidth savings) and make the `rev-list --count`
# below report a bogus huge number. Detect shallow once and fetch
# `--depth 1` to keep the boundary; full clones (developers,
# pre-#39423 installs) keep the historical plain-fetch + count path.
is_shallow = _is_shallow_clone(git_cmd, PROJECT_ROOT)
print("→ Fetching updates...")
fetch_args = ["fetch", "origin"]
if is_shallow:
fetch_args = ["fetch", "--depth", "1", "origin", _resolve_update_branch(args)]
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + fetch_args,
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@@ -10431,15 +10523,29 @@ def _cmd_update_impl(args, gateway_mode: bool):
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
)
# Check if there are updates
result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=True,
)
commit_count = int(result.stdout.strip())
# Check if there are updates. On a shallow clone there is no history
# to count across the grafted boundary, so `rev-list HEAD..origin/X`
# would be bogus — compare tip SHAs instead and treat any difference
# as "an update is available" (exact count is unknowable). Full clones
# keep the precise count.
if is_shallow:
local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD")
target_sha = (
_git_rev(git_cmd, PROJECT_ROOT, f"origin/{branch}")
or _git_rev(git_cmd, PROJECT_ROOT, "FETCH_HEAD")
)
commit_count = (
0 if (local_sha and target_sha and local_sha == target_sha) else 1
)
else:
result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=True,
)
commit_count = int(result.stdout.strip())
if commit_count == 0:
_invalidate_update_cache()
@@ -10468,7 +10574,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("✓ Already up to date!")
return
print(f"→ Found {commit_count} new commit(s)")
if is_shallow:
print("→ Update available")
else:
print(f"→ Found {commit_count} new commit(s)")
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
@@ -10496,33 +10605,61 @@ def _cmd_update_impl(args, gateway_mode: bool):
# the bad commit and the fix landing).
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
try:
pull_result = subprocess.run(
git_cmd + ["pull", "--ff-only", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if pull_result.returncode != 0:
# ff-only failed — local and remote have diverged (e.g. upstream
# force-pushed or rebase). Since local changes are already
# stashed, reset to match the remote exactly.
print(
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
if is_shallow:
# Shallow clone: there's no merge base to fast-forward across,
# and `pull` would re-negotiate full history and un-shallow the
# repo. The `--depth 1` fetch above already advanced
# origin/{branch} (and FETCH_HEAD) to the new tip, so hard-reset
# the working tree to it — this keeps the clone shallow and is
# the equivalent of a fast-forward for a single-commit install.
reset_target = (
f"origin/{branch}"
if _git_rev(git_cmd, PROJECT_ROOT, f"origin/{branch}")
else "FETCH_HEAD"
)
reset_result = subprocess.run(
git_cmd + ["reset", "--hard", f"origin/{branch}"],
pull_result = subprocess.run(
git_cmd + ["reset", "--hard", reset_target],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if reset_result.returncode != 0:
print(f"✗ Failed to reset to origin/{branch}.")
if reset_result.stderr.strip():
print(f" {reset_result.stderr.strip()}")
if pull_result.returncode != 0:
print(f"✗ Failed to update to {reset_target} (shallow).")
if pull_result.stderr.strip():
print(f" {pull_result.stderr.strip().splitlines()[0]}")
print(
f" Try manually: git fetch origin && git reset --hard origin/{branch}"
f" Try manually: git fetch --depth 1 origin {branch} && "
f"git reset --hard origin/{branch}"
)
sys.exit(1)
else:
pull_result = subprocess.run(
git_cmd + ["pull", "--ff-only", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if pull_result.returncode != 0:
# ff-only failed — local and remote have diverged (e.g. upstream
# force-pushed or rebase). Since local changes are already
# stashed, reset to match the remote exactly.
print(
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
)
reset_result = subprocess.run(
git_cmd + ["reset", "--hard", f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if reset_result.returncode != 0:
print(f"✗ Failed to reset to origin/{branch}.")
if reset_result.stderr.strip():
print(f" {reset_result.stderr.strip()}")
print(
f" Try manually: git fetch origin && git reset --hard origin/{branch}"
)
sys.exit(1)
# Post-pull syntax guard: validate critical-path files actually
# parse before declaring the update successful. If a bad commit

View File

@@ -825,3 +825,105 @@ termux = ["rich>=14"]
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"]
class TestCmdUpdateShallowClone:
"""Shallow-aware update flow (installer `git clone --depth 1`, PR #39423).
On a shallow clone a plain `git fetch` un-shallows the repo and
`rev-list --count HEAD..origin/main` reports a bogus huge number. The
update flow must instead fetch `--depth 1` and reset to the fetched tip,
while full clones keep the historical fetch + count + ff-only path.
These tests fully mock `subprocess.run` — they never touch a real repo.
"""
@staticmethod
def _shallow_side_effect(*, head_sha="aaa", target_sha="bbb"):
"""subprocess.run side-effect simulating a shallow clone.
Records every git invocation in ``calls`` (attached to the returned
function) so tests can assert which commands ran.
"""
calls: list[str] = []
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
calls.append(joined)
# Shallow probe → "true"
if "rev-parse" in joined and "--is-shallow-repository" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="true\n", stderr="")
# current branch
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="")
# HEAD sha
if "rev-parse" in joined and joined.strip().endswith("HEAD"):
return subprocess.CompletedProcess(cmd, 0, stdout=f"{head_sha}\n", stderr="")
# tip sha (origin/main or FETCH_HEAD)
if "rev-parse" in joined and ("origin/main" in joined or "FETCH_HEAD" in joined):
return subprocess.CompletedProcess(cmd, 0, stdout=f"{target_sha}\n", stderr="")
if "rev-parse" in joined and "--verify" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
# rev-list must never run on a shallow clone — flag loudly if it does
if "rev-list" in joined:
raise AssertionError(f"rev-list should not run on shallow clone: {joined}")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
side_effect.calls = calls
return side_effect
@patch("hermes_cli.main._build_web_ui")
@patch("hermes_cli.main._update_node_dependencies")
@patch("hermes_cli.main._refresh_active_lazy_features")
@patch("hermes_cli.main._install_python_dependencies_with_optional_fallback")
@patch("hermes_cli.main._validate_critical_files_syntax", return_value=(True, None, None))
@patch("hermes_cli.main._clear_bytecode_cache", return_value=0)
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_shallow_fetches_depth1_and_resets(
self, mock_run, _which, _bytecode, _syntax, _deps, _lazy, _node, _web,
mock_args, monkeypatch, capsys,
):
from hermes_cli import main as hm
# HEAD != tip → an update is available.
se = self._shallow_side_effect(head_sha="aaa", target_sha="bbb")
mock_run.side_effect = se
# Avoid touching real install-method detection / snapshots.
monkeypatch.setattr(hm, "detect_use_zip_update", lambda *a, **k: False, raising=False)
try:
hm.cmd_update(mock_args)
except SystemExit:
pass # build steps are mocked; we only care about the git pipeline
joined_calls = se.calls
# 1) The fetch is shallow-preserving.
assert any("fetch --depth 1 origin main" in c for c in joined_calls), joined_calls
# 2) No plain `fetch origin` (which would un-shallow).
assert not any(c.endswith("fetch origin") for c in joined_calls), joined_calls
# 3) Advanced via reset to the fetched tip, not `pull --ff-only`.
assert any("reset --hard" in c for c in joined_calls), joined_calls
assert not any("pull --ff-only" in c for c in joined_calls), joined_calls
@patch("hermes_cli.config.detect_install_method", return_value="source")
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_check_shallow_compares_tips(
self, mock_run, _which, _method, capsys, monkeypatch,
):
from hermes_cli import main as hm
# HEAD == tip → already up to date, and no rev-list runs.
se = self._shallow_side_effect(head_sha="same", target_sha="same")
mock_run.side_effect = se
hm._cmd_update_check(branch="main")
out = capsys.readouterr().out
assert "Already up to date" in out
# Shallow-preserving fetch, no plain fetch, no rev-list.
assert any("fetch --depth 1 origin main" in c for c in se.calls), se.calls
assert not any("rev-list" in c for c in se.calls), se.calls