mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 13:49:15 +08:00
Compare commits
1 Commits
feat/apify
...
emo/presev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2950c6fa2e |
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user