Enforcing Commit Style

#git#organization#bash::alemi::2022-10-11 13:45

I write ugly commit messages more often than I'd like to admit: too long, too deep, too obscure, too messy, too angry...

To help me improve, it would be really helpful if git just wouldn't accept "bad" commit messages. While objectively defining (and identifying) "bad" is a subjective and potentially complex task, having git prevent me from committing is pretty easy thanks to git hooks.

What are git hooks?

Git hooks are scripts that git invokes in certain conditions. They live under .git/hooks folder in your project. Most of the times, an example script (ending with .sample) for each event is included.

$ ls .git/hooks/
applypatch-msg.sample      post-update.sample     pre-merge-commit.sample    pre-rebase.sample        update.sample
commit-msg.sample          pre-applypatch.sample  prepare-commit-msg.sample  pre-receive.sample
fsmonitor-watchman.sample  pre-commit.sample      pre-push.sample            push-to-checkout.sample

Some pre-action hooks returning non-zero exit codes will cancel further operations (you can always skip hooks on a single commit with --no-verify flag).

Read more about git hooks on git's site.

What should we hook on?

Searching in git documentation we can find commit-msg hook. It runs after a commit message has been created, and receives 1 parameter: path to a buffer containing the whole commit message. We can try it out like this:

#!/bin/bash
# bash because we're going to need sone things
echo $1
cat $1
exit 1

It prints received path and its content, and exits with code 1 to avoid actually committing (and save us a git reset HEAD~1 every time).

$ git commit -m "this commit won't work"
.git/COMMIT_EDITMSG
this commit won't work

Note that no changes happened since the commit wasn't actually created.

Basic styling

Commit style is subjective and there is no really correct or wrong way to do this. What follows is my preference.

First of all, let's store the commit message in a variable, strip commented lines and split by newline:

COMMIT_MSG="$(cat $1 | sed -e 's/^#.*$//g')"
IFS=$'\n' readarray COMMIT_LINES <<< "$COMMIT_MSG"

An initial style check could be making sure that 1st line is shorter than 80 characters.

if [[ ${#COMMIT_LINES[0]} -ge 80 ]]; then
	echo "[!] commit title too long (${#COMMIT_LINES[0]})"
	exit 1
fi

We can then make sure that, if there's a description, then a blank line has been left after commit title:

if [[ ${#COMMIT_LINES[*]} -gt 1 ]]; then
	if [[ ${#COMMIT_LINES[1]} -gt 1 ]]; then # newline is included
		echo "[!] no blank line after commit title"
		exit 1
	fi
fi

Commit Convention

There's a project named Conventional Commits which proposes a nice standard. It's nothing groundbreaking but we can reuse their specification to implement a commit verifier for ourselves.

This could use a regular expression, and after some tinkering on regex101 I came up with

CONVENTIONAL_REGEX="^(build|chore|fix)([[(](\w+)[])]|)(!|): (.{1,80})(\n\n.*|)"

Plugging this in our verifier should start enforcing Conventional Commits on our messages:

if [[ $COMMIT_MSG =~ $CONVENTIONAL_REGEX ]]; then
	echo "[*] commit message respects Conventional Commits"
else
	echo "[!] commit message does not respect Conventional Commits"
fi

Making it persist

Manually adding this git hook to each repository is not really user-friendly. We can tell git to initialize all new repositories from a template where we store our hooks:

$ git init --template ~/.git_template
$ vim ~/.git_template/hooks/commit-msg # add your hooks
$ git config --global init.templatedir '~/.git_template'

You can run git init on existing repositories to add your hooks.

Making it more user friendly

To avoid consistently bypassing this tool out of frustration, some more user experience should be baked into it. With some regex magic we can match almost right commits and somewhat find what went wrong. Most errors are just wrong categories. Some instruction prints to help remember how it works also are very appreciated by future self.

Check whole script on this gist.