- Introduction
- Hook Architecture
- Writing Your First Hook
- Best Practices
- Testing Hooks
- Security Guidelines
- Common Patterns
- Troubleshooting
Pre-commit hooks are scripts that run automatically before a commit is made to ensure code quality, consistency, and security. This guide will help you develop robust, secure, and efficient hooks for this repository.
hooks/
├── ci/ # CI/CD related hooks
├── commits/ # Commit message hooks
├── configs/ # Configuration file hooks
├── nix/ # Nix-specific hooks
├── terraform/ # Terraform hooks
├── web/ # Web development hooks
│ ├── css/ # CSS linting
│ ├── js/ # JavaScript tools
│ └── scss/ # SCSS linting
└── ... # Other categories
Every hook should follow this basic structure:
#!/usr/bin/env bash
# 1. Set error handling
set -euo pipefail
# 2. Define help text
show_help() {
cat << EOF
Hook: your-hook-name
Purpose: Brief description of what this hook does
Usage: your-hook.sh [options] [files...]
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
EOF
}
# 3. Check dependencies
check_dependencies() {
if ! command -v required-tool &> /dev/null; then
echo "Error: required-tool is not installed"
echo "Install with: [installation command]"
exit 1
fi
}
# 4. Main logic
main() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-v|--verbose)
VERBOSE=1
shift
;;
*)
FILES+=("$1")
shift
;;
esac
done
# Check dependencies
check_dependencies
# Process files
for file in "${FILES[@]}"; do
process_file "$file"
done
}
# 5. Run main
main "$@"# Create hook file
touch hooks/category/my-hook.sh
chmod +x hooks/category/my-hook.sh#!/usr/bin/env bash
set -euo pipefail
# Simple example: Check file size
MAX_SIZE=1048576 # 1MB
for file in "$@"; do
if [[ -f "$file" ]]; then
size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file")
if [[ $size -gt $MAX_SIZE ]]; then
echo "Error: $file exceeds maximum size ($size > $MAX_SIZE bytes)"
exit 1
fi
fi
done
echo "All files pass size check"# Trap errors and clean up
cleanup() {
local exit_code=$?
# Clean up temporary files
rm -f "$TEMP_FILE"
exit $exit_code
}
trap cleanup EXIT ERRAlways validate and sanitize inputs:
# Good: Quote variables
process_file "$file"
# Bad: Unquoted variable
process_file $file
# Good: Validate file exists
if [[ ! -f "$file" ]]; then
echo "Error: File not found: $file"
exit 1
fiCheck for required tools:
# Check if in npm project
if [[ -f "package.json" ]]; then
# Use local installation
npx eslint "$@"
else
# Install globally or fail gracefully
if ! command -v eslint &> /dev/null; then
echo "Installing eslint..."
npm install -g eslint
fi
eslint "$@"
fiUse consistent exit codes:
0: Success1: General error2: Misuse of shell command126: Command cannot execute127: Command not found
Provide clear feedback:
# Show progress for long operations
echo "Processing ${#FILES[@]} files..."
for i in "${!FILES[@]}"; do
echo "[$((i+1))/${#FILES[@]}] Processing ${FILES[$i]}"
process_file "${FILES[$i]}"
doneCreate tests/category/test_my_hook.bats:
#!/usr/bin/env bats
load ../test_helper
setup() {
setup_test_env
}
teardown() {
teardown_test_env
}
@test "hook exists and is executable" {
run test -x "$ORIGINAL_DIR/hooks/category/my-hook.sh"
[ "$status" -eq 0 ]
}
@test "hook validates input correctly" {
create_test_file "test.txt" "content"
run "$ORIGINAL_DIR/hooks/category/my-hook.sh" "test.txt"
[ "$status" -eq 0 ]
}
@test "hook fails on invalid input" {
run "$ORIGINAL_DIR/hooks/category/my-hook.sh" "nonexistent.txt"
[ "$status" -eq 1 ]
[[ "$output" =~ "not found" ]]
}# Run single test
bats tests/category/test_my_hook.bats
# Run all tests
./tests/run_tests.sh# Bad: Direct interpolation
eval "command $user_input"
# Good: Use arrays
cmd=("command" "$user_input")
"${cmd[@]}"# Validate paths
realpath=$(readlink -f "$file")
if [[ ! "$realpath" =~ ^"$(pwd)" ]]; then
echo "Error: Path traversal attempt"
exit 1
fi# Create secure temp files
TEMP_FILE=$(mktemp)
trap 'rm -f "$TEMP_FILE"' EXIT
# Set restrictive permissions
chmod 600 "$TEMP_FILE"# Process files in parallel
process_files() {
local pids=()
for file in "$@"; do
process_file "$file" &
pids+=($!)
done
# Wait for all processes
for pid in "${pids[@]}"; do
wait "$pid" || exit_code=$?
done
return ${exit_code:-0}
}# Load configuration
CONFIG_FILE="${HOME}/.hookconfig"
if [[ -f "$CONFIG_FILE" ]]; then
source "$CONFIG_FILE"
fi
# Use defaults
MAX_SIZE="${MAX_SIZE:-1048576}"
VERBOSE="${VERBOSE:-0}"# Handle different stat commands
get_file_size() {
local file=$1
if [[ "$OSTYPE" == "darwin"* ]]; then
stat -f%z "$file"
else
stat -c%s "$file"
fi
}-
Hook not executable
chmod +x hooks/category/my-hook.sh
-
Command not found
# Add to PATH or use full path export PATH="/usr/local/bin:$PATH"
-
Permission denied
# Check file permissions ls -la hooks/category/my-hook.sh
-
Enable debug mode
set -x # Print commands as executed
-
Add logging
debug() { [[ $VERBOSE -eq 1 ]] && echo "DEBUG: $*" >&2 }
-
Test in isolation
# Test hook directly ./hooks/category/my-hook.sh test-file.txt
When contributing a new hook:
- Follow the directory structure
- Include comprehensive tests
- Document dependencies
- Add usage examples
- Ensure cross-platform compatibility
- Run all tests before submitting
See existing hooks for examples:
- Simple:
hooks/configs/yamlfmt.sh - Complex:
hooks/witness.sh - With dependencies:
hooks/nix/nix-build.sh