Git update logic broken for shallow/diverged/non-main branches

NousResearch/hermes-agent · cluster 3213 · hermes cli

Data as of

3 open items. Fix merged in #3492. Check it covers your case.

The short answer

2open issues
1open PR
12items grouped
Known?
Yes. 12 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #3492. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #86318. That is separate from the fix event.
Still open
2 issues and 1 PR.

Multiple reports and fixes address the update and install commands incorrectly counting commits, destroying local commits, and failing when run on shallow clones, diverged histories, or non-main branches. Windows is specifically called out as affected.

Open issues2

  • Issue#64977install.sh git reset --hard origin/\$BRANCH destroys custom branches during bootstrap
  • Issue#68484Windows git-install: banner '1 commit behind' misleads on diverged feature + live Desktop cannot hermes update

Open PRs1

  • PR#68677fix(cli): do not report tip behind-count on diverged branches

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3492 fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage #3489)

    merged to main

    files: hermes_cli/main.py, tests/hermes_cli/test_update_autostash.py

    Show the diff
    • hermes_cli/main.py
      @@ -2632,7 +2632,12 @@ def _restore_stashed_changes(
                   print("Resolve conflicts manually, then run: git stash drop")
       
               print(f"Restore your changes with: git stash apply {stash_ref}")
      -        sys.exit(1)
      +        # In non-interactive mode (gateway /update), don't abort — the code
      +        # update itself succeeded, only the stash restore had conflicts.
      +        # Aborting would report the entire update as failed.
      +        if prompt_user:
      +            sys.exit(1)
      +        return False
       
           stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
           if stash_selector is None:
      @@ -2706,30 +2711,60 @@ def cmd_update(args):
       
           # Fetch and pull
           try:
      -        print("→ Fetching updates...")
               git_cmd = ["git"]
               if sys.platform == "win32":
                   git_cmd = ["git", "-c", "windows.appendAtomically=false"]
      -        
      -        subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
      -        
      -        # Get current branch
      +
      +        print("→ Fetching updates...")
      +        fetch_result = subprocess.run(
      +            git_cmd + ["fetch", "origin"],
      +            cwd=PROJECT_ROOT,
      +            capture_output=True,
      +            text=True,
      +        )
      +        if fetch_result.returncode != 0:
      +            stderr = fetch_result.stderr.strip()
      +            if "Could not resolve host" in stderr or "unable to access" in stderr:
      +                print("✗ Network error — cannot reach the remote repository.")
      +                print(f"  {stderr.splitlines()[0]}" if stderr else "")
      +            elif "Authentication failed" in stderr or "could not read Username" in stderr:
      +                print("✗ Authentication failed — check your git credentials or SSH key.")
      +            else:
      +                print(f"✗ Failed to fetch updates from origin.")
      +                if stderr:
      +                    print(f"  {stderr.splitlines()[0]}")
      +            sys.exit(1)
      +
      +        # Get current branch (returns literal "HEAD" when detached)
               result = subprocess.run(
                   git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
                   cwd=PROJECT_ROOT,
                   capture_output=True,
                   text=True,
      -            check=True
      +            check=True,
               )
      -        branch = result.stdout.strip()
      +        current_branch = result.stdout.strip()
       
      -        # Fall back to main if the current branch doesn't exist on the remote
      -        verify = subprocess.run(
      -            git_cmd + ["rev-parse", "--verify", f"origin/{branch}"],
      -            cwd=PROJECT_ROOT, capture_output=True, text=True,
      -        )
      -        if verify.returncode != 0:
      -            branch = "main"
      +        # Always update against main
      +        branch = "main"
      +
      +        # If user is on a non-main branch or detached HEAD, switch to main
      +        if current_branch != "main":
      +            label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'"
      +            print(f"  ⚠ Currently on {label} — switching to main for update...")
      +            # Stash before checkout so uncommitted work isn't lost
      +            auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
      +            subprocess.run(
      +                git_cmd + ["checkout", "main"],
      +                cwd=PROJECT_ROOT,
      +                capture_output=True,
      +                text=True,
      +                check=True,
      +            )
      +        else:
      +            auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
      +
      +        prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
       
               # Check if there are updates
               result = subprocess.run(
      @@ -2737,31 +2772,69 @@ def cmd_update(args):
                   cwd=PROJECT_ROOT,
                   capture_output=True,
                   text=True,
      -            check=True
      +            check=True,
               )
               commit_count = int(result.stdout.strip())
      -        
      +
               if commit_count == 0:
                   _invalidate_update_cache()
      +            # Restore stash and switch back to original branch if we moved
      +            if auto_stash_ref is not None:
      +                _restore_stashed_changes(
      +                    git_cmd, PROJECT_ROOT, auto_stash_ref,
      +                    prompt_user=prompt_for_restore,
      +                )
      +            if current_branch not in ("main", "HEAD"):
      +                subprocess.run(
      +                    git_cmd + ["checkout", current_branch],
      +                    cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
      +                )
                   print("✓ Already up to date!")
                   return
      -        
      -        print(f"→ Found {commit_count} new commit(s)")
       
      -        auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
      -        prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
      +        print(f"→ Found {commit_count} new commit(s)")
       
               print("→ Pulling updates...")
      +        update_succeeded = False
               try:
      -            subprocess.run(git_cmd + ["pull", "--ff-only", "origin", branch], cwd=PROJECT_ROOT, check=True)
      +            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("  Try manually: git fetch origin && git reset --hard origin/main")
      +                    sys.exit(1)
      +            update_succeeded = True
               finally:
                   if auto_stash_ref is not None:
      -                _restore_stashed_changes(
      -                    git_cmd,
      -                    PROJECT_ROOT,
      -                    auto_stash_ref,
      -                    prompt_user=prompt_for_restore,
      -                )
      +                # Don't attempt stash restore if the code update itself failed —
      +                # working tree is in an unknown state.
      +                if not update_succeeded:
      +                    print(f"  ℹ️  Local changes preserved in stash (ref: {auto_stash_ref})")
      +                    print(f"  Restore manually with: git stash apply")
      +                else:
      +                    _restore_stashed_changes(
      +                        git_cmd,
      +                        PROJECT_ROOT,
      +                        auto_stash_ref,
      +                        prompt_user=prompt_for_restore,
      +                    )
               
               _invalidate_update_cache()
               
    • tests/hermes_cli/test_update_autostash.py
      @@ -267,7 +267,8 @@ def fake_run(cmd, **kwargs):
       
       
       def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys):
      -    """Non-interactive mode auto-resets without prompting."""
      +    """Non-interactive mode auto-resets without prompting and returns False
      +    instead of sys.exit(1) so the update can continue (gateway /update path)."""
           calls = []
       
           def fake_run(cmd, **kwargs):
      @@ -282,9 +283,9 @@ def fake_run(cmd, **kwargs):
       
           monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
       
      -    with pytest.raises(SystemExit, match="1"):
      -        hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
      +    result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
       
      +    assert result is False
           out = capsys.readouterr().out
           assert "Working tree reset to clean state" in out
           reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
      @@ -384,3 +385,236 @@ def fake_run(cmd, **kwargs):
           install_cmds = [c for c in recorded if "pip" in c and "install" in c]
           assert len(install_cmds) == 1
           assert ".[all]" in install_cmds[0]
      +
      +
      +# ---------------------------------------------------------------------------
      +# ff-only fallback to reset --hard on diverged history
      +# ---------------------------------------------------------------------------
      +
      +def _make_update_side_effect(
      +    current_branch="main",
      +    commit_count="3",
      +    ff_only_fails=False,
      +    reset_fails=False,
      +    fetch_fails=False,
      +    fetch_stderr="",
      +):
      +    """Build a subprocess.run side_effect for cmd_update tests."""
      +    recorded = []
      +
      +    def side_effect(cmd, **kwargs):
      +        recorded.append(cmd)
      +        joined = " ".join(str(c) for c in cmd)
      +        if "fetch" in joined and "origin" in joined:
      +            if fetch_fails:
      +                return SimpleNamespace(stdout="", stderr=fetch_stderr, returncode=128)
      +            return SimpleNamespace(stdout="", stderr="", returncode=0)
      +        if "rev-parse" in joined and "--abbrev-ref" in joined:
      +            return SimpleNamespace(stdout=f"{current_branch}\n", stderr="", returncode=0)
      +        if "checkout" in joined and "main" in joined:
      +            return SimpleNamespace(stdout="", stderr="", returncode=0)
      +        if "rev-list" in joined:
      +            return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0)
      +        if "--ff-only" in joined:
      +            if ff_only_fails:
      +                return SimpleNamespace(
      +                    stdout="",
      +                    stderr="fatal: Not possible to fast-forward, aborting.\n",
      +                    returncode=128,
      +                )
      +            return SimpleNamespace(stdout="Updating abc..def\n", stderr="", returncode=0)
      +        if "reset" in joined and "--hard" in joined:
      +            if reset_fails:
      +                return SimpleNamespace(stdout="", stderr="error: unable to write\n", returncode=1)
      +            return SimpleNamespace(stdout="HEAD is now at abc123\n", stderr="", returncode=0)
      +        return SimpleNamespace(returncode=0, stdout="", stderr="")
      +
      +    return side_effect, recorded
      +
      +
      +def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys):
      +    """When --ff-only fails (diverged history), update resets to origin/{branch}."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    side_effect, recorded = _make_update_side_effect(ff_only_fails=True)
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    reset_calls = [c for c in recorded if "reset" in c and "--hard" in c]
      +    assert len(reset_calls) == 1
      +    assert reset_calls[0] == ["git", "reset", "--hard", "origin/main"]
      +
      +    out = capsys.readouterr().out
      +    assert "Fast-forward not possible" in out
      +
      +
      +def test_cmd_update_no_reset_when_ff_only_succeeds(monkeypatch, tmp_path):
      +    """When --ff-only succeeds, no reset is attempted."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    side_effect, recorded = _make_update_side_effect()
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    reset_calls = [c for c in recorded if "reset" in c and "--hard" in c]
      +    assert len(reset_calls) == 0
      +
      +
      +# ---------------------------------------------------------------------------
      +# Non-main branch → auto-checkout main
      +# ---------------------------------------------------------------------------
      +
      +def test_cmd_update_switches_to_main_from_feature_branch(monkeypatch, tmp_path, capsys):
      +    """When on a feature branch, update checks out main before pulling."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    side_effect, recorded = _make_update_side_effect(current_branch="fix/something")
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    checkout_calls = [c for c in recorded if "checkout" in c and "main" in c]
      +    assert len(checkout_calls) == 1
      +
      +    out = capsys.readouterr().out
      +    assert "fix/something" in out
      +    assert "switching to main" in out
      +
      +
      +def test_cmd_update_switches_to_main_from_detached_head(monkeypatch, tmp_path, capsys):
      +    """When in detached HEAD state, update checks out main before pulling."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    side_effect, recorded = _make_update_side_effect(current_branch="HEAD")
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    checkout_calls = [c for c in recorded if "checkout" in c and "main" in c]
      +    assert len(checkout_calls) == 1
      +
      +    out = capsys.readouterr().out
      +    assert "detached HEAD" in out
      +
      +
      +def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatch, tmp_path, capsys):
      +    """When on a feature branch with no updates, stash is restored and branch switched back."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    # Enable stash so it returns a ref
      +    monkeypatch.setattr(
      +        hermes_main, "_stash_local_changes_if_needed",
      +        lambda *a, **kw: "abc123deadbeef",
      +    )
      +    restore_calls = []
      +    monkeypatch.setattr(
      +        hermes_main, "_restore_stashed_changes",
      +        lambda *a, **kw: restore_calls.append(1) or True,
      +    )
      +
      +    side_effect, recorded = _make_update_side_effect(
      +        current_branch="fix/something", commit_count="0",
      +    )
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    # Stash should have been restored
      +    assert len(restore_calls) == 1
      +
      +    # Should have checked out back to the original branch
      +    checkout_back = [c for c in recorded if "checkout" in c and "fix/something" in c]
      +    assert len(checkout_back) == 1
      +
      +    out = capsys.readouterr().out
      +    assert "Already up to date" in out
      +
      +
      +def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
      +    """When already on main, no checkout is needed."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
      +
      +    side_effect, recorded = _make_update_side_effect()
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    hermes_main.cmd_update(SimpleNamespace())
      +
      +    checkout_calls = [c for c in recorded if "checkout" in c]
      +    assert len(checkout_calls) == 0
      +
      +
      +# ---------------------------------------------------------------------------
      +# Fetch failure — friendly error messages
      +# ---------------------------------------------------------------------------
      +
      +def test_cmd_update_network_error_shows_friendly_message(monkeypatch, tmp_path, capsys):
      +    """Network failures during fetch show a user-friendly message."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +
      +    side_effect, _ = _make_update_side_effect(
      +        fetch_fails=True,
      +        fetch_stderr="fatal: unable to access 'https://...': Could not resolve host: github.com",
      +    )
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    with pytest.raises(SystemExit, match="1"):
      +        hermes_main.cmd_update(SimpleNamespace())
      +
      +    out = capsys.readouterr().out
      +    assert "Network error" in out
      +
      +
      +def test_cmd_update_auth_error_shows_friendly_message(monkeypatch, tmp_path, capsys):
      +    """Auth failures during fetch show a user-friendly message."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +
      +    side_effect, _ = _make_update_side_effect(
      +        fetch_fails=True,
      +        fetch_stderr="fatal: Authentication failed for 'https://...'",
      +    )
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    with pytest.raises(SystemExit, match="1"):
      +        hermes_main.cmd_update(SimpleNamespace())
      +
      +    out = capsys.readouterr().out
      +    assert "Authentication failed" in out
      +
      +
      +# ---------------------------------------------------------------------------
      +# reset --hard failure — don't attempt stash restore
      +# ---------------------------------------------------------------------------
      +
      +def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, capsys):
      +    """When reset --hard fails, stash restore is skipped with a helpful message."""
      +    _setup_update_mocks(monkeypatch, tmp_path)
      +    # Re-enable stash so it actually returns a ref
      +    monkeypatch.setattr(
      +        hermes_main, "_stash_local_changes_if_needed",
      +        lambda *a, **kw: "abc123deadbeef",
      +    )
      +    restore_calls = []
      +    monkeypatch.setattr(
      +        hermes_main, "_restore_stashed_changes",
      +        lambda *a, **kw: restore_calls.append(1) or True,
      +    )
      +
      +    side_effect, _ = _make_update_side_effect(ff_only_fails=True, reset_fails=True)
      +    monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
      +
      +    with pytest.raises(SystemExit, match="1"):
      +        hermes_main.cmd_update(SimpleNamespace())
      +
      +    # Stash restore should NOT have been called
      +    assert len(restore_calls) == 0
      +
      +    out = capsys.readouterr().out
      +    assert "preserved in stash" in out
  • #86318 fix(update): stop reporting bogus 'Found 9980 new commit(s)' on shallow installs

    merged to main

    files: hermes_cli/update_cmd.py, tests/hermes_cli/test_update_apply_shallow_count.py

3 PRs closed without merging

From the close comments: duplicate 2 · done elsewhere 1

  • #3216 fix(cli): handle update from non-main branches and ff-only failures · done elsewhere → 9d4b3e5, #3492, #3213
  • #53498 fix(update): avoid bogus shallow commit counts in cmd_update · duplicate → #53494, #53479, #50784
  • #53512 fix: skip bogus shallow update counts · duplicate → #53498, #53479
Closed issues · 4
  • Closed#3213fix(cli): update fails with ff-only when on a feature branch
  • Closed#53479CLI updater still trusts rev-list counts for shallow/diverged installs
  • Closed#63577Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open
  • Closed#63578Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#3492, #86318
Closed issues
4
Attempts
3 PRs closed without merging
Fix link checked
14 Aug 2026

For your agent

The same facts, typed.