diff --git a/.lefthook.yml b/.lefthook.yml new file mode 100644 index 0000000000..1010a9413f --- /dev/null +++ b/.lefthook.yml @@ -0,0 +1,23 @@ +# .lefthook.yml +# Fast pre-commit hooks that check all changed files (staged + unstaged + untracked) +# Install with: bundle exec lefthook install + +pre-commit: + parallel: true + commands: + autofix: + run: bin/lefthook/ruby-autofix all-changed + + rubocop: + run: bin/lefthook/ruby-lint all-changed + + prettier: + run: bin/lefthook/prettier-format all-changed + + trailing-newlines: + run: bin/lefthook/check-trailing-newlines all-changed + +pre-push: + commands: + branch-lint: + run: bin/lefthook/ruby-lint branch diff --git a/CLAUDE.md b/CLAUDE.md index dd5c622141..927c07beb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co These requirements are non-negotiable. CI will fail if not followed. +**πŸš€ AUTOMATIC: Git hooks are installed automatically during setup** + +Git hooks will automatically run linting on **all changed files (staged + unstaged + untracked)** before each commit - making it fast while preventing CI failures! + +**Note:** Git hooks are for React on Rails gem developers only, not for users who install the gem. + ## Development Commands ### Essential Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b20d1d7321..82473521ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,10 @@ ## Prerequisites - [Yalc](https://github.com/whitecolor/yalc) must be installed globally for most local development. +- **Git hooks setup** (automatic during normal setup): + +Git hooks are installed automatically when you run the standard setup commands. They will run automatic linting on **all changed files (staged + unstaged + untracked)** - making commits fast while preventing CI failures. + - After updating code via Git, to prepare all examples: ```sh @@ -457,7 +461,9 @@ This approach: ## Pre-Commit Requirements -**CRITICAL**: Before committing any changes, always run the following commands to ensure code quality: +**AUTOMATED**: If you've set up Lefthook (see Prerequisites), linting runs automatically on changed files before each commit. + +**MANUAL OPTION**: If you need to run linting manually: ```bash # Navigate to the main react_on_rails directory @@ -476,14 +482,14 @@ rake lint:rubocop rake lint ``` -**Automated checks:** +**Git hooks automatically run:** -- Format all JavaScript/TypeScript files with Prettier +- Format JavaScript/TypeScript files with Prettier (on changed files only) - Check and fix linting issues with ESLint -- Check and fix Ruby style issues with RuboCop -- Ensure all tests pass before pushing +- Check and fix Ruby style issues with RuboCop (on all changed files) +- Ensure trailing newlines on all files -**Tip**: Set up your IDE to run these automatically on save to catch issues early. +**Setup**: Automatic during normal development setup ## πŸ€– Best Practices for AI Coding Agents diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index 1b4814c94a..d04a6fb099 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -38,6 +38,7 @@ group :development, :test do gem "rubocop-rspec", "~>2.26", require: false gem "scss_lint", require: false gem "spring", "~> 4.0" + gem "lefthook", require: false end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index f03f1a028b..387e96ab52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,6 +156,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) + lefthook (1.13.1) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -412,6 +413,7 @@ DEPENDENCIES jbuilder jquery-rails launchy + lefthook listen package_json pry diff --git a/bin/lefthook/check-trailing-newlines b/bin/lefthook/check-trailing-newlines new file mode 100755 index 0000000000..4cd6dd87ed --- /dev/null +++ b/bin/lefthook/check-trailing-newlines @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Check for trailing newlines on all changed files +set -euo pipefail + +CONTEXT="${1:-staged}" +files="$(bin/lefthook/get-changed-files "$CONTEXT" '.*')" + +if [ -z "$files" ]; then + echo "βœ… No files to check for trailing newlines" + exit 0 +fi + +if [ "$CONTEXT" = "all-changed" ]; then + echo "πŸ” Checking trailing newlines on all changed files..." +else + echo "πŸ” Checking trailing newlines on $CONTEXT files..." +fi + +failed_files="" +for file in $files; do + if [ -f "$file" ] && [ -s "$file" ]; then + if ! tail -c 1 "$file" | grep -q '^$'; then + echo "❌ Missing trailing newline: $file" + failed_files="$failed_files $file" + fi + fi +done + +if [ -n "$failed_files" ]; 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/get-changed-files b/bin/lefthook/get-changed-files new file mode 100755 index 0000000000..7d501f72a0 --- /dev/null +++ b/bin/lefthook/get-changed-files @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Get changed files based on context (staged, branch, or all) +set -euo pipefail + +CONTEXT="${1:-staged}" +PATTERN="${2:-.*}" + +case "$CONTEXT" in + staged) + git diff --cached --name-only --diff-filter=ACM | grep -E "$PATTERN" || true + ;; + all-changed) + # Get all changed files (staged + unstaged + untracked) vs working directory + (git diff --cached --name-only --diff-filter=ACM; git diff --name-only --diff-filter=ACM; git ls-files --others --exclude-standard) | sort -u | grep -E "$PATTERN" || true + ;; + branch) + # Find base branch (prefer main over master) + base="origin/main" + git rev-parse --verify --quiet "$base" >/dev/null || base="origin/master" + git diff --name-only --diff-filter=ACM "$base"...HEAD | grep -E "$PATTERN" || true + ;; + *) + echo "Usage: $0 {staged|all-changed|branch} [pattern]" >&2 + exit 1 + ;; +esac diff --git a/bin/lefthook/prettier-format b/bin/lefthook/prettier-format new file mode 100755 index 0000000000..728f2f432c --- /dev/null +++ b/bin/lefthook/prettier-format @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Format JS/TS/JSON/MD files with Prettier +set -euo pipefail + +CONTEXT="${1:-staged}" +files="$(bin/lefthook/get-changed-files "$CONTEXT" '\.(js|jsx|ts|tsx|json|md|yml|yaml)$')" + +if [ -z "$files" ]; then + echo "βœ… No files to format with Prettier" + exit 0 +fi + +if [ "$CONTEXT" = "all-changed" ]; then + echo "πŸ’… Prettier on all changed files:" +else + echo "πŸ’… Prettier on $CONTEXT files:" +fi +printf " %s\n" $files + +yarn run prettier --write $files + +# Re-stage files if running on staged or all-changed context +if [ "$CONTEXT" = "staged" ] || [ "$CONTEXT" = "all-changed" ]; then + echo $files | xargs -r git add + echo "βœ… Re-staged formatted files" +fi diff --git a/bin/lefthook/ruby-autofix b/bin/lefthook/ruby-autofix new file mode 100755 index 0000000000..f3a257c798 --- /dev/null +++ b/bin/lefthook/ruby-autofix @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Auto-fix Ruby files using rake autofix +set -euo pipefail + +CONTEXT="${1:-staged}" +files="$(bin/lefthook/get-changed-files "$CONTEXT" '\.(rb|rake|ru)$')" + +if [ -z "$files" ]; then + echo "βœ… No Ruby files to autofix" + exit 0 +fi + +if [ "$CONTEXT" = "all-changed" ]; then + echo "🎨 Autofix on all changed Ruby files:" +else + echo "🎨 Autofix on $CONTEXT Ruby files:" +fi +printf " %s\n" $files + +bundle exec rake autofix + +# Re-stage files if running on staged or all-changed context +if [ "$CONTEXT" = "staged" ] || [ "$CONTEXT" = "all-changed" ]; then + echo $files | xargs -r git add + echo "βœ… Re-staged formatted files" +fi diff --git a/bin/lefthook/ruby-lint b/bin/lefthook/ruby-lint new file mode 100755 index 0000000000..7e987fdb93 --- /dev/null +++ b/bin/lefthook/ruby-lint @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Lint Ruby files with RuboCop +set -euo pipefail + +CONTEXT="${1:-staged}" +files="$(bin/lefthook/get-changed-files "$CONTEXT" '\.(rb|rake|ru)$')" + +if [ -z "$files" ]; then + echo "βœ… No Ruby files to lint" + exit 0 +fi + +if [ "$CONTEXT" = "all-changed" ]; then + echo "πŸ” RuboCop on all changed Ruby files:" +else + echo "πŸ” RuboCop on $CONTEXT Ruby files:" +fi +printf " %s\n" $files + +if ! bundle exec rubocop --force-exclusion --display-cop-names -- $files; then + echo "" + echo "❌ RuboCop check failed!" + echo "πŸ’‘ Auto-fix: bundle exec rubocop --auto-correct --force-exclusion -- $files" + echo "🚫 Skip hook: git commit --no-verify" + exit 1 +fi +echo "βœ… RuboCop checks passed for Ruby files" diff --git a/package.json b/package.json index 3ce4e03cb5..9a33758804 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,8 @@ "type-check": "yarn run tsc --noEmit --noErrorTruncation", "release:patch": "node_package/scripts/release patch", "release:minor": "node_package/scripts/release minor", - "release:major": "node_package/scripts/release major" + "release:major": "node_package/scripts/release major", + "postinstall": "test -f .lefthook.yml && test -d .git && command -v bundle >/dev/null 2>&1 && bundle exec lefthook install || true" }, "repository": { "type": "git", diff --git a/script/bootstrap b/script/bootstrap index cb2a2e93cd..9b6543368a 100644 --- a/script/bootstrap +++ b/script/bootstrap @@ -30,4 +30,10 @@ if [ -f "Gemfile" ]; then bundle check --path vendor/gems 2>&1 >/dev/null || { bundle install --path vendor/gems --quiet --without production } + + # Install Git hooks for code quality (development only) + if [ -f ".lefthook.yml" ] && [ -d ".git" ]; then + echo "==> Installing Git hooks for code quality…" + bundle exec lefthook install + fi fi