Files
hermes-agent/tools/working_diff.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

146 lines
6.0 KiB
Python

"""Working-tree git diff collection shared by the CLI and gateway ``/diff``.
Surface-agnostic so the CLI (colored terminal) and gateway (fenced, truncated
messages) render the same data. Modes: ``working`` (unstaged + untracked),
``staged`` (``git diff --cached``), ``all`` (everything since HEAD plus untracked).
Untracked files are folded in via ``git diff --no-index /dev/null <file>`` so
brand-new files show as additions instead of being invisible.
"""
from __future__ import annotations
import functools
import os
import shutil
import subprocess
from contextlib import suppress
from typing import Dict, List, Optional
from hermes_cli._subprocess_compat import harden_git_argv, noninteractive_git_env
_GIT_TIMEOUT = 15
_MAX_UNTRACKED_FILES = 50 # sanity cap so a node_modules explosion can't hang us
_MODE_ARGS = {
"working": ["diff"],
"staged": ["diff", "--cached"],
"all": ["diff", "HEAD"],
}
VALID_MODES = tuple(_MODE_ARGS)
@functools.lru_cache(maxsize=1)
def _git_command() -> Optional[List[str]]:
"""Resolve the git invocation: pm's pinned Git first, then system git.
pm's git package is the canonical Windows git (Git for Windows,
pinned in pm/lock.json) — it wins over PATH so a stale or broken
system git never breaks diff collection. On POSIX pm deliberately
gaps git (system git by choice), and when pm can't provide it for any
other reason we fall back to bare ``git`` on PATH. None when git is
nowhere — the caller reports it unavailable.
"""
try:
import pm
runner = pm.ensure("git")
for candidate in ("git.exe", "git"):
resolved = shutil.which(candidate, path=runner.env.get("PATH"))
if resolved:
return [resolved]
except Exception:
pass
return ["git"] if shutil.which("git") else None
def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT):
"""Run git, returning (returncode, stdout). Never raises on git failure.
Hardened against a malicious repo's ``.git/config`` (GHSA-7x36-8jrh-v4pw):
``noninteractive_git_env`` disables fsmonitor/hooks/pager/editor/credential
sinks, and ``harden_git_argv`` appends ``--no-ext-diff --no-textconv`` to
the diff-rendering subcommands so attribute-scoped diff/textconv drivers
can't execute either.
"""
command = _git_command()
if command is None:
return 127, ""
proc = subprocess.run(
[*command, "-c", "core.quotePath=false", *harden_git_argv(args)],
cwd=cwd, capture_output=True, text=True, timeout=timeout,
encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL, env=noninteractive_git_env(),
)
return proc.returncode, proc.stdout
def _untracked_files(cwd: str) -> List[str]:
code, out = _run(["ls-files", "--others", "--exclude-standard"], cwd)
return [line for line in out.splitlines() if line.strip()] if code == 0 else []
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]:
with suppress(subprocess.TimeoutExpired, OSError):
# --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-ext-diff", "--no-index", "--", os.devnull, rel], cwd,
)
if out.strip():
chunks.append(out.rstrip("\n"))
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 _git_command() is None:
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."}
# --no-ext-diff: a user-configured external differ (diff.external in
# gitconfig, e.g. difftastic) replaces the unified-diff format that the
# CLI/gateway renderers and truncation logic parse. Force the internal
# diff engine so the collected output shape is stable for all users.
# (_run's harden_git_argv also enforces this; explicit here for clarity.)
base_args = [a for a in _MODE_ARGS[mode] if a != "diff"]
base_args = ["diff", "--no-ext-diff", *base_args]
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 = _untracked_files(cwd) if mode in ("working", "all") and not paths else []
untracked_diff = _untracked_diff(cwd, untracked) if untracked else ""
except subprocess.TimeoutExpired:
return {"success": False, "error": "git diff timed out."}
except OSError as e:
return {"success": False, "error": f"git failed: {e}"}
stat, diff = stat_out.strip(), 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