mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
preserve shallow clones and show correct update values for them
This commit is contained in:
parent
150687447b
commit
2950c6fa2e
4 changed files with 454 additions and 58 deletions
|
|
@ -1243,7 +1243,23 @@ async function checkUpdates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
branch = await resolveHealedBranch(updateRoot, branch)
|
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) {
|
if (fetched.code !== 0) {
|
||||||
return {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|
@ -1256,6 +1272,38 @@ async function checkUpdates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
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([
|
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
|
||||||
git(['rev-parse', 'HEAD']),
|
git(['rev-parse', 'HEAD']),
|
||||||
git(['rev-parse', `origin/${branch}`]),
|
git(['rev-parse', `origin/${branch}`]),
|
||||||
|
|
@ -1272,6 +1320,8 @@ async function checkUpdates() {
|
||||||
branch,
|
branch,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
behind,
|
behind,
|
||||||
|
behindExact: true,
|
||||||
|
shallow: false,
|
||||||
currentSha,
|
currentSha,
|
||||||
targetSha,
|
targetSha,
|
||||||
commits,
|
commits,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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]:
|
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:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "fetch", "origin", "--quiet"],
|
fetch_cmd,
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
cwd=str(repo_dir),
|
cwd=str(repo_dir),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Offline or timeout — use stale refs, that's fine
|
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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "rev-list", "--count", "HEAD..origin/main"],
|
["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
|
return None
|
||||||
|
|
||||||
ahead = 0
|
ahead = 0
|
||||||
try:
|
# On a shallow clone there is no history before the grafted boundary, so
|
||||||
result = subprocess.run(
|
# `origin/main..HEAD` would be bogus (and any fetch that populated
|
||||||
["git", "rev-list", "--count", "origin/main..HEAD"],
|
# origin/main already happened via the shallow-preserving path elsewhere).
|
||||||
capture_output=True,
|
# A `--depth 1` install is pinned to a single commit, so "carried commits"
|
||||||
text=True,
|
# is definitionally zero — skip the count and report ahead=0. Full clones
|
||||||
timeout=5,
|
# (developers, pre-#39423 installs) keep the exact count.
|
||||||
cwd=str(repo_dir),
|
if not _is_shallow_clone(repo_dir):
|
||||||
)
|
try:
|
||||||
if result.returncode == 0:
|
result = subprocess.run(
|
||||||
ahead = int((result.stdout or "0").strip() or "0")
|
["git", "rev-list", "--count", "origin/main..HEAD"],
|
||||||
except Exception:
|
capture_output=True,
|
||||||
ahead = 0
|
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)}
|
return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6761,6 +6761,54 @@ def _capture_head_sha(git_cmd, cwd) -> str | None:
|
||||||
return 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]:
|
def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]:
|
||||||
"""Compile each file in ``_UPDATE_CRITICAL_FILES`` to catch SyntaxErrors.
|
"""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":
|
if sys.platform == "win32":
|
||||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||||
|
|
||||||
# Fetch both origin and upstream; prefer upstream as the canonical reference.
|
# Installer checkouts are shallow (`git clone --depth 1`, PR #39423).
|
||||||
# Note: upstream/<branch> may not exist for non-main branches (a fork's
|
# A plain fetch would un-shallow the repo and the `rev-list --count` below
|
||||||
# bb/gui has no upstream counterpart), so when the caller picks a
|
# would be bogus across the grafted boundary. On a shallow clone we fetch
|
||||||
# non-default branch we skip the upstream probe and use origin directly.
|
# `--depth 1` straight from origin (a shallow install has no `upstream`
|
||||||
if branch == "main":
|
# 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...")
|
print("→ Fetching from upstream...")
|
||||||
fetch_result = subprocess.run(
|
fetch_result = subprocess.run(
|
||||||
git_cmd + ["fetch", "upstream"],
|
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]}.")
|
print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
rev_result = subprocess.run(
|
if is_shallow:
|
||||||
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
|
# No history to count across the shallow boundary — compare tip SHAs.
|
||||||
cwd=PROJECT_ROOT,
|
local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD")
|
||||||
capture_output=True,
|
target_sha = (
|
||||||
text=True,
|
_git_rev(git_cmd, PROJECT_ROOT, compare_branch)
|
||||||
check=True,
|
or _git_rev(git_cmd, PROJECT_ROOT, "FETCH_HEAD")
|
||||||
)
|
)
|
||||||
behind = int(rev_result.stdout.strip())
|
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:
|
if behind == 0:
|
||||||
print("✓ Already up to date.")
|
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:
|
else:
|
||||||
commits_word = "commit" if behind == 1 else "commits"
|
commits_word = "commit" if behind == 1 else "commits"
|
||||||
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
|
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
|
# Fetch and pull
|
||||||
try:
|
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...")
|
print("→ Fetching updates...")
|
||||||
|
fetch_args = ["fetch", "origin"]
|
||||||
|
if is_shallow:
|
||||||
|
fetch_args = ["fetch", "--depth", "1", "origin", _resolve_update_branch(args)]
|
||||||
fetch_result = subprocess.run(
|
fetch_result = subprocess.run(
|
||||||
git_cmd + ["fetch", "origin"],
|
git_cmd + fetch_args,
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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()))
|
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are updates
|
# Check if there are updates. On a shallow clone there is no history
|
||||||
result = subprocess.run(
|
# to count across the grafted boundary, so `rev-list HEAD..origin/X`
|
||||||
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
|
# would be bogus — compare tip SHAs instead and treat any difference
|
||||||
cwd=PROJECT_ROOT,
|
# as "an update is available" (exact count is unknowable). Full clones
|
||||||
capture_output=True,
|
# keep the precise count.
|
||||||
text=True,
|
if is_shallow:
|
||||||
check=True,
|
local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD")
|
||||||
)
|
target_sha = (
|
||||||
commit_count = int(result.stdout.strip())
|
_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:
|
if commit_count == 0:
|
||||||
_invalidate_update_cache()
|
_invalidate_update_cache()
|
||||||
|
|
@ -10468,7 +10574,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||||
print("✓ Already up to date!")
|
print("✓ Already up to date!")
|
||||||
return
|
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.)
|
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||||
# before pulling so a user can recover if something goes wrong.
|
# 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).
|
# the bad commit and the fix landing).
|
||||||
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
|
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
|
||||||
try:
|
try:
|
||||||
pull_result = subprocess.run(
|
if is_shallow:
|
||||||
git_cmd + ["pull", "--ff-only", "origin", branch],
|
# Shallow clone: there's no merge base to fast-forward across,
|
||||||
cwd=PROJECT_ROOT,
|
# and `pull` would re-negotiate full history and un-shallow the
|
||||||
capture_output=True,
|
# repo. The `--depth 1` fetch above already advanced
|
||||||
text=True,
|
# origin/{branch} (and FETCH_HEAD) to the new tip, so hard-reset
|
||||||
)
|
# the working tree to it — this keeps the clone shallow and is
|
||||||
if pull_result.returncode != 0:
|
# the equivalent of a fast-forward for a single-commit install.
|
||||||
# ff-only failed — local and remote have diverged (e.g. upstream
|
reset_target = (
|
||||||
# force-pushed or rebase). Since local changes are already
|
f"origin/{branch}"
|
||||||
# stashed, reset to match the remote exactly.
|
if _git_rev(git_cmd, PROJECT_ROOT, f"origin/{branch}")
|
||||||
print(
|
else "FETCH_HEAD"
|
||||||
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
|
|
||||||
)
|
)
|
||||||
reset_result = subprocess.run(
|
pull_result = subprocess.run(
|
||||||
git_cmd + ["reset", "--hard", f"origin/{branch}"],
|
git_cmd + ["reset", "--hard", reset_target],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if reset_result.returncode != 0:
|
if pull_result.returncode != 0:
|
||||||
print(f"✗ Failed to reset to origin/{branch}.")
|
print(f"✗ Failed to update to {reset_target} (shallow).")
|
||||||
if reset_result.stderr.strip():
|
if pull_result.stderr.strip():
|
||||||
print(f" {reset_result.stderr.strip()}")
|
print(f" {pull_result.stderr.strip().splitlines()[0]}")
|
||||||
print(
|
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)
|
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
|
# Post-pull syntax guard: validate critical-path files actually
|
||||||
# parse before declaring the update successful. If a bad commit
|
# parse before declaring the update successful. If a bad commit
|
||||||
|
|
|
||||||
|
|
@ -825,3 +825,105 @@ termux = ["rich>=14"]
|
||||||
|
|
||||||
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
|
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
|
||||||
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue