From 51689a420696b2bd4883281ce0ee2848b28e9a9a Mon Sep 17 00:00:00 2001 From: emozilla Date: Wed, 20 May 2026 22:18:47 -0400 Subject: [PATCH] feat(cli): add --branch flag to `hermes update` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes update` has always hard-coded its target to `main`. Add --branch so callers can update against a non-default channel while preserving every existing behavior at the default: - `hermes update` still pulls main (no behavior change) - `hermes update --branch X` pulls origin/X, auto-stashing and switching local HEAD to X first if needed - `hermes update --check --branch X` reports behindness against origin/X (and skips the upstream/X probe, since forks don't have upstream copies of their own feature branches) - Branch absent locally → retry as `checkout -B X origin/X` (track) - Branch absent everywhere → exit 1 with a clear error, after restoring the user's prior stash so we don't strand them in a weird state The fork-upstream sync logic was already guarded on `branch == 'main'`, so non-main updates correctly skip the upstream trampling without further changes. 5 new tests cover: explicit --branch, default-to-main, switch-from-other, track-from-origin, and the fail-cleanly case. Full test_cmd_update.py suite (15 tests) passes on main. --- hermes_cli/main.py | 119 +++++++++++++++++------ tests/hermes_cli/test_cmd_update.py | 143 ++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 27 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 925f93e77c6..e2bd362ef8c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8045,8 +8045,13 @@ def _finalize_update_output(state): pass -def _cmd_update_check(): - """Implement ``hermes update --check``: fetch and report without installing.""" +def _cmd_update_check(branch: str = "main"): + """Implement ``hermes update --check``: fetch and report without installing. + + ``branch`` selects which branch the check compares against. Default is + "main"; callers can pass another branch to ask "are there new commits + on origin/?" without performing the update. + """ from hermes_cli.config import detect_install_method method = detect_install_method(PROJECT_ROOT) if method == "pip": @@ -8072,16 +8077,34 @@ def _cmd_update_check(): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - # Fetch both origin and upstream; prefer upstream as the canonical reference - print("→ Fetching from upstream...") - fetch_result = subprocess.run( - git_cmd + ["fetch", "upstream"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - if fetch_result.returncode != 0: - # Fallback to origin if upstream doesn't exist + # Fetch both origin and upstream; prefer upstream as the canonical reference. + # Note: upstream/ may not exist for non-main branches (a fork's + # bb/gui has no upstream counterpart), so when the caller picks a + # non-default branch we skip the upstream probe and use origin directly. + if branch == "main": + print("→ Fetching from upstream...") + fetch_result = subprocess.run( + git_cmd + ["fetch", "upstream"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if fetch_result.returncode != 0: + # Fallback to origin if upstream doesn't exist + print("→ Fetching from origin...") + fetch_result = subprocess.run( + git_cmd + ["fetch", "origin"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + upstream_exists = False + compare_branch = f"origin/{branch}" + else: + upstream_exists = True + compare_branch = f"upstream/{branch}" + else: + # Non-default branch: compare against origin/ directly. print("→ Fetching from origin...") fetch_result = subprocess.run( git_cmd + ["fetch", "origin"], @@ -8090,10 +8113,7 @@ def _cmd_update_check(): text=True, ) upstream_exists = False - compare_branch = "origin/main" - else: - upstream_exists = True - compare_branch = "upstream/main" + compare_branch = f"origin/{branch}" if fetch_result.returncode != 0: stderr = fetch_result.stderr.strip() @@ -8325,7 +8345,10 @@ def cmd_update(args): return if getattr(args, "check", False): - _cmd_update_check() + # --check honors --branch so the "any new commits?" answer matches + # what a subsequent `hermes update --branch=` would actually pull. + branch = (getattr(args, "branch", None) or "main").strip() or "main" + _cmd_update_check(branch=branch) return gateway_mode = getattr(args, "gateway", False) @@ -8485,26 +8508,57 @@ def _cmd_update_impl(args, gateway_mode: bool): ) current_branch = result.stdout.strip() - # Always update against main - branch = "main" + # Determine the target branch. Default is "main" (the long-standing + # CLI behavior); --branch overrides for callers that want to update + # against a non-default channel. + branch = (getattr(args, "branch", None) or "main").strip() or "main" - # If user is on a non-main branch or detached HEAD, switch to main - if current_branch != "main": + # If user is on a different branch than the update target, switch + # to the target. When the target is "main" this is the historical + # "always update against main" behavior; for any other target it's + # the same thing — get HEAD onto the requested branch first, then + # fast-forward. + if current_branch != branch: label = ( "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'" ) - print(f" ⚠ Currently on {label} — switching to main for update...") + print(f" ⚠ Currently on {label} — switching to {branch} 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"], + checkout_result = subprocess.run( + git_cmd + ["checkout", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, - check=True, ) + if checkout_result.returncode != 0: + # Local checkout doesn't have this branch yet. Try to set + # it up as a tracking branch of origin/. This is + # the common case when the requested branch exists upstream + # but was never checked out locally. + track_result = subprocess.run( + git_cmd + ["checkout", "-B", branch, f"origin/{branch}"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if track_result.returncode != 0: + # Restore the user's prior branch + stash before bailing + # so we don't leave them stranded in a weird state. + if auto_stash_ref is not None: + _restore_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + prompt_user=False, + input_fn=gw_input_fn, + ) + print(f"✗ Branch '{branch}' does not exist locally or on origin.") + if track_result.stderr.strip(): + print(f" {track_result.stderr.strip().splitlines()[0]}") + sys.exit(1) else: auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) @@ -8535,7 +8589,7 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - if current_branch not in {"main", "HEAD"}: + if current_branch not in {branch, "HEAD"}: subprocess.run( git_cmd + ["checkout", current_branch], cwd=PROJECT_ROOT, @@ -8597,7 +8651,7 @@ def _cmd_update_impl(args, gateway_mode: bool): if reset_result.stderr.strip(): print(f" {reset_result.stderr.strip()}") print( - " Try manually: git fetch origin && git reset --hard origin/main" + f" Try manually: git fetch origin && git reset --hard origin/{branch}" ) sys.exit(1) @@ -12835,6 +12889,17 @@ Examples: default=False, help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.", ) + update_parser.add_argument( + "--branch", + default=None, + metavar="NAME", + help=( + "Update against this branch instead of the default (main). " + "If the local checkout is on a different branch, hermes will " + "switch to the requested branch first (auto-stashing any " + "uncommitted changes)." + ), + ) update_parser.add_argument( "--force", action="store_true", diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index b9087c06663..fb83ae6b6f3 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -276,6 +276,149 @@ class TestCmdUpdateProfileSkillSync: assert default_p.path in synced_paths +class TestCmdUpdateBranchFlag: + """``hermes update --branch `` targets the requested branch. + + The CLI default stays 'main'; --branch lets callers pick a different + target without monkey-patching the implementation. + """ + + def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"): + """Mock side-effect that knows about checkout/track behavior. + + - ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns + - ``target_branch`` passed via --branch; what we expect the code to switch to + - ``checkout_fails`` if True, ``git checkout `` returns non-zero + (simulates branch absent locally; code should retry with -B) + - ``track_fails`` if True, ``git checkout -B origin/`` ALSO fails + (simulates branch absent on origin too) + - ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind) + """ + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + + if "rev-parse" in joined and "--abbrev-ref" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="") + + if "checkout" in joined and "-B" in joined: + rc = 128 if track_fails else 0 + err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else "" + return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err) + + if "checkout" in joined and "-B" not in joined and "rev-parse" not in joined: + rc = 128 if checkout_fails else 0 + err = f"error: pathspec '{target_branch}' did not match\n" if checkout_fails else "" + return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err) + + if "rev-list" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="") + + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + return side_effect + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, capsys): + """--branch bb/gui makes rev-list and pull target origin/bb/gui.""" + mock_run.side_effect = self._branch_side_effect( + current_branch="bb/gui", target_branch="bb/gui", commit_count="3" + ) + args = SimpleNamespace(branch="bb/gui") + + cmd_update(args) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + + # rev-list must compare against origin/bb/gui, not origin/main + rev_list_cmds = [c for c in commands if "rev-list" in c] + assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds + assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds + + # pull must target bb/gui + pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c] + assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_branch_flag_defaults_to_main_when_none(self, mock_run, _mock_which, capsys): + """No --branch (or --branch=None) preserves the historical 'main' default.""" + mock_run.side_effect = self._branch_side_effect( + current_branch="main", target_branch="main", commit_count="0" + ) + args = SimpleNamespace(branch=None) + + cmd_update(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 all("origin/main" in c for c in rev_list_cmds), rev_list_cmds + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_branch_flag_switches_from_different_branch(self, mock_run, _mock_which, capsys): + """When HEAD is on main and --branch=bb/gui, switch to bb/gui first.""" + mock_run.side_effect = self._branch_side_effect( + current_branch="main", target_branch="bb/gui", commit_count="2" + ) + args = SimpleNamespace(branch="bb/gui") + + cmd_update(args) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + # First checkout call should switch us to bb/gui (not -B; happy-path branch exists locally) + checkout_cmds = [c for c in commands if "checkout" in c and "rev-parse" not in c] + assert len(checkout_cmds) >= 1 + assert "bb/gui" in checkout_cmds[0] + + out = capsys.readouterr().out + assert "switching to bb/gui" in out + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_branch_flag_tracks_remote_when_branch_absent_locally(self, mock_run, _mock_which, capsys): + """If local lacks the branch but origin has it, fall back to ``checkout -B``.""" + mock_run.side_effect = self._branch_side_effect( + current_branch="main", + target_branch="bb/gui", + checkout_fails=True, # plain checkout fails + track_fails=False, # -B from origin/bb/gui succeeds + commit_count="2", + ) + args = SimpleNamespace(branch="bb/gui") + + cmd_update(args) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + # Should have BOTH a failed `checkout bb/gui` AND a successful `checkout -B bb/gui origin/bb/gui` + track_cmds = [c for c in commands if "checkout" in c and "-B" in c] + assert len(track_cmds) == 1 + assert "bb/gui" in track_cmds[0] + assert "origin/bb/gui" in track_cmds[0] + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_branch_flag_fails_when_branch_missing_everywhere(self, mock_run, _mock_which, capsys): + """If branch doesn't exist locally OR on origin, exit non-zero with clear error.""" + mock_run.side_effect = self._branch_side_effect( + current_branch="main", + target_branch="nonexistent", + checkout_fails=True, + track_fails=True, + commit_count="0", + ) + args = SimpleNamespace(branch="nonexistent") + + with pytest.raises(SystemExit) as exc_info: + cmd_update(args) + assert exc_info.value.code == 1 + + out = capsys.readouterr().out + assert "does not exist locally or on origin" in out + assert "nonexistent" in out + + def test_is_termux_env_true_for_termux_prefix(): from hermes_cli import main as hm