Martian Chronicles
Evil Martians’ team blog
Full-stack

Lefthook: Knock your team’s code back into shape

Meet Lefthook, the fastest polyglot Git hooks manager out there, and make sure not a single line of unruly code makes it into production. See how easy it is to install Lefthook, recently adopted by Discourse, Logux, and Openstax, for most common front-end and back-end environments and ensure all developers on your team can rely on a single flexible tool. And it also has emojis 🥊

Days, when a single piece of software that millions rely on was created by a single developer in an ivory tower, are long gone. Even Git, universally believed to be the brainchild of Linus Torvalds alone, was created with the help of contributors and is now being maintained by a team of dozens.

No matter if you work on an open-source project with the whole world being your oyster, or you are blooming in a walled garden of proprietary commercial software—you still work in a team. And even with a well-organized system of pull requests and code reviews maintaining the code quality across the large codebase with dozens of contributors is not an easy task.

Hook me up

Hooks—ways to fire off custom scripts when certain important actions (commit, push, etc.) occur—are baked right into Git, so if you are comfortable with Bash and the internals of the world most popular version control system—you don’t need any external tools per se: just edit ./.git/hooks/pre-commit and put in some well-formed script that will, for instance, lint your files before you commit.

However, when you work on a project, you are most interested in writing project’s code—not the code that checks it. In the world of modern web development tooling is everything, and myriads of tools exist for a single reason: reducing overhead and complexity. Git hooks are not the exception: in JavaScript community, the weapon of choice is Husky with Webpack, Babel, and create-react-app relying on this Node-based tool; Rails-centric backend world, however, is mostly ruled by Overcommit that comes as a Ruby gem.

Find detailed comparisons of Lefthook with other tools in the project’s wiki.

Both tools are excellent in their regard, but in a mixed team of front-end and back-end developers, as Evil Martians are, you will often end up having two separate setups for Ruby and JavaScript with front-enders and back-enders linting their commits each in their way.

With Lefthook, you don’t need to think twice—it’s a single Go binary that has wrappers both for JavaScript and for Ruby. It can also be used as a standalone tool for any other environment.

For most common use cases, Lefthook requires zero setup.

Go language makes Lefthook lightning-fast and provides support for concurrently executed scripts out of the box. The fact that the executable is a single machine code binary also removes the need for minding external dependencies (Husky + lint-staged add roughly fifteen hundred dependencies to your node_modules). It also removes the headache of reinstalling dependencies after each update of your development environment (try running a globally installed Ruby gem with another version of Ruby).

With Lefthook mentioned either in package.json or Gemfile, and a lefthook.yml configured in the project’s root (see examples below) the tool will be installed and used against your code automatically on the next git pull, yarn install/ bundle install and git add/git commit—with zero overhead for new contributors.

An extensive README describes all possible usage scenarios. The straightforward syntax for configuration does not hide actual commands being run by Lefthook—making sure that nothing funny is happening below the belt.

Discourse with a punch

Discourse—an incredibly popular open-source platform for forum-style discussions—has recently transitioned from Overcommit to Lefthook and never looked back. With almost 700 contributors authoring 34K commits and counting, running linters on all new contributions is a priority. With Overcommit though, team members had to remind newcomers to install required tools continually

Now with @arkweid/lefthook being a dev dependency in the project’s package.json, no setup is necessary for new contributors.

Lefthook allowed to half the amount of time that pre-commit scripts take on localhost.

The PR that changed the Git hook manager required, in essence, changing .overcommit.yml to lefthook.yml. If you compare them—you will see that Lefthook’s configuration is much more explicit while the Overcommit’s one relies mostly on the magic of plugins.

Discourse's CI output before and after Lefthook

Discourse’s CI output before and after Lefthook

Besides changing the way the output looks—Lefthook offers a nice summary of everything it does—Lefthook allowed to half the amount of time that pre-commit scripts take on localhost, and increase the CI run by 20% (on CI environments with better support for parallel execution the gain can be considerably more).

A pretty bonus

A pretty bonus

Round one

Everything Lefthook needs to function—is a lefthook binary installed somewhere in your system (either globally or locally), and a lefthook.yml file in a project root.

The binary can either be installed globally (with Homebrew for macOS, snap for Ubuntu, AUR for Arch, or go get anywhere), or listed as a development dependency either in Ruby’s Gemfile, or Node.js’ package.json.

If you are configuring Lefthook for the first time in your project—you need to choose between these options, depending on your preferences. The main upside of putting lefthook in your Gemfile or @arkweid/lefthook in your package.json is that you don’t need to worry that your contributors may not have lefthook installed system-wide—after the next bundle install or yarn install the binary will be in place.

After you have lefthook in your system—run lefthook install in the project’s root to generate lefthook.yml and visit the project’s repo for examples on how to use the syntax, here is a particularly full one.

Here is how the code describing actions on each pre-commit (right after you type git commit -m "new feature" but right before it gets committed) might look like:

pre-commit:
  commands:
    stylelint:
      tags: frontend style
      glob: "*.{js}"
      run: yarn stylelint {staged_files}
    rubocop:
      tags: backend style
      glob: "*.{rb}"
      exclude: "application.rb|routes.rb"
      run: bundle exec rubocop {all_files}
  scripts:
    "good_job.js":
      runner: node

Then commit lefthook.yml to your repository and push it to your favorite remote—now everyone on your team will see it in action every time they commit code.

Blow by blow

If you want to check Lefthook out on a demo project quickly—we recommend cloning the evil_chat repository—a project we build in our celebrated “Modern Front-End in Rails” series of posts (Pt. 1, Pt. 2, Pt. 3).

In this project, we use pre-commit hooks configured with Lefthook for formatting JavaScript and CSS files with Prettier, and linting them with ESlint and stylelint.

Here’s how to quickly see Lefthook in action. First, clone the repo and run package managers:

$ git clone git@github.com:demiazz/evil_chat.git
$ bundle && yarn

Now, go and break some CSS or JS in any .pcss or .js files.

$ git add . && git commit -m "Now I am become death, destroyer of worlds"

Wait for it!

If all goes well (meaning you succeeded in breaking the code), this is what you are going to see:

A bad commit

A bad commit

The failing scripts are shown with a boxing glove emoji on the output—giving you a real left hook to bring back your attention!

For now, our linting covers only the front-end part of the app. What about adding some good old Rubocop to the mix?

Edit your lefthook.yml to include the following lines:

# lefthook.yml

pre-commit:
  parallel: true # tell Lefthook to utilise all cores
  commands:
    js:
      glob: "*.js"
      run: yarn prettier --write {staged_files} && yarn eslint {staged_files} && git add {staged_files}
    css:
      glob: "*.{css,pcss}"
      run: yarn prettier --write {staged_files} && yarn stylelint --fix {staged_files} && git add {staged_files}
    # Add these lines
    rubocop:
      glob: "*.{rb}"
      run: rubocop {staged_files} --parallel

Note the handy {staged_files} shortcut that allows you to target only the files that are staged for a current commit.

Now go back to fix your JS and CSS and commit a style offense in any of the Ruby files (yes, you have our permission). Feel free to throw in some comments here and there so that git picks up changes for different filetypes.

Rubocop comes into play

Rubocop comes into play

Now CSS and JS are fine, but Ruby needs another look, hence the left hook!

Roll with the punches

Here is the summary of the features that make Lefthook stand out of competition, and bring flexibility to your workflow, see the complete list here.

Speed

Lefthook squeezes out every bit of parallelism from your machine (or a CI server), you only need to toggle one setting: parallel: true.

Here’s the config file that describes a lint series of commands that you can run with lefthook run lint from your command line. These are the same commands that Discourse used to run on Travis.

Lefthook gives you an ability to run custom tasks like that. As an alternative, you can set the same commands to be run on pre-commit, pre-push, post-checkout, post-merge, or any other available Git hooks.

# lefthook.yml

lint:
  # parallel: true
  commands:
    rubocop:
      run: bundle exec rubocop --parallel
    prettier:
      run: yarn prettier --list-different "app/assets/stylesheets/**/*.scss" "app/assets/javascripts/**/*.es6" "test/javascripts/**/*.es6"
    eslint-assets:
      run: yarn eslint --ext .es6 app/assets/javascripts
    eslint-test:
      run: yarn eslint --ext .es6 test/javascripts
    eslint-plugins-assets:
      run: yarn eslint --ext .es6 plugins/**/assets/javascripts
    eslint-plugins-test:
      run: yarn eslint --ext .es6 plugins/**/test/javascripts
    eslint-assets-tests:
      run: yarn eslint app/assets/javascripts test/javascripts

With parallel: true commented out, on my system, this task takes over 30 seconds.

With the parallel feature turned on, it takes 15.5 seconds—twice as fast!

Flexibility

  • Direct control

If you want to run your hook directly, without waiting for a Git action:

$ lefthook run pre-commit
  • Flexible lists of files

You can use built-in shortcuts {staged_files} and {all_files}, or define your own lists according to specific selection.

pre-commit:
  commands:
    frontend-linter:
      run: yarn eslint {staged_files}
    backend-linter:
      run: bundle exec rubocop {all_files}
    frontend-style:
      files: git diff --name-only HEAD @{push}
      run: yarn stylelint {files}
  • Glob/Regex filters

If you want to filter a list of files on the fly with a glob or a Regex.

pre-commit:
  commands:
    backend-linter:
      glob: "*.{rb}" # glob filter
      exclude: "application.rb|routes.rb" # regexp filter
      run: bundle exec rubocop {all_files}
  • Run your own scripts

If one-liners are not enough, you can tell Lefthook to execute custom scripts.

commit-msg:
  scripts:
    "good_job":
      runner: bash
  • Tags and local config for even more flexibility

You can group your tasks by tags and then exclude them when you run hooks locally (e.g., you are a back-end developer and not interested in running tasks on front-end code).

With Lefthook, you can create a lefthook-local.yml file in your project root (don’t forget to add it to your .gitignore): all the settings described here would override the ones from the main lefthook.yml. Now you can assign tags to different series of commands…

# lefthook.yml

pre-push:
  commands:
    stylelint:
      tags: frontend-style # a tag
      files: git diff --name-only master
      glob: "*.{js}"
      run: yarn stylelint {files}
    rubocop:
      tags: backend-style # a tag
      files: git diff --name-only master
      glob: "*.{rb}"
      run: bundle exec rubocop {files}

… and exclude them from being run locally:

# lefthook-local.yml

pre-push:
  exlude_tags:
    - frontend-style

K.O.

Do you use Docker for local development? Perhaps you do, perhaps not yet, but there is a big chance that someone else on your team does otherwise. Some people embrace containerized development fully, while others prefer to rely on well-groomed local environments.

Your main lefthook.yml may contain the following:

post-push:
  scripts:
    "good_job.js":
      runner: bash

However, you want to run the same task in a Docker container, but you don’t want to mess up the setup for everyone else. By using a lefthook-local.yml file (the one you don’t check in into version control), you can alter the command just slightly, and just for your local setup, by using a {cmd} shortcut, just like that:

# lefthook-local.yml

pre-commit:
  scripts:
    "good_job.js":
      runner: docker exec -it --rm <container_id_or_name> {cmd}

{cmd} will be replaced by a command from the main config.

The resulting command will look like this:

docker exec -it --rm <container_id_or_name> node good_job.js

8… 9… 10… Knockout! 🥊


We are confident that Lefthook is currently the fastest and most flexible Git hook manager in our galaxy, so we encourage everyone to follow the example of Discourse and either add Lefthook to your projects or create a pull request proposing the change in your favorite open-source repositories.

The polyglot nature of Lefthook allows it to be used in pure front-end, pure back-end, or mixed full-stack teams, and with all common development setups on all major operating systems, including Windows.

Find the best way to install it, depending on your stack, and give it a go! See how we use Lefthook in tandem with Crystalball in our commercial projects by checking out this post on Dev.to.

If you see our work on Lefthook as yet another perfect example of reinventing the wheel, still give it a try—you will soon realize that Lefthook is more of a jet pack, then yet another good old wheel for git hooks management.

And never, never throw in the towel on automating your Git and GitHub workflow!

Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.