From 2f8727f673dfe49695f4f51d830bfa631e179487 Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sat, 9 May 2026 12:39:58 -0300 Subject: [PATCH 1/2] feat(cli): add /diff command to show git changes in working directory Salvage of NousResearch/hermes-agent#4839. /diff shows stat summary + full diff for both staged and unstaged changes in the current working directory. /diff --stat shows the summary only. Uses git directly with per-call timeouts; works in any git repo and prints a clear message when not inside one. Co-Authored-By: Claude Opus 4.7 --- cli.py | 63 +++++++++++ hermes_cli/commands.py | 2 + tests/cli/test_diff_command.py | 196 +++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 tests/cli/test_diff_command.py diff --git a/cli.py b/cli.py index fed96a157bd7..ab584501a22b 100644 --- a/cli.py +++ b/cli.py @@ -5697,6 +5697,67 @@ def _handle_branch_command(self, cmd_original: str) -> None: _cprint(f" Original session: {parent_session_id}") _cprint(f" Branch session: {new_session_id}") + def _handle_diff_command(self, cmd: str) -> None: + """Show git diff of changes in the current working directory.""" + import subprocess as _sp + + cwd = os.getenv("TERMINAL_CWD", os.getcwd()) + parts = cmd.split()[1:] + stat_only = "--stat" in parts + + # Check if we're in a git repo + try: + _sp.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=cwd, capture_output=True, check=True, timeout=5, + ) + except Exception: + _cprint(f" {_DIM}Not a git repository.{_RST}") + return + + try: + # Show stat summary + stat_result = _sp.run( + ["git", "diff", "--stat"], + cwd=cwd, capture_output=True, text=True, timeout=10, + ) + staged_result = _sp.run( + ["git", "diff", "--cached", "--stat"], + cwd=cwd, capture_output=True, text=True, timeout=10, + ) + + stat_out = stat_result.stdout.strip() + staged_out = staged_result.stdout.strip() + + if not stat_out and not staged_out: + _cprint(f" {_DIM}No changes.{_RST}") + return + + if staged_out: + _cprint(f"\n {_BOLD}Staged:{_RST}") + self._console_print(_rich_text_from_ansi(staged_out)) + if stat_out: + _cprint(f"\n {_BOLD}Unstaged:{_RST}") + self._console_print(_rich_text_from_ansi(stat_out)) + + if stat_only: + return + + # Show full diff + diff_result = _sp.run( + ["git", "diff", "--cached"] if staged_out and not stat_out else ["git", "diff"], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + diff_out = diff_result.stdout.strip() + if diff_out: + _cprint("") + self._console_print(_rich_text_from_ansi(diff_out)) + + except _sp.TimeoutExpired: + _cprint(f" {_DIM}Git diff timed out.{_RST}") + except Exception as e: + _cprint(f" {_DIM}Git diff error: {e}{_RST}") + def save_conversation(self): """Save the current conversation to a JSON snapshot under ~/.hermes/sessions/saved/. @@ -6912,6 +6973,8 @@ def process_command(self, command: str) -> bool: self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" self._console_print(f" Status bar {state}") + elif canonical == "diff": + self._handle_diff_command(cmd_original) elif canonical == "verbose": self._toggle_verbose() elif canonical == "footer": diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index de41bcfae7e8..c8e010fac50c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -124,6 +124,8 @@ class CommandDef: args_hint="[name]"), CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", cli_only=True, aliases=("sb",)), + CommandDef("diff", "Show git diff of changes in the current working directory", "Info", + cli_only=True, args_hint="[--stat]"), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), diff --git a/tests/cli/test_diff_command.py b/tests/cli/test_diff_command.py new file mode 100644 index 000000000000..01188d795d48 --- /dev/null +++ b/tests/cli/test_diff_command.py @@ -0,0 +1,196 @@ +"""Tests for the /diff command — shows git changes in working directory.""" + +import subprocess +from unittest.mock import MagicMock, patch + +from cli import HermesCLI +from hermes_cli.commands import resolve_command + + +def _make_cli(): + cli = HermesCLI.__new__(HermesCLI) + cli.config = {} + cli.console = MagicMock() + cli.agent = None + cli.conversation_history = [] + cli.session_id = "session-diff-test" + cli._pending_input = MagicMock() + cli._status_bar_visible = True + cli.model = "openai/gpt-4o" + cli.provider = "openai" + return cli + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + + +def test_diff_command_registered(): + cmd = resolve_command("diff") + assert cmd is not None + assert cmd.cli_only is True + + +def test_diff_command_has_stat_args_hint(): + cmd = resolve_command("diff") + assert cmd.args_hint == "[--stat]" + + +# --------------------------------------------------------------------------- +# process_command routing +# --------------------------------------------------------------------------- + + +def test_process_command_diff_dispatches(): + cli = _make_cli() + with patch.object(cli, "_handle_diff_command", create=True) as mock: + result = cli.process_command("/diff") + assert result is True + mock.assert_called_once_with("/diff") + + +# --------------------------------------------------------------------------- +# _handle_diff_command behaviour +# --------------------------------------------------------------------------- + + +def _fake_run(outcomes): + """Return a side_effect that yields subprocess results from *outcomes* in order.""" + call_count = {"n": 0} + + def _run(cmd, **kwargs): + n = call_count["n"] + call_count["n"] += 1 + result = outcomes[n] if n < len(outcomes) else MagicMock(returncode=0, stdout="", stderr="") + if isinstance(result, Exception): + raise result + return result + + return _run + + +def _mock_proc(stdout="", returncode=0): + m = MagicMock() + m.stdout = stdout + m.returncode = returncode + return m + + +def test_not_in_git_repo_prints_message(): + cli = _make_cli() + printed = [] + with ( + patch("subprocess.run", side_effect=_fake_run([Exception("not a repo")])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + ): + cli._handle_diff_command("/diff") + + assert any("not a git" in p.lower() for p in printed) + + +def test_no_changes_prints_no_changes(): + cli = _make_cli() + printed = [] + inside_wt = _mock_proc() + no_diff = _mock_proc(stdout="") + no_staged = _mock_proc(stdout="") + with ( + patch("subprocess.run", side_effect=_fake_run([inside_wt, no_diff, no_staged])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + ): + cli._handle_diff_command("/diff") + + assert any("no changes" in p.lower() for p in printed) + + +def test_unstaged_changes_shown(): + cli = _make_cli() + printed = [] + inside_wt = _mock_proc() + diff_stat = _mock_proc(stdout=" foo.py | 2 ++\n 1 file changed") + no_staged = _mock_proc(stdout="") + diff_full = _mock_proc(stdout="diff --git a/foo.py b/foo.py\n+hello") + with ( + patch("subprocess.run", side_effect=_fake_run([inside_wt, diff_stat, no_staged, diff_full])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + patch("cli._rich_text_from_ansi", side_effect=lambda t: t), + patch.object(cli, "_console_print", create=True), + ): + cli._handle_diff_command("/diff") + + assert any("unstaged" in p.lower() for p in printed) + + +def test_staged_changes_shown(): + cli = _make_cli() + printed = [] + inside_wt = _mock_proc() + no_diff = _mock_proc(stdout="") + staged_stat = _mock_proc(stdout=" bar.py | 1 +\n 1 file changed") + staged_full = _mock_proc(stdout="diff --git a/bar.py b/bar.py\n+world") + with ( + patch("subprocess.run", side_effect=_fake_run([inside_wt, no_diff, staged_stat, staged_full])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + patch("cli._rich_text_from_ansi", side_effect=lambda t: t), + patch.object(cli, "_console_print", create=True), + ): + cli._handle_diff_command("/diff") + + assert any("staged" in p.lower() for p in printed) + + +def test_stat_only_flag_skips_full_diff(): + cli = _make_cli() + run_calls = [] + + def _tracking_run(cmd, **kwargs): + run_calls.append(list(cmd)) + if "rev-parse" in cmd: + return _mock_proc() + return _mock_proc(stdout=" foo.py | 2 ++\n 1 file changed") + + with ( + patch("subprocess.run", side_effect=_tracking_run), + patch("cli._cprint"), + patch("cli._rich_text_from_ansi", side_effect=lambda t: t), + patch.object(cli, "_console_print", create=True), + ): + cli._handle_diff_command("/diff --stat") + + full_diff_calls = [ + c for c in run_calls + if c[:2] == ["git", "diff"] and "--stat" not in c and "--cached" not in c + ] + assert full_diff_calls == [], ( + f"Full diff should not run with --stat flag, got: {full_diff_calls}" + ) + + +def test_timeout_prints_message(): + cli = _make_cli() + printed = [] + inside_wt = _mock_proc() + with ( + patch("subprocess.run", side_effect=_fake_run([inside_wt, subprocess.TimeoutExpired("git", 10)])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + ): + cli._handle_diff_command("/diff") + + assert any("timed out" in p.lower() for p in printed) + + +# --------------------------------------------------------------------------- +# Source-inspection: _handle_diff_command wired in process_command +# --------------------------------------------------------------------------- + + +def test_diff_routing_in_process_command_source(): + import inspect + import cli as cli_mod + + src = inspect.getsource(cli_mod.HermesCLI.process_command) + assert ('"diff"' in src or "'diff'" in src), ( + "process_command must route canonical == 'diff'" + ) + assert "_handle_diff_command" in src From 046fe1e93f6f5c70b01de3c596e9479dfcbc7069 Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sat, 9 May 2026 15:23:14 -0300 Subject: [PATCH 2/2] fix(cli/diff): narrow exception handling and emit both staged and unstaged full diffs Repo-check now distinguishes FileNotFoundError (git missing), TimeoutExpired, and CalledProcessError (not a repo) instead of catching all exceptions as 'Not a git repository.'. Validates cwd before invoking git. Full-diff section now runs both 'git diff --cached' and 'git diff' (each under its own header) when their corresponding stat output is non-empty, matching the documented behavior. Adds regression tests for: git missing, both staged+unstaged present. --- cli.py | 43 +++++++++++++++++----- tests/cli/test_diff_command.py | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/cli.py b/cli.py index ab584501a22b..8ce1dfd75d77 100644 --- a/cli.py +++ b/cli.py @@ -5705,13 +5705,24 @@ def _handle_diff_command(self, cmd: str) -> None: parts = cmd.split()[1:] stat_only = "--stat" in parts + # Validate cwd before invoking git, so we can give a precise error. + if not os.path.isdir(cwd): + _cprint(f" {_DIM}Working directory does not exist: {cwd}{_RST}") + return + # Check if we're in a git repo try: _sp.run( ["git", "rev-parse", "--is-inside-work-tree"], cwd=cwd, capture_output=True, check=True, timeout=5, ) - except Exception: + except FileNotFoundError: + _cprint(f" {_DIM}git is not installed or not in PATH.{_RST}") + return + except _sp.TimeoutExpired: + _cprint(f" {_DIM}git timed out checking repository state.{_RST}") + return + except _sp.CalledProcessError: _cprint(f" {_DIM}Not a git repository.{_RST}") return @@ -5743,18 +5754,30 @@ def _handle_diff_command(self, cmd: str) -> None: if stat_only: return - # Show full diff - diff_result = _sp.run( - ["git", "diff", "--cached"] if staged_out and not stat_out else ["git", "diff"], - cwd=cwd, capture_output=True, text=True, timeout=30, - ) - diff_out = diff_result.stdout.strip() - if diff_out: - _cprint("") - self._console_print(_rich_text_from_ansi(diff_out)) + # Show full diffs — staged and unstaged each in their own section. + if staged_out: + staged_full = _sp.run( + ["git", "diff", "--cached"], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + staged_full_out = staged_full.stdout.strip() + if staged_full_out: + _cprint(f"\n {_BOLD}Staged diff:{_RST}") + self._console_print(_rich_text_from_ansi(staged_full_out)) + if stat_out: + unstaged_full = _sp.run( + ["git", "diff"], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + unstaged_full_out = unstaged_full.stdout.strip() + if unstaged_full_out: + _cprint(f"\n {_BOLD}Unstaged diff:{_RST}") + self._console_print(_rich_text_from_ansi(unstaged_full_out)) except _sp.TimeoutExpired: _cprint(f" {_DIM}Git diff timed out.{_RST}") + except FileNotFoundError: + _cprint(f" {_DIM}git is not installed or not in PATH.{_RST}") except Exception as e: _cprint(f" {_DIM}Git diff error: {e}{_RST}") diff --git a/tests/cli/test_diff_command.py b/tests/cli/test_diff_command.py index 01188d795d48..6bc6afcb1fc0 100644 --- a/tests/cli/test_diff_command.py +++ b/tests/cli/test_diff_command.py @@ -80,8 +80,9 @@ def _mock_proc(stdout="", returncode=0): def test_not_in_git_repo_prints_message(): cli = _make_cli() printed = [] + not_a_repo = subprocess.CalledProcessError(128, ["git", "rev-parse"]) with ( - patch("subprocess.run", side_effect=_fake_run([Exception("not a repo")])), + patch("subprocess.run", side_effect=_fake_run([not_a_repo])), patch("cli._cprint", side_effect=lambda t: printed.append(t)), ): cli._handle_diff_command("/diff") @@ -89,6 +90,70 @@ def test_not_in_git_repo_prints_message(): assert any("not a git" in p.lower() for p in printed) +def test_git_not_installed_prints_message(): + cli = _make_cli() + printed = [] + with ( + patch("subprocess.run", side_effect=_fake_run([FileNotFoundError("git")])), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + ): + cli._handle_diff_command("/diff") + + assert any("not installed" in p.lower() for p in printed) + + +def test_both_staged_and_unstaged_full_diffs_shown(): + cli = _make_cli() + printed = [] + run_calls = [] + + inside_wt = _mock_proc() + diff_stat = _mock_proc(stdout=" foo.py | 2 ++\n 1 file changed") + staged_stat = _mock_proc(stdout=" bar.py | 1 +\n 1 file changed") + staged_full = _mock_proc(stdout="diff --git a/bar.py b/bar.py\n+staged-line") + unstaged_full = _mock_proc(stdout="diff --git a/foo.py b/foo.py\n+unstaged-line") + + def _tracking_run(cmd, **kwargs): + run_calls.append(list(cmd)) + if "rev-parse" in cmd: + return inside_wt + if "--stat" in cmd and "--cached" not in cmd: + return diff_stat + if "--stat" in cmd and "--cached" in cmd: + return staged_stat + if cmd[:3] == ["git", "diff", "--cached"]: + return staged_full + if cmd[:2] == ["git", "diff"]: + return unstaged_full + return _mock_proc() + + with ( + patch("subprocess.run", side_effect=_tracking_run), + patch("cli._cprint", side_effect=lambda t: printed.append(t)), + patch("cli._rich_text_from_ansi", side_effect=lambda t: t), + patch.object(cli, "_console_print", create=True) as console_print, + ): + cli._handle_diff_command("/diff") + + cached_full_calls = [ + c for c in run_calls + if c[:3] == ["git", "diff", "--cached"] and "--stat" not in c + ] + unstaged_full_calls = [ + c for c in run_calls + if c[:2] == ["git", "diff"] and "--cached" not in c and "--stat" not in c + ] + assert cached_full_calls, "expected `git diff --cached` for staged full diff" + assert unstaged_full_calls, "expected `git diff` for unstaged full diff" + + headers = " ".join(printed).lower() + assert "staged" in headers and "unstaged" in headers + + rendered = [str(call.args[0]) for call in console_print.call_args_list] + assert any("staged-line" in r for r in rendered) + assert any("unstaged-line" in r for r in rendered) + + def test_no_changes_prints_no_changes(): cli = _make_cli() printed = []