Add support for pre-commit.sh git hook (#8095)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan 2025-05-05 09:56:07 -04:00 committed by GitHub
parent e5aad11995
commit 64b5c0a604
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 357 additions and 1 deletions

73
.openhands/pre-commit.sh Normal file
View File

@ -0,0 +1,73 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
# Return to the original directory
cd ..
if [ $EXIT_CODE -eq 0 ]; then
echo "Frontend checks passed!"
fi
else
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
# This makes our hook additive rather than replacing existing hooks
if [ -f ".git/hooks/pre-commit.local" ]; then
echo "Running existing pre-commit hooks..."
bash .git/hooks/pre-commit.local
if [ $? -ne 0 ]; then
echo "Existing pre-commit hooks failed."
EXIT_CODE=1
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
echo "All pre-commit checks passed!"
else
echo "Some pre-commit checks failed. Please fix the issues before committing."
fi
exit $EXIT_CODE

View File

@ -2,4 +2,11 @@
echo "Setting up the environment..."
# Install pre-commit package
python -m pip install pre-commit
# Install pre-commit hooks if .git directory exists
if [ -d ".git" ]; then
echo "Installing pre-commit hooks..."
pre-commit install
fi

View File

@ -99,6 +99,13 @@ check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-s
There you'll find resources on how to use different LLM providers,
troubleshooting resources, and advanced configuration options.
### Custom Scripts
OpenHands supports custom scripts that run at different points in the runtime lifecycle:
- **setup.sh**: Place this script in the `.openhands` directory of your repository to run custom setup commands when the runtime initializes.
- **pre-commit.sh**: Place this script in the `.openhands` directory to add a custom git pre-commit hook that runs before each commit. This can be used to enforce code quality standards, run tests, or perform other checks before allowing commits.
## 🤝 How to Join the Community
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication

View File

@ -310,6 +310,7 @@ export enum I18nKey {
STATUS$PREPARING_CONTAINER = "STATUS$PREPARING_CONTAINER",
STATUS$CONTAINER_STARTED = "STATUS$CONTAINER_STARTED",
STATUS$SETTING_UP_WORKSPACE = "STATUS$SETTING_UP_WORKSPACE",
STATUS$SETTING_UP_GIT_HOOKS = "STATUS$SETTING_UP_GIT_HOOKS",
ACCOUNT_SETTINGS_MODAL$DISCONNECT = "ACCOUNT_SETTINGS_MODAL$DISCONNECT",
ACCOUNT_SETTINGS_MODAL$SAVE = "ACCOUNT_SETTINGS_MODAL$SAVE",
ACCOUNT_SETTINGS_MODAL$CLOSE = "ACCOUNT_SETTINGS_MODAL$CLOSE",

View File

@ -4388,6 +4388,21 @@
"tr": "Çalışma alanı ayarlanıyor...",
"ja": "ワークスペースを設定中..."
},
"STATUS$SETTING_UP_GIT_HOOKS": {
"en": "Setting up git hooks...",
"zh-CN": "正在设置 git 钩子...",
"zh-TW": "正在設置 git 鉤子...",
"de": "Git-Hooks werden eingerichtet...",
"ko-KR": "git 훅을 설정하는 중...",
"no": "Setter opp git-hooks...",
"it": "Configurazione degli hook git...",
"pt": "Configurando hooks do git...",
"es": "Configurando hooks de git...",
"ar": "جاري إعداد خطافات git...",
"fr": "Configuration des hooks git...",
"tr": "Git kancaları ayarlanıyor...",
"ja": "git フックを設定中..."
},
"ACCOUNT_SETTINGS_MODAL$DISCONNECT": {
"en": "Disconnect",
"es": "Desconectar",

View File

@ -126,6 +126,8 @@ def initialize_repository_for_runtime(
)
# Run setup script if it exists
runtime.maybe_run_setup_script()
# Set up git hooks if pre-commit.sh exists
runtime.maybe_setup_git_hooks()
return repo_directory

View File

@ -131,7 +131,7 @@ class GitLabService(BaseGitService, GitService):
payload = {
'query': query,
'variables': variables,
'variables': variables if variables is not None else {},
}
response = await client.post(

View File

@ -72,6 +72,7 @@ STATUS_MESSAGES = {
'STATUS$CONTAINER_STARTED': 'Container started.',
'STATUS$WAITING_FOR_CLIENT': 'Waiting for client...',
'STATUS$SETTING_UP_WORKSPACE': 'Setting up workspace...',
'STATUS$SETTING_UP_GIT_HOOKS': 'Setting up git hooks...',
}
@ -424,6 +425,97 @@ class Runtime(FileEditRuntimeMixin):
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log('error', f'Setup script failed: {obs.content}')
def maybe_setup_git_hooks(self):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
pre_commit_script = '.openhands/pre-commit.sh'
read_obs = self.read(FileReadAction(path=pre_commit_script))
if isinstance(read_obs, ErrorObservation):
return
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_GIT_HOOKS', 'Setting up git hooks...'
)
# Ensure the git hooks directory exists
action = CmdRunAction('mkdir -p .git/hooks')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log('error', f'Failed to create git hooks directory: {obs.content}')
return
# Make the pre-commit script executable
action = CmdRunAction(f'chmod +x {pre_commit_script}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error', f'Failed to make pre-commit script executable: {obs.content}'
)
return
# Check if there's an existing pre-commit hook
pre_commit_hook = '.git/hooks/pre-commit'
pre_commit_local = '.git/hooks/pre-commit.local'
# Read the existing pre-commit hook if it exists
read_obs = self.read(FileReadAction(path=pre_commit_hook))
if not isinstance(read_obs, ErrorObservation):
# If the existing hook wasn't created by OpenHands, preserve it
if 'This hook was installed by OpenHands' not in read_obs.content:
self.log('info', 'Preserving existing pre-commit hook')
# Move the existing hook to pre-commit.local
action = CmdRunAction(f'mv {pre_commit_hook} {pre_commit_local}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error',
f'Failed to preserve existing pre-commit hook: {obs.content}',
)
return
# Make it executable
action = CmdRunAction(f'chmod +x {pre_commit_local}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error',
f'Failed to make preserved hook executable: {obs.content}',
)
return
# Create the pre-commit hook that calls our script
pre_commit_hook_content = f"""#!/bin/bash
# This hook was installed by OpenHands
# It calls the pre-commit script in the .openhands directory
if [ -x "{pre_commit_script}" ]; then
source "{pre_commit_script}"
exit $?
else
echo "Warning: {pre_commit_script} not found or not executable"
exit 0
fi
"""
# Write the pre-commit hook
write_obs = self.write(
FileWriteAction(path=pre_commit_hook, content=pre_commit_hook_content)
)
if isinstance(write_obs, ErrorObservation):
self.log('error', f'Failed to write pre-commit hook: {write_obs.content}')
return
# Make the pre-commit hook executable
action = CmdRunAction(f'chmod +x {pre_commit_hook}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error', f'Failed to make pre-commit hook executable: {obs.content}'
)
return
self.log('info', 'Git pre-commit hook installed successfully')
def get_microagents_from_selected_repo(
self, selected_repository: str | None
) -> list[BaseMicroagent]:

View File

@ -333,6 +333,7 @@ class AgentSession:
git_provider_tokens, selected_repository, selected_branch
)
await call_sync_from_async(self.runtime.maybe_run_setup_script)
await call_sync_from_async(self.runtime.maybe_setup_git_hooks)
self.logger.debug(
f'Runtime initialized with plugins: {[plugin.name for plugin in self.runtime.plugins]}'

View File

@ -0,0 +1,158 @@
from unittest.mock import MagicMock, call
import pytest
from openhands.events.action import CmdRunAction, FileReadAction
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
)
from openhands.runtime.base import Runtime
class TestGitHooks:
@pytest.fixture
def mock_runtime(self):
# Create a mock runtime
mock_runtime = MagicMock(spec=Runtime)
mock_runtime.status_callback = None
# Set up read to return different values based on the path
def mock_read(action):
if action.path == '.openhands/pre-commit.sh':
return FileReadObservation(
content="#!/bin/bash\necho 'Test pre-commit hook'\nexit 0",
path='.openhands/pre-commit.sh',
)
elif action.path == '.git/hooks/pre-commit':
# Simulate no existing pre-commit hook
return ErrorObservation(content='File not found')
return ErrorObservation(content='Unexpected path')
mock_runtime.read.side_effect = mock_read
mock_runtime.run_action.return_value = CmdOutputObservation(
content='', exit_code=0, command='test command'
)
mock_runtime.write.return_value = None
return mock_runtime
def test_maybe_setup_git_hooks_success(self, mock_runtime):
# Test successful setup of git hooks
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read the pre-commit script
assert mock_runtime.read.call_args_list[0] == call(
FileReadAction(path='.openhands/pre-commit.sh')
)
# Verify that the runtime created the git hooks directory
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.called
# Verify that the runtime made the pre-commit script executable
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.called
# Verify that the runtime wrote the pre-commit hook
assert mock_runtime.write.called
# Verify that the runtime made the pre-commit hook executable
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.call_count >= 3
# Verify that the runtime logged success
mock_runtime.log.assert_called_with(
'info', 'Git pre-commit hook installed successfully'
)
def test_maybe_setup_git_hooks_no_script(self, mock_runtime):
# Test when pre-commit script doesn't exist
mock_runtime.read.side_effect = lambda action: ErrorObservation(
content='File not found'
)
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read the pre-commit script
mock_runtime.read.assert_called_with(
FileReadAction(path='.openhands/pre-commit.sh')
)
# Verify that no other actions were taken
mock_runtime.run_action.assert_not_called()
mock_runtime.write.assert_not_called()
def test_maybe_setup_git_hooks_mkdir_failure(self, mock_runtime):
# Test failure to create git hooks directory
def mock_run_action(action):
if (
isinstance(action, CmdRunAction)
and action.command == 'mkdir -p .git/hooks'
):
return CmdOutputObservation(
content='Permission denied',
exit_code=1,
command='mkdir -p .git/hooks',
)
return CmdOutputObservation(content='', exit_code=0, command=action.command)
mock_runtime.run_action.side_effect = mock_run_action
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to create the git hooks directory
assert mock_runtime.run_action.called
# Verify that the runtime logged an error
mock_runtime.log.assert_called_with(
'error', 'Failed to create git hooks directory: Permission denied'
)
# Verify that no other actions were taken
mock_runtime.write.assert_not_called()
def test_maybe_setup_git_hooks_with_existing_hook(self, mock_runtime):
# Test when there's an existing pre-commit hook
def mock_read(action):
if action.path == '.openhands/pre-commit.sh':
return FileReadObservation(
content="#!/bin/bash\necho 'Test pre-commit hook'\nexit 0",
path='.openhands/pre-commit.sh',
)
elif action.path == '.git/hooks/pre-commit':
# Simulate existing pre-commit hook
return FileReadObservation(
content="#!/bin/bash\necho 'Existing hook'\nexit 0",
path='.git/hooks/pre-commit',
)
return ErrorObservation(content='Unexpected path')
mock_runtime.read.side_effect = mock_read
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read both scripts
assert len(mock_runtime.read.call_args_list) >= 2
# Verify that the runtime preserved the existing hook
assert mock_runtime.log.call_args_list[0] == call(
'info', 'Preserving existing pre-commit hook'
)
# Verify that the runtime moved the existing hook
move_calls = [
call
for call in mock_runtime.run_action.call_args_list
if isinstance(call[0][0], CmdRunAction) and 'mv' in call[0][0].command
]
assert len(move_calls) > 0
# Verify that the runtime wrote the new pre-commit hook
assert mock_runtime.write.called
# Verify that the runtime logged success
assert mock_runtime.log.call_args_list[-1] == call(
'info', 'Git pre-commit hook installed successfully'
)