feat: add git_status, git_log, git_diff orchestrator tools
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 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,11 @@ from tools.agent_notes import (
|
|||||||
agent_notes_append as _agent_notes_append,
|
agent_notes_append as _agent_notes_append,
|
||||||
agent_notes_clear as _agent_notes_clear,
|
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.agents import spawn_agent as _spawn_agent
|
||||||
from tools.homeassistant import (
|
from tools.homeassistant import (
|
||||||
ha_get_state as _ha_get_state,
|
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.scratch as _mod_scratch
|
||||||
import tools.notify as _mod_notify
|
import tools.notify as _mod_notify
|
||||||
import tools.agent_notes as _mod_agent_notes
|
import tools.agent_notes as _mod_agent_notes
|
||||||
|
import tools.git as _mod_git
|
||||||
import tools.agents as _mod_agents
|
import tools.agents as _mod_agents
|
||||||
import tools.homeassistant as _mod_homeassistant
|
import tools.homeassistant as _mod_homeassistant
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ import tools.homeassistant as _mod_homeassistant
|
|||||||
TOOL_CATEGORIES: dict[str, list[str]] = {
|
TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||||
"Web": ["web_search", "http_fetch", "web_read", "http_post"],
|
"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"],
|
"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"],
|
"System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
||||||
"Shell": ["shell_exec", "claude_allow_dir"],
|
"Shell": ["shell_exec", "claude_allow_dir"],
|
||||||
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
|
"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_write": _agent_notes_write,
|
||||||
"agent_notes_append": _agent_notes_append,
|
"agent_notes_append": _agent_notes_append,
|
||||||
"agent_notes_clear": _agent_notes_clear,
|
"agent_notes_clear": _agent_notes_clear,
|
||||||
|
"git_status": _git_status,
|
||||||
|
"git_log": _git_log,
|
||||||
|
"git_diff": _git_diff,
|
||||||
"spawn_agent": _spawn_agent,
|
"spawn_agent": _spawn_agent,
|
||||||
"ha_get_state": _ha_get_state,
|
"ha_get_state": _ha_get_state,
|
||||||
"ha_get_states": _ha_get_states,
|
"ha_get_states": _ha_get_states,
|
||||||
@@ -319,6 +329,11 @@ TOOL_RISK: dict[str, str] = {
|
|||||||
"agent_notes_append": "low",
|
"agent_notes_append": "low",
|
||||||
"agent_notes_clear": "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
|
# Agents — spawning a subprocess with broad permissions is high
|
||||||
"spawn_agent": "high",
|
"spawn_agent": "high",
|
||||||
|
|
||||||
@@ -343,6 +358,7 @@ def _role_allowed(tool_name: str, role: str) -> bool:
|
|||||||
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
||||||
_mod_web.DECLARATIONS
|
_mod_web.DECLARATIONS
|
||||||
+ _mod_files.DECLARATIONS
|
+ _mod_files.DECLARATIONS
|
||||||
|
+ _mod_git.DECLARATIONS
|
||||||
+ _mod_system.DECLARATIONS
|
+ _mod_system.DECLARATIONS
|
||||||
+ _mod_tasks.DECLARATIONS
|
+ _mod_tasks.DECLARATIONS
|
||||||
+ _mod_cron.DECLARATIONS
|
+ _mod_cron.DECLARATIONS
|
||||||
|
|||||||
158
cortex/tools/git.py
Normal file
158
cortex/tools/git.py
Normal file
@@ -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",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user