Featured image of post Conventional Commits Change my Brain

Conventional Commits Change my Brain

More than changelogs

What is a conventional commit?

In case you’re not aware, conventional commits is a standard format for writing commit summaries. It looks like this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
build(deps): bump git2 from 0.18.3 to 0.19.0

Bumps [git2](https://github.com/rust-lang/git2-rs) from 0.18.3 to 0.19.0.
- [Changelog](https://github.com/rust-lang/git2-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/git2-rs/compare/git2-0.18.3...git2-0.19.0)

---
updated-dependencies:
- dependency-name: git2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

This example is from dependabot, which does conform to conventional commits. Notice that only the first line, the commit summary, is part of the conventional commits spec here. The last line is a “footer”[1], or technically, a trailer added by the git commit -s flag, -s short for --signoff.

The exact spec looks like this:

1
2
3
4
5
<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Unlike many project specific standards, such as that of angular or nixpkgs, what sets conventional commits apart is that it aims to be a universal commit standard, a bit like how semantic versioning seeks to be a universal software versioning standard.

In the same sense as semantic versioning provides meaning through version numbers, such as a major number increasing meaning a breaking change, conventional commits also seeks to encode information about the state of a project in the commit summaries, and provides enough information to reasonably tell when it’s time to do a patch, minor, or major version bump.

It further also provides ways to specify if a commit contains a breaking change, via a git footer.

It may be easiest to explain this by just showing you the spec.


The commit contains the following structural elements, to communicate intent to the consumers of your library:

  1. fix: a commit of the type fix patches a bug in your codebase (this correlates with PATCH in Semantic Versioning).
  2. feat: a commit of the type feat introduces a new feature to the codebase (this correlates with MINOR in Semantic Versioning).
  3. BREAKING CHANGE: a commit that has a footer BREAKING CHANGE:, or appends a ! after the type/scope, introduces a breaking API change (correlating with MAJOR in Semantic Versioning). A BREAKING CHANGE can be part of commits of any type.
  4. types other than fix: and feat: are allowed, for example @commitlint/config-conventional (based on the Angular convention) recommends build:, chore:, ci:, docs:, style:, refactor:, perf:, test:, and others.
  5. footers other than BREAKING CHANGE: <description> may be provided and follow a convention similar to git trailer format.

Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE).

A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays.


So basically, if there is a type fix, that’s a PATCH bump to your semver, a feat is a MINOR bump, and a BREAKING CHANGE: footer or a ! after the type/scope is a MAJOR bump.

If we for instance have commit with the summary fix: change windows-only imports to be windows-only, that’s a PATCH change, and if we are on version 3.2.48, then next version will have to be at least 3.2.49.

If we instead had a summary feat: add --no-|show-symlinks flags for filtering output, then that would a MINOR change, and again, if we were on 3.2.48, next release would be 3.3.0.

Lastly, if we had a summary feat(flags)!: add --classify=always,auto,never, even thou it has a feat type, the ! indicates this is a breaking change, and so we must cut a new MAJOR version on next release if we include this commit, meaning that if we were on 3.2.48, we’d go to 4.0.0.

Yes. Breaking changes means a new major version. Yes, that might seem sever to you if you’re not used to being at the helm of something being used by a lot of people, but that’s how we make these sausages (at least if we’re actually trying to do it correctly). The guy who authored the semver spec, Tom Preston-Werner (also known for co-founding GitHub and creating gravatar) has a blog post about this called Major Version Numbers are Not Sacred.

What people end up doing in practice is building up a lot of breaking changes and then releasing them all at once, to not constantly destroy the API/ABI and UX of their software. How this ties in with a more continuous development is a very interesting question, e.g. how does eza manage to release a new version every week, yet also work on breaking changes like config files or moving to a new command line parser (specifically clap). I think such a discussion is far out of scope for this post, but I may write about it in the future.

Conventional Commits and Linguistic Relativity

If you were to ask wikipedia what “Behavioral Design” means, it would reply:

Behavioural design is a sub-category of design, which is concerned with how design can shape, or be used to influence human behaviour.

Now, how is that relevant to us as developers, maintainers, or just hobbyist hackers?

Well you see, the design of conventional commits, whether intentional or not, has a lot of decisions that end up shaping how contributors interact with your project.

When I initially forked eza, I mostly moved us to conventional commits fairly early because I was concerned with a maintenance burden. I knew that exa had been a project of maintainer burnout, and what I was trying to do was create longevity for the project. That meant that if there was something I could offload to someone or something else, I would, including changelogs.

And yes, conventional commits means I don’t have to write changelogs, I can automate it, and it means I don’t have to think too hard about what version is next, it’s automatically generated. In fact, I can release eza every week because a release is just running two scripts, doing a cargo publish, and a nix-update eza in the nixpkgs repo and opening a PR.

While that release velocity is great, I found another, much more interesting perk of using conventional commits. Namely that conventional commits lead to better commits, better commit summaries, better PRs, and better structured commits.

For instance, if a project just allows a commit summary to be an arbitrary string, what you’ll often end up with is either something like made extensions work, a single commit that encompasses vast refactorings of the codebase, introduces a feature, fixes 3 different bugs, and is one big monolithic and unrevievable mess.

On the other hand, it also saves you from the series of wip, wip 2, formatted code and added quantum cryptography, made compile. These are equally unreviewable, and completely destroy your git log.

Now, you may have the discipline of projects like nixpkgs of asking contributors to rebase their commits, so that they each represent unique atomic changes. But even then, what conventional commits gives you is a language to express what these changes actually are.

Before I had conventional commits as something I could just write without looking at the spec, I would genuinly struggle putting words to what my changes did. It made it hard to create good PRs to projects.

Now, it’s much easier. I just go:

  • refactor(parser): used enums instead of bools in match logic
  • fix(parser): capital letters not differentiated
  • fix(parser): long lines ignored
  • feat(parser): add long flags
  • docs(parser): document long flag usage
  • style: change flag rendering
  • ci: check parser flags workflow

Not only does this make my PR much more structured, but by getting into that habit, I find that my work also becomes much more organized. Instead of just working on some hodgepodge mix of things at the same time and committing either to save my work or to put all of it together in a disorganized sack of a single commit, I find that I am much more able to dissect a problem into discrete solvable steps.

I’d even go as far as saying this makes you a better programmer, as breaking down a large problem into small solvable chunks is a great problem solving strategy.

What conventional commits does is it gives you this language to describe the steps it takes to solve a problem, and getting familiar with that vocabulary not only makes you a better programmer, but it makes it much easier to work with you, and it heavily reduced the burden on maintainers.

Linguistic relativity is the idea that the structure of a language influences the speakers world view and cognition, changing how you interact with your surroundings. Linguistic relativity is a hypothesis, but I find it pretty easy to prove the weak case that if you can’t signify something, you will struggle to operate with it.

In other words, conventional commits get you merged faster, and makes your code better. Hopefully you’re sold by this point.

Footnotes

[1]: There are some tools for dealing with these, such as git-footers. Personally, I don’t really use those. But it’s good to know they exist.