mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
88d45edca2
commit
0fa5e41c86
30 changed files with 1035 additions and 64 deletions
61
cli.py
61
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/.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 والرسائل عبر المنصّات هنا."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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í."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 ジョブとプラットフォーム間メッセージはここに配信されます。"
|
||||
|
|
|
|||
|
|
@ -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크론 작업과 플랫폼 간 메시지가 여기로 전달됩니다."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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-задачи и межплатформенные сообщения будут доставляться сюда."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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-завдання та міжплатформні повідомлення доставлятимуться сюди."
|
||||
|
|
|
|||
|
|
@ -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排程任務和跨平台訊息將傳送至此處。"
|
||||
|
|
|
|||
|
|
@ -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定时任务和跨平台消息将发送到此处。"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
180
tests/gateway/test_diff_command.py
Normal file
180
tests/gateway/test_diff_command.py
Normal 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()
|
||||
190
tests/hermes_cli/test_diff_command.py
Normal file
190
tests/hermes_cli/test_diff_command.py
Normal 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
|
||||
|
|
@ -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"] == ""
|
||||
|
|
|
|||
111
tests/tools/test_working_diff.py
Normal file
111
tests/tools/test_working_diff.py
Normal 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"]
|
||||
|
|
@ -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
130
tools/working_diff.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue