feat(diff): cross-surface /diff with staged/all/session modes

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 <ninso112@proton.me>
Co-authored-by: Harshkamdar67 <harshkamdar67@gmail.com>
This commit is contained in:
teknium1 2026-07-26 14:25:28 -07:00 committed by Teknium
parent 88d45edca2
commit 0fa5e41c86
30 changed files with 1035 additions and 64 deletions

61
cli.py
View file

@ -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/.

View file

@ -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)

View file

@ -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 <prompt> — run a prompt in a separate background session.

View file

@ -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] <path...> 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.

View file

@ -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:

View file

@ -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."

View file

@ -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 والرسائل عبر المنصّات هنا."

View file

@ -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."

View file

@ -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."

View file

@ -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í."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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 ジョブとプラットフォーム間メッセージはここに配信されます。"

View file

@ -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크론 작업과 플랫폼 간 메시지가 여기로 전달됩니다."

View file

@ -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."

View file

@ -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-задачи и межплатформенные сообщения будут доставляться сюда."

View file

@ -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."

View file

@ -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-завдання та міжплатформні повідомлення доставлятимуться сюди."

View file

@ -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排程任務和跨平台訊息將傳送至此處。"

View file

@ -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定时任务和跨平台消息将发送到此处。"

View file

@ -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) <p> Run prompt in background
/queue (/q) <prompt> Queue prompt for next turn

View file

@ -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()

View file

@ -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

View file

@ -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"] == ""

View file

@ -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"]

View file

@ -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)

130
tools/working_diff.py Normal file
View file

@ -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 <file>`` 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

View file

@ -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 <N>`). `--stat` prints just the changed-file summary; path arguments restrict the diff. |
| `/snapshot [create\|restore <id>\|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore <id>` reverts to it, `prune [N]` removes old snapshots, or list all with no args. |
| `/stop` | Kill all running background processes |
| `/queue <prompt>` (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 <prompt>` | 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 <prompt>` (alias: `/q`) | Queue a prompt for the next turn without interrupting the current one. |
| `/steer <prompt>` | 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 <id-or-title>` for saved or closed transcripts.