Files
CoreRasurae d37cfbe769
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 17s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 3m58s
CI / unit_tests (pull_request) Successful in 5m59s
CI / docker (pull_request) Successful in 38s
CI / benchmark-regression (pull_request) Successful in 16m0s
CI / coverage (pull_request) Successful in 16m29s
CI / lint (push) Successful in 14s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 17s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 37s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m44s
CI / unit_tests (push) Successful in 5m27s
CI / docker (push) Successful in 39s
CI / benchmark-publish (push) Successful in 9m11s
CI / coverage (push) Successful in 16m23s
test(robot): fix failing integration tests related to safe.directory config and git version
2026-02-21 18:11:31 +00:00

467 lines
17 KiB
Python

"""Step definitions for the Built-in Git Tools feature."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from cleveragents.tool.builtins import register_git_tools
from cleveragents.tool.builtins.git_tools import (
ALL_GIT_TOOLS,
_map_git_error,
_resolve_sandbox_root,
validate_repo_path,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
"""Run a git command in the given directory.
Sets ``safe.directory`` so git does not refuse to operate in temp
directories owned by a different user (common in Docker / CI).
"""
env = {
**os.environ,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "safe.directory",
"GIT_CONFIG_VALUE_0": cwd,
}
return subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=30,
env=env,
)
def _run_git_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None:
"""Execute a built-in git tool through the runner.
Lazily initialises a shared ``ToolRunner`` on *context* to avoid
creating a fresh registry per invocation within the same scenario.
The temp repo directory is passed as ``_sandbox_root`` so that the
absolute ``/tmp/…`` path used in tests passes sandbox validation.
"""
if not hasattr(context, "_git_runner"):
registry = ToolRegistry()
register_git_tools(registry)
context._git_runner = ToolRunner(registry)
# Point repo_path at the temp git repo unless already set
if "repo_path" not in inputs:
inputs["repo_path"] = context.git_repo_dir
# Allow the absolute temp-dir path through sandbox validation
if hasattr(context, "git_repo_dir") and "_sandbox_root" not in inputs:
inputs["_sandbox_root"] = context.git_repo_dir
context.tool_result = context._git_runner.execute(tool_name, inputs)
# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------
@given("a temporary git repository")
def step_given_temp_git_repo(context: Any) -> None:
repo_dir = tempfile.mkdtemp()
context.git_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
# Initialise a git repo with an initial commit
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
# Create an initial commit so HEAD exists
readme = Path(repo_dir) / "README.md"
readme.write_text("# Test repo\n")
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", "Initial commit"], cwd=repo_dir)
@given('a tracked file "{name}" with content "{content}"')
def step_given_tracked_file(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
_git(["add", name], cwd=context.git_repo_dir)
_git(["commit", "-m", f"Add {name}"], cwd=context.git_repo_dir)
@given('a tracked file "{name}" with multiline content')
def step_given_tracked_file_multiline(context: Any, name: str) -> None:
path = Path(context.git_repo_dir) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("line one\nline two\nline three\n")
_git(["add", name], cwd=context.git_repo_dir)
_git(["commit", "-m", f"Add {name}"], cwd=context.git_repo_dir)
@given('the file "{name}" is modified to "{content}"')
def step_given_file_modified(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.write_text(content)
@given('the file "{name}" is modified and staged to "{content}"')
def step_given_file_modified_and_staged(context: Any, name: str, content: str) -> None:
path = Path(context.git_repo_dir) / name
path.write_text(content)
_git(["add", name], cwd=context.git_repo_dir)
@given("a temporary git repository with multiple commits")
def step_given_temp_git_repo_multi(context: Any) -> None:
repo_dir = tempfile.mkdtemp()
context.git_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
for i in range(3):
f = Path(repo_dir) / f"file{i}.txt"
f.write_text(f"content {i}")
_git(["add", "."], cwd=repo_dir)
_git(["commit", "-m", f"Commit {i}"], cwd=repo_dir)
@given("a temporary non-git directory")
def step_given_temp_non_git_dir(context: Any) -> None:
# Force /tmp so the directory is NOT inside a git worktree (the nox
# session creates temp dirs under /app/.nox/… which IS inside one).
tmp_dir = tempfile.mkdtemp(dir="/tmp")
context.non_git_dir = tmp_dir
context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
@given("a temporary git repository with no commits")
def step_given_temp_git_repo_no_commits(context: Any) -> None:
# Force /tmp for the same reason as the non-git directory step.
repo_dir = tempfile.mkdtemp(dir="/tmp")
context.empty_repo_dir = repo_dir
context._cleanup_handlers.append(
lambda: shutil.rmtree(repo_dir, ignore_errors=True)
)
_git(["init"], cwd=repo_dir)
_git(["config", "user.email", "test@test.com"], cwd=repo_dir)
_git(["config", "user.name", "Test User"], cwd=repo_dir)
@given('an unrecognized git error message "{stderr}"')
def step_given_unrecognized_git_error(context: Any, stderr: str) -> None:
context.raw_git_stderr = stderr
@given("a sandbox root directory")
def step_given_sandbox_root_dir(context: Any) -> None:
tmp_dir = tempfile.mkdtemp(dir="/tmp")
context.sandbox_root_path = Path(tmp_dir)
context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
@given("the git subprocess is configured to time out")
def step_given_git_subprocess_timeout(context: Any) -> None:
context.git_timeout_patch = patch(
"cleveragents.tool.builtins.git_tools._run_git",
side_effect=subprocess.TimeoutExpired(cmd=["git"], timeout=30),
)
context.git_timeout_mock = context.git_timeout_patch.start()
context._cleanup_handlers.append(context.git_timeout_patch.stop)
# Note: "a tool registry" step is already defined in tool_runtime_steps.py
# and reused here via Behave's shared step registry.
# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------
@when('I execute the "builtin/git-status" tool')
def step_when_git_status(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {})
@when('I execute the "builtin/git-status" tool with short format')
def step_when_git_status_short(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {"short": True})
@when('I execute the "builtin/git-status" tool with repo_path "{path}"')
def step_when_git_status_repo_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-status", {"repo_path": path})
@when('I execute the "builtin/git-diff" tool')
def step_when_git_diff(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {})
@when('I execute the "builtin/git-diff" tool with staged flag')
def step_when_git_diff_staged(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {"staged": True})
@when('I execute the "builtin/git-diff" tool with stat flag')
def step_when_git_diff_stat(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {"stat": True})
@when('I execute the "builtin/git-log" tool')
def step_when_git_log(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {})
@when('I execute the "builtin/git-log" tool with max_count {count:d}')
def step_when_git_log_max(context: Any, count: int) -> None:
_run_git_tool(context, "builtin/git-log", {"max_count": count})
@when('I execute the "builtin/git-log" tool with oneline format')
def step_when_git_log_oneline(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {"oneline": True})
@when('I execute the "builtin/git-blame" tool for "{path}"')
def step_when_git_blame(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-blame", {"path": path})
@when('I execute the "builtin/git-blame" tool for "{path}" lines {start:d} to {end:d}')
def step_when_git_blame_range(context: Any, path: str, start: int, end: int) -> None:
_run_git_tool(
context,
"builtin/git-blame",
{"path": path, "line_start": start, "line_end": end},
)
@when('I execute the "builtin/git-diff" tool with ref "{ref}"')
def step_when_git_diff_ref(context: Any, ref: str) -> None:
_run_git_tool(context, "builtin/git-diff", {"ref": ref})
@when('I execute the "builtin/git-diff" tool with path "{path}"')
def step_when_git_diff_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-diff", {"path": path})
@when('I execute the "builtin/git-log" tool with author "{author}"')
def step_when_git_log_author(context: Any, author: str) -> None:
_run_git_tool(context, "builtin/git-log", {"author": author})
@when('I execute the "builtin/git-log" tool with since "{since}"')
def step_when_git_log_since(context: Any, since: str) -> None:
_run_git_tool(context, "builtin/git-log", {"since": since})
@when('I execute the "builtin/git-log" tool with until "{until}"')
def step_when_git_log_until(context: Any, until: str) -> None:
_run_git_tool(context, "builtin/git-log", {"until": until})
@when('I execute the "builtin/git-log" tool with path "{path}"')
def step_when_git_log_path(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-log", {"path": path})
@when(
'I execute the "builtin/git-blame" tool for "{path}" with only line_start {start:d}'
)
def step_when_git_blame_partial_range(context: Any, path: str, start: int) -> None:
_run_git_tool(
context,
"builtin/git-blame",
{"path": path, "line_start": start},
)
@when('I execute the "builtin/git-status" tool in the non-git directory')
def step_when_git_status_non_git(context: Any) -> None:
_run_git_tool(
context,
"builtin/git-status",
{"repo_path": context.non_git_dir, "_sandbox_root": context.non_git_dir},
)
@when('I execute the "builtin/git-log" tool in the empty repository')
def step_when_git_log_empty_repo(context: Any) -> None:
_run_git_tool(
context,
"builtin/git-log",
{
"repo_path": context.empty_repo_dir,
"_sandbox_root": context.empty_repo_dir,
},
)
@when("I map the git error to a user-friendly message")
def step_when_map_git_error(context: Any) -> None:
context.mapped_error = _map_git_error(context.raw_git_stderr)
@when("I validate the repo path with no user-supplied path")
def step_when_validate_repo_path_none(context: Any) -> None:
context.validated_path = validate_repo_path(
None,
sandbox_root=context.sandbox_root_path,
)
@when("I resolve a sandbox root from an empty value")
def step_when_resolve_sandbox_root_empty(context: Any) -> None:
context.resolved_sandbox_root = _resolve_sandbox_root(None)
@when('I execute the "builtin/git-status" tool against the timed-out subprocess')
def step_when_git_status_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-status", {})
@when('I execute the "builtin/git-diff" tool against the timed-out subprocess')
def step_when_git_diff_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-diff", {})
@when('I execute the "builtin/git-log" tool against the timed-out subprocess')
def step_when_git_log_timeout(context: Any) -> None:
_run_git_tool(context, "builtin/git-log", {})
@when(
'I execute the "builtin/git-blame" tool for "{path}"'
" against the timed-out subprocess"
)
def step_when_git_blame_timeout(context: Any, path: str) -> None:
_run_git_tool(context, "builtin/git-blame", {"path": path})
@when("I register all git tools")
def step_when_register_git_tools(context: Any) -> None:
register_git_tools(context.registry)
# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------
# Note: the following step definitions are named uniquely to avoid conflicts
# with existing tool_builtins_steps.py steps. Generic steps like
# "the tool result should be successful" are already defined there and
# reused via Behave's step registry.
@then('the git output should contain "{text}"')
def step_then_git_output_contains(context: Any, text: str) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert text in output, f"Expected '{text}' in output:\n{output}"
@then("the git output should be empty")
def step_then_git_output_empty(context: Any) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert output.strip() == "", f"Expected empty output, got:\n{output}"
@then('the git output should not contain "{text}"')
def step_then_git_output_not_contains(context: Any, text: str) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
@then("the git log output should contain at most {count:d} commit")
def step_then_git_log_max_commits(context: Any, count: int) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
# Standard git log separates commits with lines starting with "commit "
commit_count = output.count("commit ")
assert commit_count <= count, (
f"Expected at most {count} commit(s), found {commit_count}:\n{output}"
)
@then("the git log output should use oneline format")
def step_then_git_log_oneline_format(context: Any) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "").strip()
# Oneline format: each non-empty line is "<hash> <message>" — no multi-line
# commit blocks. Verify no line starts with "Author:" or "Date:".
for line in output.splitlines():
assert not line.startswith("Author:"), (
f"Oneline format should not contain Author lines:\n{output}"
)
assert not line.startswith("Date:"), (
f"Oneline format should not contain Date lines:\n{output}"
)
@then("the git blame output should contain exactly {count:d} lines")
def step_then_git_blame_line_count(context: Any, count: int) -> None:
assert context.tool_result.success, f"Tool failed: {context.tool_result.error}"
output = context.tool_result.output.get("output", "")
# git blame produces one output line per source line
blame_lines = [ln for ln in output.splitlines() if ln.strip()]
assert len(blame_lines) == count, (
f"Expected {count} blame line(s), got {len(blame_lines)}:\n{output}"
)
@then('the mapped error should be "{expected}"')
def step_then_mapped_error_equals(context: Any, expected: str) -> None:
assert context.mapped_error == expected, (
f"Expected mapped error '{expected}', got '{context.mapped_error}'"
)
@then("the validated path should equal the sandbox root")
def step_then_validated_path_equals_sandbox_root(context: Any) -> None:
assert context.validated_path == context.sandbox_root_path.resolve(), (
f"Expected {context.sandbox_root_path.resolve()}, got {context.validated_path}"
)
@then("the resolved sandbox root should be absent")
def step_then_resolved_sandbox_root_is_none(context: Any) -> None:
assert context.resolved_sandbox_root is None, (
f"Expected None, got {context.resolved_sandbox_root}"
)
@then("all registered git tools should be read-only")
def step_then_all_read_only(context: Any) -> None:
for spec in ALL_GIT_TOOLS:
tool = context.registry.get(spec.name)
assert tool is not None, f"Tool {spec.name} not found"
assert tool.capabilities.read_only is True, f"Tool {spec.name} is not read_only"
assert tool.capabilities.writes is False, f"Tool {spec.name} has writes=True"