See more posts

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:

bash
$ 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.

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.

ruby
require 'shellwords'

Specify rubocop executable:

ruby
RUBOCOP = "rubocop -c .rubocop.yml"

Now set up a few variables to get information about this commit.

ruby
# 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
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:

ruby
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:

bash
npm install eslint --save-dev
# or
yarn add eslint --dev

In a very similar manner, we can run this before to check for errors:

ruby
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:

ruby
#!/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!

bash
$ 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

Thanks for reading! See more posts Leave feedback