Lefthook: knock your team’s code back into shape

Cover for Lefthook: knock your team’s code back into shape


If you’re interested in translating or adapting this post, please contact us first.

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 frontend and backend environments and ensure all your team’s developers can rely on a single flexible tool. Also, it has emojis 🥊

The days when a single piece of software, relied on by millions, was created by a single developer off 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 a number of contributors and is now being maintained by a team of dozens.

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

Hook me up

Hooks—a method of firing 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’s 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 them.

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

Both tools are excellent in their own right, but in a mixed team of frontend and backend developers—like Evil Martians—you’ll often end up having two separate setups for Ruby and JavaScript with the frontenders and backenders linting their commits in their preferred 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.

The 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 to mind external dependencies (Husky + lint-staged add roughly fifteen hundred dependencies to your node_modules). It also removes the headache of reinstalling dependencies each time your development environment is updated (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 configuration syntax doesn’t hide the actual commands being run by Lefthook—ensuring that nothing funny is happening behind the curtain.

Discourse with a punch

Discourse is an incredibly popular open source platform for forum-style discussions which 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, team members had to remind newcomers to continually install required tools.

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

Lefthook allowed for 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’ll see that Lefthook’s configuration is much more explicit, while Overcommit’s mostly relies 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. It allowed us 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

All Lefthook needs to function is a lefthook binary installed somewhere in your system (either globally or locally) and a lefthook.yml file in the 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 package.json when using Node.js.

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 @evilmartians/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 complete example.

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

      tags: frontend style
      glob: "*.{js}"
      run: yarn stylelint {staged_files}
      tags: backend style
      glob: "*.{rb}"
      exclude: "application.rb|routes.rb"
      run: bundle exec rubocop {all_files}
      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 quickly check Lefthook out on a demo project, we recommend cloning the evil_chat repository: it’s the project we build in our celebrated “Modern Frontend 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 file.

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

Now, 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 draw your attention where it needs to be!

For now, our linting only covers the frontend part of the app. What about adding some good ol’ Rubocop to the mix?

Edit your lefthook.yml to include the following lines:

# lefthook.yml

  parallel: true # tell Lefthook to utilize all cores
      glob: "*.js"
      run: yarn prettier --write {staged_files} && yarn eslint {staged_files} && git add {staged_files}
      glob: "*.{css,pcss}"
      run: yarn prettier --write {staged_files} && yarn stylelint --fix {staged_files} && git add {staged_files}
    # Add these lines
      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 the current commit.

Now go back and fix your JS and CSS then 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 a summary of the features that make Lefthook stand out of competition, and bring flexibility to your workflow. See the complete list here.


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

Here’s the config file that describes the 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.

And that’s how Lefthook gives you the ability to run custom tasks. 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 hook.

# lefthook.yml

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

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

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


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

      run: yarn eslint {staged_files}
      run: bundle exec rubocop {all_files}
      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:

      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:

      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., let’s say you’re a backend developer and not interested in running tasks on frontend 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 those from the main lefthook.yml file. Now you can assign tags to different series of commands…

# lefthook.yml

      tags: frontend-style # a tag
      files: git diff --name-only master
      glob: "*.{js}"
      run: yarn stylelint {files}
      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

    - frontend-style


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 anyway. Some people fully embrace containerized development, while others prefer to rely on well-configured local environments.

Your main lefthook.yml may contain the following:

      runner: bash

However, what if 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 into version control), you can alter the command just slightly—and only for your local setup—by using a {cmd} shortcut, just like this:

# lefthook-local.yml

      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’re 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 frontend, pure backend, 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.

If you see our work on Lefthook as yet another perfect example of reinventing the wheel, we suggest you still give it a try—you’ll soon realize that Lefthook is more of a jet pack, and not just another wheel for Git hooks management.

And never, never throw in the towel when it comes to automating your Git and GitHub workflow!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.

Let's solve your hard problems

Martians at a glance
years in business

We're experts at helping developer products grow, with a proven track record in UI design, product iterations, cost-effective scaling, and much more. We'll lay out a strategy before our engineers and designers leap into action.

If you prefer email, write to us at surrender@evilmartians.com