[Bug]: hermes update reports a false 'detached checkout' no-op when a tree:0 checkout has a missing commit (merge --ff-only exits 0 without moving HEAD)
Bug Description
On a --filter=tree:0 (partial clone) checkout whose commit history has a hole (one commit object missing locally), git merge --ff-only origin/main prints error: Could not read <sha> / error: could not parse commit <sha>, then Already up to date., and exits 0. HEAD does not move. _verify_head_after_pull (hermes_cli/update_cmd.py, post_pull_sha == pre_pull_sha) then reports:
→ Found 1528 new commit(s)
→ Pulling updates...
✗ Code did not move — update was a no-op.
HEAD is pinned to af26acab73 (detached checkout); origin/main advanced but the working tree stayed put.
Reattach to the branch and retry: git -C ~/.hermes/hermes-agent checkout main && hermes updateHEAD was not detached (.git/HEAD = ref: refs/heads/main, git symbolic-ref -q HEAD = refs/heads/main), so the suggested checkout main is a no-op and every retry fails identically. Three consecutive runs from the Desktop/dashboard update button failed the same way (receipts: outcome: failed, pre_update.sha == post_update.sha, stop_reason: sys.exit(1)).
The commit count was also wrong: the updater printed Found 1528 new commit(s), while after the hole was filled git rev-list --count HEAD..origin/main returned 1133. With the hole, the walk cannot see that the commits behind it are already ancestors of HEAD. That mismatch could be used for detection.
Steps to Reproduce
Pure git, no Hermes needed (git 2.50.1, Apple Git-155):
T=$(mktemp -d); cd "$T"
git init -q -b main up; cd up; git config user.email t@t; git config user.name t
c(){ echo $1 > $1; git add .; GIT_COMMITTER_DATE="2026-01-$2 10:00" git commit -qm $1 --date "2026-01-$2 10:00"; }
c a 01; C1=$(git rev-parse HEAD); c b 02; HOLE=$(git rev-parse HEAD); c d 03; c e 04; OLD=$(git rev-parse HEAD)
git checkout -q -b side $C1; c s1 05; git checkout -q main; c f 06
GIT_COMMITTER_DATE="2026-01-07 10:00" git merge -q --no-ff side -m merge; c g 08
cd ..; git clone -q up down; cd down
git reset -q --hard $OLD
git config core.repositoryformatversion 1; git config extensions.partialclone origin; git config remote.origin.promisor true
rm -f .git/objects/${HOLE:0:2}/${HOLE:2} # simulate the history hole
PRE=$(git rev-parse HEAD)
git rev-list --count HEAD..origin/main # 5 (true answer: 3)
git merge --ff-only origin/main; echo "rc=$?" # Could not read <HOLE> ... Already up to date. rc=0
[ "$(git rev-parse HEAD)" = "$PRE" ] && echo "HEAD did not move"On a real install: a tree:0 checkout missing one commit object from the ancestry of HEAD, then hermes update.
Expected Behavior
- The updater notices that the fast-forward did nothing while the target is ahead, surfaces git's stderr (
Could not read <sha>/could not parse commit <sha>), and either heals (fetch the missing commit from the promisor, e.g.git fetch origin <sha>, then retry the merge once) or names the real cause. - The "detached checkout" wording only appears when HEAD is actually detached.
Actual Behavior
The merge's stderr is discarded because only returncode is checked (_pull_updates, merge_result = _git_run(git_cmd, ["merge", "--ff-only", merge_ref]), still the same on origin/main 95db0e2ada). The no-op guard then blames a detached HEAD and advises a command that changes nothing. The update stays stuck until the user fixes git by hand.
Affected Component
CLI (hermes update), also reached from the Desktop/dashboard update action.
Operating System
macOS 27.0.1 (Apple Silicon)
Python Version
3.14.7 (managed runtime)
Hermes Version
0.21.5 (af26acab73), git install, remote.origin.partialclonefilter=tree:0, no local commits, clean tree, origin = NousResearch/hermes-agent.
Root Cause Analysis
git merge --ff-onlycan exit 0 without moving HEAD when the merge-base walk hits a missing commit in a partial clone. The updater treats rc 0 as "merge completed" and drops stderr._verify_head_after_pullhardcodes "(detached checkout)" and thecheckout <branch>advice for everypost == precase, whether or not HEAD is detached.- How the hole appeared is not established. This checkout was shallow and was unshallowed by
heal_shallow_historyduring an update on 2026-09-29 (✓ Fetched the commit history this shallow checkout was missing). The missing commit (4bdd64b334ad, 2026-09-11) is older than that run. A link is plausible but unproven. - Healing: one history walk with lazy fetch enabled (a plain
git login the checkout) fetched the missing commit as a new promisor pack. After that,merge-base HEAD origin/mainandGIT_NO_LAZY_FETCH=1 git rev-list --count HEADrun cleanly, andhermes update --checkreports a consistent count. In the minimal repro the failing merge itself lazy-fetches the commit, so a second run succeeds. On the real install three consecutive runs failed and the commit was still missing afterwards. I could not determine why the lazy fetch did not land there.
Proposed Fix
- In
_pull_updates, whenmerge --ff-onlyreturns 0 but HEAD did not move andrev-list --count HEAD..<target>> 0, check stderr forCould not read/could not parse commit. On a match, fetch the named commit(s) from origin (lazy fetch allowed) and retry the merge once. Otherwise print git's stderr instead of the generic no-op text. - In
_verify_head_after_pull, only use the "detached checkout / checkout " wording whengit symbolic-ref -q HEADfails. Otherwise print the git output captured from the merge.
Related but distinct: #79678 (introduced the no-op guard for a real re-detach), #124642 (ff-only failures misclassified as divergence; that one covers non-zero exits, this one an rc-0 no-op).
Source: NousResearch/hermes-agent