# NousResearch/hermes-agent · Git update logic broken for shallow/diverged/non-main branches

## Open now

- 2 issues, 1 PR open on GitHub.
- #64977: install.sh git reset --hard origin/\\$BRANCH destroys custom branches during bootstrap
- #68484: Windows git-install: banner '1 commit behind' misleads on diverged feature + live Desktop cannot hermes update
- PR #68677: fix(cli): do not report tip behind-count on diverged branches

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.

## TL;DR

- 12 member(s): 2 open issue(s), 4 closed issue(s), 1 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #3492. Verify it addresses your case.
- **Recommended:** read PR #3492 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-86318`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #3213: fix(cli): update fails with ff-only when on a feature branch
- #53479: CLI updater still trusts rev-list counts for shallow/diverged installs
- #63577: Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open
- #63578: Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open

## Prior & competing PRs

- **#3492: fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage #3489)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_update_autostash.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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"]]
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/update_cmd.py`, `tests/hermes_cli/test_update_apply_shallow_count.py`
- **#3216: fix(cli): handle update from non-main branches and ff-only failures**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9d4b3e5470fb668ede1a2048f4620ed27477b4fa`, `#3492`, `#3213`
- **#53498: fix(update): avoid bogus shallow commit counts in cmd_update**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#53494`, `#53479`, `#53494`, `#50784`
- **#53512: fix: skip bogus shallow update counts**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#53498`, `#53479`
- **#68677: fix(cli): do not report tip behind-count on diverged branches**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `e3fab0437`, `#86257`, `#86257`
  - files: `apps/desktop/src/app/settings/about-settings.tsx`, `apps/desktop/src/app/updates-overlay.tsx`, `apps/desktop/src/lib/version-status.test.ts`, `apps/desktop/src/lib/version-status.ts`, `hermes_cli/_startup_fast.py`, `hermes_cli/banner.py`, `hermes_cli/web_routers/actions.py`, `tests/hermes_cli/test_update_behind_count_recovery.py`, `tests/hermes_cli/test_update_check.py`, `ui-tui/src/components/branding.tsx`, `web/src/App.tsx`, `web/src/pages/SystemPage.tsx`

## Fix status

- Merged to main in PR #3492 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-14`.
- Subsystem: `hermes_cli`
