rickvanlieshout.com/content/posts/2022/adopting-convential-commits/index.md

18 KiB

title date template category tags description socialImage
Adopting conventional commits and standard versions in a git and npm codebase 2022-11-07 post Development
git
conventional commits
semantic commits
INFORIT
Standard releases
A dive into conventional commits and their benefit whilst releasing with standard versions. ./media/husky-error.png

At my place of work, INFORIT, we are talking about adopting conventional commits as a company standard. However... debating commits is a tricky endeavor, everyone seems to have their own opinion about it and pretty much no one is following even the basic git rules (e.g title + body). It is precisely because of all of this that an earlier proposal about standardizing commit messages brought us nothing.

Now though, a solid 2 years later, our company has grown tremendously! Not just in actual size, but also in knowledge. The developers that were resistant to standardization, hell... even git, have since come around to many of the ideas that were implemented. Especially after seeing their, often new, colleagues thrive with it.

After seeing one of the new hires use conventional commits and their team being fine with it I decided to take the plunge and submit a proposal for it myself. Though the proposal only covers "conventional commits" and generating changelog entries from it I will be explaining the entire process of adopting conventional commits and standard versions in this blog post.

Getting started

Before we can do anything we need a git repository. I'll be using a hosted git repository (on Github) so I will clone it:

git clone git@github.com:Mastermindzh/conventional-commits-showcase.git

But, if you want to follow along without a hosted repository you can also manually create it yourself:

mkdir conventional-commits-showcase # create a folder with the name "conventional-commits-showcase"
cd conventional-commits-showcase
git init

What exactly are "conventional commits"

"Conventional commits" is a specification that allows developers to write commits that are easily parsable by both developers and systems alike. Or in their own words:

The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. This convention dovetails with SemVer, by describing the features, fixes, and breaking changes made in commit messages.

According to the specifications the commit message should be structured as follows:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

The specification also allows us to specify a "footer" with breaking changes and ticket references. A full example, with a breaking change and a fictional ticket number of 95043 would look like this:

feat(api): A test title for a change to the API

I made a few changes to the API. Now I have to write a body.
This is a looooooong body.
Multiple lines :O

BREAKING CHANGE: Well, I have to write something here... so :)
fixes #95043

Let's get the dependencies installed

First, let's make sure our repository contains an npm environment. If not, run:

npm init

Now that we have a package.json file we can start installing some dependencies:

npm install --save-dev husky @commitlint/cz-commitlint commitizen commitlint standard-version

That will give us all the dependencies we need during this blog:

Dependency function
Husky Allows us to easily manage and execute git hooks
commitlint Checks whether our commit messages meet the conventional commits specification
@commitlint/cz-commitlint adapter for commitlint to use commitizen
commitizen Provides a prompt during commit to fill in necessary information
standard-version Semver based release management and changelog generator based on conventional commits

Setting up the commitlint and commitizen configuration

First, let's add a script to the package.json that will do our commit for us:

{
  "scripts": {
    "commit": "git-cz",
  }
}

Now, when we call npm run commit it will actually run git-cz which will start our commitizen prompt. You can try it if you want, it won't actually do anything but throw an error right now. We can also configure commitizen in the package.json as well, to do so we add commitizen to the package.json's config object:

{
  "config": {
    "commitizen": {
      "path": "@commitlint/cz-commitlint"
    }
  }
}

If you ran npm run commit before and got an error, you can now do it again and face another, but different, error!

Now we'll set up the actual commit linting. Start by creating a file commitlint.config.js with the following content:

module.exports = {
  rules: {
    "body-leading-blank": [1, "always"],
    "body-max-line-length": [2, "always", 100],
    "footer-leading-blank": [1, "always"],
    "footer-max-line-length": [2, "always", 100],
    "header-max-length": [2, "always", 100],
    "subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]],
    "subject-empty": [2, "never"],
    "subject-full-stop": [2, "never", "."],
    "type-case": [2, "always", "lower-case"],
    "type-empty": [2, "never"],
    "type-enum": [
      2,
      "always",
      [
        "first type",
        "second type",
      ],
    ],
  },
  prompt: {
    questions: {
      type: {
        description: "This is the first prompt!",
      },
    },
  },
};

As you can see we have configured a whole bunch of things, but there are 2 things that matter for now:

  • a single "question" in our prompt.
  • a few types (first and second type)

Staging a few files (git add <file>) and running npm run commit will show us our prompt and a choice between the two types:

terminal showing the result of npm run commit

Now, our current config isn't very useful, so let's add some more types and a few additional questions (the defaults):

module.exports = {
  rules: {
    "body-leading-blank": [1, "always"],
    "body-max-line-length": [2, "always", 100],
    "footer-leading-blank": [1, "always"],
    "footer-max-line-length": [2, "always", 100],
    "header-max-length": [2, "always", 100],
    "subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]],
    "subject-empty": [2, "never"],
    "subject-full-stop": [2, "never", "."],
    "type-case": [2, "always", "lower-case"],
    "type-empty": [2, "never"],
    "type-enum": [
      2,
      "always",
      [
        "build",
        "chore",
        "ci",
        "docs",
        "feat",
        "fix",
        "perf",
        "refactor",
        "revert",
        "style",
        "test",
        "content",
        "blog",
      ],
    ],
  },
  prompt: {
    questions: {
      type: {
        description: "Select the type of change that you're committing",
        enum: {
          content: {
            description: "A new piece of content",
            title: "Content",
            emoji: "✨",
          },
          feat: {
            description: "A new feature",
            title: "Features",
            emoji: "✨",
          },
          fix: {
            description: "A bug fix",
            title: "Bug Fixes",
            emoji: "🐛",
          },
          docs: {
            description: "Documentation only changes",
            title: "Documentation",
            emoji: "📚",
          },
          style: {
            description:
              "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)",
            title: "Styles",
            emoji: "💎",
          },
          refactor: {
            description:
              "A code change that neither fixes a bug nor adds a feature",
            title: "Code Refactoring",
            emoji: "📦",
          },
          perf: {
            description: "A code change that improves performance",
            title: "Performance Improvements",
            emoji: "🚀",
          },
          test: {
            description: "Adding missing tests or correcting existing tests",
            title: "Tests",
            emoji: "🚨",
          },
          build: {
            description:
              "Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)",
            title: "Builds",
            emoji: "🛠",
          },
          ci: {
            description:
              "Changes to our CI configuration files and scripts (example scopes: Docker, Kubernetes, npm, git)",
            title: "Continuous Integrations",
            emoji: "⚙️",
          },
          chore: {
            description: "Other changes that don't modify src or test files",
            title: "Chores",
            emoji: "♻️",
          },
          revert: {
            description: "Reverts a previous commit",
            title: "Reverts",
            emoji: "🗑",
          },
        },
      },
      scope: {
        description:
          "What is the scope of this change (e.g. component or file name)",
      },
      subject: {
        description:
          "Write a short, imperative tense description of the change",
      },
      body: {
        description: "Provide a longer description of the change",
      },
      isBreaking: {
        description: "Are there any breaking changes?",
      },
      breakingBody: {
        description:
          "A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself",
      },
      breaking: {
        description: "Describe the breaking changes",
      },
      isIssueAffected: {
        description: "Does this change affect any open issues?",
      },
      issuesBody: {
        description:
          "If issues are closed, the commit requires a body. Please enter a longer description of the commit itself",
      },
      issues: {
        description: 'Add issue references (e.g. "fix #123", "re #123".)',
      },
    },
  },
};

As you can see we have a fair few questions, each with its own description. The one that will be new to most people is "content", which is a custom type that I added for this very blog. Reenacting our example commit from before will look like this:

terminal output of npm run commit showing the full commit

That's pretty nice already! Creating our example commit is nice and easy now. Next, let's add a git hook to stop us from messing up.

Setting up git hooks

Without the git hook, we can simply make a commit that doesn't adhere to conventional commits:

mkdir test-files && touch test-files/test1
git commit -m "this is not conventional :O"

Yikes! That is not what we want! Let's quickly revert it:

git reset --soft HEAD~1

Now, let's add a Husky git hook to combat future commits that are unconventional:

npx husky-init && npm install

This will automatically create the .husky/pre-commit file. Let's edit it and add commitlint:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit "$1"

Let's try our unconventional commit again:

touch test-files/test2
git add test-files/test2
git commit -m "this is not conventional :O"

a commit that does not adhere to conventional commits and Husky stopping our commit

Great, our commits seem to be working. Let's commit all the things we have now before we move on, crucially I will be using the regular git commit and providing the conventional commit syntax myself:

git commit -m "ci(git): Added conventional commits and some dependencies"

Releases

Now that we've successfully set up conventional commits we want to use those to release our first version and generate a changelog to boot. First, let's add some npm scripts again:

{
  "scripts": {
    "release": "standard-version",
    "release:minor": "standard-version --release-as minor",
    "release:patch": "standard-version --release-as patch",
    "release:major": "standard-version --release-as major",
  }
}

Now, let's add a configuration file for standard-version so that it will generate changelog entries. Create a file named .versionrc.json with the following content:

{
  "types": [
    {
      "type": "feat",
      "section": "Features"
    },
    {
      "type": "fix",
      "section": "Bug Fixes"
    },
    {
      "type": "content",
      "section": "New content",
      "hidden": true
    },
    {
      "type": "chore",
      "hidden": true
    },
    {
      "type": "docs",
      "hidden": true
    },
    {
      "type": "style",
      "hidden": true
    },
    {
      "type": "refactor",
      "hidden": true
    },
    {
      "type": "perf",
      "hidden": true
    },
    {
      "type": "test",
      "hidden": true
    }
  ],
  "commitUrlFormat": "https://github.com/mastermindzh/conventional-commits-showcase/commits/{{hash}}",
  "compareUrlFormat": "https://github.com/mastermindzh/conventional-commits-showcase/compare/{{previousTag}}...{{currentTag}}"
}

The first thing we declare is an array of types, for every type we can define a "section" and whether it is hidden or not. All hidden types will not show up in the changelog, the others will be grouped by type and will have a header with the text specified in the "section" field. Let's try it:

git add .versionrc.json package.json
git commit -m "ci(git): Set up standard-version"

# and let's add a feature
touch test-files/test3
git add test-files/test3
git commit -m "feat: Feature test3"

note: Where I've been using git commit -m you can also use npm run commit to get the interactive git prompt. Now, let's release a major version:

npm run release:major

terminal output of npm run release:major

The output is kind enough to tell us how to push our changes to the remote so let's just do it:

git push --follow-tags origin master

Now let's have a look at our git log:

output of git log showing our own commits and a new commit of type chore with the release

Now, let's also look at our changelog:

the first changelog

That looks great! standard-version automatically added a header for our version, sub-headers for our types and even linked our commits to the remote as configured in .versionrc.json.

Let's play a little

Now that we have a successful setup we can try a few commits and releases. Let's start with 2 bugfixes and a new feature:

# our first bugfix
touch test-files/test4
git add test-files/test4
git commit -m "fix: fixed test 4 by doing fancy stuff"

# our second bugfix
touch test-files/test5
git add test-files/test5
git commit -m "fix: test5 was difficult, but we got it"

# and finally a new feature
touch test-files/test6
git add test-files/test6
git commit -m "feat: Added feature number 6! So nice"

Now we'll release this as a minor, because we added a new feature. Execute

npm run release:minor

the changelog with different sections and their commits

breaking changes

All is well, but let's introduce a breaking change:

touch test-files/test7
git add test-files/test7
npm run commit

Output:

> conventional-commits-showcase@1.1.0 commit
> git-cz

cz-cli@4.2.5, @commitlint/cz-commitlint@17.2.0

? Select the type of change that you're committing: feat
? What is the scope of this change (e.g. component or file name) (press enter
to skip): (max 96 chars)
 (0)
? Write a short, imperative tense description of the change: (max 96 chars)
 (38) feature 7, with a few breaking changes
? Provide a longer description of the change (press enter to skip):
 We broke some stuff, woopsie
? Are there any breaking changes?: Yes
? Describe the breaking changes:
 api endpoint 'test7' now returns json instead of xml
? Does this change affect any open issues?: No
[master e7f7b0e] feat: feature 7, with a few breaking changes
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 test-files/test7

Which gives us the following changelog:

changelog with a very clear breaking changes banner

Conclusion

Phew! That was quite a long post. Hopefully, you've managed to set up conventional commits and/or standard-releases to your liking and if not there's always the comment section. The end-result can be found on Github, the Changelog is especially pretty.