butler/tools/git_tools.py
Rama main · réplica de lectura del repositorio original.
"""Git tools for Butler v4 — workspace-scoped, opt-in, no network subcommands."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Any, Callable
from butler.tools.path_safety import check_tool_path, default_tool_workdir, safe_subprocess_env
from butler.defaults.env_defaults import (
ENABLE_GIT_DEFAULT,
ENABLE_GIT_PUSH_DEFAULT,
ENABLE_GIT_WRITE_DEFAULT,
)
logger = logging.getLogger(__name__)
_MAX_GIT_OUTPUT = 30_000
_GIT_TIMEOUT_SECONDS = 30
def git_read_enabled() -> bool:
return os.getenv("BUTLER_ENABLE_GIT", ENABLE_GIT_DEFAULT).strip() == "1"
def git_write_enabled() -> bool:
return (
git_read_enabled()
and os.getenv("BUTLER_ENABLE_GIT_WRITE", ENABLE_GIT_WRITE_DEFAULT).strip() == "1"
)
def _disabled_read_msg() -> str:
return json.dumps({
"error": "Git tools are disabled. Set BUTLER_ENABLE_GIT=1 in .env.",
"code": "GIT_DISABLED",
})
def _disabled_write_msg() -> str:
return json.dumps({
"error": "Git write tools require BUTLER_ENABLE_GIT_WRITE=1 (and BUTLER_ENABLE_GIT=1).",
"code": "GIT_WRITE_DISABLED",
})
def _resolve_git_workdir(workdir: str | None) -> tuple[Path | None, str | None]:
if workdir and str(workdir).strip():
safety = check_tool_path(workdir)
else:
safety = default_tool_workdir()
if not safety.allowed:
return None, safety.error
if not safety.path.is_dir():
return None, f"Not a directory: {workdir or safety.path}"
return safety.path, None
def _run_git(args: list[str], *, workdir: str | None = None) -> dict[str, Any]:
cwd, err = _resolve_git_workdir(workdir)
if err:
return {"error": err, "exit_code": -1}
cmd = ["git", *args]
try:
proc = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SECONDS,
env=safe_subprocess_env(),
)
except subprocess.TimeoutExpired:
return {"error": "Git command timed out", "exit_code": -1}
except FileNotFoundError:
return {"error": "git executable not found", "exit_code": -1}
except OSError as exc:
return {"error": str(exc), "exit_code": -1}
out = (proc.stdout or "")[:_MAX_GIT_OUTPUT]
err_text = (proc.stderr or "")[:_MAX_GIT_OUTPUT]
payload: dict[str, Any] = {
"exit_code": proc.returncode,
"stdout": out,
"stderr": err_text,
"cwd": str(cwd),
}
if len(proc.stdout or "") > _MAX_GIT_OUTPUT:
payload["truncated"] = True
return payload
def _tool_git_status(workdir: str | None = None, **_: Any) -> str:
if not git_read_enabled():
return _disabled_read_msg()
return json.dumps(_run_git(["status", "--short", "--branch"], workdir=workdir))
def _tool_git_diff(
staged: bool = False,
stat_only: bool = False,
file: str = "",
ref: str = "",
workdir: str | None = None,
**_: Any,
) -> str:
if not git_read_enabled():
return _disabled_read_msg()
args = ["diff"]
if staged:
args.append("--cached")
if stat_only:
args.append("--stat")
if ref:
args.append(ref)
if file:
if (check := check_tool_path(file)).allowed:
args.extend(["--", str(check.path)])
else:
return json.dumps({"error": check.error, "exit_code": -1})
return json.dumps(_run_git(args, workdir=workdir))
def _tool_git_log(
count: int = 10,
oneline: bool = True,
file: str = "",
author: str = "",
since: str = "",
workdir: str | None = None,
**_: Any,
) -> str:
if not git_read_enabled():
return _disabled_read_msg()
try:
n = max(1, min(int(count), 100))
except (TypeError, ValueError):
return json.dumps({"error": "count must be an integer", "exit_code": -1})
args = ["log", f"-{n}"]
if oneline:
args.append("--oneline")
else:
args.extend(["--format=%H %an %ad %s", "--date=short"])
if author:
args.append(f"--author={author}")
if since:
args.append(f"--since={since}")
if file:
if (check := check_tool_path(file)).allowed:
args.extend(["--", str(check.path)])
else:
return json.dumps({"error": check.error, "exit_code": -1})
return json.dumps(_run_git(args, workdir=workdir))
def _tool_git_branch(
action: str = "list",
name: str = "",
workdir: str | None = None,
**_: Any,
) -> str:
act = (action or "list").strip().lower()
if act == "list":
if not git_read_enabled():
return _disabled_read_msg()
return json.dumps(
_run_git(["branch", "-a", "--sort=-committerdate"], workdir=workdir)
)
if not git_write_enabled():
return _disabled_write_msg()
if act == "create":
if not name.strip():
return json.dumps({"error": "branch name required for create", "exit_code": -1})
return json.dumps(_run_git(["checkout", "-b", name.strip()], workdir=workdir))
if act == "switch":
if not name.strip():
return json.dumps({"error": "branch name required for switch", "exit_code": -1})
return json.dumps(_run_git(["checkout", name.strip()], workdir=workdir))
if act == "delete":
if not name.strip():
return json.dumps({"error": "branch name required for delete", "exit_code": -1})
return json.dumps(_run_git(["branch", "-d", name.strip()], workdir=workdir))
return json.dumps({"error": f"Unknown action: {action}", "exit_code": -1})
def _tool_git_add(files: list[str] | None = None, workdir: str | None = None, **_: Any) -> str:
if not git_write_enabled():
return _disabled_write_msg()
paths = list(files or [])
if not paths:
return json.dumps({"error": "files list is required", "exit_code": -1})
argv = ["add"]
for raw in paths:
token = str(raw).strip()
if token == ".":
argv.append(".")
continue
check = check_tool_path(token)
if not check.allowed:
return json.dumps({"error": check.error, "exit_code": -1})
argv.append(str(check.path))
return json.dumps(_run_git(argv, workdir=workdir))
def _tool_git_commit(message: str = "", workdir: str | None = None, **_: Any) -> str:
if not git_write_enabled():
return _disabled_write_msg()
msg = (message or "").strip()
if not msg:
return json.dumps({"error": "commit message is required", "exit_code": -1})
return json.dumps(_run_git(["commit", "-m", msg], workdir=workdir))
def _tool_git_push(
remote: str = "origin",
branch: str = "",
force: bool = False,
workdir: str | None = None,
**_: Any,
) -> str:
"""Push to remote — requires BUTLER_ENABLE_GIT_PUSH=1 and Owner approval."""
if not git_write_enabled():
return _disabled_write_msg()
push_enabled = os.getenv("BUTLER_ENABLE_GIT_PUSH", ENABLE_GIT_PUSH_DEFAULT).strip() == "1"
if not push_enabled:
return json.dumps({
"error": "Git push requires BUTLER_ENABLE_GIT_PUSH=1 in .env.",
"code": "GIT_PUSH_DISABLED",
})
if force:
return json.dumps({
"error": "Force push is blocked for safety. Use manual git push --force.",
"code": "GIT_FORCE_PUSH_BLOCKED",
})
cwd, err = _resolve_git_workdir(workdir)
if err:
return json.dumps({"error": err, "exit_code": -1})
remote_name = (remote or "origin").strip()
branch_name = (branch or "").strip()
if not branch_name:
result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], workdir=workdir)
branch_name = (result.get("stdout") or "").strip()
if not branch_name:
return json.dumps({"error": "Cannot detect current branch", "exit_code": -1})
if branch_name in ("main", "master"):
return json.dumps({
"error": f"Push to protected branch '{branch_name}' is blocked.",
"code": "GIT_PROTECTED_BRANCH",
})
approval_key = f"git_push:{remote_name}:{branch_name}"
try:
from butler.tools.terminal_approval import check_approval
approved = check_approval(approval_key)
if not approved:
return json.dumps({
"ok": False,
"code": "PUSH_REQUIRES_APPROVAL",
"error": (
f"Git push to {remote_name}/{branch_name} requires Owner approval.\n"
f"请在微信确认:/批准一次 git_push 或 /始终允许 git_push"
),
"remote": remote_name,
"branch": branch_name,
})
except ImportError:
pass
args = ["push", remote_name, branch_name]
result = _run_git(args, workdir=workdir)
return json.dumps(result)
def register_git_tools(register: Callable[..., None]) -> None:
"""Register git_* tools (always visible; gated at runtime by env)."""
register(
name="git_status",
description=(
"【porcelain·names】Branch plus modified/staged/untracked path buckets. "
"Filenames only; zero +/- line content."
),
schema={
"type": "object",
"properties": {
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
},
handler=_tool_git_status,
toolset="git",
)
register(
name="git_diff",
description=(
"【unified-patch】+/- line hunks for staged or unstaged changes (stat/ref/file). "
"Patch text only; zero branch name, zero path inventory table."
),
schema={
"type": "object",
"properties": {
"staged": {"type": "boolean", "description": "Staged diff only", "default": False},
"stat_only": {"type": "boolean", "description": "Stat summary only", "default": False},
"file": {"type": "string", "description": "Limit to file path"},
"ref": {"type": "string", "description": "Compare ref (e.g. HEAD~1)"},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
},
handler=_tool_git_diff,
toolset="git",
)
register(
name="git_log",
description=(
"【history·timeline】Recent commits: sha, author, date, subject. "
"Optional file/author/since filters. No working-tree status, no diff hunks."
),
schema={
"type": "object",
"properties": {
"count": {"type": "integer", "description": "Number of commits", "default": 10},
"oneline": {"type": "boolean", "description": "One line per commit", "default": True},
"file": {"type": "string", "description": "File path filter"},
"author": {"type": "string", "description": "Author filter"},
"since": {"type": "string", "description": "Since date"},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
},
handler=_tool_git_log,
toolset="git",
)
register(
name="git_branch",
description=(
"List or manage branches. list requires BUTLER_ENABLE_GIT=1; "
"create/switch/delete require BUTLER_ENABLE_GIT_WRITE=1."
),
schema={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "create", "switch", "delete"],
"description": "Branch operation",
},
"name": {"type": "string", "description": "Branch name (for create/switch/delete)"},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
"required": ["action"],
},
handler=_tool_git_branch,
toolset="git",
)
register(
name="git_add",
description=(
"【stage·index】Put paths into the git index; no commit object is created."
),
schema={
"type": "object",
"properties": {
"files": {
"type": "array",
"items": {"type": "string"},
"description": "Paths to stage, or ['.'] for all",
},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
"required": ["files"],
},
handler=_tool_git_add,
toolset="git",
)
register(
name="git_commit",
description=(
"【snapshot·hash】Finalize the staged index into a new commit with message. "
"Requires BUTLER_ENABLE_GIT_WRITE=1."
),
schema={
"type": "object",
"properties": {
"message": {"type": "string", "description": "Commit message"},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
"required": ["message"],
},
handler=_tool_git_commit,
toolset="git",
)
register(
name="git_push",
description=(
"Push commits to remote. Requires BUTLER_ENABLE_GIT_PUSH=1 + Owner approval. "
"Force push and push to main/master are blocked."
),
schema={
"type": "object",
"properties": {
"remote": {
"type": "string",
"description": "Remote name (default: origin)",
"default": "origin",
},
"branch": {
"type": "string",
"description": "Branch to push (default: current branch)",
},
"workdir": {"type": "string", "description": "Working directory (optional)"},
},
},
handler=_tool_git_push,
toolset="git",
)