Why is this commit message just ‘fix stuff’?
If you’ve ever muttered these words while reviewing code, you’re not alone. While developers use Git commits daily, many overlook their importance as a communication tool.
I recently explored ways to make them more meaningful - and discovered some surprising performance implications worth sharing.
Why Care About Commit Formatting?
The Problem with Unstructured Commits
Consider these two commit histories:
|
|
versus:
|
|
The first history tells a clear story: new authentication features were added, a bug in password resets was fixed, and documentation was updated. The second leaves us guessing - which “stuff” was updated? What “thing” was fixed?
Clear Communication
Well-structured commits serve as documentation, telling the story of your project’s evolution. They help team members (including future you) understand:
- What changed and why
- The scope and impact of changes
- Whether updates might introduce breaking changes
- How features evolved over time
Practical Benefits
I first realized the importance of structured commits while using Neovim with lazy.nvim for plugin management. During updates, I found myself reviewing changelogs regularly. Projects following conventional commit standards made this process significantly more efficient - changes were clearly categorized, making it easy to:
- Identify new features
- Spot potential breaking changes
- Understand bug fixes
- Assess update impact
No more getting lost in a sea of random commit messages.
Understanding Conventional Commits
The Conventional Commits specification provides a standardized format for commit messages.
The basic structure looks like this: <type>(scope): message
For example: feat(blog): add comment system
Common types include:
feat: New features- Example:
feat(ui): add dark mode support
- Example:
fix: Bug fixes- Example:
fix(api): handle null response from user service
- Example:
docs: Documentation changes- Example:
docs(readme): clarify installation steps
- Example:
chore: Maintenance tasks- Example:
chore(deps): update left-pad to 1.30
- Example:
The scope, while optional, is super helpful for categorizing changes in bigger
projects. For example, a web application might use scopes like api, auth,
ui, or db.
This structured format makes it easier to:
- Automatically generate changelogs
- Determine semantic version bumps
- Parse and understand commit history
- Maintain consistency across teams
Semantic Versioning Made Easy
One of the most compelling reasons to use conventional commits is how they simplify semantic versioning.
Semantic versioning (MAJOR.MINOR.PATCH) follows these rules:
MAJORversion increments for breaking changesMINORversion increments for new featuresPATCHversion increments for bug fixes
With conventional commits, determining version bumps becomes programmatic:
feat!:orfix!:commits triggerMAJORversion bumpsfeat:commits triggerMINORversion bumpsfix:commits triggerPATCHversion bumps
Example:
|
|
Tools like release-please from Google can automatically:
- Parse your conventional commits
- Generate appropriate version numbers
- Create changelogs
- Generate release notes
- Create release pull requests
This automation eliminates manual version management and reduces human error in the release process.
Automating Commit Standards
Rather than relying on self-regulation to follow the Conventional Commits spec (and trying to remember all the conventions, because who needs that extra mental load?), we can leverage Git hooks to enforce these standards automatically. Git hooks are scripts that run at specific points in Git’s execution cycle. Atlassian has a great explanation if you want to dive deeper into how hooks work.
Tools Overview
There are two key components used to automate commit message standards with
git hooks:
- A
githook manager - A commit message linter
Git Hook Managers
While Git hooks are powerful, managing them directly can be cumbersome:
- Hooks aren’t versioned by default
- Hook scripts need manual installation and updates
- Different projects might need different hook configurations
- Hook dependencies need manual management
Hook managers solve these problems by:
- Versioning hooks alongside your code
- Providing declarative configuration
- Automatically managing hook dependencies
- Enabling easy sharing of hook configurations
- Supporting multiple programming languages and tools
- Offering a plugin ecosystem for common tasks
There are two popular options for managing hooks:
-
pre-commit - A Python-based framework
- A simple
pip install pre-commitis all you need
- A simple
-
Husky - A JavaScript-based framework
- Installs with
npm install --save-dev husky
- Installs with
Both options install easily, as pip and npm are found on most systems; and
it is generally pretty painless to add them if they aren’t.
There may be some edge cases for each option, but for general use cases they
seem reasonably equivalents. It’s worth noting that I did find husky a bit
easier to configure post-install.
Commit Linters
For linting commit messages, I explored two options:
-
commitlint - The popular choice, especially in
nodeland. It’s mature and well-documented.- Pros:
- Offers comprehensive rule configuration
- Has extensive documentation and examples
- Has prettier output formatting, though this is largely cosmetic
- Cons:
- Requires
nodeecosystem - Depends on
huskyfor hook installation and nopre-commitsupport - Provides plugins for various workflows
- Requires
- Pros:
-
conform - Created by Sidero Labs, the company behind Talos (of Kubernetes fame), is a Go-based tool that offers more features than just commit linting:
- Pros:
- Installs as a single binary
- Fewer dependencies
- Many useful built-in policies:
- Conventional Commits
- GPG signatures
- License headers
- Spell checks
- Cons:
- Still in alpha release phase
- The config documentation is lacking
- Pros:
Performance Benchmarks
I was curious how conform would perform against the commitlint setup, since
it is written in Go and lacks the dependence on a hook manager.
Performance might not seem that important at first, until you consider that this is going to run every time you try to make a commit.
Git hooks often get a bad reputation because people load up on hooks and every commit ends up taking a long time to complete. When making commits frequently, this gets extremely annoying very quickly.
Test Setup
While far from a comprehensive test, I setup a quick test for each tool.
- Create a temporary directory
- Install the tools
- Add a basic config
- Run
hyperfine(a benchmarking tool) with pass/fail commands - Copy the
results.mdto this post.
Since the temporary directory is inside /tmp/, which is mounted as a tmpfs,
there shouldn’t be a concern about storage bottlenecking.
Some more machine details for the curious:
- OS: Fedora 41
- CPU: AMD EPYC 7302 (16 cores - 3GHz)
- RAM: 128GB DDR4-2666
git version 2.47.1conform version v0.1.0-alpha.30 (43d9fb6d)[email protected]@commitlint/[email protected]
Test Scripts:
🗒️ Note
The proper install method is covered below in Tool Install if you are curious about install instructions.
Husky + commitlint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
## Set up test repo
tmp_dir=$(mktemp -d /tmp/husky_commitlint.XXX)
cd "$tmp_dir" && git init
## Install tools
npm install --save-dev husky @commitlint/{cli,config-conventional}
### Configure the repo to use Husky
npx husky
### Configure Husky to use commitlint
echo "npx --no -- commitlint --edit \$1" >.husky/commit-msg
## Setup commitlint
cat <<-EOF >.commitlintrc.yaml
extends:
- "@commitlint/config-conventional"
EOF
## Benchmark fail and pass cases
hyperfine --export-markdown results-fail.md --time-unit millisecond --ignore-failure 'git commit --allow-empty -m "fail"'
hyperfine --export-markdown results-pass.md --time-unit millisecond 'git commit --allow-empty -m "fix: fixed thing"'
Conform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
## Set up test repo
tmp_dir=$(mktemp -d /tmp/conform.XXX)
cd "$tmp_dir" && git init
### Install commit-msg hooks
cat <<EOF | tee .git/hooks/commit-msg
#!/bin/sh
$tmp_dir/conform-linux-amd64 enforce --commit-msg-file \$1
EOF
chmod +x .git/hooks/commit-msg
## Install tools
### linux-amd64
wget -qO- https://api.github.com/repos/siderolabs/conform/releases |
jq -r '.[].tag_name' |
sed -n 1p |
xargs -I {} wget -qO- https://api.github.com/repos/siderolabs/conform/releases/tags/{} |
jq '.assets.[] | select(.name == "conform-linux-amd64") | .browser_download_url' |
xargs -I {} wget -q {} && chmod +x conform-linux-amd64
## Setup conform
cat <<-EOF >.conform.yaml
policies:
- type: commit
spec:
conventional:
type:
EOF
## Benchmark fail and pass cases
hyperfine --export-markdown results-fail.md --time-unit millisecond --ignore-failure 'git commit --allow-empty -m "fail"'
hyperfine --export-markdown results-pass.md --time-unit millisecond 'git commit --allow-empty -m "fix: fixed thing"'
Results
Husky + commitlint:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fail" |
1422 ± 0.14 | 1400 | 1439 | 1.00 |
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fix: thing" |
2338 ± 0.44 | 2285 | 2414 | 1.00 |
Conform:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fail" |
11.4 ± 0.9 | 8.9 | 14.5 | 1.00 |
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fix: thing" |
969.4 ± 5.7 | 966.4 | 985.5 | 1.00 |
🤯 - I was expecting at least some improvement because of the lack
of husky, and conform being a Go binary.
But a 100x speedup? nice.
The reduced speedup during successful commits is from git itself. But the 2x
speedup is still pretty significant from a user standpoint. A one second
difference is definitely noticeable.
Hook Manager Overhead or Programming Language?
Now my curiosity was piqued. How much of the slowdown was from the husky hook
manager overhead and how much was from the fact conform is written in Go? I
decided to set up conform using pre-commit and husky to see what might be
causing the slowdown.
Conform + pre-commit:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fail" |
357.2 ± 7.3 | 344.6 | 367.0 | 1.00 |
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fix: thing" |
1258 ± 0.19 | 1238 | 1298 | 1.00 |
Conform + Husky:
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fail" |
639.1 ± 3.8 | 631.5 | 644.0 | 1.00 |
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
git commit --allow-empty -m "fix: thing" |
1541 ± 0.16 | 1519 | 1570 | 1.00 |
🤔 Interesting. The hook manager is definitely adding some overhead and the programming language is certainly a factor.
It might be worth investigating some more hook managers for performance benefits. Maybe even make one?
Test Conclusion
The performance differences are striking:
- Conform processes failed commits 100x faster than
husky+commitlint - Successful commits show a 2x speed improvement with
conform - Even when using a hook manager,
conformoutperformscommitlintsignificantly
Fail Tests:
| Configuration | Mean [ms] | Min [ms] | Max [ms] | Rel-Slowdown |
|---|---|---|---|---|
| Conform | 11.4 ± 0.9 | 8.9 | 14.5 | 0% |
| pre-commit + Conform | 357.2 ± 7.3 | 344.6 | 367.0 | -3003% |
| Husky + Conform | 639.1 ± 3.8 | 631.5 | 644.0 | -5506% |
| Husky + commitlint | 1422 ± 0.14 | 1400 | 1439 | -12374% |
Pass Tests:
| Configuration | Mean [ms] | Min [ms] | Max [ms] | Rel-Slowdown |
|---|---|---|---|---|
| Conform | 969.4 ± 5.7 | 966.4 | 985.5 | 0% |
| pre-commit + Conform | 1258 ± 0.19 | 1238 | 1298 | -30% |
| Husky + Conform | 1541 ± 0.16 | 1519 | 1570 | -59% |
| Husky + commitlint | 2338 ± 0.44 | 2285 | 2414 | -141% |
Conform is the clear winner in terms of performance.
Tool Install
While most of these commands will look familiar if you checked out the benchmark scripts, I wanted to add a more thorough install guide, now that you may have a better idea of what you might want to use.
Conform
Unfortunately, since conform is marked as a pre-release, there isn’t a
latest tag to grab. Anyone else having flashbacks to getting the Hugo binary?
No? Maybe it’s just me then 😬
So - we are going to have to do a little extra footwork to download the latest binary release.
Getting the Pseudo-Latest Release
|
|
Installing Conform
Get the See Getting the Pseudo-Latest Release
above. Create a Add Make Binary install method
conform binary:conform config:.conform.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
policies:
- type: commit
spec:
conventional:
descriptionLength: 72
scopes: [".*"] # Allow all scopes (regex)
types:
- build
- chore
- ci
- docs
- feat
- fix
- perf
- refactor
- revert
- style
- test
header:
case: lower
imperative: true
invalidLastCharacters: .
length: 72
spellcheck:
locale: US
git commit-msg hook:.git/hooks/commit-msg
1
2
3
#!/bin/sh
conform enforce --commit-msg-file "$1"
git hook executable:chmod +x .git/hooks/commit-msg
Install Initialize a project: Create Install the hook with Create 💡 Tip Once Installing with
pre-commit
pre-commit:pip install pre-commitmkdir example-project && cd example-project && git initpre-commit config:.pre-commit-config.yaml
1
2
3
4
5
6
7
repos:
- repo: https://github.com/siderolabs/conform
rev: main
hooks:
- id: conform
stages:
- commit-msg
pre-commit:pre-commit install --hook-type commit-msgconform config:.conform.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
policies:
- type: commit
spec:
conventional:
descriptionLength: 72
scopes: [".*"] # Allow all scopes (regex)
types:
- build
- chore
- ci
- docs
- feat
- fix
- perf
- refactor
- revert
- style
- test
header:
case: lower
imperative: true
invalidLastCharacters: .
length: 72
spellcheck:
locale: US
pre-commit and conform are installed, this script can quickly
configure a repo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/bin/sh
## Create pre-commit config
cat <<EOF >.pre-commit-config.yaml
# install with `pre-commit install -t commit-msg`
repos:
- repo: https://github.com/siderolabs/conform
rev: main
hooks:
- id: conform
stages:
- commit-msg
EOF
## Install pre-commit hooks
pre-commit install --hook-type commit-msg
## Create conform config
cat <<EOF >.conform.yaml
policies:
- type: commit
spec:
conventional:
descriptionLength: 72
scopes: [".*"] # Allow all scopes (regex)
types:
- build
- chore
- ci
- docs
- feat
- fix
- perf
- refactor
- revert
- style
- test
header:
case: lower
imperative: true
invalidLastCharacters: .
length: 72
spellcheck:
locale: US
EOF
Husky and Commitlint
Run the following commands from within a git repo.
husky:
Link to the official documentation
|
|
commitlint:
Link to the official documentation
|
|
Adding commitlint to husky:
|
|
Setting Up Git Hooks Automatically
So - we have our tools installed, but one of the semi-annoying things about Git hooks is that they need to be set up for each repository. However, we can partially automate this process for new repositories using Git’s template directory feature.
We can automate hook setup for new repositories:
- Create a template directory with your desired hooks
- Configure Git to use this template by default for new repositories
- Every new
git initwill automatically include your hook scripts
❗ Important: Cloning a repo will still require a new install of hooks to that repo
🗒️ Note
It is not possible to have files included in the repo with a template. This means no pre-populating a base config for the hooks. A workaround is to add an
init.shscript that is manually executed post init, but this isn’t ideal.
Using a Git Template With Conform
Create a template directory:
mkdir -p ~/git-templates/conform/hooks
Download the conform binary:
See: Conform or grab it from siderolabs/conform
Put the binary at ~/git-templates/conform/hooks/conform
🗒️ Note
The
commit-msgscript below executes the binaryconforminside the hooks directory, so make sure the binary isn’t named something likeconform-linux-amd64from when it was downloaded.Alternatively, adjust the
commit-msgfile to use a different executable name.
Add your commit-msg hook:
~/git-templates/conform/hooks/commit-msg
|
|
Make the hook executable:
chmod +x ~/git-templates/conform/hooks/commit-msg
Optional - make an init.sh:
~/git-templates/conform/hooks/init.sh
|
|
Make the init.sh executable:
chmod +x ~/git-templates/conform/hooks/init.sh
Tell git to use this template:
Option 1 - specify a template for each git init:
git init --template="$HOME/git-templates/conform"
Option 2 - use a global template:
git config --global init.templateDir ~/.git-template/conform
Add a conform config to the repo post-init:
From inside the new repo .git/hooks/init.sh (if added) or manually adding
.conform.yaml
Looking Forward: CI/CD Pipeline Integration
While local commit hooks are valuable, moving some hooks to your CI/CD pipeline can significantly improve developer experience and enable more comprehensive checks.
Moving Beyond Local Validation
An extended local git hook toolset often slows down development by running hooks far beyond basic commit linting and code formatting. They can quickly become bloated with hooks for things like tests and builds on every commit.
Benefits of Pipeline-Based Validation
By offloading these tasks to your CI/CD pipeline, developers can:
- Commit changes quickly without waiting for checks
- Push to feature branches for comprehensive validation
- Faster local development cycles
- Consistent validation environment
- Comprehensive security scanning
- Automated policy enforcement
- Parallel execution of intensive tasks
By moving extended validation to your CI/CD pipeline, developers can focus on writing code while still ensuring all necessary checks are performed thoroughly and consistently.
Conclusion
Structured commits might seem like a small detail, but they significantly impact project maintenance and team collaboration. The performance improvements offered by modern tools remove traditional friction points, making it easier than ever to maintain high commit standards.
Start with small steps - perhaps just categorizing commits as feat or fix.
As you see the benefits in your workflow, gradually adopt more conventions.
Remember, the goal isn’t perfection but better communication and automation in
your development process.