diff --git a/Gemfile b/Gemfile index ce4f885..11a7eb9 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" # Specify your gem's dependencies in package_json.gemspec gemspec +gem "lefthook", require: false gem "rake", "~> 13.0" gem "rspec", "~> 3.0" gem "rubocop", "< 1.51" # TODO: this version dropped support for Ruby 2.6 diff --git a/Gemfile.lock b/Gemfile.lock index 16f0442..0ed3466 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,6 +10,7 @@ GEM diff-lcs (1.5.0) docile (1.4.0) json (2.7.6) + lefthook (2.0.2) parallel (1.23.0) parser (3.2.2.3) ast (~> 2.4.1) @@ -73,6 +74,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + lefthook package_json! rake (~> 13.0) rspec (~> 3.0) diff --git a/README.md b/README.md index 4582901..74c8460 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,43 @@ release a new version, update the version number in `version.rb`, and then tag the commit with the version prefixed with a `v`, which will trigger the release workflow to publish the new version to [rubygems.org](https://rubygems.org). +### Git Hooks + +This project uses [Lefthook](https://github.com/evilmartians/lefthook) to manage +Git hooks, providing fast and efficient pre-commit checks. Hooks are +automatically installed when you run `bin/setup`. + +To manually install or update hooks: + + bundle exec lefthook install + +#### Pre-commit Hooks (Fast) + +The pre-commit hooks run in parallel on staged files only: + +- **RuboCop**: Lints staged Ruby files with auto-fix suggestions +- **Trailing Newlines**: Ensures all files end with a newline + +These hooks complete in seconds, providing immediate feedback on code quality. + +#### Pre-push Hooks (Comprehensive) + +Before pushing to remote, Lefthook runs the full test suite in parallel: + +- **Full RuboCop**: Lints entire codebase +- **Full RSpec**: Runs all tests + +This ensures comprehensive verification before sharing your changes. + +#### Bypassing Hooks + +In exceptional cases, you can bypass hooks: + + git commit --no-verify # Skip pre-commit hooks + git push --no-verify # Skip pre-push hooks + +**Note:** CI will enforce all checks regardless of local bypasses. + ## Contributing Bug reports and pull requests are welcome on GitHub at diff --git a/bin/lefthook/check-trailing-newlines b/bin/lefthook/check-trailing-newlines new file mode 100755 index 0000000..b400957 --- /dev/null +++ b/bin/lefthook/check-trailing-newlines @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Check for trailing newlines on staged files +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "✅ No files to check for trailing newlines" + exit 0 +fi + +echo "🔍 Checking trailing newlines on staged files..." + +failed_files=() +for file in "$@"; do + if [ -f "$file" ] && [ -s "$file" ]; then + # Files ending with newline will have empty output from tail -c 1 + if [ -n "$(tail -c 1 "$file")" ]; then + echo "❌ Missing trailing newline: $file" + failed_files+=("$file") + fi + fi +done + +if [ ${#failed_files[@]} -gt 0 ]; then + echo "" + echo "❌ Trailing newline check failed!" + echo "💡 Add trailing newlines to: ${failed_files[*]}" + echo "🔧 Quick fix: for file in \"\${failed_files[@]}\"; do echo >> \"\$file\"; done" + echo "🚫 Skip hook: git commit --no-verify" + exit 1 +fi + +echo "✅ All files have proper trailing newlines" diff --git a/bin/lefthook/rubocop-lint b/bin/lefthook/rubocop-lint new file mode 100755 index 0000000..40c6ff0 --- /dev/null +++ b/bin/lefthook/rubocop-lint @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Lint Ruby files with RuboCop +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "✅ No Ruby files to lint" + exit 0 +fi + +# Filter out non-existent files (e.g., deleted files) +existing_files=() +for file in "$@"; do + if [ -f "$file" ]; then + existing_files+=("$file") + fi +done + +if [ ${#existing_files[@]} -eq 0 ]; then + echo "✅ No existing Ruby files to lint" + exit 0 +fi + +echo "🔍 RuboCop on staged Ruby files:" +printf " %s\n" "${existing_files[@]}" + +if ! bundle exec rubocop --force-exclusion --display-cop-names -- "${existing_files[@]}"; then + echo "" + echo "❌ RuboCop check failed!" + echo "💡 Auto-fix: bundle exec rubocop -a --force-exclusion -- " + echo "🚫 Skip hook: git commit --no-verify" + exit 1 +fi + +echo "✅ RuboCop checks passed" diff --git a/bin/setup b/bin/setup index dce67d8..b2830ae 100755 --- a/bin/setup +++ b/bin/setup @@ -5,4 +5,5 @@ set -vx bundle install -# Do any other automated setup that you need to do here +# Set up Git hooks with Lefthook +bundle exec lefthook install diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..1357b30 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,24 @@ +# Lefthook configuration +# Fast pre-commit hooks that check staged files only +# Install with: bundle exec lefthook install + +pre-commit: + parallel: true + commands: + rubocop: + glob: '**/*.rb' + # Uses --force-exclusion to respect .rubocop.yml exclusions (e.g., bin/**) + # even when files are explicitly passed as arguments + run: bin/lefthook/rubocop-lint {staged_files} + + trailing-newlines: + run: bin/lefthook/check-trailing-newlines {staged_files} + +pre-push: + parallel: true + commands: + full-rubocop: + run: bundle exec rubocop + + full-rspec: + run: bundle exec rspec