Ensuring code consistency with git hooks
If you've worked on a team that uses a styleguide, perhaps you've been shamed by Hound. This is easy enough to avoid by running a linter locally to check for style violations, but you have to remember to do this every time you make a commit.
These are the linters I usually use:
Luckily git provides hooks that get run automatically with certain actions:
$ ls .git/hooks
applypatch-msg.sample fsmonitor-watchman.sample pre-applypatch.sample pre-merge-commit.sample pre-rebase.sample prepare-commit-msg.sample
commit-msg.sample post-update.sample pre-commit.sample pre-push.sample pre-receive.sample update.sample
We can tap into these hooks to run our linters locally automatically, and even reject the commit if something is wrong.
According to the comment in the existing pre-commit.sample
file:
The hook should exit with non-zero status after issuing an appropriate message if it wants to stop the commit.
pre-commit Hook
Create a new file: .git/hooks/pre-commit
and be sure to add the ruby shebang since we like working in ruby.
#!/usr/bin/env ruby
When working with shell commands, it's a good idea to use Ruby's Shellwords
lib to handle unexpected paths, etc.
require 'shellwords'
Specify rubocop executable:
RUBOCOP = "rubocop -c .rubocop.yml"
Now set up a few variables to get information about this commit.
# Project root is just a few directories above the. current directory
proj_root = File.expand_path(File.join(__dir__, '..', '..'))
# Get the current working branch
branch = `git branch --show-current`.chomp
# For git version < 2.22 use this instead:
# branch = `git symbolic-ref HEAD 2>/dev/null`.chomp[11..-1]
# Get our staged for commit file paths
changed_files = `git diff --name-only --staged`.split(/\n/).compact
# Set this if you want to ignore listing when merging
is_merging = File.exists?(File.join(proj_root, '.git/MERGE_HEAD'))
Now, grab only the files that we're interested in for this particular linter. We need to make sure to escape weird shell paths I.E. /my files/foo
=> /my\ files/foo
.
ruby_files = changed_files
.select { |f| %w[.rb].include? File.extname(f) } # Only .rb files
.reject{ |f| !File.exists?(f) } # File removed?
.map { |f| Shellwords.shellescape(f) } # Escapes shell provided paths
All that's left is to run Rubocop:
passed_ruby_linting =
unless ruby_files.empty?
system "#{RUBOCOP} #{ruby_files.join(' ')}"
$?.success?
else
true
end
unless passed_ruby_linting
# Prevent commit
exit 1
end
How to Enforce correct javascript linting:
Add eslint to your project:
npm install eslint --save-dev
# or
yarn add eslint --dev
In a very similar manner, we can run this before to check for errors:
ESLINT = "./node_modules/.bin/eslint -c .eslintrc.json"
js_files = changed_files
.select{ |f| %w[.js .jsx].include? File.extname(f) } # Only js, jsx files
.reject{ |f| !File.exists?(f) } # No non-existent files
.map{ |f| Shellwords.shellescape(f) } # escape file name
passed_js_linting =
unless js_files.empty?
system "#{ESLINT} #{js_files.join(' ')}"
$?.success?
else
true
end
# ...
The complete hook:
#!/usr/bin/env ruby
require 'shellwords'
RUBOCOP = "rubocop -c .rubocop.yml"
ESLINT = "./node_modules/.bin/eslint -c .eslintrc.json"
$stdout.sync = true
proj_root = File.expand_path(File.join(__dir__, '..', '..'))
branch = `git branch --show-current`.chomp
changed_files = `git diff --name-only --staged`.split(/\n/).compact
is_merging = File.exists?(File.join(proj_root, '.git/MERGE_HEAD'))
exit 0 if is_merging
ruby_files = changed_files
.select { |f| %w[.rb].include? File.extname(f) } # Only .rb files
.reject{ |f| !File.exists?(f) } # File removed?
.map { |f| Shellwords.shellescape(f) } # Escapes shell provided paths
js_files = changed_files
.select{ |f| %w[.js .jsx].include? File.extname(f) } # Only js, jsx files
.reject{ |f| !File.exists?(f) } # No non-existent files
.map{ |f| Shellwords.shellescape(f) } # escape file name
passed_ruby_linting =
unless ruby_files.empty?
$stdout.puts "Running Rubocop..."
system "#{RUBOCOP} #{ruby_files.join(' ')}"
$?.success?
else
true
end
passed_js_linting =
unless js_files.empty?
$stdout.puts "Running ESLint..."
system "#{ESLINT} #{js_files.join(' ')}"
$?.success?
else
true
end
# Prevent commit
exit 1 unless passed_ruby_linting && passed_js_linting
...and here's the output!
$ git commit
Running Rubocop...
Inspecting 3 files
.C.
Offenses:
app/controllers/activities_controller.rb:3:38: C: Style/SymbolArray: Use %i or %I for an array of symbols.
before_action :set_activity, only: [:show, :update, :destroy]
^^^^^^^^^^^^^^^^^^^^^^^^^^
app/controllers/activities_controller.rb:22:11: C: Layout/SpaceAfterSemicolon: Space missing after semicolon.
def show;end