Leveraging Lefthook to enforce commit guidelines at exile.watch

15 min read

exile.watch logo

When it comes to collaboration with others (whether from the same organization, team, or in open source), having clear rules is essential. No one really enjoys chaos, and if you do, then godspeed.

Focusing on architecture from the start may bring some pain and frustration, but as time flies, a well-set architecture will save you far more headaches and money.

One of the key rules and architectural aspects are git hooks.

What are git hooks?

Git has a way to fire off custom scripts when certain important actions occur.

There are two groups of these hooks: client-side and server-side.

Client-side hooks are triggered by operations such as committing and merging, while server-side hooks run on network operations like receiving pushed commits.

In this post, we will solely focus on client-side hooks, setting the stage for an efficient management system.

Git hooks managers

Understanding git hooks sets the stage, but managing them efficiently is where git hooks managers come into play.

The first thing you'll likely find about git hooks is the official Git Hooks documentation.

However, there are already git hooks managers available that are fast, well-maintained, and battle-tested, so I didn't see the need to create a custom git hook manager.

To name a few: Husky, pre-commit, overcommit, and more!

What about Lefthook?

While there are several git hooks managers out there, my googling led me to a particular one that stood out—Lefthook.

I've always worked with Husky, but ever since the v5 fiasco, it became clear that, at the time, this was an unstable project that breached the trust.

When I gave Husky v9 a shot a few weeks ago, I'll be honest: I had no clue how to create a shareable configuration and was somewhat wary.

Add lint-staged, commonly used with Husky, to the mix, and you get a sense that things could have been simpler in 2024.

Before we dive into Lefthook, let's quickly mention commit guidelines

If you visit commit message guidelines at https://docs.exile.watch/ you will notice following banner at the very top:

With a clear framework for commit messages established, integrating Lefthook to enforce these standards was our next step, particularly within our Lerna environment.

A word about commitizen

Commitizen is a CLI tool designed to help developers create more consistent and structured commit messages following conventional commit guidelines.

A word about commitlint

commitlint checks if commit messages meet specified guidelines, playing a crucial role in our commit process.

Cooking up Lefthook in Lerna environment

If this is your first encounter with Lerna and how it's applied at exile.watch, I suggest giving Lerna - the hidden powerhouse of exile.watch a read.

If you're short on time, here's the gist: exile.watch relies heavily on dependencies structured as boilerplates for consumer applications to use.

And guess what? Lefthook is one of these crucial "boilerplates."


Lefthook finds its place within the @exile-watch/splinters build tools monorepo, specifically under the @exile-watch/lefthook-config package.

To give you an idea, here's how the @exile-watch/splinters repo is organized, focusing just on the Lefthook part:

// Simplified @exile-watch/splinters repo structure
.
├── .lefthook
│   └── commit-msg
│       └── commitlint.sh
├── packages
│   └── lefthook-config
│       ├── scripts
│       │   └── commitlint.sh
│       ├── README.md
│       ├── lefthook.yml
│       └── package.json
├── lefthook.yml
├── lerna.json 
├── commitlint.config.js
└── package.json  // @exile-watch/lefthook-config as a devDependency

Right off the bat, 4 key components stand out:

  1. .lefthook/commit-msg withcommitlint.sh bash script at the root of the repository

  2. lefthook-config having scripts directory with commitlint.sh

  3. lefthook.yml in both - the root of the repository and lefthook-config

  4. commitlint.config.js

Let's delve into the roles each plays.

The dual role of commitlint.sh

Diving into the specifics, let's tackle an essential caveat first:

If you find yourself with more than one Lefthook file in the project, be warned—only one will be actively used, and it's anyone's guess which one that might be.

This situation could mess up our setup.

To avoid this problem, we make sure the root .lefthook/commit-msg/commitlint.sh script calls the script inside the package directly:

# .lefthook/commit-msg/commitlint.sh - our root repo .lefthook config

#!/bin/bash
source node_modules/@exile-watch/lefthook-config/scripts/commitlint.sh

run_commitlint
# lefthook-config/scripts/commitlint.sh - our lefthook-config package
#!/bin/bash
function run_commitlint {
  echo $(head -n1 $1) | npx commitlint --edit --color --help-url='https://docs.exile.watch/development/commit-message-guidelines'
}

This setup essentially ensures that our root .lefthook's commitlint.sh script activates the script defined in the package itself, thereby enforcing our commit guidelines directly from the package.

By doing so, we maintain control and clarity, using the root script to trigger the package-defined script, thereby unifying our commit linting process under one predictable mechanism.

One more note: The /scripts directory is also required to make remotes work—more on that later.

Why Lefthook and commitlint are essential

Our primary aim is to uphold commit standards, necessitated by commitlint, which avails the commitlint command.

The question then becomes: how best to activate our commitlint package?

Through the commitlint.sh bash script, of course.

This crucial detail explains why having the @exile-watch/lefthook-config as a devDependency is vital—it empowers us to execute Lefthook within the same repository where the package resides:

# Enabling the use of Lefthook's commitlint within the same repo
source node_modules/@exile-watch/lefthook-config/scripts/commitlint.sh

And by invoking run_commitlint, we kickstart the linting process as defined within our package's commitlint.sh script.


The naming conventions here are flexible; I opted for simplicity and consistency across the board, hence the recurrent use of "commitlint."

Deciphering the commitlint.sh script

Let's break down this script bit by bit:

echo $(head -n1 $1) | npx commitlint --edit --color --help-url='https://docs.exile.watch/development/commit-message-guidelines'
  • $1 and head -n1 $1: Fetches and echoes the first line of the commit message, preparing it for linting.

  • npx commitlint: Executes commitlint, applying our project's rules to the commit message in question.

  • --edit, --color, --help-url: Options enhancing usability, like colored output and providing a URL for guideline assistance.

Integrating Lefthook with commitlint.sh

But how do we ensure Lefthook autonomously triggers this script at the pre-commit stage? That's where lefthook.yml within lefthook-config comes into play

# {root}/packages/lefthook-config/lefthook.yml
pre-commit:
  parallel: true
  commands:
    lint:
      glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,yml,yaml}"
      run: npx biome check --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}

    "lint:apply":
      glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,yml,yaml}"
      run: npx biome check --apply --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} && git update-index --again

    "typecheck":
      glob: "*.{ts,tsx}"
      run: npx tsc

commit-msg:
  scripts:
    "commitlint.sh":
      runner: bash

Notice this part:

commit-msg:
  scripts:
    "commitlint.sh":
      runner: bash

This configuration informs Lefthook about the commitlint.sh script, ensuring it's triggered appropriately.

Eating the cooked lefthook-config package in other repositories

The brilliance and simplicity of extending this setup to other repositories lie in the lefthook.yml file at the root, pointing to our configured lefthook.yml within the lefthook-config package:

# {root}/lefthook.yml
remotes:
  - git_url: https://github.com/exile-watch/splinters
    configs:
      - packages/lefthook-config/lefthook.yml

You're essentially instructing Lefthook to fetch and merge the specified configurations, making project-wide guideline enforcement a breeze.

Check how nucleus and crucible repos are consuming our lefthook-config.

Understanding commitlint.config.js

Last component in our highlighted setup is commitlint.config.js file.

// {root}/commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
};
// package.json
{
  // ... 
  "config": {
    "commitizen": {
      "path": "cz-conventional-changelog"
    }
  }
}

This config sets our commit message rules, ensuring every message meets our project standards. It's key for keeping our git history clean and meaningful.

Lastly, synchronizing our git hooks with a postinstall step

Before we evaluate our setup's complexity, there is one last step that we have to follow up with.

After installing dependencies, we run Lefthook script:

// package.json
{
  // ... 
  "scripts": {
    // ...
    "postinstall": "npx lefthook install"
  }
}

By post installing Lefthook, we are synchronizing our git hooks with latest changes that are provided by our lefthook-config package.

> postinstall
> npx lefthook install

sync hooks: ✔️ (pre-commit, commit-msg)

Is this approach... Overcooked?

However, there's a bit of uncertainty on my end about whether this setup is the way it's supposed to work.

The idea of a consumer package pulling its configuration from the lefthook-config package, which then executes a script from the root of its own repository, works like a charm in practice.

Yet, it leaves me wondering if we've taken the simplicity of Lefthook's intended use and stretched it a bit too far.

I've sparked a conversation on the Lefthook repository to clarify this very point.

Ultimately, our approach works seamlessly for now, but it raises the question:

Is this how it was meant to be used?

Only time will tell if adjustments or a nod of approval await us down the line.


Author: Sebastian Krzyżanowski About exile.watch: https://docs.exile.watch/ Github: https://github.com/exile-watch Visit https://exile.watch/ to experience it first hand

Last updated

Was this helpful?