blob: 847b503c2b6e43bafa9e7a37e195f58770514828 [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from __future__ import annotations
import dataclasses
import io
import os
import shutil
import subprocess
import tempfile
import unittest
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from unittest import mock
from agents.lib import git_staging, githooks
from agents.lib.githooks import adapters, runner
from agents.lib.githooks.reporters import ConsoleReporter
from agents_testing.workspace import GitWorkspaceTestCase
class AgentDetectionTest(unittest.TestCase):
"""Unit tests for agent detection."""
def test_is_invoked_by_agent(self) -> None:
self.assertFalse(runner.is_invoked_by_agent(env={}))
self.assertFalse(runner.is_invoked_by_agent(env={"USER": "alice"}))
for var in (
"ANTIGRAVITY_AGENT",
"GEMINI_CLI",
"ANTIGRAVITY_EDITOR_APP_ROOT",
):
self.assertTrue(runner.is_invoked_by_agent(env={var: "1"}))
for val in ("1", "true", "yes", "y", "on"):
self.assertTrue(
runner.is_invoked_by_agent(env={"ANTIGRAVITY_AGENT": val})
)
for val in ("0", "false", "no", "n", "off", ""):
self.assertFalse(
runner.is_invoked_by_agent(env={"ANTIGRAVITY_AGENT": val})
)
class RunnerTest(GitWorkspaceTestCase):
"""Unit tests for githooks runner."""
def make_mock_reporter(
self,
spec: Any = None,
has_errors: bool = False,
) -> mock.MagicMock:
"""Factory helper to construct a mock ConsoleReporter."""
reporter = mock.MagicMock(spec=spec) if spec else mock.MagicMock()
reporter.has_errors = has_errors
return reporter
def test_run_pre_commit_hook_skip_env(self) -> None:
for val in ("1", "true", "yes", "on"):
with mock.patch.dict(os.environ, {"FUCHSIA_SKIP_HOOKS": val}):
self.assertEqual(githooks.run_pre_commit_hook(), 0)
def test_run_pre_commit_hook_not_in_git_repo(self) -> None:
with tempfile.TemporaryDirectory() as non_git:
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_pre_commit_hook(
repo_dir=Path(non_git), reporter=reporter
)
self.assertEqual(ret, 1)
def test_run_pre_commit_hook_success(self) -> None:
mock_fmt = mock.MagicMock(return_value=True)
action = adapters.HookAction(
name="mock_fmt", action_fn=mock_fmt, is_mutating=True
)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action],
reporter=reporter,
)
self.assertEqual(ret, 0)
def test_run_pre_commit_hook_restaged_files(self) -> None:
pipeline_result = git_staging.PipelineResult(
success=True,
restaged_files=("foo.py",),
)
mock_reporter = self.make_mock_reporter(ConsoleReporter)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=pipeline_result,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
reporter=mock_reporter,
)
self.assertEqual(ret, 0)
mock_reporter.on_files_restaged.assert_called_once_with(
["foo.py"],
message="Auto-formatted and re-staged",
)
mock_reporter.finish.assert_called_once()
def test_run_pre_commit_hook_partial_staging_conflict(self) -> None:
pipeline_result = git_staging.PipelineResult(
success=False,
partial_conflicts=("bar.py",),
error="Custom error",
)
mock_reporter = self.make_mock_reporter(ConsoleReporter)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=pipeline_result,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
reporter=mock_reporter,
)
self.assertEqual(ret, 1)
mock_reporter.on_partial_staging_conflict.assert_called_once_with(
["bar.py"],
message="Custom error",
remediation_cmds=["fx format-code --files=bar.py"],
)
mock_reporter.finish.assert_called_once()
def test_run_pre_commit_hook_partial_staging_conflict_default_message(
self,
) -> None:
pipeline_result = git_staging.PipelineResult(
success=False,
partial_conflicts=("bar.py",),
error="",
)
mock_reporter = self.make_mock_reporter(ConsoleReporter)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=pipeline_result,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
reporter=mock_reporter,
)
self.assertEqual(ret, 1)
mock_reporter.on_partial_staging_conflict.assert_called_once_with(
["bar.py"],
message=(
"Formatting issues in partially staged files. Auto-fix"
" skipped to prevent stash conflicts."
),
remediation_cmds=["fx format-code --files=bar.py"],
)
def test_run_pre_commit_hook_partial_staging_conflict_custom_remediation(
self,
) -> None:
pipeline_result = git_staging.PipelineResult(
success=False,
partial_conflicts=("bar.py", "baz.py"),
error="Custom error",
)
mock_reporter = self.make_mock_reporter(ConsoleReporter)
custom_action = adapters.HookAction(
name="custom_action",
action_fn=lambda ctx, files: True,
is_mutating=True,
remediation_cmd_fn=lambda files: [f"custom-fix {' '.join(files)}"],
)
action_without_remediation = adapters.HookAction(
name="no_remediation",
action_fn=lambda ctx, files: True,
is_mutating=False,
)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=pipeline_result,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[custom_action, action_without_remediation],
reporter=mock_reporter,
)
self.assertEqual(ret, 1)
mock_reporter.on_partial_staging_conflict.assert_called_once_with(
["bar.py", "baz.py"],
message="Custom error",
remediation_cmds=["custom-fix bar.py baz.py"],
)
def test_run_pre_commit_hook_multiple_actions_filter_by_extensions(
self,
) -> None:
action1_files: list[list[str]] = []
action2_files: list[list[str]] = []
def record_action1(
ctx: git_staging.ActionContext, files: Sequence[str]
) -> bool:
action1_files.append(list(files))
return True
def record_action2(
ctx: git_staging.ActionContext, files: Sequence[str]
) -> bool:
action2_files.append(list(files))
return True
action1 = adapters.HookAction(
name="py_action",
action_fn=record_action1,
is_mutating=True,
extensions=(".py",),
remediation_cmd_fn=lambda files: [f"py-fix {' '.join(files)}"],
)
action2 = adapters.HookAction(
name="cc_action",
action_fn=record_action2,
is_mutating=True,
extensions=(".cc",),
remediation_cmd_fn=lambda files: [f"cc-fix {' '.join(files)}"],
)
pipeline_result = git_staging.PipelineResult(
success=False,
partial_conflicts=("foo.py", "bar.cc", "baz.txt"),
error="Partial conflict",
)
def invoke_pipeline(
context: git_staging.ActionContext,
mutating_actions: Sequence[git_staging.StagedFileAction],
read_only_checks: Sequence[git_staging.StagedFileAction],
extensions: Sequence[str] | None = None,
) -> git_staging.PipelineResult:
for act in mutating_actions:
act(context, ["foo.py", "bar.cc", "baz.txt"])
return pipeline_result
mock_reporter = self.make_mock_reporter(ConsoleReporter)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
side_effect=invoke_pipeline,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action1, action2],
reporter=mock_reporter,
)
self.assertEqual(ret, 1)
# Action 1 only receives .py files and Action 2 only receives .cc files
self.assertEqual(action1_files, [["foo.py"]])
self.assertEqual(action2_files, [["bar.cc"]])
# Remediation commands are filtered by action extensions for partial conflicts
mock_reporter.on_partial_staging_conflict.assert_called_once_with(
["foo.py", "bar.cc", "baz.txt"],
message="Partial conflict",
remediation_cmds=["py-fix foo.py", "cc-fix bar.cc"],
)
def test_run_pre_commit_hook_wrap_action_silenced_in_check_only(
self,
) -> None:
mock_reporter = self.make_mock_reporter(
ConsoleReporter, has_errors=False
)
action = adapters.HookAction(
name="failing_action",
action_fn=lambda ctx, files: False,
is_mutating=True,
)
def invoke_pipeline(
context: git_staging.ActionContext,
mutating_actions: Sequence[git_staging.StagedFileAction],
read_only_checks: Sequence[git_staging.StagedFileAction],
extensions: Sequence[str] | None = None,
) -> git_staging.PipelineResult:
# Simulate pipeline running mutating action in check_only mode
check_ctx = dataclasses.replace(context, check_only=True)
for act in mutating_actions:
act(check_ctx, ["foo.py"])
return git_staging.PipelineResult(success=True)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
side_effect=invoke_pipeline,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action],
reporter=mock_reporter,
)
self.assertEqual(ret, 0)
mock_reporter.on_error.assert_not_called()
def test_run_pre_commit_hook_console_reporter_unicode_error_marker(
self,
) -> None:
stderr_buf = io.StringIO()
reporter = ConsoleReporter(
stderr_stream=stderr_buf,
unicode=True,
)
pipeline_result = git_staging.PipelineResult(
success=False,
partial_conflicts=("bar.py",),
error="Formatting error in partially staged files",
)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=pipeline_result,
):
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
reporter=reporter,
)
self.assertEqual(ret, 1)
output = stderr_buf.getvalue()
self.assertIn(
"❌ Formatting error in partially staged files", output
)
self.assertNotIn("Error:", output)
self.assertIn("fx format-code --files=bar.py", output)
def test_run_pre_commit_hook_default_reporter(self) -> None:
with mock.patch(
"agents.lib.githooks.runner.ConsoleReporter"
) as mock_create:
mock_create.return_value = self.make_mock_reporter(ConsoleReporter)
with mock.patch(
"agents.lib.git_staging.run_staged_pipeline",
return_value=git_staging.PipelineResult(success=True),
):
with mock.patch.dict(os.environ, {}, clear=True):
ret = githooks.run_pre_commit_hook(repo_dir=self.test_dir)
self.assertEqual(ret, 0)
mock_create.assert_called_once_with()
def test_run_pre_commit_hook_forwards_reporter_to_format_code_action(
self,
) -> None:
staged_file = self.stage_file("foo.py", "a = 1\n")
mock_reporter = self.make_mock_reporter(ConsoleReporter)
orig_which = shutil.which
with (
mock.patch(
"shutil.which",
side_effect=lambda cmd: "/bin/fx"
if cmd == "fx"
else orig_which(cmd),
),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="",
stderr="Formatting syntax error",
)
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
reporter=mock_reporter,
)
self.assertEqual(ret, 1)
mock_reporter.on_error.assert_any_call("Formatting syntax error")
mock_reporter.finish.assert_called_once()
def test_run_pre_commit_hook_action_error_suppresses_duplicate_error(
self,
) -> None:
staged_file = self.stage_file("foo.py", "a = 1\n")
def failing_action(
ctx: adapters.HookContext, files: Sequence[str]
) -> bool:
reporter = ctx.reporter
if reporter is not None:
reporter.on_error("Action syntax error")
return False
action = adapters.HookAction(
name="failing_action",
action_fn=failing_action,
is_mutating=True,
)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action],
reporter=reporter,
)
self.assertEqual(ret, 1)
self.assertEqual(reporter.errors, ["Action syntax error"])
def test_run_pre_commit_hook_read_only_action(self) -> None:
initial_content = "a = 1\n"
staged_file = self.stage_file("foo.py", initial_content)
check_calls: list[tuple[bool, list[str]]] = []
def read_only_check(
ctx: git_staging.ActionContext, files: Sequence[str]
) -> bool:
check_calls.append((ctx.check_only, list(files)))
return True
action = adapters.HookAction(
name="lint_check",
action_fn=read_only_check,
is_mutating=False,
)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action],
reporter=reporter,
)
self.assertEqual(ret, 0)
self.assertEqual(check_calls, [(True, ["foo.py"])])
self.assertEqual(
staged_file.read_text(encoding="utf-8"), initial_content
)
status = self.git("status", "--porcelain")
self.assertEqual(status.stdout.strip(), "A foo.py")
def test_run_pre_commit_hook_action_failure_attributes_action_name(
self,
) -> None:
staged_file = self.stage_file("foo.py", "a = 1\n")
action = adapters.HookAction(
name="custom_check",
action_fn=lambda ctx, files: False,
is_mutating=False,
)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_pre_commit_hook(
repo_dir=self.test_dir,
actions=[action],
reporter=reporter,
)
self.assertEqual(ret, 1)
self.assertEqual(reporter.errors, ["Action 'custom_check' failed."])
def test_run_commit_msg_hook_default_reporter(self) -> None:
msg_file = self.test_dir / "COMMIT_EDITMSG"
msg_file.write_text("[agents] Test commit\n", encoding="utf-8")
with mock.patch(
"agents.lib.githooks.runner.ConsoleReporter"
) as mock_create:
mock_create.return_value = mock.MagicMock(
spec=ConsoleReporter, has_errors=False
)
with mock.patch.dict(os.environ, {}, clear=True):
ret = githooks.run_commit_msg_hook(
str(msg_file),
repo_dir=self.test_dir,
checker_fn=lambda ctx, files: True,
)
self.assertEqual(ret, 0)
mock_create.assert_called_once_with()
def test_run_commit_msg_hook_missing_file(self) -> None:
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_commit_msg_hook(
"non_existent_msg_file",
repo_dir=self.test_dir,
reporter=reporter,
)
self.assertEqual(ret, 1)
def test_run_commit_msg_hook_success(self) -> None:
msg_file = self.test_dir / "COMMIT_EDITMSG"
msg_file.write_text("[agents] Test commit\n", encoding="utf-8")
mock_checker = mock.MagicMock(return_value=True)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_commit_msg_hook(
str(msg_file),
repo_dir=self.test_dir,
checker_fn=mock_checker,
reporter=reporter,
)
self.assertEqual(ret, 0)
mock_checker.assert_called_once()
def test_run_commit_msg_hook_worktree(self) -> None:
worktree_dir = self.create_worktree(
Path(self.temp_dir.name) / "wt", branch="wt-branch"
)
git_dir = worktree_dir / ".git"
self.assertTrue(git_dir.is_file()) # .git is a file in a worktree
from agents.lib import paths
actual_git_dir = paths.get_git_dir(worktree_dir)
msg_file = actual_git_dir / "COMMIT_EDITMSG"
msg_file.write_text(
"[agents] Worktree branch commit\n", encoding="utf-8"
)
mock_checker = mock.MagicMock(return_value=True)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_commit_msg_hook(
str(msg_file),
repo_dir=worktree_dir,
checker_fn=mock_checker,
reporter=reporter,
)
self.assertEqual(ret, 0)
mock_checker.assert_called_once()
self.assertEqual(
str(mock_checker.call_args[0][1][0]), str(msg_file.resolve())
)
def test_run_commit_msg_hook_from_subdirectory(self) -> None:
subdir = self.test_dir / "sub" / "dir"
subdir.mkdir(parents=True, exist_ok=True)
msg_file = self.test_dir / ".git" / "COMMIT_EDITMSG"
msg_file.write_text(
"[agents] Test commit from subdir\n", encoding="utf-8"
)
mock_checker = mock.MagicMock(return_value=True)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
# Relative msg_file_path with repo_dir pointing to subdirectory
ret = githooks.run_commit_msg_hook(
".git/COMMIT_EDITMSG",
repo_dir=subdir,
checker_fn=mock_checker,
reporter=reporter,
)
self.assertEqual(ret, 0)
mock_checker.assert_called_once()
self.assertEqual(
str(mock_checker.call_args[0][1][0]), str(msg_file.resolve())
)
# Relative msg_file_path when cwd is subdirectory and repo_dir is None
mock_checker.reset_mock()
self.addCleanup(os.chdir, Path.cwd())
os.chdir(subdir)
ret = githooks.run_commit_msg_hook(
".git/COMMIT_EDITMSG",
checker_fn=mock_checker,
reporter=reporter,
)
self.assertEqual(ret, 0)
mock_checker.assert_called_once()
self.assertEqual(
str(mock_checker.call_args[0][1][0]), str(msg_file.resolve())
)
def test_main_routing(self) -> None:
with mock.patch(
"agents.lib.githooks.runner.run_commit_msg_hook"
) as mock_commit:
mock_commit.return_value = 0
ret = githooks.main(["commit-msg", "COMMIT_EDITMSG"])
self.assertEqual(ret, 0)
mock_commit.assert_called_once()
self.assertEqual(
mock_commit.call_args.kwargs["msg_file_path"], "COMMIT_EDITMSG"
)
with mock.patch(
"agents.lib.githooks.runner.run_pre_commit_hook"
) as mock_pre:
mock_pre.return_value = 0
ret = githooks.main(["pre-commit"])
self.assertEqual(ret, 0)
mock_pre.assert_called_once()
# Bypass env var
with mock.patch.dict(os.environ, {"FUCHSIA_SKIP_HOOKS": "1"}):
self.assertEqual(githooks.main(["pre-commit"]), 0)
# Invalid subcommand returns 1 or 2 per standard argparse
with mock.patch("sys.stderr", new_callable=io.StringIO):
self.assertIn(githooks.main(["unknown-cmd"]), (1, 2))
self.assertIn(githooks.main([]), (1, 2))
def test_run_commit_msg_hook_passes_is_agent_to_context(self) -> None:
msg_file = self.test_dir / "COMMIT_EDITMSG"
msg_file.write_text("[agents] Test commit\n", encoding="utf-8")
mock_checker = mock.MagicMock(return_value=True)
reporter = ConsoleReporter(
stdout_stream=io.StringIO(), stderr_stream=io.StringIO()
)
ret = githooks.run_commit_msg_hook(
str(msg_file),
repo_dir=self.test_dir,
checker_fn=mock_checker,
reporter=reporter,
is_agent=True,
)
self.assertEqual(ret, 0)
context = mock_checker.call_args[0][0]
self.assertTrue(context.is_agent)
if __name__ == "__main__":
unittest.main()