Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5697,6 +5697,90 @@ 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

# 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 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
Comment on lines +5714 to +5727

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 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}")

def save_conversation(self):
"""Save the current conversation to a JSON snapshot under ~/.hermes/sessions/saved/.

Expand Down Expand Up @@ -6912,6 +6996,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":
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
261 changes: 261 additions & 0 deletions tests/cli/test_diff_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
"""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 = []
not_a_repo = subprocess.CalledProcessError(128, ["git", "rev-parse"])
with (
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")

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 = []
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
Loading