From b7144d5903a913f97483cf5295dd85a02602e167 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 12 May 2026 00:12:00 -0400 Subject: [PATCH] feat: add git_status, git_log, git_diff orchestrator tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only wrappers around git commands, project-scoped. Covers working tree status, commit history browsing (with optional path filter), and diffs between refs or the working tree — cleaner than shell_exec for code review and change verification. Co-Authored-By: Claude Sonnet 4.6 --- cortex/tools/__init__.py | 16 ++++ cortex/tools/git.py | 158 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 cortex/tools/git.py diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index b2e5181..5234209 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -82,6 +82,11 @@ from tools.agent_notes import ( agent_notes_append as _agent_notes_append, agent_notes_clear as _agent_notes_clear, ) +from tools.git import ( + git_status as _git_status, + git_log as _git_log, + git_diff as _git_diff, +) from tools.agents import spawn_agent as _spawn_agent from tools.homeassistant import ( ha_get_state as _ha_get_state, @@ -102,6 +107,7 @@ import tools.reminders as _mod_reminders import tools.scratch as _mod_scratch import tools.notify as _mod_notify import tools.agent_notes as _mod_agent_notes +import tools.git as _mod_git import tools.agents as _mod_agents import tools.homeassistant as _mod_homeassistant @@ -110,6 +116,7 @@ import tools.homeassistant as _mod_homeassistant TOOL_CATEGORIES: dict[str, list[str]] = { "Web": ["web_search", "http_fetch", "web_read", "http_post"], "Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"], + "Git": ["git_status", "git_log", "git_diff"], "System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"], "Shell": ["shell_exec", "claude_allow_dir"], "System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"], @@ -189,6 +196,9 @@ _CALLABLES: dict[str, callable] = { "agent_notes_write": _agent_notes_write, "agent_notes_append": _agent_notes_append, "agent_notes_clear": _agent_notes_clear, + "git_status": _git_status, + "git_log": _git_log, + "git_diff": _git_diff, "spawn_agent": _spawn_agent, "ha_get_state": _ha_get_state, "ha_get_states": _ha_get_states, @@ -319,6 +329,11 @@ TOOL_RISK: dict[str, str] = { "agent_notes_append": "low", "agent_notes_clear": "low", + # Git — all read-only inspections + "git_status": "low", + "git_log": "low", + "git_diff": "low", + # Agents — spawning a subprocess with broad permissions is high "spawn_agent": "high", @@ -343,6 +358,7 @@ def _role_allowed(tool_name: str, role: str) -> bool: _ALL_DECLARATIONS: list[types.FunctionDeclaration] = ( _mod_web.DECLARATIONS + _mod_files.DECLARATIONS + + _mod_git.DECLARATIONS + _mod_system.DECLARATIONS + _mod_tasks.DECLARATIONS + _mod_cron.DECLARATIONS diff --git a/cortex/tools/git.py b/cortex/tools/git.py new file mode 100644 index 0000000..42597fc --- /dev/null +++ b/cortex/tools/git.py @@ -0,0 +1,158 @@ +""" +Git inspection tools — project-scoped, read-only. + + git_status — working tree status (staged, unstaged, untracked changes) + git_log — recent commit history with optional path filter + git_diff — diff between commits, branches, or working tree vs HEAD +""" + +import asyncio +import logging +from pathlib import Path + +from google.genai import types + +logger = logging.getLogger(__name__) + +_PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.resolve() +_MAX_OUTPUT = 50_000 + + +async def _git(*args: str, timeout: int = 15) -> tuple[int, str]: + """Run a git command in the project root. Returns (returncode, output).""" + proc = await asyncio.create_subprocess_exec( + "git", "-C", str(_PROJECT_ROOT), *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return 1, "git command timed out" + out = (stdout or b"").decode(errors="replace").strip() + err = (stderr or b"").decode(errors="replace").strip() + combined = out if out else err + return proc.returncode, combined + + +def _cap(text: str) -> str: + if len(text) > _MAX_OUTPUT: + return text[:_MAX_OUTPUT] + "\n… [truncated]" + return text + + +async def git_status() -> str: + """Return the current git working tree status.""" + rc, out = await _git("status") + if rc != 0: + return f"git status failed: {out}" + return out or "Working tree clean — nothing to report." + + +async def git_log(n: int = 20, path: str = "", oneline: bool = True) -> str: + """Return recent git commit history.""" + args = ["log"] + if oneline: + args += ["--oneline"] + else: + args += ["--format=%H %as %an%n %s", "--date=short"] + args += [f"-{max(1, min(n, 200))}"] + if path: + args += ["--", path] + rc, out = await _git(*args) + if rc != 0: + return f"git log failed: {out}" + return _cap(out) or "No commits found." + + +async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False) -> str: + """Show a git diff. Defaults to working tree vs HEAD (unstaged changes).""" + args = ["diff"] + if stat_only: + args += ["--stat"] + if ref_a and ref_b: + args += [f"{ref_a}..{ref_b}"] + elif ref_a: + args += [ref_a] + if path: + args += ["--", path] + rc, out = await _git(*args) + # diff exits 1 when there are differences — that's normal + if rc not in (0, 1): + return f"git diff failed: {out}" + return _cap(out) or "No differences found." + + +# ── Declarations ────────────────────────────────────────────────────────────── + +DECLARATIONS = [ + types.FunctionDeclaration( + name="git_status", + description=( + "Show the current git working tree status for the Cortex project: " + "staged changes, unstaged modifications, and untracked files. " + "Use to check whether there are uncommitted changes before restarting or deploying." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={}, + ), + ), + types.FunctionDeclaration( + name="git_log", + description=( + "Show recent git commit history for the Cortex project. " + "Returns commit hashes, dates, and messages. " + "Optionally filter to a specific file or directory path." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "n": types.Schema( + type=types.Type.INTEGER, + description="Number of commits to return (default 20, max 200)", + ), + "path": types.Schema( + type=types.Type.STRING, + description="Optional file or directory path to filter commits by", + ), + "oneline": types.Schema( + type=types.Type.BOOLEAN, + description="Use compact one-line format (default true). Set false for more detail.", + ), + }, + ), + ), + types.FunctionDeclaration( + name="git_diff", + description=( + "Show a git diff for the Cortex project. " + "With no arguments: shows unstaged working tree changes vs HEAD. " + "With ref_a only: shows changes between that ref and HEAD. " + "With ref_a and ref_b: shows changes between the two refs (commits, branches, or tags). " + "Use stat_only to get a summary of changed files instead of full patch output." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "ref_a": types.Schema( + type=types.Type.STRING, + description="First ref (commit hash, branch name, or tag). Omit for working tree diff.", + ), + "ref_b": types.Schema( + type=types.Type.STRING, + description="Second ref. When provided with ref_a, shows diff between the two.", + ), + "path": types.Schema( + type=types.Type.STRING, + description="Optional file or directory path to restrict the diff to", + ), + "stat_only": types.Schema( + type=types.Type.BOOLEAN, + description="Return only a file-change summary (--stat) instead of the full diff", + ), + }, + ), + ), +]