From e1824ef8a6c22fe0e97d15c6c9fca631e44168ee Mon Sep 17 00:00:00 2001 From: stoicneko Date: Thu, 12 Mar 2026 06:20:47 -0700 Subject: [PATCH] fix(cli): fall back to main when current branch has no remote counterpart `hermes update` crashed with CalledProcessError when run on a local-only branch (e.g. fix/stoicneko) because `git rev-list HEAD..origin/{branch}` fails when origin/{branch} doesn't exist. Now verifies the remote branch exists first and falls back to origin/main. --- hermes_cli/main.py | 10 ++- tests/hermes_cli/test_cmd_update.py | 107 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_cmd_update.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9609f3998..3d910907d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2056,7 +2056,15 @@ def cmd_update(args): check=True ) 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" + # Check if there are updates result = subprocess.run( git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py new file mode 100644 index 000000000..0ccb7af81 --- /dev/null +++ b/tests/hermes_cli/test_cmd_update.py @@ -0,0 +1,107 @@ +"""Tests for cmd_update — branch fallback when remote branch doesn't exist.""" + +import subprocess +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli.main import cmd_update, PROJECT_ROOT + + +def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"): + """Build a side_effect function for subprocess.run that simulates git commands.""" + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + + # git rev-parse --abbrev-ref HEAD (get current branch) + if "rev-parse" in joined and "--abbrev-ref" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="") + + # git rev-parse --verify origin/{branch} (check remote branch exists) + if "rev-parse" in joined and "--verify" in joined: + rc = 0 if verify_ok else 128 + return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="") + + # git rev-list HEAD..origin/{branch} --count + if "rev-list" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="") + + # Fallback: return a successful CompletedProcess with empty stdout + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + return side_effect + + +@pytest.fixture +def mock_args(): + return SimpleNamespace() + + +class TestCmdUpdateBranchFallback: + """cmd_update falls back to main when current branch has no remote counterpart.""" + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_falls_back_to_main_when_branch_not_on_remote( + self, mock_run, _mock_which, mock_args, capsys + ): + mock_run.side_effect = _make_run_side_effect( + branch="fix/stoicneko", verify_ok=False, commit_count="3" + ) + + cmd_update(mock_args) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + + # rev-list should use origin/main, not origin/fix/stoicneko + rev_list_cmds = [c for c in commands if "rev-list" in c] + assert len(rev_list_cmds) == 1 + assert "origin/main" in rev_list_cmds[0] + assert "origin/fix/stoicneko" not in rev_list_cmds[0] + + # pull should use main, not fix/stoicneko + pull_cmds = [c for c in commands if "pull" in c] + assert len(pull_cmds) == 1 + assert "main" in pull_cmds[0] + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_uses_current_branch_when_on_remote( + self, mock_run, _mock_which, mock_args, capsys + ): + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="2" + ) + + cmd_update(mock_args) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + + rev_list_cmds = [c for c in commands if "rev-list" in c] + assert len(rev_list_cmds) == 1 + assert "origin/main" in rev_list_cmds[0] + + pull_cmds = [c for c in commands if "pull" in c] + assert len(pull_cmds) == 1 + assert "main" in pull_cmds[0] + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_already_up_to_date( + self, mock_run, _mock_which, mock_args, capsys + ): + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="0" + ) + + cmd_update(mock_args) + + captured = capsys.readouterr() + assert "Already up to date!" in captured.out + + # Should NOT have called pull + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + pull_cmds = [c for c in commands if "pull" in c] + assert len(pull_cmds) == 0