From 7b037f0efad19ead8de667a3acde4a782f3fbec2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:44:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20opt-in=20git=5Fbranch=20status-bar?= =?UTF-8?q?=20field=20(=E2=8E=87=20current=20branch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniMax Code CLI 0.3.1 added a status-line segment showing the git branch for the current workspace. Hermes' status bar had no repo-awareness field. Adds `git_branch` to display.status_bar.fields (opt-in only — the default set never probes the filesystem). Reads .git/HEAD directly with a 5s per-directory TTL cache (no subprocess per repaint); follows gitdir: pointer files so worktrees/submodules resolve their private HEAD; a detached HEAD renders the abbreviated commit. Inspired by MiniMax Code CLI 0.3.1 changelog (agent.minimax.io/docs/changelog). --- hermes_cli/cli_status_bar_mixin.py | 21 ++++++- hermes_cli/config_defaults.py | 3 +- hermes_cli/status_bar_git.py | 65 ++++++++++++++++++++++ tests/cli/test_status_bar_git_branch.py | 70 ++++++++++++++++++++++++ website/docs/user-guide/configuration.md | 4 +- 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 hermes_cli/status_bar_git.py create mode 100644 tests/cli/test_status_bar_git_branch.py diff --git a/hermes_cli/cli_status_bar_mixin.py b/hermes_cli/cli_status_bar_mixin.py index cbac7c56d4..2c84513be0 100644 --- a/hermes_cli/cli_status_bar_mixin.py +++ b/hermes_cli/cli_status_bar_mixin.py @@ -208,6 +208,7 @@ class CLIStatusBarMixin: "battery_label": "", "battery_category": "dim", "focus_label": "", # /focus badge: the reduced-output mode is never invisible. + "git_branch": "", "goal_active": False, "goal_turns_used": 0, "goal_max_turns": 0} @@ -220,6 +221,17 @@ class CLIStatusBarMixin: except Exception: pass + # Git branch (⎇) — opt-in via display.status_bar.fields, so the filesystem probe + # (TTL-cached in status_bar_git) only runs when the user asked for the segment. + try: + _fields = self._get_status_bar_field_set() + if _fields is not None and "git_branch" in _fields: + from hermes_cli.status_bar_git import current_git_branch + + snapshot["git_branch"] = current_git_branch() + except Exception: + pass + # Battery reads are memoised inside agent.battery, so per-repaint polling is cheap. if getattr(self, "_battery_visible", False): try: @@ -955,9 +967,9 @@ class CLIStatusBarMixin: ``CLI_CONFIG``; no per-render YAML parse). ``None`` = not customized, show everything. Fields: model, context_detail, context_pct, cache_hit, latency, tps, compressions, - bg_tasks, bg_processes, bg_subagents, goal, duration, prompt_elapsed, idle_since, - focus, yolo, stash, battery, title, total_tokens (opt-in only). Order is fixed; the - config controls visibility only. + bg_tasks, bg_processes, bg_subagents, goal, git_branch (opt-in only), duration, + prompt_elapsed, idle_since, focus, yolo, stash, battery, title, total_tokens + (opt-in only). Order is fixed; the config controls visibility only. """ from cli import CLI_CONFIG if hasattr(self, "_status_bar_field_set_cache"): @@ -1043,6 +1055,9 @@ class CLIStatusBarMixin: add_count("bg_subagents", "active_background_subagents", "⛓") if goal_segment: add("goal", _STRONG, goal_segment) + git_branch = snapshot.get("git_branch") or "" + if git_branch: + add("git_branch", _DIM, f"⎇ {git_branch}") if not narrow: add("duration", _DIM, duration_label) if wide: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 5273d0beb3..270ca09f67 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -896,7 +896,8 @@ DEFAULT_CONFIG = { # CLI/TUI status bar fields. Non-empty = only listed fields show (built-in order kept, # config controls visibility not ordering); empty = default set. Available: model, # context_detail, context_pct, cache_hit, latency, tps, compressions, bg_tasks, - # bg_processes, bg_subagents, goal, duration, prompt_elapsed, idle_since, focus, yolo, + # bg_processes, bg_subagents, goal, git_branch (⎇ current branch, opt-in only), duration, + # prompt_elapsed, idle_since, focus, yolo, # stash, battery, title, total_tokens (session Σ, opt-in only). Narrow terminals still drop # context_detail/prompt_elapsed/idle_since. "status_bar": { diff --git a/hermes_cli/status_bar_git.py b/hermes_cli/status_bar_git.py new file mode 100644 index 0000000000..f3321524aa --- /dev/null +++ b/hermes_cli/status_bar_git.py @@ -0,0 +1,65 @@ +"""Current git branch for the CLI status bar. + +Reads ``.git/HEAD`` directly (no subprocess) so status-bar repaints stay cheap, with a +short per-directory TTL cache. Worktrees and submodules (``.git`` as a ``gitdir:`` +pointer file) resolve to their private git dir, whose HEAD is per-worktree. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Optional + +_TTL_SECONDS = 5.0 +_cache: dict = {} + + +def _resolve_git_dir(start: Path) -> Optional[Path]: + """Nearest enclosing git dir for ``start``, following worktree pointer files.""" + for parent in (start, *start.parents): + dotgit = parent / ".git" + if dotgit.is_dir(): + return dotgit + if dotgit.is_file(): + try: + line = dotgit.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + if line.startswith("gitdir:"): + target = (parent / line.split(":", 1)[1].strip()).resolve() + return target if target.is_dir() else None + return None + return None + + +def current_git_branch(cwd: Optional[str] = None) -> str: + """Branch name for ``cwd`` (defaults to the process cwd); ``""`` outside a repo. + + A detached HEAD renders as the abbreviated commit (``a1b2c3d…``). Results are + cached ~5s per directory so per-repaint calls never re-walk the tree. + """ + try: + base = Path(cwd or os.getcwd()).resolve() + except OSError: + return "" + key = str(base) + now = time.monotonic() + hit = _cache.get(key) + if hit and now - hit[0] < _TTL_SECONDS: + return hit[1] + label = "" + git_dir = _resolve_git_dir(base) + if git_dir is not None: + try: + head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip() + except OSError: + head = "" + if head.startswith("ref:"): + ref = head.split(":", 1)[1].strip() + label = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref.rsplit("/", 1)[-1] + elif head: + label = f"{head[:8]}…" + _cache[key] = (now, label) + return label diff --git a/tests/cli/test_status_bar_git_branch.py b/tests/cli/test_status_bar_git_branch.py new file mode 100644 index 0000000000..e6a6910d7b --- /dev/null +++ b/tests/cli/test_status_bar_git_branch.py @@ -0,0 +1,70 @@ +"""Invariant tests for the opt-in git-branch status-bar field.""" + +from datetime import datetime, timedelta + +from cli import HermesCLI +from hermes_cli import status_bar_git +from hermes_cli.status_bar_git import current_git_branch + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.model = "anthropic/claude-sonnet-4-20250514" + cli_obj.session_start = datetime.now() - timedelta(minutes=3) + cli_obj.conversation_history = [{"role": "user", "content": "hi"}] + cli_obj.agent = None + return cli_obj + + +def test_current_git_branch_reads_head_and_worktree_pointer(tmp_path): + """Branch resolves from .git/HEAD in a plain repo AND through a gitdir: pointer + (worktree layout); a non-repo dir yields ''.""" + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/minimax-inspired/status-bar-git-branch\n") + status_bar_git._cache.clear() + assert current_git_branch(str(repo)) == "minimax-inspired/status-bar-git-branch" + + # Worktree: .git is a file pointing at a private git dir with its own HEAD. + private = tmp_path / "gitdir-store" + private.mkdir() + (private / "HEAD").write_text("ref: refs/heads/feature-x\n") + wt = tmp_path / "wt" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {private}\n") + status_bar_git._cache.clear() + assert current_git_branch(str(wt)) == "feature-x" + + plain = tmp_path / "plain" + plain.mkdir() + status_bar_git._cache.clear() + assert current_git_branch(str(plain)) == "" + + +def test_git_branch_segment_is_opt_in(monkeypatch, tmp_path): + """The ⎇ segment renders only when 'git_branch' is in the configured field list — + default field set (None) never probes or shows it.""" + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + monkeypatch.chdir(repo) + status_bar_git._cache.clear() + + cli_obj = _make_cli() + cli_obj._status_bar_field_set_cache = None # default set + snapshot = cli_obj._get_status_bar_snapshot() + assert snapshot["git_branch"] == "" + text = "".join( + t for seg in cli_obj._status_bar_segments( + snapshot, 120, None, False, styled=False) for _, t in seg) + assert "⎇" not in text + + cli_obj2 = _make_cli() + fields = frozenset({"model", "git_branch"}) + cli_obj2._status_bar_field_set_cache = fields + snapshot2 = cli_obj2._get_status_bar_snapshot() + assert snapshot2["git_branch"] == "main" + text2 = "".join( + t for seg in cli_obj2._status_bar_segments( + snapshot2, 120, fields, False, styled=False) for _, t in seg) + assert "⎇ main" in text2 diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 99367001f4..959ff517fb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2115,11 +2115,11 @@ display: fields: ["model", "duration", "total_tokens"] # visibility only; built-in order is preserved ``` -Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `cache_hit` (prompt cache hit ratio — resets on model switch and compression), `latency` (rolling mean API latency, last 10 calls), `tps` (rolling output tokens/sec, last 10 calls), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, `stash`, `battery`, `title` (right-aligned session badge), and `total_tokens` (session Σ — opt-in only, never shown by default). +Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `cache_hit` (prompt cache hit ratio — resets on model switch and compression), `latency` (rolling mean API latency, last 10 calls), `tps` (rolling output tokens/sec, last 10 calls), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `git_branch` (⎇ current git branch of the working directory — opt-in only, never shown by default; detached HEAD shows the abbreviated commit), `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, `stash`, `battery`, `title` (right-aligned session badge), and `total_tokens` (session Σ — opt-in only, never shown by default). Notes: -- An empty list (the default) keeps the standard set — everything except `total_tokens`. +- An empty list (the default) keeps the standard set — everything except `total_tokens` and `git_branch`. - The config controls **visibility, not order**; fields render in their built-in positions. - Narrow terminals still drop wide-mode-only fields (`context_detail`, `cache_hit`, `latency`, `tps`, `prompt_elapsed`, `idle_since`) regardless of config (`cache_hit` also shows in the medium ≥52-col tier). - `latency`/`tps` stay hidden until API calls have been recorded (e.g. the Codex app-server backend reports no latency).