{"repo":"NousResearch/hermes-agent","public_id":"3213","display_label":"Git update logic broken for shallow/diverged/non-main branches","summary":"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.","subsystem":"hermes_cli","counts":{"members":12,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":4,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3492},"resolution":{"state":"partially_resolved","by":"pr-86318"},"freshness":{"state":"verified","last_verified_at":"2026-08-14T20:36:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1181818181818182},"fix_edges":[{"issue_number":3213,"pr_number":3492,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53479,"pr_number":86318,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":80049,"pr_number":86318,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3213,"title":"fix(cli): update fails with ff-only when on a feature branch","url":null,"state":"closed"},{"kind":"issue","number":53479,"title":"CLI updater still trusts rev-list counts for shallow/diverged installs","url":null,"state":"closed"},{"kind":"issue","number":63577,"title":"Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open","url":null,"state":"closed"},{"kind":"issue","number":63578,"title":"Critical: hermes update on Windows destroys local commits + kills active chat + blocks when desktop is open","url":null,"state":"closed"},{"kind":"issue","number":64977,"title":"install.sh git reset --hard origin/\\$BRANCH destroys custom branches during bootstrap","url":null,"state":"open"},{"kind":"issue","number":68484,"title":"Windows git-install: banner '1 commit behind' misleads on diverged feature + live Desktop cannot hermes update","url":null,"state":"open"},{"kind":"pr","number":3216,"title":"fix(cli): handle update from non-main branches and ff-only failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9d4b3e5470fb668ede1a2048f4620ed27477b4fa","number":0},{"kind":"pr","value":"#3492","number":3492},{"kind":"pr","value":"#3213","number":3213}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":3492,"title":"fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage #3489)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_update_autostash.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -2632,7 +2632,12 @@ def _restore_stashed_changes(\n             print(\"Resolve conflicts manually, then run: git stash drop\")\n \n         print(f\"Restore your changes with: git stash apply {stash_ref}\")\n-        sys.exit(1)\n+        # In non-interactive mode (gateway /update), don't abort — the code\n+        # update itself succeeded, only the stash restore had conflicts.\n+        # Aborting would report the entire update as failed.\n+        if prompt_user:\n+            sys.exit(1)\n+        return False\n \n     stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)\n     if stash_selector is None:","@@ -2706,30 +2711,60 @@ def cmd_update(args):\n \n     # Fetch and pull\n     try:\n-        print(\"→ Fetching updates...\")\n         git_cmd = [\"git\"]\n         if sys.platform == \"win32\":\n             git_cmd = [\"git\", \"-c\", \"windows.appendAtomically=false\"]\n-        \n-        subprocess.run(git_cmd + [\"fetch\", \"origin\"], cwd=PROJECT_ROOT, check=True)\n-        \n-        # Get current branch\n+\n+        print(\"→ Fetching updates...\")\n+        fetch_result = subprocess.run(\n+            git_cmd + [\"fetch\", \"origin\"],\n+            cwd=PROJECT_ROOT,\n+            capture_output=True,\n+            text=True,\n+        )\n+        if fetch_result.returncode != 0:\n+            stderr = fetch_result.stderr.strip()\n+            if \"Could not resolve host\" in stderr or \"unable to access\" in stderr:\n+                print(\"✗ Network error — cannot reach the remote repository.\")\n+                print(f\"  {stderr.splitlines()[0]}\" if stderr else \"\")\n+            elif \"Authentication failed\" in stderr or \"could not read Username\" in stderr:\n+                print(\"✗ Authentication failed — check your git credentials or SSH key.\")\n+            else:\n+                print(f\"✗ Failed to fetch updates from origin.\")\n+                if stderr:\n+                    print(f\"  {stderr.splitlines()[0]}\")\n+            sys.exit(1)\n+\n+        # Get current branch (returns literal \"HEAD\" when detached)\n         result = subprocess.run(\n             git_cmd + [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"],\n             cwd=PROJECT_ROOT,\n             capture_output=True,\n             text=True,\n-            check=True\n+            check=True,\n         )\n-        branch = result.stdout.strip()\n+        current_branch = result.stdout.strip()\n \n-        # Fall back to main if the current branch doesn't exist on the remote\n-        verify = subprocess.run(\n-            git_cmd + [\"rev-parse\", \"--verify\", f\"origin/{branch}\"],\n-            cwd=PROJECT_ROOT, capture_output=True, text=True,\n-        )\n-        if verify.returncode != 0:\n-            branch = \"main\"\n+        # Always update against main\n+        branch = \"main\"\n+\n+        # If user is on a non-main branch or detached HEAD, switch to main\n+        if current_branch != \"main\":\n+            label = \"detached HEAD\" if current_branch == \"HEAD\" else f\"branch '{current_branch}'\"\n+            print(f\"  ⚠ Currently on {label} — switching to main for update...\")\n+            # Stash before checkout so uncommitted work isn't lost\n+            auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)\n+            subprocess.run(\n+                git_cmd + [\"checkout\", \"main\"],\n+                cwd=PROJECT_ROOT,\n+                capture_output=True,\n+                text=True,\n+                check=True,\n+            )\n+        else:\n+            auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)\n+\n+        prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()\n \n         # Check if there are updates\n         result = subprocess.run(","@@ -2737,31 +2772,69 @@ def cmd_update(args):\n             cwd=PROJECT_ROOT,\n             capture_output=True,\n             text=True,\n-            check=True\n+            check=True,\n         )\n         commit_count = int(result.stdout.strip())\n-        \n+\n         if commit_count == 0:\n             _invalidate_update_cache()\n+            # Restore stash and switch back to original branch if we moved\n+            if auto_stash_ref is not None:\n+                _restore_stashed_changes(\n+                    git_cmd, PROJECT_ROOT, auto_stash_ref,\n+                    prompt_user=prompt_for_restore,\n+                )\n+            if current_branch not in (\"main\", \"HEAD\"):\n+                subprocess.run(\n+                    git_cmd + [\"checkout\", current_branch],\n+                    cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,\n+                )\n             print(\"✓ Already up to date!\")\n             return\n-        \n-        print(f\"→ Found {commit_count} new commit(s)\")\n \n-        auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)\n-        prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()\n+        print(f\"→ Found {commit_count} new commit(s)\")\n \n         print(\"→ Pulling updates...\")\n+        update_succeeded = False\n         try:\n-            subprocess.run(git_cmd + [\"pull\", \"--ff-only\", \"origin\", branch], cwd=PROJECT_ROOT, check=True)\n+            pull_result = subprocess.run(\n+                git_cmd + [\"pull\", \"--ff-only\", \"origin\", branch],\n+                cwd=PROJECT_ROOT,\n+                capture_output=True,\n+                text=True,\n+            )\n+            if pull_result.returncode != 0:\n+                # ff-only failed — local and remote have diverged (e.g. upstream\n+                # force-pushed or rebase).  Since local changes are already\n+                # stashed, reset to match the remote exactly.\n+                print(\"  ⚠ Fast-forward not possible (history diverged), resetting to match remote...\")\n+                reset_result = subprocess.run(\n+                    git_cmd + [\"reset\", \"--hard\", f\"origin/{branch}\"],\n+                    cwd=PROJECT_ROOT,\n+                    capture_output=True,\n+                    text=True,\n+                )\n+                if reset_result.returncode != 0:\n+                    print(f\"✗ Failed to reset to origin/{branch}.\")\n+                    if reset_result.stderr.strip():\n+                        print(f\"  {reset_result.stderr.strip()}\")\n+                    print(\"  Try manually: git fetch origin && git reset --hard origin/main\")\n+                    sys.exit(1)\n+            update_succeeded = True\n         finally:\n             if auto_stash_ref is not None:\n-                _restore_stashed_changes(\n-                    git_cmd,\n-                    PROJECT_ROOT,\n-                    auto_stash_ref,\n-                    prompt_user=prompt_for_restore,\n-                )\n+                # Don't attempt stash restore if the code update itself failed —\n+                # working tree is in an unknown state.\n+                if not update_succeeded:\n+                    print(f\"  ℹ️  Local changes preserved in stash (ref: {auto_stash_ref})\")\n+                    print(f\"  Restore manually with: git stash apply\")\n+                else:\n+                    _restore_stashed_changes(\n+                        git_cmd,\n+                        PROJECT_ROOT,\n+                        auto_stash_ref,\n+                        prompt_user=prompt_for_restore,\n+                    )\n         \n         _invalidate_update_cache()\n         "]},{"file":"tests/hermes_cli/test_update_autostash.py","hunks":["@@ -267,7 +267,8 @@ def fake_run(cmd, **kwargs):\n \n \n def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys):\n-    \"\"\"Non-interactive mode auto-resets without prompting.\"\"\"\n+    \"\"\"Non-interactive mode auto-resets without prompting and returns False\n+    instead of sys.exit(1) so the update can continue (gateway /update path).\"\"\"\n     calls = []\n \n     def fake_run(cmd, **kwargs):","@@ -282,9 +283,9 @@ def fake_run(cmd, **kwargs):\n \n     monkeypatch.setattr(hermes_main.subprocess, \"run\", fake_run)\n \n-    with pytest.raises(SystemExit, match=\"1\"):\n-        hermes_main._restore_stashed_changes([\"git\"], tmp_path, \"abc123\", prompt_user=False)\n+    result = hermes_main._restore_stashed_changes([\"git\"], tmp_path, \"abc123\", prompt_user=False)\n \n+    assert result is False\n     out = capsys.readouterr().out\n     assert \"Working tree reset to clean state\" in out\n     reset_calls = [c for c, _ in calls if c[1:3] == [\"reset\", \"--hard\"]]","@@ -384,3 +385,236 @@ def fake_run(cmd, **kwargs):\n     install_cmds = [c for c in recorded if \"pip\" in c and \"install\" in c]\n     assert len(install_cmds) == 1\n     assert \".[all]\" in install_cmds[0]\n+\n+\n+# ---------------------------------------------------------------------------\n+# ff-only fallback to reset --hard on diverged history\n+# ---------------------------------------------------------------------------\n+\n+def _make_update_side_effect(\n+    current_branch=\"main\",\n+    commit_count=\"3\",\n+    ff_only_fails=False,\n+    reset_fails=False,\n+    fetch_fails=False,\n+    fetch_stderr=\"\",\n+):\n+    \"\"\"Build a subprocess.run side_effect for cmd_update tests.\"\"\"\n+    recorded = []\n+\n+    def side_effect(cmd, **kwargs):\n+        recorded.append(cmd)\n+        joined = \" \".join(str(c) for c in cmd)\n+        if \"fetch\" in joined and \"origin\" in joined:\n+            if fetch_fails:\n+                return SimpleNamespace(stdout=\"\", stderr=fetch_stderr, returncode=128)\n+            return SimpleNamespace(stdout=\"\", stderr=\"\", returncode=0)\n+        if \"rev-parse\" in joined and \"--abbrev-ref\" in joined:\n+            return SimpleNamespace(stdout=f\"{current_branch}\\n\", stderr=\"\", returncode=0)\n+        if \"checkout\" in joined and \"main\" in joined:\n+            return SimpleNamespace(stdout=\"\", stderr=\"\", returncode=0)\n+        if \"rev-list\" in joined:\n+            return SimpleNamespace(stdout=f\"{commit_count}\\n\", stderr=\"\", returncode=0)\n+        if \"--ff-only\" in joined:\n+            if ff_only_fails:\n+                return SimpleNamespace(\n+                    stdout=\"\",\n+                    stderr=\"fatal: Not possible to fast-forward, aborting.\\n\",\n+                    returncode=128,\n+                )\n+            return SimpleNamespace(stdout=\"Updating abc..def\\n\", stderr=\"\", returncode=0)\n+        if \"reset\" in joined and \"--hard\" in joined:\n+            if reset_fails:\n+                return SimpleNamespace(stdout=\"\", stderr=\"error: unable to write\\n\", returncode=1)\n+            return SimpleNamespace(stdout=\"HEAD is now at abc123\\n\", stderr=\"\", returncode=0)\n+        return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+    return side_effect, recorded\n+\n+\n+def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys):\n+    \"\"\"When --ff-only fails (diverged history), update resets to origin/{branch}.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    side_effect, recorded = _make_update_side_effect(ff_only_fails=True)\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    reset_calls = [c for c in recorded if \"reset\" in c and \"--hard\" in c]\n+    assert len(reset_calls) == 1\n+    assert reset_calls[0] == [\"git\", \"reset\", \"--hard\", \"origin/main\"]\n+\n+    out = capsys.readouterr().out\n+    assert \"Fast-forward not possible\" in out\n+\n+\n+def test_cmd_update_no_reset_when_ff_only_succeeds(monkeypatch, tmp_path):\n+    \"\"\"When --ff-only succeeds, no reset is attempted.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    side_effect, recorded = _make_update_side_effect()\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    reset_calls = [c for c in recorded if \"reset\" in c and \"--hard\" in c]\n+    assert len(reset_calls) == 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Non-main branch → auto-checkout main\n+# ---------------------------------------------------------------------------\n+\n+def test_cmd_update_switches_to_main_from_feature_branch(monkeypatch, tmp_path, capsys):\n+    \"\"\"When on a feature branch, update checks out main before pulling.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    side_effect, recorded = _make_update_side_effect(current_branch=\"fix/something\")\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    checkout_calls = [c for c in recorded if \"checkout\" in c and \"main\" in c]\n+    assert len(checkout_calls) == 1\n+\n+    out = capsys.readouterr().out\n+    assert \"fix/something\" in out\n+    assert \"switching to main\" in out\n+\n+\n+def test_cmd_update_switches_to_main_from_detached_head(monkeypatch, tmp_path, capsys):\n+    \"\"\"When in detached HEAD state, update checks out main before pulling.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    side_effect, recorded = _make_update_side_effect(current_branch=\"HEAD\")\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    checkout_calls = [c for c in recorded if \"checkout\" in c and \"main\" in c]\n+    assert len(checkout_calls) == 1\n+\n+    out = capsys.readouterr().out\n+    assert \"detached HEAD\" in out\n+\n+\n+def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatch, tmp_path, capsys):\n+    \"\"\"When on a feature branch with no updates, stash is restored and branch switched back.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    # Enable stash so it returns a ref\n+    monkeypatch.setattr(\n+        hermes_main, \"_stash_local_changes_if_needed\",\n+        lambda *a, **kw: \"abc123deadbeef\",\n+    )\n+    restore_calls = []\n+    monkeypatch.setattr(\n+        hermes_main, \"_restore_stashed_changes\",\n+        lambda *a, **kw: restore_calls.append(1) or True,\n+    )\n+\n+    side_effect, recorded = _make_update_side_effect(\n+        current_branch=\"fix/something\", commit_count=\"0\",\n+    )\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    # Stash should have been restored\n+    assert len(restore_calls) == 1\n+\n+    # Should have checked out back to the original branch\n+    checkout_back = [c for c in recorded if \"checkout\" in c and \"fix/something\" in c]\n+    assert len(checkout_back) == 1\n+\n+    out = capsys.readouterr().out\n+    assert \"Already up to date\" in out\n+\n+\n+def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):\n+    \"\"\"When already on main, no checkout is needed.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    side_effect, recorded = _make_update_side_effect()\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    checkout_calls = [c for c in recorded if \"checkout\" in c]\n+    assert len(checkout_calls) == 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fetch failure — friendly error messages\n+# ---------------------------------------------------------------------------\n+\n+def test_cmd_update_network_error_shows_friendly_message(monkeypatch, tmp_path, capsys):\n+    \"\"\"Network failures during fetch show a user-friendly message.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+\n+    side_effect, _ = _make_update_side_effect(\n+        fetch_fails=True,\n+        fetch_stderr=\"fatal: unable to access 'https://...': Could not resolve host: github.com\",\n+    )\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    with pytest.raises(SystemExit, match=\"1\"):\n+        hermes_main.cmd_update(SimpleNamespace())\n+\n+    out = capsys.readouterr().out\n+    assert \"Network error\" in out\n+\n+\n+def test_cmd_update_auth_error_shows_friendly_message(monkeypatch, tmp_path, capsys):\n+    \"\"\"Auth failures during fetch show a user-friendly message.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+\n+    side_effect, _ = _make_update_side_effect(\n+        fetch_fails=True,\n+        fetch_stderr=\"fatal: Authentication failed for 'https://...'\",\n+    )\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    with pytest.raises(SystemExit, match=\"1\"):\n+        hermes_main.cmd_update(SimpleNamespace())\n+\n+    out = capsys.readouterr().out\n+    assert \"Authentication failed\" in out\n+\n+\n+# ---------------------------------------------------------------------------\n+# reset --hard failure — don't attempt stash restore\n+# ---------------------------------------------------------------------------\n+\n+def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, capsys):\n+    \"\"\"When reset --hard fails, stash restore is skipped with a helpful message.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    # Re-enable stash so it actually returns a ref\n+    monkeypatch.setattr(\n+        hermes_main, \"_stash_local_changes_if_needed\",\n+        lambda *a, **kw: \"abc123deadbeef\",\n+    )\n+    restore_calls = []\n+    monkeypatch.setattr(\n+        hermes_main, \"_restore_stashed_changes\",\n+        lambda *a, **kw: restore_calls.append(1) or True,\n+    )\n+\n+    side_effect, _ = _make_update_side_effect(ff_only_fails=True, reset_fails=True)\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", side_effect)\n+\n+    with pytest.raises(SystemExit, match=\"1\"):\n+        hermes_main.cmd_update(SimpleNamespace())\n+\n+    # Stash restore should NOT have been called\n+    assert len(restore_calls) == 0\n+\n+    out = capsys.readouterr().out\n+    assert \"preserved in stash\" in out\n"]}]},{"kind":"pr","number":53498,"title":"fix(update): avoid bogus shallow commit counts in cmd_update","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53494","number":53494},{"kind":"pr","value":"#53479","number":53479},{"kind":"pr","value":"#53494","number":53494},{"kind":"pr","value":"#50784","number":50784}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53512,"title":"fix: skip bogus shallow update counts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53498","number":53498},{"kind":"pr","value":"#53479","number":53479}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":68677,"title":"fix(cli): do not report tip behind-count on diverged branches","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"e3fab0437","number":0},{"kind":"pr","value":"#86257","number":86257},{"kind":"pr","value":"#86257","number":86257}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":86318,"title":"fix(update): stop reporting bogus 'Found 9980 new commit(s)' on shallow installs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/update_cmd.py","tests/hermes_cli/test_update_apply_shallow_count.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}