The previous module ended with an uncomfortable diagnosis: the task-manager team now has an impeccable process — forks, pull requests, reviews, protected branches, continuous integration — and it can still end up with a history nobody is able to read. We start putting that right at the most everyday and most neglected point of all: the commit message.

This lesson finally settles two of the course's debts. In lesson 02-04 we learnt to commit changes with git commit -m and said that message conventions would arrive here. In lesson 06-01 we wrote a commit-msg hook that demanded a ticket in the form GT-NNN, but we were validating a convention we had not yet explained. And in 07-06 we validated that same convention in CI. It is time to explain where all of that comes from.

It is worth understanding the scale of the matter before we start. A developer writes between five and twenty commit messages a day. They are the only prose the project produces systematically, the only text that survives refactors, and the only one that will still be there when the original author has left the company. The code says what the program does; the history is the only place where why it does it that way can be written down.

Contents

  1. Who a commit message is written for
  2. The why matters more than the what
  3. Anatomy of a good message
  4. The subject line: the seven rules
  5. The body: motivation and context
  6. The footer: references, tickets and co-authorship
  7. Bad messages and their good version
  8. Conventional Commits
  9. BREAKING CHANGE and the derived version
  10. Templates with commit.template
  11. Writing in the editor, not with -m
  12. Enforcing the convention: hook and CI
  13. The language of the message

  1. Who a commit message is written for

There is an intuitive and wrong answer: "for me, so I remember what I did". It is wrong because you, a week from now, will not need the message: you will have the context fresh. The message exists for three readers who do not have it.

Reader 1: you, two years from now. You remember nothing of the context. Not the meeting where that was decided, nor the client who complained, nor the browser limitation that forced you into something odd. The commit is all that is left.

Reader 2: whoever runs git blame on an odd line. It is the most frequent case and the most valuable. Somebody finds an absurd condition in app.js:

// Never filter by date if the browser is Safari < 15
if (!isOldSafari()) { applyDateFilter(); }

Their first impulse will be to delete it. In lesson 06-03 we saw that git blame answers which commit introduced that line. If that commit says fixes, the reader will delete the condition and reintroduce the bug. If it says Avoid the date filter on Safari 14: its Intl.DateTimeFormat returns the time zone in a format that breaks parsing, they will not delete it. The quality of the message decides whether the bug comes back.

Reader 3: whoever reviews your pull request. In lesson 07-02 we saw that reviewing well requires understanding the intent before the diff. A PR with atomic, well-described commits is reviewed commit by commit and in half the time. A PR with eight commits called wip, wip2 and now it works forces the reviewer to read a giant diff with no guidance at all.

There is a fourth reader that is also a machine: the tools that generate changelogs, derive versions and filter the history. That is what section 8 is about.

flowchart LR
    C["Commit<br/>+ message"] --> A["Your future self<br/>(2 years)"]
    C --> B["git blame<br/>on an odd line"]
    C --> D["The PR reviewer"]
    C --> E["Tools:<br/>changelog, SemVer"]

  1. The why matters more than the what

This is the central idea of the whole lesson, and the hardest one to take on board.

The diff already says the what. It is there, complete, exact and for ever. git show displays it with character-level precision. A message that says Change the button colour to blue when the diff says - color: #c00; / + color: #06c; is redundant: it does not add a single bit of new information.

What the diff can never tell you:

The diff knows The diff does not know
Which lines changed Why they had to change
Which files were touched Which alternatives were discarded and why
The new value of a constant Where that value comes from
That a condition was added Which specific bug it prevents
That a function was deleted Whether it was dead or whether it moved
That a dependency version went up Whether it was for a vulnerability or for a feature

Compare these two messages about exactly the same diff of task-manager:

Raise the timeout to 30 seconds
Raise the sync timeout to 30 seconds

The previous value of 5 seconds came from the prototype, when the
sync only sent the modified tasks. Since GT-118 we also send the
attachments, and on slow mobile connections the first upload of a
task with an image comfortably exceeds that limit: the user sees
an error and retries, duplicating the task.

30 seconds covers the 99th percentile of the times measured in
pre-production over a week. We do not raise it any further because
beyond that point the user assumes the application has hung.

Refs: GT-134

The first is information that was already in the diff. The second contains four facts that are nowhere else in the repository: where the old value came from, which specific bug it caused, how the new one was chosen and why it is not higher. Two years from now, when somebody proposes lowering it again "because 30 seconds is outrageous", that message is the answer.

The rule: if your message can be worked out by reading the diff, you have not written the message yet.

  1. Anatomy of a good message

Git imposes no format, but it does treat the text in a structured way, and almost every tool in the ecosystem assumes the same convention. The canonical structure has three parts:

Summarise the change in 50 characters or less

Explanatory body, wrapped at 72 columns. Explain the motivation
for the change and the context needed to understand it. The what
is in the diff; the why goes here.

It can have several paragraphs, separated by blank lines.

- And lists, if they make things clearer
- For example, to enumerate the effects

Refs: GT-134
Co-authored-by: Bruno Salas <bruno.salas@example.com>
flowchart TD
    A["Subject line<br/>≤ 50 characters, imperative, no full stop"] --> B["Blank line<br/>(mandatory)"]
    B --> C["Body<br/>72 columns · motivation and context"]
    C --> D["Blank line"]
    D --> E["Footer<br/>Refs, Co-authored-by, BREAKING CHANGE"]

The blank line between the subject and the body is not decoration: it is syntax. Git uses the first line as the subject in git log --oneline, in %s of --pretty, in the subject of the emails produced by git format-patch and in the default title of a pull request. If you skip it, the whole message becomes the subject:

# WRONG: no blank line
git commit -m "Fix the filter
The filter was not case-insensitive."

git log --oneline -1
a1b2c3d Fix the filter The filter was not case-insensitive.

All the text run together, and --oneline rendered useless.

  1. The subject line: the seven rules

Rule 1: 50 characters or less

It is not an arbitrary magic number: it is the practical limit for git log --oneline, a hosting platform's commit list and git shortlog to fit on one line without being truncated. Hosting platforms cut off visually at around 72 characters, and GitHub warns you from 50.

If it does not fit in 50 characters, it is nearly always one of two things: the commit does too many things (we will see this in lesson 08-02) or you are putting into the subject what belongs in the body.

Rule 2: in the imperative mood

Write as if you were giving an order to the repository. The infallible test is completing this sentence:

"If applied, this commit _______"

Wrong Right
~~Added~~ the filter by label Add the filter by label
~~Adding~~ the filter by label Add the filter by label
~~I fixed~~ the saving Fix saving to local storage
~~Assorted fixes~~ Fix the overflow on mobile

It is not a whim: Git itself writes that way. Merge branch 'x', Revert "...", Initial commit. If your messages use another tense, the history mixes two voices.

Rule 3: no full stop at the end

It is a title, not a sentence. And in 50 characters, one character counts.

Rule 4: initial capital letter

Unless you use Conventional Commits (section 8), where the usual convention is lower case after the colon. Pick one and be consistent.

Rule 5: be specific

Fix the error says nothing. Fix the deletion of tasks with an attachment does.

Rule 6: do not repeat the file name

Change app.js is useless: git log --stat already says that. The scope, if you need one, goes with the Conventional Commits syntax.

Rule 7: if you need "and", it is probably two commits

Add the filter by label and fix the footer margin describes two independent changes. Lesson 08-02 develops this idea in depth.

  1. The body: motivation and context

The body is optional for a trivial change and mandatory for any non-obvious change. It is wrapped manually at 72 columns for a very specific reason: git log indents the body with four spaces, so 72 + 4 = 76, and it fits in an 80-column terminal without Git rewrapping the text (it does not: Git never reformats your message).

A useful body answers these questions, not necessarily all of them:

  1. What was the problem or the need? The previous state and why it was not good enough.
  2. Why this solution? And above all, which alternatives were discarded.
  3. What side effects does it have? Performance, compatibility, migrations.
  4. What does this change NOT do? Setting the boundaries is as useful as describing.
  5. What do you need to know in order to review it? A link, a measurement, a command to reproduce it.

A complete example, about a real change to task-manager that Carla made:

Store the tasks in IndexedDB instead of localStorage

localStorage has a practical limit of 5 MB per origin and is
synchronous: every save blocks the main thread. With lists of more
than 2,000 tasks, Carla measured 180 ms freezes on every keystroke
in the search field, because autosave fires on every change.

IndexedDB is asynchronous and has no such limit. The migration is
transparent: on start-up, if we detect data in localStorage and
the database is empty, we import it and clear the old key. That
migration code can be deleted once two versions have passed
(GT-141).

Using the File System Access API was discarded because it is only
available in Chromium-based browsers and Bruno needs Safari.

This change does NOT touch the sync with the server, which still
reads from the same data access module.

Refs: GT-137

Notice what it contributes that is not in the diff: the specific measurement, the discarded alternative with its reason, the expiry date of the migration code and the explicit limit of the change.

  1. The footer: references, tickets and co-authorship

The footer (trailer in Git's terminology) is a block of Key: value lines at the end of the message, separated by a blank line. Git understands it natively through git interpret-trailers, and the platforms interpret it too.

Trailer What it is for
Refs: GT-134 Relates the commit to a ticket without closing it
Closes: GT-134 Closes the ticket on merge (depending on the platform)
Fixes: GT-134 The same, for bug fixes
Co-authored-by: Name <email> Attributes the commit to more than one person
Signed-off-by: Name <email> Certifies the origin (DCO); git commit -s adds it
Reviewed-by: Name <email> Records the review in the commit itself
BREAKING CHANGE: ... Marks a compatibility break (section 9)

Co-authorship deserves a special mention, because it solves a real problem for the team. When Ana and Bruno pair-program, the commit is signed only by whoever types it, and git blame (lesson 06-03) attributes everything to that person. With the trailer, the platforms recognise both:

git commit -m "Refactor the sync module" -m "$(cat <<'EOF'
Extract the retry logic into its own module so it can be tested
without a network.

Co-authored-by: Bruno Salas <bruno.salas@example.com>
EOF
)"

An important detail: the Co-authored-by line must go at the end, preceded by a blank line, and the email must be the one the person has registered on the platform. Otherwise the name shows up but is not linked to the account.

You can automate it with git interpret-trailers:

# Add a trailer to a message that is already written
git interpret-trailers --in-place --trailer "Refs: GT-134" message.txt

# And query them afterwards
git log -1 --pretty="%(trailers:key=Refs,valueonly)"

  1. Bad messages and their good version

This table collects real messages from the early history of task-manager, back when the team had no convention, alongside what they should have been.

Real message Why it is bad Good version
changes Says absolutely nothing Add the filtering of tasks by label
fix What was fixed? Where? Fix the deletion of tasks with an attachment
update app.js The file name is already in the diff Extract the list rendering into a function
wip Should not be on main (see 08-02) Squash into the final commit with rebase -i
asdfasdf Haste Add the Ctrl+K shortcut for the search box
Fixed the bug Carla mentioned yesterday Ephemeral context; "yesterday" means nothing a year from now Fix the order of overdue tasks + a body with the detail
Changes requested in the review True today, incomprehensible tomorrow Validate the title length before saving
Now it works Depends on the previous commit to make sense Squash with --fixup (lesson 05-02)
Add the filter and fix the footer CSS and bump the dependency Three changes in one commit Three commits
Merge branch 'main' of git.example.com:... A noise merge caused by running pull without --rebase Avoid it with pull.rebase true (lesson 04-04)
Update dependencies Which ones? Why? Bump marked to 12.0.1 for a CVE in HTML sanitising
. The universal classic Anything at all

Notice the pattern: bad messages are nearly always short out of haste or dependent on context that evaporates ("yesterday", "what Carla mentioned", "now it works").

  1. Conventional Commits

So far we have talked about prose for humans. Conventional Commits is a convention that additionally makes the subject machine-readable, without losing human readability. It is the one used by the commit-msg hook we wrote in lesson 06-01.

The format:

<type>(<optional scope>): <description>

[optional body]

[optional footer(s)]

Examples on task-manager:

feat(filters): add the filter by label
fix(sync): avoid duplicating tasks when retrying the upload
docs(readme): document the required environment variables
refactor(app): extract the list rendering into a function
perf(list): virtualise the list above 500 tasks
test(sync): cover the retry with an intermittent network
build(deps): bump marked to 12.0.1
ci(actions): cache node_modules by lockfile hash
style(css): sort the properties in styles.css
chore(git): add .env to .gitignore

Table of types

Type What it means Does it affect the SemVer version?
feat New functionality for the user MINOR
fix A bug fix PATCH
docs Documentation only No
style Formatting, spaces, commas; no behaviour change No
refactor Restructuring without changing behaviour No
perf Performance improvement PATCH (sometimes MINOR)
test Adds or fixes tests No
build Build system or dependencies No
ci Continuous integration configuration No
chore Maintenance tasks with no effect on production code No
revert Reverts an earlier commit (lesson 05-06) It depends

The scope in brackets is free-form and each project defines its own. On task-manager the team agreed on: filters, sync, list, ui, css, readme, deps, ci. A stable scope turns git log --oneline | grep '(sync)' into a useful query.

What you gain

  1. Filtering the history is trivial. git log --oneline --grep '^feat' gives you every new feature.
  2. Automatic changelog. Tools such as git-cliff, standard-version or semantic-release group the commits by type and generate CHANGELOG.md with no human intervention.
  3. A derived SemVer version. This links directly to lesson 05-05: there we agreed on MAJOR.MINOR.PATCH and annotated tags, but we decided the number by hand. With Conventional Commits, the number is derived from the history.
  4. Granularity discipline. If you do not know which type to use, it is nearly always because the commit does more than one thing.

An example of derivation

Suppose that since v1.4.0 the history of task-manager contains:

git log --oneline v1.4.0..HEAD
9f3a1c2 docs(readme): fix the installation link
7b2e4d1 fix(sync): avoid duplicating tasks when retrying the upload
4c8a9f0 feat(filters): add the filter by label
2d1b3e7 test(sync): cover the retry with an intermittent network

There is one feat and no BREAKING CHANGE, so the next version is v1.5.0. If there had only been fix commits, it would be v1.4.1. If there had been neither feat nor fix, there would be no version to release.

  1. BREAKING CHANGE and the derived version

A compatibility break is a change that forces whoever consumes your code to do something. In SemVer it raises the MAJOR number, and it is the only case where automatic derivation cannot fail without serious consequences.

It is marked in two equivalent ways:

Form 1 — with ! after the type or the scope:

feat(api)!: remove the `order` parameter from listTasks()

Form 2 — with a trailer in the footer (which lets you explain the migration):

feat(api): unify the sorting into a single parameter

BREAKING CHANGE: `listTasks()` no longer accepts the `order`
parameter. Use `criterion`, which accepts the same values plus
`due`. Replace `listTasks({order: 'alpha'})` with
`listTasks({criterion: 'alpha'})`.

Refs: GT-152

The second is clearly better: the message includes the migration guide, and the changelog tools copy it verbatim into the breaking changes section. Use both at once if you want the ! to be visible in --oneline.

Types present since the last tag Next version
Only docs, test, chore, ci, style No release
At least one fix, no feat PATCH: 1.4.01.4.1
At least one feat, no breaks MINOR: 1.4.01.5.0
At least one BREAKING CHANGE or ! MAJOR: 1.4.02.0.0

Beware of squashing. If you integrate pull requests with squash merge (we will look at this in depth in lesson 08-02), the message that counts for the derivation is the one on the squashed commit, not the ones on the branch. A BREAKING CHANGE hidden in the third commit of a branch disappears if the squash title is feat: assorted improvements. It is a strong argument for reviewing the squash title before merging.

  1. Templates with commit.template

The cheapest way to get a whole team writing better messages is to put the reminder in front of their eyes at the exact moment. That is what commit.template does: a file whose content preloads the editor every time you commit.

Create the file in the repository, so that Ana, Bruno and Carla all use the same one:

# In the root of task-manager

Content of .gitmessage:

# <type>(<scope>): <description in the imperative, ≤50 characters>
#
# Types: feat fix docs style refactor perf test build ci chore revert
# Scopes: filters sync list ui css readme deps ci
#
# --- Body (wrap at 72 columns) -----------------------------------|
# Why was this change necessary? What problem does it solve?
# Which alternatives were discarded and why?
# What side effects does it have?
#
# --- Footer ------------------------------------------------------
# Refs: GT-NNN
# Co-authored-by: Name <email@example.com>
# BREAKING CHANGE: describe the migration required

And it is switched on with:

# Only for this repository (recommended: the template belongs to the project)
git config --local commit.template .gitmessage

# Or for all your repositories
git config --global commit.template ~/.gitmessage

Three details that matter:

  • Lines starting with # are discarded when you save, so the instructions never reach the history. The comment character can be changed with core.commentChar if you need a literal # (for example, to write #123).
  • commit.template is not versioned by itself. The .gitmessage file is indeed in the repository, but the configuration that activates it lives in .git/config, which as we saw in lesson 01-05 is local. Document the git config --local commit.template .gitmessage in the README.md, or add it to the project's bootstrap script.
  • The template does not apply with -m. Only when Git opens the editor. Which brings us to the next section.

  1. Writing in the editor, not with -m

git commit -m "..." is convenient and that is why it is everybody's default habit. It is also the structural cause of bad messages, for three reasons:

  1. The terminal quote pressures you into being brief. Typing a paragraph inside quotes in the shell is awkward, so you do not type it.
  2. You cannot see the context. The editor shows you, commented out, the list of modified files. Very often that is where you discover you have staged a file you did not want.
  3. There is no review. With -m you press Enter and that is that. In the editor you read what you have written before saving.

Compare:

# The quick habit
git commit -m "fix the filter"

# The good habit: opens the editor with the template and the context
git commit

When you run plain git commit, the editor (core.editor, lesson 01-06) opens with the template and with this underneath:

# Please enter the commit message for your changes.
# Lines starting with '#' will be ignored.
#
# On branch GT-134-timeout-sync
# Your branch is up to date with 'origin/GT-134-timeout-sync'.
#
# Changes to be committed:
#	modified:   app.js
#	modified:   README.md

And with -v you also get the full diff inside the editor, which is the best possible aid for writing the why:

git commit -v

Turn it on for good:

git config --global commit.verbose true

When -m is fine: for genuinely trivial commits (docs: fix a typo), in scripts, and for the --fixup commits of lesson 05-02, which are going to disappear in the rebase.

If the message is long and you would rather not depend on the editor, there is a clean alternative:

# Several -m flags become paragraphs separated by a blank line
git commit -m "fix(sync): avoid duplicating tasks when retrying" \
           -m "The retry did not check whether the previous upload had arrived. With an intermittent network that created duplicates." \
           -m "Refs: GT-134"

  1. Enforcing the convention: hook and CI

A convention that is not checked erodes within three weeks. In lesson 06-01 we wrote a commit-msg hook; now that the convention has been explained, here is the full, commented version.

#!/usr/bin/env bash
# .githooks/commit-msg — validates the format of the message
# Git passes the path of the temporary file holding the message as $1.

full_message=$(cat "$1")
# First line that is neither a comment nor empty: the subject.
subject=$(grep -v '^#' "$1" | grep -v '^[[:space:]]*$' | head -n 1)

# Merges and reverts are generated by Git: we do not validate them.
if echo "$subject" | grep -qE '^(Merge|Revert) '; then
  exit 0
fi

# Format: type(optional scope)!: description
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,}$'

if ! echo "$subject" | grep -qE "$pattern"; then
  echo "ERROR: the subject does not follow Conventional Commits." >&2
  echo "  Received: $subject" >&2
  echo "  Format:   type(scope): description" >&2
  echo "  Types:    feat fix docs style refactor perf test build ci chore revert" >&2
  exit 1
fi

# Length of the subject
if [ ${#subject} -gt 72 ]; then
  echo "ERROR: the subject is ${#subject} characters long (maximum 72, ideal 50)." >&2
  exit 1
fi

# No full stop at the end
case "$subject" in
  *.) echo "ERROR: the subject must not end in a full stop." >&2; exit 1 ;;
esac

# A ticket reference is mandatory somewhere in the message
if ! echo "$full_message" | grep -qE 'GT-[0-9]{3,}'; then
  echo "ERROR: the ticket reference (GT-NNN) is missing." >&2
  echo "  Add a 'Refs: GT-134' line at the end of the message." >&2
  exit 1
fi

exit 0

Remember from lesson 06-01 the three requirements for it to work:

chmod +x .githooks/commit-msg
git config --local core.hooksPath .githooks

And remember too the warning that lesson 07-06 turned into a principle: a client-side hook is not a control. git commit --no-verify skips it. That is why the same check has to be in CI, where nobody is in charge:

# .github/workflows/ci.yml (fragment)
  commit-messages:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # we need the full history
      - name: Validate the messages of the PR
        run: |
          # Only the commits this branch contributes, not those of main
          BASE="${{ github.event.pull_request.base.sha }}"
          failures=0
          while read -r sha; do
            subject=$(git log -1 --pretty=%s "$sha")
            if ! echo "$subject" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .+'; then
              echo "::error::$sha — invalid subject: $subject"
              failures=1
            fi
          done < <(git rev-list "$BASE..HEAD" --no-merges)
          exit $failures

An important nuance that is always forgotten: if your integration policy is squash merge, validating the branch's individual commits makes little sense — they are going to disappear — and what you have to validate is the title of the pull request, which will be the subject of the squashed commit. Which of the two policies is the right one is exactly the question the next lesson answers.

  1. The language of the message

It is a discussion that comes up in every team and it has a single correct answer: pick one and stick to it.

Option For Against
English It is the language of the ecosystem; the Conventional Commits keywords already are English; it makes hiring and collaborating abroad easier If the team is not fluent, the messages turn telegraphic and lose the why
The team's own language Richer, more precise messages if it is the working language; less friction when writing long bodies It clashes visually with feat/fix; it complicates opening the project to outside contributors

What is genuinely bad is not choosing wrongly: it is not choosing. A history where half the subjects read feat: add the filter by label and the other half say exactly the same thing in the team's own language is harder to search (--grep stops working), harder to read and conveys carelessness.

A very common compromise in teams that do not work in English is to keep the type and the scope in English (because they are keywords of the convention and the tools understand them) and to write the description, the body and the trailers in the working language (because it lets people explain the why without impoverishing it). On task-manager the four of them work in English, so the team agreed, and wrote it in the README.md: everything in English, from the type to the last trailer.

feat(filters): add the filter by label

Lists of more than 200 tasks were unmanageable without filtering...

Refs: GT-134

Diego, the outside contributor, found the decision written in the README.md of his fork before opening his first pull request. That is where it belongs: in the repository, not in anyone's head.

Common Mistakes and Tips

Mistake 1: describing the what instead of the why. It is the dominant mistake. Change the value to 30 adds nothing; The 5 s value came from the prototype and no longer covers uploading attachments adds everything. Acid test: if the message can be worked out from the diff, you have not written the message yet.

Mistake 2: forgetting the blank line after the subject. It breaks --oneline, the PR titles and %s in --pretty. It is syntax, not style.

Mistake 3: ephemeral context. "As we discussed yesterday", "what Carla asked for", "according to this morning's email". Six months from now they mean nothing. Write the content, not the pointer.

Mistake 4: linking to an external system instead of summarising. See GT-134 leaves you stranded the day the ticket tracker is migrated or somebody reads the history without access to it. Reference the ticket as well as summarising, never instead of.

Mistake 5: process messages. Changes requested in the review, Now it works, Another try. They describe your working day, not the code. Squash them with --fixup and rebase -i --autosquash (lesson 05-02) before publishing.

Mistake 6: believing it can be fixed later. The message of a published commit can only be changed by rewriting the history, and that clashes with the golden rule of lesson 05-01. A bad published message is permanent. (If you have not pushed it yet, git commit --amend fixes it; lesson 09-02 develops the undo cases.)

Mistake 7: applying Conventional Commits halfway. Half a dozen commits with feat: and the rest in free prose produces the worst of both worlds: the discipline of the convention with none of its automatic benefits, because the changelog comes out incomplete.

Tip 1: write the message before the code. It sounds odd and it works. If you cannot summarise in one line what you are about to do, the change is not properly scoped yet.

Tip 2: turn on commit.verbose today. git config --global commit.verbose true puts the diff inside the editor. It is the cheapest quality improvement in the whole module.

Tip 3: read your own history once a month. git log --oneline -30. If you do not understand your commits from three weeks ago, you have a problem that will only grow.

Tip 4: use git shortlog -sn --no-merges and git log --oneline --grep. If your queries against the history return nothing useful, it is a sign that the messages are not useful either.

Tip 5: in reviews, review the messages too. It is perfectly legitimate to ask in a PR for "rewrite the message of the second commit". It is the only way the convention survives.

Tip 6: the template in the repository, not in your $HOME. That way Diego has it as soon as he clones the fork.

Exercises

Exercise 1: rewrite five bad messages

For each of these real messages from the early history of task-manager, write the good version: a subject in the imperative of ≤50 characters and, where the case calls for it, a body. Invent whatever plausible context is missing.

  1. fix css
  2. update app.js
  3. Added the search box and fixed the footer margin
  4. Now it really works
  5. Bump the dependency

Exercise 2: set up the complete infrastructure

In a test repository:

  1. Create a template file .gitmessage with the Conventional Commits structure and activate it with commit.template at local level.
  2. Turn on commit.verbose.
  3. Install the commit-msg hook from section 12 in .githooks/ and configure core.hooksPath.
  4. Check that it rejects assorted fixes, that it rejects feat: add the filter (the ticket is missing) and that it accepts feat(filters): add the filter by label with Refs: GT-134 in the body.
  5. Show that --no-verify dodges it and explain which barrier would stop it.

Exercise 3: derive the version

Given this history since the v2.3.1 tag of task-manager:

e5f6a7b chore(deps): bump the development dependencies
c4d5e6f feat(sync): allow syncing in the background
b3c4d5e fix(list): fix the order of overdue tasks
a2b3c4d docs(readme): document the new offline mode
9182b3c feat(api)!: remove the `order` parameter from listTasks()
  1. Which SemVer version should be released and why?
  2. Write the complete message for commit 9182b3c with its BREAKING CHANGE trailer and its migration guide.
  3. Write the git log command that would extract only the feat commits from that range.
  4. If the team integrated this branch with squash merge and the squash title were feat: sync improvements, which version would the tool derive? What problem does that reveal?

Solutions

Solution 1:

Original Good version
fix css fix(css): fix the footer overflow on mobile

Body: "The footer used a fixed width of 960 px, which on screens narrower than 360 px caused horizontal scrolling across the whole page. It is replaced by max-width with width: 100%." + Refs: GT-121
update app.js refactor(app): extract the list rendering into a function

The file name is superfluous: --stat already says it. What matters is what was done inside.
Added the search box and fixed the footer margin Two commits. feat(ui): add the search box for task titles and fix(css): fix the footer margin on narrow screens. Besides, Added is not imperative.
Now it really works It has no good version: it is a process commit. The right thing to do is git commit --fixup=<sha-of-the-broken-commit> and squash it with git rebase -i --autosquash before publishing (lesson 05-02).
Bump the dependency build(deps): bump marked to 12.0.1 for a CVE in sanitising

Specify which one and why. "For security" and "for a new feature" deserve different levels of urgency.

Solution 2:

mkdir /tmp/practice-messages && cd /tmp/practice-messages
git init -b main
git config user.name "Ana Ferrer"
git config user.email "ana.ferrer@example.com"
# 1. The template
cat > .gitmessage <<'EOF'
# <type>(<scope>): <description in the imperative, ≤50 characters>
#
# Types: feat fix docs style refactor perf test build ci chore revert
#
# --- Body (72 columns) -------------------------------------------|
# Why was it necessary? Which alternatives were discarded?
#
# --- Footer ------------------------------------------------------
# Refs: GT-NNN
EOF

git config --local commit.template .gitmessage

# 2. The diff inside the editor
git config --local commit.verbose true
# 3. The hook (copy the script from section 12 here)
mkdir -p .githooks
# ... create .githooks/commit-msg with the content from the lesson ...
chmod +x .githooks/commit-msg
git config --local core.hooksPath .githooks
# 4. The three tests
echo "hello" > app.js && git add .

git commit -m "assorted fixes"
# ERROR: the subject does not follow Conventional Commits.

git commit -m "feat: add the filter"
# ERROR: the ticket reference (GT-NNN) is missing.

git commit -m "feat(filters): add the filter by label" -m "Refs: GT-134"
# [main a1b2c3d] feat(filters): add the filter by label
# 5. The escape route
echo "more" >> app.js && git add .
git commit --no-verify -m "anything at all"
# [main d4e5f6a] anything at all   ← the hook did not even run

--no-verify is a client-side option: it does not travel over the network and the server never finds out it was used. The barrier that does stop it is the CI one (section 12) declared as a required check on a protected branch, exactly as we saw in lesson 07-06. The local hook is a convenience for the developer; the protected branch is the control.

Solution 3:

1. It should be v3.0.0. The presence of feat(api)! marks a compatibility break, and in SemVer that raises the MAJOR number and sets MINOR and PATCH to zero, regardless of there also being two feat commits and a fix. The break always wins.

2.

feat(api)!: remove the `order` parameter from listTasks()

We had two ways of sorting the list: the `order` parameter
(inherited from the prototype, with values 'alpha' and 'date') and
the `criterion` parameter (introduced in 2.1 to support sorting by
due date). Keeping both meant resolving the precedence on every
call and was a constant source of bugs such as GT-149.

`order` is removed and `criterion` is kept, since it is a strict
superset.

BREAKING CHANGE: `listTasks()` no longer accepts the `order`
parameter. Use `criterion`, which accepts the same values plus
`due`:

  listTasks({order: 'alpha'}) ->  listTasks({criterion: 'alpha'})
  listTasks({order: 'date'})  ->  listTasks({criterion: 'date'})

If you pass `order`, it is silently ignored; review your calls.

Refs: GT-152

3.

git log --oneline --grep '^feat' v2.3.1..HEAD

And to locate the breaks specifically, since they may be in the body:

git log v2.3.1..HEAD --grep 'BREAKING CHANGE' --pretty='%h %s'

4. The tool would derive v2.4.0 (one feat, no visible break), because the BREAKING CHANGE was in the body of a commit that the squash made disappear. The result is serious: a change that breaks every consumer is released as a MINOR, and those consumers upgrade trusting SemVer's compatibility guarantee.

What it reveals is that the integration policy is not an aesthetic decision: it changes which messages survive on the mainline and, therefore, which information stays available to people and to tools. If you squash, the squash title must be validated with the same rigour as a commit, and the relevant trailers must be propagated to it. That policy is exactly the topic of the next lesson.

Conclusion

The essentials of this lesson:

  • A commit message is written for three readers with no context: you two years from now, whoever runs blame on an odd line and whoever reviews your PR. And for a fourth that is a machine.
  • The diff already says the what; the message exists for the why. If your message can be worked out from the diff, you have not written it yet. What only fits in the message is the motivation, the discarded alternatives, the side effects and the limits of the change.
  • The anatomy is syntax, not decoration: a subject of ≤50 characters in the imperative and with no full stop, a mandatory blank line, a body at 72 columns, and a footer with trailers (Refs, Co-authored-by, BREAKING CHANGE).
  • Conventional Commits makes the subject machine-readable without it ceasing to be human-readable: type(scope): description. In exchange for a minimal discipline, you get history filtering, an automatic changelog and, linking back to lesson 05-05, the SemVer version derived from the history itself.
  • A template with commit.template in the repository puts the reminder in front of your eyes at just the right moment. And writing in the editor with commit.verbose instead of with -m improves quality more than any other single measure.
  • The convention is held up by the commit-msg hook of lesson 06-01 for the developer's convenience and by the validation in CI on a protected branch from lesson 07-06 for what is non-negotiable, because --no-verify exists.
  • The language is chosen once, written down in the README.md, and never argued about again.

One loose end remains that exercise 3 has brought into view: an excellent message is worth nothing if the integration policy erases it. And that policy — merge, squash or rebase — is exactly the decision we left open in lesson 07-04 about GitHub Flow.

We settle it now, along with everything else that makes a history readable, bisectable and reversible, in lesson 08-02: Keeping a Clean History.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved