From 0fa5e41c86f022bba147797849f0b44865721476 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:25:28 -0700 Subject: [PATCH] feat(diff): cross-surface /diff with staged/all/session modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the cherry-picked /diff base (#4839 by @SHL0MS) into one cross-surface implementation, folding in the review feedback and the best ideas from the two sibling PRs (#22703, #53527): - tools/working_diff.py: shared git collection layer — unstaged (default), staged, and all (vs HEAD) modes; untracked files folded in via `git diff --no-index` so new files appear as additions (Codex /diff parity); shlex-split arguments preserve quoted paths. - CLI: handler moved to hermes_cli/cli_commands_mixin.py per the current god-file decomposition (dispatch stays in cli.py), renders through the rich console with a 400-line terminal-flood guard. - Gateway: _handle_diff_command in gateway/slash_commands.py + dispatch in gateway/run.py; fenced ```diff output truncated to 60 lines / 3000 chars before the platform senders apply their own per-platform message clamps (tool-progress-style layered truncation). Localized strings in all 17 locale catalogs. - /diff session (from #53527): cumulative checkpoint-baseline diff of everything Hermes changed, via new CheckpointManager.session_diff(); docstring records the retained-baseline approximation caveat from review. Works on both surfaces; degrades with an actionable message when checkpoints are off. - Slack: /diff routed via /hermes diff (50-slash cap; keeps telegram-parity test green and /version native). - Registry: cross-surface CommandDef with staged|all|session subcommands; docs: slash-commands reference (CLI + gateway tables + both-surfaces list) and hermes-agent skill reference. - Tests: tests/tools/test_working_diff.py (real git repos), tests/hermes_cli/test_diff_command.py (real git + stubbed checkpoint manager), tests/gateway/test_diff_command.py (end-to-end handler, real checkpoint store), TestSessionDiff in tests/tools/test_checkpoint_manager.py. Salvaged from the /diff PR cluster #4839 + #22703 + #53527. Co-authored-by: Ninso112 Co-authored-by: Harshkamdar67 --- cli.py | 61 ------ gateway/run.py | 3 + gateway/slash_commands.py | 110 ++++++++++ hermes_cli/cli_commands_mixin.py | 134 ++++++++++++ hermes_cli/commands.py | 9 +- locales/af.yaml | 5 + locales/ar.yaml | 5 + locales/de.yaml | 5 + locales/en.yaml | 5 + locales/es.yaml | 5 + locales/fr.yaml | 5 + locales/ga.yaml | 5 + locales/hu.yaml | 5 + locales/it.yaml | 5 + locales/ja.yaml | 5 + locales/ko.yaml | 5 + locales/pt.yaml | 5 + locales/ru.yaml | 5 + locales/tr.yaml | 5 + locales/uk.yaml | 5 + locales/zh-hant.yaml | 5 + locales/zh.yaml | 5 + .../hermes-agent/references/slash-commands.md | 1 + tests/gateway/test_diff_command.py | 180 +++++++++++++++++ tests/hermes_cli/test_diff_command.py | 190 ++++++++++++++++++ tests/tools/test_checkpoint_manager.py | 50 +++++ tests/tools/test_working_diff.py | 111 ++++++++++ tools/checkpoint_manager.py | 32 +++ tools/working_diff.py | 130 ++++++++++++ website/docs/reference/slash-commands.md | 3 + 30 files changed, 1035 insertions(+), 64 deletions(-) create mode 100644 tests/gateway/test_diff_command.py create mode 100644 tests/hermes_cli/test_diff_command.py create mode 100644 tests/tools/test_working_diff.py create mode 100644 tools/working_diff.py diff --git a/cli.py b/cli.py index b9bd5dfcfac3..34bb7bb6b35b 100644 --- a/cli.py +++ b/cli.py @@ -8024,67 +8024,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return True - def _handle_diff_command(self, cmd: str): - """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/. diff --git a/gateway/run.py b/gateway/run.py index a8dcca69554d..c45b53fdfec9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11961,6 +11961,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "rollback": return await self._handle_rollback_command(event) + if canonical == "diff": + return await self._handle_diff_command(event) + if canonical == "background": return await self._handle_background_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index be4b884ad8b1..780026bfa2fa 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3073,6 +3073,116 @@ class GatewaySlashCommandsMixin: ) return t("gateway.rollback.restore_failed", error=result["error"]) + async def _handle_diff_command(self, event: MessageEvent) -> str: + """Handle /diff — show git changes in the working directory. + + ``/diff`` (default) shows unstaged + untracked changes, ``/diff + staged`` the staged ones, ``/diff all`` everything since HEAD, and + ``/diff session`` the cumulative checkpoint-baseline diff of what + Hermes itself changed. ``--stat`` limits output to the summary. + + The diff body is truncated hard here (messaging surfaces are not a + pager); platform senders additionally split/clamp long messages to + per-platform limits, the same way tool-progress output is truncated + in three layers before delivery. + """ + args = event.get_command_args().strip() + + stat_only = False + mode = "working" + for arg in args.split(): + low = arg.lower() + if low in ("--stat", "stat"): + stat_only = True + elif low in ("staged", "--staged", "cached", "--cached"): + mode = "staged" + elif low in ("all", "--all", "head"): + mode = "all" + elif low == "session": + mode = "session" + + cwd = os.getenv("TERMINAL_CWD", str(Path.home())) + + if mode == "session": + return await self._gateway_session_diff(cwd, stat_only) + + from tools.working_diff import collect_working_diff + + result = await asyncio.to_thread(collect_working_diff, cwd, mode) + if not result.get("success"): + return t("gateway.diff.failed", + error=result.get("error", "Could not generate diff")) + + stat = result.get("stat", "") + diff = result.get("diff", "") + untracked = result.get("untracked", []) + if result.get("empty") or (not stat and not diff and not untracked): + return t("gateway.diff.no_changes") + + out: list[str] = [] + if stat: + out.append(f"```\n{stat}\n```") + if untracked: + shown = "\n".join(f"+ {rel}" for rel in untracked[:15]) + more = f"\n... and {len(untracked) - 15} more" if len(untracked) > 15 else "" + out.append(f"**Untracked:**\n```\n{shown}{more}\n```") + if not stat_only and diff: + out.append(self._fenced_truncated_diff(diff)) + return "\n\n".join(out) + + async def _gateway_session_diff(self, cwd: str, stat_only: bool) -> str: + """Cumulative checkpoint-baseline diff for /diff session (gateway).""" + from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config + from tools.checkpoint_manager import CheckpointManager + + cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config()) + if not cp_kwargs["checkpoints_enabled"]: + return t("gateway.diff.not_enabled") + + mgr = CheckpointManager( + enabled=True, + max_snapshots=cp_kwargs["checkpoint_max_snapshots"], + max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"], + max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"], + ) + + result = await asyncio.to_thread(mgr.session_diff, cwd) + if not result.get("success"): + return t("gateway.diff.failed", + error=result.get("error", "Could not generate diff")) + + stat = result.get("stat", "") + diff = result.get("diff", "") + if result.get("empty") or (not stat and not diff): + return t("gateway.diff.no_changes") + + out: list[str] = [] + if stat: + out.append(f"```\n{stat}\n```") + if not stat_only and diff: + out.append(self._fenced_truncated_diff(diff)) + return "\n\n".join(out) + + @staticmethod + def _fenced_truncated_diff(diff: str, max_lines: int = 60, + max_chars: int = 3000) -> str: + """Fence a diff body, truncating to messaging-friendly size.""" + diff_lines = diff.splitlines() + truncated = False + if len(diff_lines) > max_lines: + diff = "\n".join(diff_lines[:max_lines]) + truncated = True + if len(diff) > max_chars: + diff = diff[:max_chars] + truncated = True + note = "" + if truncated: + note = ( + f"\n... (truncated — {len(diff_lines)} lines total; " + "use /diff --stat for a summary)" + ) + return f"```diff\n{diff}{note}\n```" + async def _handle_background_command(self, event: MessageEvent) -> str: """Handle /background — run a prompt in a separate background session. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4ab6224fb657..83ebcf53703c 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -142,6 +142,140 @@ class CLICommandsMixin: else: print(f" ❌ {result['error']}") + def _handle_diff_command(self, command: str): + """Handle /diff — show git changes in the working directory. + + Syntax: + /diff — unstaged changes + untracked files + /diff staged — staged changes (git diff --cached) + /diff all — staged + unstaged + untracked (vs HEAD) + /diff session — everything Hermes changed (checkpoint baseline) + /diff [mode] --stat — summary only (changed files + counts) + /diff [mode] — restrict to specific paths + """ + import shlex + + try: + parts = shlex.split(command)[1:] # preserves quoted paths + except ValueError: + parts = command.split()[1:] + + stat_only = False + mode = "working" + paths: list[str] = [] + for arg in parts: + low = arg.lower() + if low in ("--stat", "stat"): + stat_only = True + elif low in ("staged", "--staged", "cached", "--cached"): + mode = "staged" + elif low in ("all", "--all", "head"): + mode = "all" + elif low == "session": + mode = "session" + else: + paths.append(arg) + + cwd = os.getenv("TERMINAL_CWD", os.getcwd()) + + if mode == "session": + self._print_session_diff(cwd, stat_only) + return + + from tools.working_diff import collect_working_diff + + result = collect_working_diff(cwd, mode=mode, paths=paths or None) + if not result.get("success"): + print(f" {result.get('error', 'Could not generate diff')}") + return + + stat = result.get("stat", "") + diff = result.get("diff", "") + untracked = result.get("untracked", []) + if result.get("empty") or (not stat and not diff and not untracked): + print(" No changes.") + return + + label = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"}[mode] + if stat: + print(f"\n {label}:") + self._print_diff_text(stat) + if untracked and mode in ("working", "all"): + print("\n Untracked:") + for rel in untracked[:20]: + print(f" + {rel}") + if len(untracked) > 20: + print(f" ... and {len(untracked) - 20} more") + if stat_only or not diff: + return + + diff_lines = diff.splitlines() + print("") + if len(diff_lines) > 400: + self._print_diff_text("\n".join(diff_lines[:400])) + print( + f"\n ... ({len(diff_lines) - 400} more lines — " + "run /diff --stat for a summary)" + ) + else: + self._print_diff_text(diff) + + def _print_session_diff(self, cwd: str, stat_only: bool): + """Print the cumulative checkpoint-baseline diff (/diff session).""" + if not hasattr(self, 'agent') or not self.agent: + print(" No active agent session.") + return + + mgr = self.agent._checkpoint_mgr + if not mgr.enabled: + print(" Checkpoints are not enabled, so there's no session baseline.") + print(" Enable with: hermes --checkpoints") + print(" Or in config.yaml: checkpoints: { enabled: true }") + print(" (Plain /diff still works — it uses git directly.)") + return + + result = mgr.session_diff(cwd) + if not result.get("success"): + print(f" {result.get('error', 'Could not generate diff')}") + return + + stat = result.get("stat", "") + diff = result.get("diff", "") + if result.get("empty") or (not stat and not diff): + print(" No changes — Hermes hasn't edited any files here yet.") + return + + if stat: + self._print_diff_text(f"\n{stat}") + if stat_only or not diff: + return + diff_lines = diff.splitlines() + print("") + if len(diff_lines) > 400: + self._print_diff_text("\n".join(diff_lines[:400])) + print( + f"\n ... ({len(diff_lines) - 400} more lines — " + "run /diff session --stat for a summary)" + ) + else: + self._print_diff_text(diff) + + def _print_diff_text(self, text: str) -> None: + """Render diff/stat text with color when a rich console is present. + + Falls back to plain print when the console isn't available (e.g. unit + tests instantiating the mixin standalone). + """ + console = getattr(self, "console", None) + if console is not None: + try: + from cli import _rich_text_from_ansi + console.print(_rich_text_from_ansi(text)) + return + except Exception: + pass + print(text) + def _handle_snapshot_command(self, command: str): """Handle /snapshot — lightweight state snapshots for Hermes config/state. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c00201021d06..368f5c745e9d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -151,8 +151,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration", cli_only=True, args_hint="[on|off|status]", subcommands=("on", "off", "status"), aliases=("ts",)), - CommandDef("diff", "Show git diff of changes in the current working directory", "Info", - cli_only=True, args_hint="[--stat]"), + CommandDef("diff", "Show git changes in the working directory", "Info", + args_hint="[staged|all|session] [--stat] [path...]", + subcommands=("staged", "all", "session")), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose -> log", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), @@ -1187,7 +1188,9 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # - version: low-frequency info command; reachable as /hermes version on # Slack. Demoted when /context claimed a native slot (context is a # recurring inspection surface; version is a one-off lookup). -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version"}) +# - diff: git working-tree diff; reached via /hermes diff on Slack so it +# doesn't displace an existing native slash at the 50-command cap. +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/locales/af.yaml b/locales/af.yaml index 525d30db5b74..40dce4e16222 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Herstel na kontrolepunt {hash}: {reason}\n'n Voor-terugrol-momentopname is outomaties gestoor." restore_failed: "❌ {error}" + diff: + not_enabled: "Kontrolepunte is nie geaktiveer nie, dus is daar geen sessie-basislyn nie.\nAktiveer in config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nGewone /diff werk steeds — dit gebruik git direk." + no_changes: "Geen veranderinge nie." + failed: "{error}" + set_home: save_failed: "Kon nie tuiste-kanaal stoor nie: {error}" success: "✅ Tuiste-kanaal gestel op **{name}** (ID: {chat_id}).\nKron-take en kruisplatform-boodskappe sal hier afgelewer word." diff --git a/locales/ar.yaml b/locales/ar.yaml index cc4bd05c8174..fcd3a6555e40 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -299,6 +299,11 @@ gateway: restored: "✅ استُعيد إلى نقطة التحقّق {hash}: {reason}\nحُفظت لقطة ما قبل التراجع تلقائيًا." restore_failed: "❌ {error}" + diff: + not_enabled: "نقاط التحقق غير مفعّلة، لذا لا يوجد خط أساس للجلسة.\nفعّلها في config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nلا يزال /diff العادي يعمل — فهو يستخدم git مباشرة." + no_changes: "لا توجد تغييرات." + failed: "{error}" + set_home: save_failed: "فشل حفظ القناة الرئيسية: {error}" success: "✅ ضُبطت القناة الرئيسية إلى **{name}** (المعرّف: {chat_id}).\nستُسلَّم مهام cron والرسائل عبر المنصّات هنا." diff --git a/locales/de.yaml b/locales/de.yaml index 693b72d5c109..bd562e879c43 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Auf Checkpoint {hash} wiederhergestellt: {reason}\nEin Pre-Rollback-Snapshot wurde automatisch gespeichert." restore_failed: "❌ {error}" + diff: + not_enabled: "Checkpoints sind nicht aktiviert, daher gibt es keine Sitzungs-Baseline.\nIn config.yaml aktivieren:\n```\ncheckpoints:\n enabled: true\n```\nEin einfaches /diff funktioniert weiterhin — es nutzt git direkt." + no_changes: "Keine Änderungen." + failed: "{error}" + set_home: save_failed: "Home-Kanal konnte nicht gespeichert werden: {error}" success: "✅ Home-Kanal auf **{name}** (ID: {chat_id}) gesetzt.\nCron-Jobs und plattformübergreifende Nachrichten werden hierher geliefert." diff --git a/locales/en.yaml b/locales/en.yaml index 0ab28192c341..72712986c795 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -291,6 +291,11 @@ gateway: restored: "✅ Restored to checkpoint {hash}: {reason}\nA pre-rollback snapshot was saved automatically." restore_failed: "❌ {error}" + diff: + not_enabled: "Checkpoints are not enabled, so there's no session baseline.\nEnable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nPlain /diff still works — it uses git directly." + no_changes: "No changes." + failed: "{error}" + set_home: save_failed: "Failed to save home channel: {error}" success: "✅ Home channel set to **{name}** (ID: {chat_id}).\nCron jobs and cross-platform messages will be delivered here." diff --git a/locales/es.yaml b/locales/es.yaml index f69e94cdfaca..8786078142ed 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -276,6 +276,11 @@ gateway: restored: "✅ Restaurado al checkpoint {hash}: {reason}\nSe guardó automáticamente un snapshot previo al rollback." restore_failed: "❌ {error}" + diff: + not_enabled: "Los checkpoints no están activados, así que no hay línea base de sesión.\nActívalos en config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nEl /diff normal sigue funcionando — usa git directamente." + no_changes: "Sin cambios." + failed: "{error}" + set_home: save_failed: "No se pudo guardar el canal principal: {error}" success: "✅ Canal principal establecido en **{name}** (ID: {chat_id}).\nLas tareas cron y los mensajes entre plataformas se entregarán aquí." diff --git a/locales/fr.yaml b/locales/fr.yaml index a4248af6f7ce..65d885d2280b 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Restauré au point de contrôle {hash} : {reason}\nUn instantané pré-rollback a été enregistré automatiquement." restore_failed: "❌ {error}" + diff: + not_enabled: "Les points de contrôle ne sont pas activés, il n'y a donc pas de référence de session.\nActivez-les dans config.yaml :\n```\ncheckpoints:\n enabled: true\n```\nLe /diff simple fonctionne toujours — il utilise git directement." + no_changes: "Aucun changement." + failed: "{error}" + set_home: save_failed: "Impossible d'enregistrer le canal principal : {error}" success: "✅ Canal principal défini sur **{name}** (ID : {chat_id}).\nLes tâches cron et les messages multi-plateformes seront livrés ici." diff --git a/locales/ga.yaml b/locales/ga.yaml index ccc0a6287544..26473ccdea27 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -283,6 +283,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Aischurtha go seicphointe {hash}: {reason}\nSábháladh roghchóip réamh-rollback go huathoibríoch." restore_failed: "❌ {error}" + diff: + not_enabled: "Níl seicphointí cumasaithe, mar sin níl aon bhonnlíne seisiúin ann.\nCumasaigh in config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nOibríonn /diff simplí fós — úsáideann sé git go díreach." + no_changes: "Gan athruithe." + failed: "{error}" + set_home: save_failed: "Theip ar shábháil chainéil bhaile: {error}" success: "✅ Cainéal baile socraithe go **{name}** (ID: {chat_id}).\nSeachadfar tascanna cron agus teachtaireachtaí trasardáin anseo." diff --git a/locales/hu.yaml b/locales/hu.yaml index 8bbcafe7c2b3..2600210fb97b 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Visszaállítva a(z) {hash} ellenőrzőpontra: {reason}\nA visszaállítás előtti pillanatkép automatikusan elmentve." restore_failed: "❌ {error}" + diff: + not_enabled: "A checkpointok nincsenek engedélyezve, így nincs munkamenet-alapvonal.\nEngedélyezd a config.yaml-ban:\n```\ncheckpoints:\n enabled: true\n```\nA sima /diff továbbra is működik — közvetlenül a gitet használja." + no_changes: "Nincs változás." + failed: "{error}" + set_home: save_failed: "Nem sikerült menteni a kezdőcsatornát: {error}" success: "✅ Kezdőcsatorna beállítva: **{name}** (ID: {chat_id}).\nA cron-feladatok és a platformok közötti üzenetek ide érkeznek." diff --git a/locales/it.yaml b/locales/it.yaml index 2b0a0677accb..d7ab72628c8a 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Ripristinato al checkpoint {hash}: {reason}\nUno snapshot pre-rollback è stato salvato automaticamente." restore_failed: "❌ {error}" + diff: + not_enabled: "I checkpoint non sono abilitati, quindi non esiste una baseline di sessione.\nAbilitali in config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nIl /diff semplice funziona comunque — usa git direttamente." + no_changes: "Nessuna modifica." + failed: "{error}" + set_home: save_failed: "Salvataggio del canale home non riuscito: {error}" success: "✅ Canale home impostato su **{name}** (ID: {chat_id}).\nI cron job e i messaggi cross-platform verranno consegnati qui." diff --git a/locales/ja.yaml b/locales/ja.yaml index 0b40dc1f614a..14c78f3d8219 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ チェックポイント {hash} に復元しました: {reason}\nロールバック前のスナップショットが自動的に保存されました。" restore_failed: "❌ {error}" + diff: + not_enabled: "チェックポイントが有効になっていないため、セッションのベースラインがありません。\nconfig.yaml で有効にしてください:\n```\ncheckpoints:\n enabled: true\n```\n通常の /diff は引き続き使えます — git を直接使用します。" + no_changes: "変更はありません。" + failed: "{error}" + set_home: save_failed: "ホームチャンネルを保存できませんでした: {error}" success: "✅ ホームチャンネルを **{name}** (ID: {chat_id}) に設定しました。\nCron ジョブとプラットフォーム間メッセージはここに配信されます。" diff --git a/locales/ko.yaml b/locales/ko.yaml index db38ba98e8d6..a2b920cafda0 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ 체크포인트 {hash}(으)로 복원됨: {reason}\n롤백 전 스냅샷이 자동으로 저장되었습니다." restore_failed: "❌ {error}" + diff: + not_enabled: "체크포인트가 활성화되지 않아 세션 기준선이 없습니다.\nconfig.yaml에서 활성화하세요:\n```\ncheckpoints:\n enabled: true\n```\n일반 /diff는 여전히 작동합니다 — git을 직접 사용합니다." + no_changes: "변경 사항이 없습니다." + failed: "{error}" + set_home: save_failed: "홈 채널 저장에 실패했습니다: {error}" success: "✅ 홈 채널이 **{name}**(ID: {chat_id})(으)로 설정되었습니다.\n크론 작업과 플랫폼 간 메시지가 여기로 전달됩니다." diff --git a/locales/pt.yaml b/locales/pt.yaml index 0f32bf53df26..be3c43d2bfe1 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Restaurado para o checkpoint {hash}: {reason}\nFoi guardado automaticamente um snapshot anterior ao rollback." restore_failed: "❌ {error}" + diff: + not_enabled: "Os checkpoints não estão ativados, então não há linha de base da sessão.\nAtive em config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nO /diff simples continua funcionando — ele usa git diretamente." + no_changes: "Sem alterações." + failed: "{error}" + set_home: save_failed: "Falha ao guardar o canal principal: {error}" success: "✅ Canal principal definido como **{name}** (ID: {chat_id}).\nAs tarefas cron e mensagens entre plataformas serão entregues aqui." diff --git a/locales/ru.yaml b/locales/ru.yaml index 51347514d052..10443501a04f 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Восстановлено до контрольной точки {hash}: {reason}\nСнимок перед откатом сохранён автоматически." restore_failed: "❌ {error}" + diff: + not_enabled: "Контрольные точки не включены, поэтому базовой линии сессии нет.\nВключите в config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nОбычный /diff по-прежнему работает — он использует git напрямую." + no_changes: "Изменений нет." + failed: "{error}" + set_home: save_failed: "Не удалось сохранить главный канал: {error}" success: "✅ Главный канал установлен на **{name}** (ID: {chat_id}).\nCron-задачи и межплатформенные сообщения будут доставляться сюда." diff --git a/locales/tr.yaml b/locales/tr.yaml index d1a9808f79c3..7ef3ac86b755 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ {hash} kontrol noktasına geri yüklendi: {reason}\nGeri alma öncesi anlık görüntü otomatik olarak kaydedildi." restore_failed: "❌ {error}" + diff: + not_enabled: "Kontrol noktaları etkin değil, bu yüzden oturum temel çizgisi yok.\nconfig.yaml içinde etkinleştirin:\n```\ncheckpoints:\n enabled: true\n```\nDüz /diff yine de çalışır — doğrudan git kullanır." + no_changes: "Değişiklik yok." + failed: "{error}" + set_home: save_failed: "Ana kanal kaydedilemedi: {error}" success: "✅ Ana kanal **{name}** (ID: {chat_id}) olarak ayarlandı.\nCron işleri ve platformlar arası mesajlar buraya iletilecek." diff --git a/locales/uk.yaml b/locales/uk.yaml index 0c67278b775d..95027ab60ea1 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ Відновлено до контрольної точки {hash}: {reason}\nЗнімок перед відкатом збережено автоматично." restore_failed: "❌ {error}" + diff: + not_enabled: "Контрольні точки не ввімкнено, тому базової лінії сесії немає.\nУвімкніть у config.yaml:\n```\ncheckpoints:\n enabled: true\n```\nЗвичайний /diff все одно працює — він використовує git напряму." + no_changes: "Змін немає." + failed: "{error}" + set_home: save_failed: "Не вдалося зберегти головний канал: {error}" success: "✅ Головний канал встановлено на **{name}** (ID: {chat_id}).\nCron-завдання та міжплатформні повідомлення доставлятимуться сюди." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index e5ef7b82ddbb..922a1d0d2137 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ 已還原至檢查點 {hash}:{reason}\n已自動儲存回復前的快照。" restore_failed: "❌ {error}" + diff: + not_enabled: "檢查點未啟用,因此沒有工作階段基準線。\n在 config.yaml 中啟用:\n```\ncheckpoints:\n enabled: true\n```\n一般 /diff 仍然可用 — 它直接使用 git。" + no_changes: "沒有變更。" + failed: "{error}" + set_home: save_failed: "無法儲存主頻道:{error}" success: "✅ 主頻道已設定為 **{name}**(ID:{chat_id})。\n排程任務和跨平台訊息將傳送至此處。" diff --git a/locales/zh.yaml b/locales/zh.yaml index f16f330028ab..512fbbcd72c3 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -279,6 +279,11 @@ Future messages in this room will use that transcript until `/reset` or another restored: "✅ 已恢复到检查点 {hash}:{reason}\n已自动保存回滚前的快照。" restore_failed: "❌ {error}" + diff: + not_enabled: "检查点未启用,因此没有会话基线。\n在 config.yaml 中启用:\n```\ncheckpoints:\n enabled: true\n```\n普通 /diff 仍然可用 — 它直接使用 git。" + no_changes: "没有更改。" + failed: "{error}" + set_home: save_failed: "无法保存主频道:{error}" success: "✅ 主频道已设置为 **{name}**(ID:{chat_id})。\n定时任务和跨平台消息将发送到此处。" diff --git a/skills/autonomous-ai-agents/hermes-agent/references/slash-commands.md b/skills/autonomous-ai-agents/hermes-agent/references/slash-commands.md index ec1982792091..3e683a502cf9 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/slash-commands.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/slash-commands.md @@ -16,6 +16,7 @@ it. New commands land often; `/help` in-session is always authoritative. /compress (/compact) Compress context ('here [N]' keeps N turns; --preview) /stop Kill background processes /rollback [N] List/restore filesystem checkpoints +/diff [mode] [--stat] Git changes in cwd (staged|all|session modes) /snapshot [sub] Create/restore Hermes config+state snapshots (CLI) /background (/bg)

Run prompt in background /queue (/q) Queue prompt for next turn diff --git a/tests/gateway/test_diff_command.py b/tests/gateway/test_diff_command.py new file mode 100644 index 000000000000..491fadb3fdc7 --- /dev/null +++ b/tests/gateway/test_diff_command.py @@ -0,0 +1,180 @@ +"""End-to-end tests for the gateway ``/diff`` command. + +Exercises the real handler against real git repositories (default modes) and +a real checkpoint store (session mode), proving the messaging surface returns +fenced, truncated diff text and degrades to friendly messages when there's +nothing to show. +""" + +import shutil +import subprocess + +import pytest + +import gateway.run as gateway_run +import tools.checkpoint_manager as cpm +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, reason="git required for /diff" +) + + +def _runner(): + runner = object.__new__(gateway_run.GatewayRunner) + runner.session_store = None + runner.config = None + return runner + + +def _event(text: str) -> MessageEvent: + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="user-1", + chat_id="chat-1", + user_name="tester", + chat_type="dm", + ) + return MessageEvent(text=text, source=source) + + +def _git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, + env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + "HOME": str(repo), + "PATH": __import__("os").environ["PATH"]}) + + +@pytest.fixture() +def repo(tmp_path, monkeypatch): + d = tmp_path / "repo" + d.mkdir() + _git(d, "init", "-q") + (d / "main.py").write_text("print('hello')\n", encoding="utf-8") + _git(d, "add", "-A") + _git(d, "commit", "-q", "-m", "init") + monkeypatch.setenv("TERMINAL_CWD", str(d)) + return d + + +def _enable_checkpoints(tmp_path, monkeypatch, enabled=True): + home = tmp_path / "home" + home.mkdir() + (home / "config.yaml").write_text( + f"checkpoints:\n enabled: {str(enabled).lower()}\n", encoding="utf-8" + ) + monkeypatch.setattr(gateway_run, "_hermes_home", home, raising=False) + monkeypatch.setattr(cpm, "CHECKPOINT_BASE", tmp_path / "checkpoints") + + +# --------------------------------------------------------------------------- +# Default (working-tree) mode +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_diff_reports_unstaged_changes_fenced(repo): + (repo / "main.py").write_text("print('changed')\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "-print('hello')" in result + assert "+print('changed')" in result + assert "```diff" in result # fenced for messaging surfaces + + +@pytest.mark.asyncio +async def test_diff_includes_untracked_files(repo): + (repo / "newfile.py").write_text("n = 1\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "newfile.py" in result + assert "+n = 1" in result + + +@pytest.mark.asyncio +async def test_diff_stat_only_omits_body(repo): + (repo / "main.py").write_text("print('changed')\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff --stat")) + + assert "main.py" in result + assert "+print('changed')" not in result + + +@pytest.mark.asyncio +async def test_diff_no_changes_message(repo): + result = await _runner()._handle_diff_command(_event("/diff")) + assert "No changes" in result + + +@pytest.mark.asyncio +async def test_diff_long_output_truncated(repo): + lines = "\n".join(f"line{i} = {i}" for i in range(300)) + "\n" + (repo / "main.py").write_text(lines, encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff")) + + # Hard-truncated in the handler before the platform senders apply their + # own message-splitting limits (3-layer tool-progress-style truncation). + assert "truncated" in result + assert len(result) < 6000 + + +@pytest.mark.asyncio +async def test_diff_non_git_directory_fails_cleanly(tmp_path, monkeypatch): + plain = tmp_path / "plain" + plain.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(plain)) + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "not a git repository" in result.lower() + + +# --------------------------------------------------------------------------- +# Session mode — checkpoint baseline +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_diff_session_reports_cumulative_changes(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch) + project = tmp_path / "project" + project.mkdir() + (project / "main.py").write_text("print('hello')\n", encoding="utf-8") + monkeypatch.setenv("TERMINAL_CWD", str(project)) + + # Baseline checkpoint (pre-edit) then an edit, so a diff exists. + mgr = cpm.CheckpointManager(enabled=True, max_snapshots=50) + assert mgr.ensure_checkpoint(str(project), "baseline") is True + (project / "main.py").write_text("print('changed')\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff session")) + + assert "-print('hello')" in result + assert "+print('changed')" in result + + +@pytest.mark.asyncio +async def test_diff_session_no_changes_message(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch) + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(project)) + + result = await _runner()._handle_diff_command(_event("/diff session")) + + assert "No changes" in result + + +@pytest.mark.asyncio +async def test_diff_session_disabled_message(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch, enabled=False) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + + result = await _runner()._handle_diff_command(_event("/diff session")) + + assert "not enabled" in result.lower() diff --git a/tests/hermes_cli/test_diff_command.py b/tests/hermes_cli/test_diff_command.py new file mode 100644 index 000000000000..f190c8013559 --- /dev/null +++ b/tests/hermes_cli/test_diff_command.py @@ -0,0 +1,190 @@ +"""Tests for the CLI ``/diff`` command handler. + +``/diff`` shows git changes in the working directory (unstaged + untracked by +default; ``staged``/``all`` modes) and ``/diff session`` shows the cumulative +checkpoint-baseline diff of everything Hermes changed. These drive the mixin +handler against real git repos (default modes) and a stubbed checkpoint +manager (session mode), asserting rendering, ``--stat``, and graceful +degradation. +""" + +import contextlib +import io +import shutil +import subprocess + +import pytest + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + +requires_git = pytest.mark.skipif( + shutil.which("git") is None, reason="git required" +) + + +class _Console: + def __init__(self, sink): + self._sink = sink + + def print(self, obj, **kwargs): + self._sink.write(getattr(obj, "plain", str(obj)) + "\n") + + +class _Mgr: + def __init__(self, result, enabled=True): + self.enabled = enabled + self._result = result + self.calls = [] + + def session_diff(self, cwd): + self.calls.append(cwd) + return self._result + + +class _Agent: + def __init__(self, mgr): + self._checkpoint_mgr = mgr + + +class _Stub(CLICommandsMixin): + def __init__(self, agent=None): + self.agent = agent + + +def _run(stub, command): + buf = io.StringIO() + stub.console = _Console(buf) + with contextlib.redirect_stdout(buf): + stub._handle_diff_command(command) + return buf.getvalue() + + +def _git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, + env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + "HOME": str(repo), + "PATH": __import__("os").environ["PATH"]}) + + +@pytest.fixture() +def repo(tmp_path, monkeypatch): + d = tmp_path / "repo" + d.mkdir() + _git(d, "init", "-q") + (d / "main.py").write_text("print('hello')\n") + _git(d, "add", "-A") + _git(d, "commit", "-q", "-m", "init") + monkeypatch.setenv("TERMINAL_CWD", str(d)) + return d + + +# --------------------------------------------------------------------------- +# Default (working-tree) mode — real git +# --------------------------------------------------------------------------- + +@requires_git +def test_diff_clean_repo_reports_no_changes(repo): + out = _run(_Stub(), "/diff") + assert "No changes" in out + + +@requires_git +def test_diff_shows_unstaged_changes(repo): + (repo / "main.py").write_text("print('changed')\n") + out = _run(_Stub(), "/diff") + assert "Unstaged" in out + assert "-print('hello')" in out + assert "+print('changed')" in out + + +@requires_git +def test_diff_lists_untracked_files(repo): + (repo / "newfile.py").write_text("n = 1\n") + out = _run(_Stub(), "/diff") + assert "Untracked" in out + assert "newfile.py" in out + assert "+n = 1" in out + + +@requires_git +def test_diff_stat_suppresses_body(repo): + (repo / "main.py").write_text("print('changed')\n") + out = _run(_Stub(), "/diff --stat") + assert "main.py" in out + assert "+print('changed')" not in out + + +@requires_git +def test_diff_staged_mode(repo): + (repo / "main.py").write_text("print('staged')\n") + _git(repo, "add", "main.py") + out = _run(_Stub(), "/diff staged") + assert "Staged" in out + assert "+print('staged')" in out + + +@requires_git +def test_diff_non_git_directory_is_graceful(tmp_path, monkeypatch): + plain = tmp_path / "plain" + plain.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(plain)) + out = _run(_Stub(), "/diff") + assert "not a git repository" in out.lower() + + +# --------------------------------------------------------------------------- +# Session mode — stubbed checkpoint manager +# --------------------------------------------------------------------------- + +def test_diff_session_prints_stat_and_diff(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + mgr = _Mgr({ + "success": True, + "stat": " main.py | 2 +-", + "diff": "--- a/main.py\n+++ b/main.py\n-print('hello')\n+print('v3')\n", + }) + out = _run(_Stub(_Agent(mgr)), "/diff session") + assert " main.py | 2 +-" in out + assert "+print('v3')" in out + assert mgr.calls # session_diff was consulted + + +def test_diff_session_stat_only_suppresses_body(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + mgr = _Mgr({ + "success": True, + "stat": " main.py | 2 +-", + "diff": "+print('v3')\n", + }) + out = _run(_Stub(_Agent(mgr)), "/diff session --stat") + assert " main.py | 2 +-" in out + assert "+print('v3')" not in out + + +def test_diff_session_empty_reports_no_changes(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + mgr = _Mgr({"success": True, "stat": "", "diff": "", "empty": True}) + out = _run(_Stub(_Agent(mgr)), "/diff session") + assert "No changes" in out + + +def test_diff_session_disabled_explains_how_to_enable(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + mgr = _Mgr({"success": True, "stat": "", "diff": ""}, enabled=False) + out = _run(_Stub(_Agent(mgr)), "/diff session") + assert "not enabled" in out.lower() + assert not mgr.calls # short-circuits before touching the store + + +def test_diff_session_without_agent_is_graceful(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + out = _run(_Stub(agent=None), "/diff session") + assert "No active agent session" in out + + +def test_diff_session_failure_surfaces_error(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + mgr = _Mgr({"success": False, "error": "boom"}) + out = _run(_Stub(_Agent(mgr)), "/diff session") + assert "boom" in out diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index b72c9b954dd3..93099dc6c766 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -1416,3 +1416,53 @@ class TestOrphanPruneRequiresObservableDeletion: after = json.loads(meta_path.read_text()) assert after["workdir_parent_dev"] == before["workdir_parent_dev"] assert after["workdir_parent_ino"] == before["workdir_parent_ino"] + + +# ========================================================================= +# session_diff — cumulative "what changed" view that powers /diff session +# ========================================================================= + +class TestSessionDiff: + def test_no_checkpoints_is_empty_success(self, mgr, work_dir): + """With nothing edited yet, session_diff succeeds and reports empty.""" + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert result.get("empty") is True + assert result["diff"] == "" + + def test_cumulative_diff_spans_all_edits(self, mgr, work_dir): + """The diff covers the first edit through the latest working tree.""" + # First checkpoint captures the pre-edit state (main.py == hello). + mgr.ensure_checkpoint(str(work_dir), "before edit 1") + (work_dir / "main.py").write_text("print('v2')\n") + mgr.new_turn() + mgr.ensure_checkpoint(str(work_dir), "before edit 2") + (work_dir / "main.py").write_text("print('v3')\n") + + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert not result.get("empty") + # Baseline is the earliest retained checkpoint. + assert result["baseline"] == mgr.list_checkpoints(str(work_dir))[-1]["hash"] + # Cumulative: the original line is removed, the final line added; the + # intermediate "v2" is neither in the baseline nor the working tree. + assert "-print('hello')" in result["diff"] + assert "+print('v3')" in result["diff"] + assert "v2" not in result["diff"] + + def test_includes_newly_added_files(self, mgr, work_dir): + mgr.ensure_checkpoint(str(work_dir), "baseline") + (work_dir / "feature.py").write_text("x = 1\n") + + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert "feature.py" in result["diff"] + assert "+x = 1" in result["diff"] + + def test_no_changes_since_baseline_reports_empty(self, mgr, work_dir): + """A checkpoint with no subsequent edits yields an empty diff.""" + mgr.ensure_checkpoint(str(work_dir), "baseline") + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert result.get("empty") is True + assert result["diff"] == "" diff --git a/tests/tools/test_working_diff.py b/tests/tools/test_working_diff.py new file mode 100644 index 000000000000..d1b0759aa4ae --- /dev/null +++ b/tests/tools/test_working_diff.py @@ -0,0 +1,111 @@ +"""Tests for tools.working_diff.collect_working_diff — the git collection +layer shared by the CLI and gateway ``/diff`` command. + +Runs against real temporary git repositories (no mocks) so the staged / +unstaged / untracked semantics are proven against actual git behaviour. +""" + +import shutil +import subprocess + +import pytest + +from tools.working_diff import collect_working_diff + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, reason="git required for working-diff tests" +) + + +def _git(repo, *args): + subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, + env={"HOME": str(repo), "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + "PATH": __import__("os").environ["PATH"]}, + ) + + +@pytest.fixture() +def repo(tmp_path): + d = tmp_path / "repo" + d.mkdir() + _git(d, "init", "-q") + (d / "tracked.py").write_text("print('hello')\n") + _git(d, "add", "-A") + _git(d, "commit", "-q", "-m", "init") + return d + + +def test_clean_repo_reports_empty(repo): + result = collect_working_diff(str(repo)) + assert result["success"] is True + assert result.get("empty") is True + assert result["diff"] == "" + + +def test_unstaged_change_appears_in_default_mode(repo): + (repo / "tracked.py").write_text("print('changed')\n") + result = collect_working_diff(str(repo)) + assert result["success"] is True + assert "-print('hello')" in result["diff"] + assert "+print('changed')" in result["diff"] + assert "tracked.py" in result["stat"] + + +def test_untracked_file_appears_as_addition(repo): + (repo / "brand_new.py").write_text("x = 1\n") + result = collect_working_diff(str(repo)) + assert result["success"] is True + assert "brand_new.py" in result["untracked"] + assert "+x = 1" in result["diff"] + assert not result.get("empty") + + +def test_staged_mode_shows_only_staged(repo): + (repo / "tracked.py").write_text("print('staged')\n") + _git(repo, "add", "tracked.py") + (repo / "unstaged.py").write_text("y = 2\n") # untracked, must not appear + + result = collect_working_diff(str(repo), mode="staged") + assert result["success"] is True + assert "+print('staged')" in result["diff"] + assert "unstaged.py" not in result["diff"] + + +def test_all_mode_spans_staged_unstaged_and_untracked(repo): + (repo / "tracked.py").write_text("print('staged')\n") + _git(repo, "add", "tracked.py") + (repo / "extra.py").write_text("z = 3\n") + + result = collect_working_diff(str(repo), mode="all") + assert result["success"] is True + assert "+print('staged')" in result["diff"] + assert "+z = 3" in result["diff"] + + +def test_paths_filter_restricts_output(repo): + (repo / "tracked.py").write_text("print('changed')\n") + (repo / "other.py").write_text("o = 1\n") + _git(repo, "add", "other.py") + _git(repo, "commit", "-q", "-m", "add other") + (repo / "other.py").write_text("o = 2\n") + + result = collect_working_diff(str(repo), paths=["tracked.py"]) + assert result["success"] is True + assert "tracked.py" in result["diff"] + assert "other.py" not in result["diff"] + + +def test_non_git_directory_fails_cleanly(tmp_path): + plain = tmp_path / "plain" + plain.mkdir() + result = collect_working_diff(str(plain)) + assert result["success"] is False + assert "not a git repository" in result["error"].lower() + + +def test_unknown_mode_rejected(repo): + result = collect_working_diff(str(repo), mode="bogus") + assert result["success"] is False + assert "bogus" in result["error"] diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 27d3680725cb..a1993187ff6b 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -880,6 +880,38 @@ class CheckpointManager: "diff": diff_out if ok_diff else "", } + def session_diff(self, working_dir: str) -> Dict: + """Show the cumulative diff of everything changed in this directory. + + This powers ``/diff session``. It answers "what has Hermes changed + here?" by diffing the *earliest retained checkpoint* — the snapshot + taken before the first recorded edit — against the current working + tree. Because checkpoints are captured just before each file-mutating + tool call, that baseline is the pre-edit state, so the diff covers the + first edit and everything after it. + + Note: checkpoints are a persistent per-project ref, so the earliest + *retained* checkpoint may predate the current session (or, after + pruning, postdate its true start). It is an approximation of "what + Hermes changed", not an exact per-session ledger. + + Returns the same shape as :meth:`diff` (``{"success", "stat", + "diff"}``). When no checkpoints exist yet — nothing has been edited — + the call still *succeeds* with empty output and ``"empty": True`` so + callers can show a friendly "no changes" message rather than an error. + """ + checkpoints = self.list_checkpoints(working_dir) + if not checkpoints: + return {"success": True, "stat": "", "diff": "", "empty": True} + + baseline = checkpoints[-1].get("hash") or "" + result = self.diff(working_dir, baseline) + if result.get("success"): + result.setdefault("baseline", baseline) + if not result.get("stat") and not result.get("diff"): + result["empty"] = True + return result + def restore(self, working_dir: str, commit_hash: str, file_path: str = None) -> Dict: """Restore files to a checkpoint state.""" hash_err = _validate_commit_hash(commit_hash) diff --git a/tools/working_diff.py b/tools/working_diff.py new file mode 100644 index 000000000000..cc0d0df101ce --- /dev/null +++ b/tools/working_diff.py @@ -0,0 +1,130 @@ +"""Working-tree git diff collection shared by the CLI and gateway ``/diff``. + +The ``/diff`` slash command answers "what changed here?" on every surface. +This module holds the surface-agnostic collection logic so the CLI (colored +terminal output) and the gateway (fenced, truncated messages) render the same +underlying data. + +Modes +----- +- ``working`` (default): unstaged changes plus untracked files — what you'd + lose with ``git checkout . && git clean -fd``. +- ``staged``: changes already staged for commit (``git diff --cached``). +- ``all``: everything since HEAD (staged + unstaged) plus untracked files. + +Untracked files are folded in via ``git diff --no-index /dev/null `` so +brand-new files show up as additions instead of being silently invisible +(mirrors Codex CLI's ``/diff`` behaviour). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from typing import Dict, List + +_GIT_TIMEOUT = 15 +_MAX_UNTRACKED_FILES = 50 # sanity cap so a node_modules explosion can't hang us + +VALID_MODES = ("working", "staged", "all") + + +def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT): + """Run git, returning (returncode, stdout). Never raises on git failure.""" + proc = subprocess.run( + ["git", "-c", "core.quotePath=false", *args], + cwd=cwd, capture_output=True, text=True, timeout=timeout, + ) + return proc.returncode, proc.stdout + + +def _untracked_files(cwd: str) -> List[str]: + code, out = _run(["ls-files", "--others", "--exclude-standard"], cwd) + if code != 0: + return [] + return [line for line in out.splitlines() if line.strip()] + + +def _untracked_diff(cwd: str, files: List[str]) -> str: + """Render untracked files as new-file diffs via ``git diff --no-index``.""" + chunks: List[str] = [] + for rel in files[:_MAX_UNTRACKED_FILES]: + try: + # --no-index exits 1 when the files differ — that's the success + # path here, so ignore the return code and keep the output. + _, out = _run( + ["diff", "--no-index", "--", os.devnull, rel], cwd, + ) + if out.strip(): + chunks.append(out.rstrip("\n")) + except (subprocess.TimeoutExpired, OSError): + continue + if len(files) > _MAX_UNTRACKED_FILES: + chunks.append( + f"... ({len(files) - _MAX_UNTRACKED_FILES} more untracked files not shown)" + ) + return "\n".join(chunks) + + +def collect_working_diff(cwd: str, mode: str = "working", + paths: List[str] | None = None) -> Dict: + """Collect a git diff of the working directory. + + Returns ``{"success", "stat", "diff", "untracked", "empty"}`` on success or + ``{"success": False, "error": ...}`` when git is unavailable / not a repo. + ``paths`` optionally restricts the diff to specific pathspecs (passed + through to git verbatim, so quoted paths with spaces survive). + """ + if mode not in VALID_MODES: + return {"success": False, + "error": f"Unknown mode '{mode}'. Use: {', '.join(VALID_MODES)}"} + + if not shutil.which("git"): + return {"success": False, "error": "git is not installed or not on PATH."} + + try: + code, _ = _run(["rev-parse", "--is-inside-work-tree"], cwd, timeout=5) + except (subprocess.TimeoutExpired, OSError) as e: + return {"success": False, "error": f"git failed: {e}"} + if code != 0: + return {"success": False, "error": "Not a git repository."} + + if mode == "staged": + base_args = ["diff", "--cached"] + elif mode == "all": + base_args = ["diff", "HEAD"] + else: # working + base_args = ["diff"] + + pathspec = ["--", *paths] if paths else [] + + try: + _, stat_out = _run([*base_args, "--stat", *pathspec], cwd) + _, diff_out = _run([*base_args, *pathspec], cwd, timeout=_GIT_TIMEOUT * 2) + + untracked: List[str] = [] + untracked_diff = "" + if mode in ("working", "all") and not paths: + untracked = _untracked_files(cwd) + if untracked: + untracked_diff = _untracked_diff(cwd, untracked) + except subprocess.TimeoutExpired: + return {"success": False, "error": "git diff timed out."} + except OSError as e: + return {"success": False, "error": f"git failed: {e}"} + + stat = stat_out.strip() + diff = diff_out.strip() + if untracked_diff: + diff = f"{diff}\n{untracked_diff}".strip() + + result = { + "success": True, + "stat": stat, + "diff": diff, + "untracked": untracked, + } + if not stat and not diff and not untracked: + result["empty"] = True + return result diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 608ec8733f7a..a8c1db8d4fc8 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -46,6 +46,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/title` | Set a title for the current session (usage: /title My Session Name) | | `/compress [here [N] \| focus topic]` | Manually compress conversation context (flush memories + summarize). `/compress here [N]` summarizes everything except the most recent N exchanges (default 2), kept verbatim — pick your own compression boundary. A focus topic narrows what a full summary preserves. | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | +| `/diff [staged\|all\|session] [--stat] [path...]` | Show git changes in the working directory. Default: unstaged changes plus untracked files. `staged` shows what's staged for commit, `all` everything since HEAD, and `session` the cumulative diff of everything Hermes changed here (from the earliest retained checkpoint baseline — requires checkpoints to be enabled; complements `/rollback diff `). `--stat` prints just the changed-file summary; path arguments restrict the diff. | | `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore ` reverts to it, `prune [N]` removes old snapshots, or list all with no args. | | `/stop` | Kill all running background processes | | `/queue ` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). | @@ -233,6 +234,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display. | | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/rollback [number]` | List or restore filesystem checkpoints. | +| `/diff [staged\|all\|session] [--stat]` | Show git changes in the working directory (fenced and truncated to platform message limits). `session` shows the cumulative diff of everything Hermes changed; `--stat` shows just the summary. | | `/background ` | Run a prompt in a separate background session. Results are delivered back to the same chat when the task finishes. See [Messaging Background Sessions](/user-guide/messaging/#background-sessions). | | `/queue ` (alias: `/q`) | Queue a prompt for the next turn without interrupting the current one. | | `/steer ` | Inject a message after the next tool call without interrupting — the model picks it up on its next iteration rather than as a new turn. | @@ -264,6 +266,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. - `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. +- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts.