diff --git a/CHANGELOG.md b/CHANGELOG.md index c25dfc0a..6a8ee78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang ### Added +- `git` tool: structured, schema-safe git operations (status, diff, log, show, blame, branch_list, add, commit, push, pull, branch_create, branch_delete, checkout, stash, tag) with built-in safety constraints replacing raw shell git commands. - `diagnose` skill: trace agent run failures and regressions using structured evidence from run artifacts. - OpenAI-compatible API client (`--api-format openai`) supporting any provider that implements the OpenAI `/v1/chat/completions` format, including Alibaba DashScope, DeepSeek, GitHub Models, Groq, Together AI, Ollama, and more. - `OPENHARNESS_API_FORMAT` environment variable for selecting the API format. diff --git a/src/openharness/tools/__init__.py b/src/openharness/tools/__init__.py index 56fc28bf..59a06b55 100644 --- a/src/openharness/tools/__init__.py +++ b/src/openharness/tools/__init__.py @@ -17,6 +17,7 @@ from openharness.tools.file_edit_tool import FileEditTool from openharness.tools.file_read_tool import FileReadTool from openharness.tools.file_write_tool import FileWriteTool +from openharness.tools.git_tool import GitTool from openharness.tools.glob_tool import GlobTool from openharness.tools.grep_tool import GrepTool from openharness.tools.list_mcp_resources_tool import ListMcpResourcesTool @@ -57,6 +58,7 @@ def create_default_tool_registry(mcp_manager=None) -> ToolRegistry: McpAuthTool(), GlobTool(), GrepTool(), + GitTool(), SkillTool(), ToolSearchTool(), WebFetchTool(), diff --git a/src/openharness/tools/git_tool.py b/src/openharness/tools/git_tool.py new file mode 100644 index 00000000..8fae78d7 --- /dev/null +++ b/src/openharness/tools/git_tool.py @@ -0,0 +1,353 @@ +"""Structured git operations tool with built-in safety constraints.""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult + +_READ_ONLY_OPS = frozenset({"status", "diff", "log", "show", "blame", "branch_list"}) + +_REJECTED_ADD_ENTRIES = frozenset({".", "-A", "--all", "-a", "*"}) + +_OUTPUT_LIMIT = 12000 + + +async def _run_git(*args: str, cwd: Path) -> tuple[int, str, str]: + """Run a git command, returning (returncode, stdout, stderr).""" + proc = await asyncio.create_subprocess_exec( + "git", + *args, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": ""}, + ) + stdout_bytes, stderr_bytes = await proc.communicate() + return ( + proc.returncode or 0, + stdout_bytes.decode(errors="replace").strip(), + stderr_bytes.decode(errors="replace").strip(), + ) + + +def _to_result(rc: int, stdout: str, stderr: str) -> ToolResult: + """Convert git subprocess output to a ToolResult.""" + output = stdout or stderr or "(no output)" + if len(output) > _OUTPUT_LIMIT: + output = f"{output[:_OUTPUT_LIMIT]}\n...[truncated]..." + return ToolResult(output=output, is_error=rc != 0) + + +class GitToolInput(BaseModel): + """Arguments for structured git operations.""" + + operation: Literal[ + "status", + "diff", + "log", + "show", + "blame", + "branch_list", + "add", + "commit", + "push", + "pull", + "branch_create", + "branch_delete", + "checkout", + "stash", + "tag", + ] = Field(description="The git operation to perform") + + files: list[str] | None = Field( + default=None, + description="Explicit list of file paths. Required for 'add' (no wildcards or '.').", + ) + message: str | None = Field( + default=None, + description="Commit or tag message. Required for 'commit' and 'tag'.", + ) + ref: str | None = Field( + default=None, + description="Branch name, commit SHA, or ref for operations that target a specific revision.", + ) + max_count: int = Field( + default=20, ge=1, le=200, description="Maximum number of log entries to return." + ) + oneline: bool = Field(default=True, description="Use --oneline format for log.") + staged: bool = Field(default=False, description="Show staged changes (--cached) for diff.") + line_start: int | None = Field(default=None, ge=1, description="Start line for blame range.") + line_end: int | None = Field(default=None, ge=1, description="End line for blame range.") + stash_action: Literal["push", "pop", "list", "drop"] = Field( + default="push", description="Stash sub-action." + ) + stash_message: str | None = Field( + default=None, description="Optional message for stash push." + ) + remote: str = Field(default="origin", description="Remote name for push/pull.") + start_point: str | None = Field( + default=None, + description="Starting point (commit/branch) for branch_create. Defaults to HEAD.", + ) + + @model_validator(mode="after") + def validate_operation_fields(self) -> "GitToolInput": + op = self.operation + + if op == "add": + if not self.files: + raise ValueError("'add' requires a non-empty 'files' list") + for f in self.files: + if f in _REJECTED_ADD_ENTRIES: + raise ValueError( + f"'add' does not allow '{f}'. Specify individual file paths." + ) + + if op == "commit" and not self.message: + raise ValueError("'commit' requires 'message'") + + if op == "blame": + if not self.files or len(self.files) != 1: + raise ValueError("'blame' requires exactly one file in 'files'") + + if op == "show" and not self.ref and not self.files: + raise ValueError("'show' requires 'ref' (commit/object) or 'files'") + + if op == "tag": + if not self.ref: + raise ValueError("'tag' requires 'ref' (the tag name)") + if not self.message: + raise ValueError("'tag' requires 'message'") + + if op == "branch_create" and not self.ref: + raise ValueError("'branch_create' requires 'ref' (the new branch name)") + + if op == "branch_delete" and not self.ref: + raise ValueError("'branch_delete' requires 'ref' (the branch to delete)") + + if op == "checkout" and not self.ref: + raise ValueError("'checkout' requires 'ref' (branch or commit to check out)") + + return self + + @property + def command(self) -> str: + """Synthesize a git command string for permission system pattern matching.""" + parts = ["git", self.operation.replace("_", " ")] + if self.ref: + parts.append(self.ref) + if self.files: + parts.extend(self.files) + return " ".join(parts) + + +class GitTool(BaseTool): + """Perform structured git operations with built-in safety constraints. + + Safety by design: dangerous operations like force push, hard reset, + clean, --no-verify, and ``git add .`` cannot be expressed in the schema. + """ + + name = "git" + description = ( + "Perform structured git operations with built-in safety constraints. " + "Supports: status, diff, log, show, blame, branch_list, add, commit, " + "push, pull, branch_create, branch_delete, checkout, stash, tag." + ) + input_model = GitToolInput + + def is_read_only(self, arguments: GitToolInput) -> bool: + return arguments.operation in _READ_ONLY_OPS + + async def execute( + self, arguments: GitToolInput, context: ToolExecutionContext + ) -> ToolResult: + cwd = context.cwd + + # Verify we are inside a git repository. + rc, _, _ = await _run_git("rev-parse", "--git-dir", cwd=cwd) + if rc != 0: + return ToolResult(output="Not a git repository", is_error=True) + + handler = _DISPATCH.get(arguments.operation) + if handler is None: + return ToolResult( + output=f"Unknown operation: {arguments.operation}", is_error=True + ) + + return await handler(arguments, cwd) + + +# --------------------------------------------------------------------------- +# Operation handlers +# --------------------------------------------------------------------------- + + +async def _handle_status(args: GitToolInput, cwd: Path) -> ToolResult: + cmd = ["status", "--short"] + if args.files: + cmd.append("--") + cmd.extend(args.files) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_diff(args: GitToolInput, cwd: Path) -> ToolResult: + cmd: list[str] = ["diff"] + if args.staged: + cmd.append("--cached") + if args.ref: + cmd.append(args.ref) + if args.files: + cmd.append("--") + cmd.extend(args.files) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_log(args: GitToolInput, cwd: Path) -> ToolResult: + cmd: list[str] = ["log", f"--max-count={args.max_count}"] + if args.oneline: + cmd.append("--oneline") + if args.ref: + cmd.append(args.ref) + if args.files: + cmd.append("--") + cmd.extend(args.files) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_show(args: GitToolInput, cwd: Path) -> ToolResult: + cmd: list[str] = ["show", args.ref or "HEAD"] + if args.files: + cmd.append("--") + cmd.extend(args.files) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_blame(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.files and len(args.files) == 1 # validated + cmd: list[str] = ["blame"] + if args.line_start is not None and args.line_end is not None: + cmd.append(f"-L{args.line_start},{args.line_end}") + elif args.line_start is not None: + cmd.append(f"-L{args.line_start},") + cmd.append("--") + cmd.append(args.files[0]) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_branch_list(args: GitToolInput, cwd: Path) -> ToolResult: + del args + rc, stdout, stderr = await _run_git("branch", "-a", "-v", cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_add(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.files # validated + for f in args.files: + if f.startswith("-"): + return ToolResult(output=f"Invalid file path: {f!r}", is_error=True) + rc, stdout, stderr = await _run_git("add", "--", *args.files, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_commit(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.message # validated + rc, stdout, stderr = await _run_git("commit", "-m", args.message, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_push(args: GitToolInput, cwd: Path) -> ToolResult: + cmd: list[str] = ["push", args.remote] + if args.ref: + cmd.append(args.ref) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_pull(args: GitToolInput, cwd: Path) -> ToolResult: + cmd: list[str] = ["pull", args.remote] + if args.ref: + cmd.append(args.ref) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_branch_create(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.ref # validated + cmd: list[str] = ["branch", args.ref] + if args.start_point: + cmd.append(args.start_point) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_branch_delete(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.ref # validated + rc, stdout, stderr = await _run_git("branch", "-d", args.ref, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_checkout(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.ref # validated + rc, stdout, stderr = await _run_git("checkout", args.ref, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_stash(args: GitToolInput, cwd: Path) -> ToolResult: + action = args.stash_action + if action == "push": + cmd: list[str] = ["stash", "push"] + if args.stash_message: + cmd.extend(["-m", args.stash_message]) + elif action == "pop": + cmd = ["stash", "pop"] + elif action == "list": + cmd = ["stash", "list"] + elif action == "drop": + cmd = ["stash", "drop"] + else: + return ToolResult(output=f"Unknown stash action: {action}", is_error=True) + rc, stdout, stderr = await _run_git(*cmd, cwd=cwd) + return _to_result(rc, stdout, stderr) + + +async def _handle_tag(args: GitToolInput, cwd: Path) -> ToolResult: + assert args.ref and args.message # validated + rc, stdout, stderr = await _run_git( + "tag", "-a", args.ref, "-m", args.message, cwd=cwd + ) + return _to_result(rc, stdout, stderr) + + +# --------------------------------------------------------------------------- +# Dispatch table +# --------------------------------------------------------------------------- + +_DISPATCH: dict[str, object] = { + "status": _handle_status, + "diff": _handle_diff, + "log": _handle_log, + "show": _handle_show, + "blame": _handle_blame, + "branch_list": _handle_branch_list, + "add": _handle_add, + "commit": _handle_commit, + "push": _handle_push, + "pull": _handle_pull, + "branch_create": _handle_branch_create, + "branch_delete": _handle_branch_delete, + "checkout": _handle_checkout, + "stash": _handle_stash, + "tag": _handle_tag, +} diff --git a/tests/test_tools/test_git_tool.py b/tests/test_tools/test_git_tool.py new file mode 100644 index 00000000..56948740 --- /dev/null +++ b/tests/test_tools/test_git_tool.py @@ -0,0 +1,391 @@ +"""Tests for the structured GitTool.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from openharness.tools import create_default_tool_registry +from openharness.tools.base import ToolExecutionContext +from openharness.tools.git_tool import GitTool, GitToolInput + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def git_repo(tmp_path: Path) -> Path: + """Initialise a git repository with one commit.""" + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + (tmp_path / "README.md").write_text("# Test\n", encoding="utf-8") + subprocess.run( + ["git", "add", "README.md"], cwd=tmp_path, check=True, capture_output=True + ) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + return tmp_path + + +@pytest.fixture() +def tool() -> GitTool: + return GitTool() + + +@pytest.fixture() +def ctx(git_repo: Path) -> ToolExecutionContext: + return ToolExecutionContext(cwd=git_repo) + + +# --------------------------------------------------------------------------- +# Read-only operations +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_status(tool: GitTool, ctx: ToolExecutionContext, git_repo: Path): + result = await tool.execute( + GitToolInput(operation="status"), ctx + ) + assert result.is_error is False + + +@pytest.mark.asyncio +async def test_diff_unstaged(tool: GitTool, ctx: ToolExecutionContext, git_repo: Path): + (git_repo / "README.md").write_text("# Changed\n", encoding="utf-8") + result = await tool.execute( + GitToolInput(operation="diff"), ctx + ) + assert result.is_error is False + assert "Changed" in result.output + + +@pytest.mark.asyncio +async def test_diff_staged(tool: GitTool, ctx: ToolExecutionContext, git_repo: Path): + (git_repo / "README.md").write_text("# Staged\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=git_repo, check=True, capture_output=True) + result = await tool.execute( + GitToolInput(operation="diff", staged=True), ctx + ) + assert result.is_error is False + assert "Staged" in result.output + + +@pytest.mark.asyncio +async def test_log(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="log"), ctx + ) + assert result.is_error is False + assert "initial" in result.output + + +@pytest.mark.asyncio +async def test_log_max_count(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="log", max_count=1), ctx + ) + assert result.is_error is False + lines = [ln for ln in result.output.splitlines() if ln.strip()] + assert len(lines) == 1 + + +@pytest.mark.asyncio +async def test_show(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="show", ref="HEAD"), ctx + ) + assert result.is_error is False + assert "initial" in result.output + + +@pytest.mark.asyncio +async def test_blame(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="blame", files=["README.md"]), ctx + ) + assert result.is_error is False + assert "Test" in result.output # author name + + +@pytest.mark.asyncio +async def test_blame_line_range(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="blame", files=["README.md"], line_start=1, line_end=1), + ctx, + ) + assert result.is_error is False + + +@pytest.mark.asyncio +async def test_branch_list(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="branch_list"), ctx + ) + assert result.is_error is False + # At least the default branch should appear + assert "main" in result.output or "master" in result.output + + +# --------------------------------------------------------------------------- +# Mutating operations +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_and_commit(tool: GitTool, ctx: ToolExecutionContext, git_repo: Path): + (git_repo / "new.txt").write_text("hello\n", encoding="utf-8") + + add_result = await tool.execute( + GitToolInput(operation="add", files=["new.txt"]), ctx + ) + assert add_result.is_error is False + + commit_result = await tool.execute( + GitToolInput(operation="commit", message="add new file"), ctx + ) + assert commit_result.is_error is False + assert "add new file" in commit_result.output or "1 file changed" in commit_result.output + + +@pytest.mark.asyncio +async def test_branch_create_and_delete( + tool: GitTool, ctx: ToolExecutionContext +): + create_result = await tool.execute( + GitToolInput(operation="branch_create", ref="test-branch"), ctx + ) + assert create_result.is_error is False + + list_result = await tool.execute( + GitToolInput(operation="branch_list"), ctx + ) + assert "test-branch" in list_result.output + + delete_result = await tool.execute( + GitToolInput(operation="branch_delete", ref="test-branch"), ctx + ) + assert delete_result.is_error is False + + +@pytest.mark.asyncio +async def test_checkout(tool: GitTool, ctx: ToolExecutionContext): + # Create a branch first, then checkout to it + await tool.execute( + GitToolInput(operation="branch_create", ref="feature"), ctx + ) + result = await tool.execute( + GitToolInput(operation="checkout", ref="feature"), ctx + ) + assert result.is_error is False + + status = await tool.execute(GitToolInput(operation="branch_list"), ctx) + assert "* feature" in status.output + + +@pytest.mark.asyncio +async def test_stash_push_and_pop( + tool: GitTool, ctx: ToolExecutionContext, git_repo: Path +): + (git_repo / "README.md").write_text("# Dirty\n", encoding="utf-8") + + push_result = await tool.execute( + GitToolInput(operation="stash", stash_action="push", stash_message="wip"), + ctx, + ) + assert push_result.is_error is False + + # Working tree should be clean after stash + status = await tool.execute(GitToolInput(operation="status"), ctx) + assert status.output.strip() == "(no output)" or "README" not in status.output + + pop_result = await tool.execute( + GitToolInput(operation="stash", stash_action="pop"), ctx + ) + assert pop_result.is_error is False + + +@pytest.mark.asyncio +async def test_stash_list(tool: GitTool, ctx: ToolExecutionContext, git_repo: Path): + (git_repo / "README.md").write_text("# Stashed\n", encoding="utf-8") + await tool.execute( + GitToolInput(operation="stash", stash_action="push", stash_message="my stash"), + ctx, + ) + + result = await tool.execute( + GitToolInput(operation="stash", stash_action="list"), ctx + ) + assert result.is_error is False + assert "my stash" in result.output + + +@pytest.mark.asyncio +async def test_tag(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="tag", ref="v1.0.0", message="release 1.0"), ctx + ) + assert result.is_error is False + + +@pytest.mark.asyncio +async def test_push_without_remote_fails(tool: GitTool, ctx: ToolExecutionContext): + result = await tool.execute( + GitToolInput(operation="push"), ctx + ) + assert result.is_error is True + + +# --------------------------------------------------------------------------- +# Validation / safety +# --------------------------------------------------------------------------- + + +def test_add_rejects_dot(): + with pytest.raises(ValidationError, match="does not allow"): + GitToolInput(operation="add", files=["."]) + + +def test_add_rejects_dash_A(): + with pytest.raises(ValidationError, match="does not allow"): + GitToolInput(operation="add", files=["-A"]) + + +def test_add_rejects_all_flag(): + with pytest.raises(ValidationError, match="does not allow"): + GitToolInput(operation="add", files=["--all"]) + + +@pytest.mark.asyncio +async def test_add_rejects_dash_prefix( + tool: GitTool, ctx: ToolExecutionContext, git_repo: Path +): + result = await tool.execute( + GitToolInput(operation="add", files=["ok.txt", "--force"]), ctx + ) + assert result.is_error is True + assert "Invalid file path" in result.output + + +def test_commit_requires_message(): + with pytest.raises(ValidationError, match="requires 'message'"): + GitToolInput(operation="commit") + + +def test_blame_requires_one_file(): + with pytest.raises(ValidationError, match="exactly one file"): + GitToolInput(operation="blame", files=["a.py", "b.py"]) + + +def test_branch_create_requires_ref(): + with pytest.raises(ValidationError, match="requires 'ref'"): + GitToolInput(operation="branch_create") + + +def test_checkout_requires_ref(): + with pytest.raises(ValidationError, match="requires 'ref'"): + GitToolInput(operation="checkout") + + +def test_tag_requires_ref_and_message(): + with pytest.raises(ValidationError, match="requires 'ref'"): + GitToolInput(operation="tag", message="msg") + with pytest.raises(ValidationError, match="requires 'message'"): + GitToolInput(operation="tag", ref="v1") + + +# --------------------------------------------------------------------------- +# Not a git repo +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_not_a_git_repo(tool: GitTool, tmp_path: Path): + ctx = ToolExecutionContext(cwd=tmp_path) + result = await tool.execute(GitToolInput(operation="status"), ctx) + assert result.is_error is True + assert "Not a git repository" in result.output + + +# --------------------------------------------------------------------------- +# is_read_only +# --------------------------------------------------------------------------- + + +def test_is_read_only_true_for_read_ops(tool: GitTool): + for op in ("status", "diff", "log", "show", "blame", "branch_list"): + # Build minimum valid input for each op + kwargs: dict = {"operation": op} + if op == "show": + kwargs["ref"] = "HEAD" + if op == "blame": + kwargs["files"] = ["f.py"] + args = GitToolInput(**kwargs) + assert tool.is_read_only(args) is True, f"{op} should be read-only" + + +def test_is_read_only_false_for_mutating_ops(tool: GitTool): + for op, kwargs in [ + ("add", {"files": ["f.py"]}), + ("commit", {"message": "msg"}), + ("push", {}), + ("pull", {}), + ("branch_create", {"ref": "x"}), + ("branch_delete", {"ref": "x"}), + ("checkout", {"ref": "x"}), + ("stash", {}), + ("tag", {"ref": "v1", "message": "m"}), + ]: + args = GitToolInput(operation=op, **kwargs) + assert tool.is_read_only(args) is False, f"{op} should be mutating" + + +# --------------------------------------------------------------------------- +# command property (permission integration) +# --------------------------------------------------------------------------- + + +def test_command_property(): + args = GitToolInput(operation="push", ref="main") + assert args.command == "git push main" + + args2 = GitToolInput(operation="add", files=["src/a.py", "src/b.py"]) + assert args2.command == "git add src/a.py src/b.py" + + args3 = GitToolInput(operation="branch_create", ref="feat") + assert args3.command == "git branch create feat" + + +def test_command_not_in_json_schema(): + schema = GitToolInput.model_json_schema() + assert "command" not in schema.get("properties", {}) + + +# --------------------------------------------------------------------------- +# Registry integration +# --------------------------------------------------------------------------- + + +def test_registry_includes_git(): + registry = create_default_tool_registry() + assert registry.get("git") is not None