commit_types=(
  "build"
  "ci"
  "docs"
  "feat"
  "fix"
  "perf"
  "refactor"
  "revert"
  "style"
  "test"
)

for t in "${commit_types[@]}"; do
  type_alternatives="${type_alternatives:+$type_alternatives}\|$t"
done

type_regex="\($type_alternatives\)"
scope_regex="\((.\+)\)\?"
commit_regex="^$type_regex$scope_regex!\?: .*$"

I use a global git commit-msg hook to enforce conventional commit formatting for all of my commit messages.

Denoting the purpose of a commit in its title makes it much easier to skim through a project's history. It has the side benefit of making me think about the units of work I'm committing and encouraging me to break out changes unrelated to that purpose.

The actual linter has different feedback for different types of errors (e.g. incorrect formatting or length). For commit messages that fail checks I have a git alias for !git commit --edit --file=$(git rev-parse --git-dir)/COMMIT_EDITMSG which reuses the last commit message so that I don't need to rewrite it again from scratch.