5 cool (and surprising) ways to configure Lefthook for automation joy

Cover for 5 cool (and surprising) ways to configure Lefthook for automation joy

Translations

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

Lefthook has been under active development for more than 4 years. While at first it was just a wrapper for calling custom scripts on Git hooks, as time has gone on, Lefthook has evolved into a tool that provides a clean and flexible way to automate your development routine, both in obvious and quite surprising ways. Want to know more? In this post, you’ll see examples of Lefthook configurations used in the real projects.

Linting, formatting, and fixing typos

Running linters and formatters over files that have been touched is the most common way to use Lefthook.

For example, I have a Go CLI project where I store documentation in Markdown format right in the repo. Whenever I fix bugs or implement features, I need to both test and lint the code, and perform a spellcheck in order to not confuse the reader. Further, all of the links within the documentation must point to existing pages. And regardless of the tools I choose, I want them to run every time I commit something. Let’s take a look at a Lefthook configuration that covers all of the above quickly, and in just 20 lines of YAML.

# lefthook.yml

pre-commit:
  parallel: true
  commands:
    lint:
      glob: "*.go"
      run: golangci-lint run --fix
      stage_fixed: true
    fix-typos:
      run: typos --write-changes {staged_files}
      stage_fixed: true
    test:
      glob: "*.go"
      run: go test -cpu 24 -race -count=1 -timeout=30s ./...
    links:
      glob: "*.md"
      run: lychee {staged_files}

Every time you commit something, the linter, typo checker, and link checker will be run in parallel. Note that stage_fixed: true automatically performs git add for the changed files in a pre-commit hook. It’s also important to mention that Lefthook respects only staged changes, so unstaged changes will remain untouched after performing git commit.

Monorepo

Here’s another example: there are two folders in my repo: client and server. client is an SPA written in TypeScript, and server is a Ruby on Rails application. I want to perform linting for both, and run client tests before I push changes, because this can be done sufficiently fast and I also don’t want to waste time on careless CI errors.

To that end, I can make Lefthook run specific commands in subfolders using the root option:

# lefthook.yml

pre-commit:
  parallel: true
  commands:
    server-lint:
      root: "server/"
      glob: "*.rb"
      run: bundle exec rubocop -A --force-exclusion {staged_files}
      stage_fixed: true
    client-lint:
      root: "client/"
      glob: "*.{ts,tsx}"
      run: yarn format {staged_files}
      stage_fixed: true

pre-push:
  commands:
    client-tests:
      root: "client/"
      glob: "*.{ts,tsx}"
      run: yarn test

The root and glob options work as filters, so if you commit something in the server part, client-lint will be just skipped, and vice-versa.

Do make sure that your Gemfile is in the server/ folder and package.json is in the client/.

Interactive hooks

There’s no need to waste extra time thinking about commit message format or enforce proper style within your team because there are well-known tools for doing that: Commitzen and commitlint. And we can save even more time with Lefthook; here’s how to use it to configure those:

# lefthook.yml

# Build commit messages interactively.
prepare-commit-msg:
  commands:
    commitzen:
      interactive: true
      run: yarn run cz
      env:
        LEFTHOOK: 0

# Validate commit messages; can be used with or without cz.
commit-msg:
  commands:
    "lint commit message":
      run: yarn run commitlint --edit {1}

The interactive: true option binds the command’s STDIN with your TTY and allows you to interact with the cz command with every git commit. (When using this configuration, the developer must always commit from the terminal, which might be inconvenient.) Nevertheless, you can use commitlint to validate that the commit message is valid.

prepare-commit-msg can also be suggested as an optional hook for a lefthook-local.yml configuration.

Implementing a CI-like pipeline

It can be a good idea to automatically run new migrations and install dependencies when pulling new commits from Git, and it makes sense to do this in a post-merge hook.

But there are two things to consider:

  • I have to install the backend dependencies before running the migrations
  • I want to install client dependencies in parallel with other steps
Example pipeline of a post-merge hook

To implement this, let’s use the helper hook:

# lefthook.yml

post-merge:
  parallel: true
  files: "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD"
  only:
    - ref: 'main'
  skip:
    - rebase
  commands:
    migrations:
      run: lefthook run migrations
      env:
        LEFTHOOK_QUIET: execution_info,meta,skips,summary
    client-dependencies:
      glob: "{yarn.lock,package.json}"
      run: yarn install

migrations:
  piped: true
  files: "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD"
  commands:
    bundle:
      priority: 1
      glob: "Gemfile*"
      run: bundle install
    migrate:
      priority: 2
      glob: "db/migrations/*"
      run: bundle exec rails db:migrate

Globs allow us to skip unneeded steps, while the migrations helper allows us to pipe 2 commands; they’ll run one after another, and migrate won’t start if bundle fails. The common files gets the new files and applies them to every command for implicit filtering.

Shared configs

What if you’re tired of configuring the same boilerplate lefthook.yml in every repo? I have hundreds of Ruby projects, numerous JavaScript projects, and a few Go microservices, and I want to control all the hooks in one place, while committing the change only once.

And sure enough, Lefthook has the remote option for that!

To start, I have 3 configurations for each project type that I’ll store in one repo. Let’s name it github.com/organization/lefthook-configs, and tere will be 3 files in it:

├── lefthook-golang.yml
├── lefthook-js.yml
└── lefthook-ruby.yml

Each config file has the pre-commit hook for linting, formatting, tests, and so on:

# lefthook-golang.yml

pre-commit:
  commands:
    lint:
      glob: "*.go"
      run: golangci-lint run --fix
      stage_fixed: true

Now, in every Go repo you need to add the following config:

# lefthook.yml

remote:
  git: https://github.com/organization/lefthook-configs
  config: lefthook-golang.yml

And that’s it! When you change something in the lefthook-configs repo, you need to make sure other developers will run lefthook install locally. The latest changes will be fetched to their hooks. While this requires strongly recommending everyone in your team chat run lefthook install, in the end, this is much easier than commiting one change to ten repos!

At Evil Martians, we transform growth-stage startups into unicorns, build developer tools, and create open source products. If you’re ready to engage warp drive, give us a shout!

Join our email newsletter

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

In the same orbit

How can we help you?

Martians at a glance
17
years in business

We transform growth-stage startups into unicorns, build developer tools, and create open source products.

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