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
- Who a commit message is written for
- The why matters more than the what
- Anatomy of a good message
- The subject line: the seven rules
- The body: motivation and context
- The footer: references, tickets and co-authorship
- Bad messages and their good version
- Conventional Commits
BREAKING CHANGEand the derived version- Templates with
commit.template - Writing in the editor, not with
-m - Enforcing the convention: hook and CI
- The language of the message
- 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:
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"]
- 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 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-134The 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.
- 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 -1All the text run together, and --oneline rendered useless.
- 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.
- 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:
- What was the problem or the need? The previous state and why it was not good enough.
- Why this solution? And above all, which alternatives were discarded.
- What side effects does it have? Performance, compatibility, migrations.
- What does this change NOT do? Setting the boundaries is as useful as describing.
- 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-137Notice 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.
- 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)"
- 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").
- 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:
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 .gitignoreTable 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
- Filtering the history is trivial.
git log --oneline --grep '^feat'gives you every new feature. - Automatic changelog. Tools such as
git-cliff,standard-versionorsemantic-releasegroup the commits by type and generateCHANGELOG.mdwith no human intervention. - A derived SemVer version. This links directly to lesson 05-05: there we agreed on
MAJOR.MINOR.PATCHand annotated tags, but we decided the number by hand. With Conventional Commits, the number is derived from the history. - 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:
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.
BREAKING CHANGE and the derived version
BREAKING CHANGE and the derived versionA 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:
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-152The 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.0 → 1.4.1 |
At least one feat, no breaks |
MINOR: 1.4.0 → 1.5.0 |
At least one BREAKING CHANGE or ! |
MAJOR: 1.4.0 → 2.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 CHANGEhidden in the third commit of a branch disappears if the squash title isfeat: assorted improvements. It is a strong argument for reviewing the squash title before merging.
- Templates with
commit.template
commit.templateThe 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:
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 requiredAnd 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 ~/.gitmessageThree details that matter:
- Lines starting with
#are discarded when you save, so the instructions never reach the history. The comment character can be changed withcore.commentCharif you need a literal#(for example, to write#123). commit.templateis not versioned by itself. The.gitmessagefile 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 thegit config --local commit.template .gitmessagein theREADME.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.
- Writing in the editor, not with
-m
-mgit 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:
- The terminal quote pressures you into being brief. Typing a paragraph inside quotes in the shell is awkward, so you do not type it.
- 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.
- There is no review. With
-myou 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 commitWhen 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.mdAnd with -v you also get the full diff inside the editor, which is the best possible aid for writing the why:
Turn it on for good:
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"
- 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 0Remember from lesson 06-01 the three requirements for it to work:
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 $failuresAn 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.
- 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-134Diego, 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.
fix cssupdate app.jsAdded the search box and fixed the footer marginNow it really worksBump the dependency
Exercise 2: set up the complete infrastructure
In a test repository:
- Create a template file
.gitmessagewith the Conventional Commits structure and activate it withcommit.templateat local level. - Turn on
commit.verbose. - Install the
commit-msghook from section 12 in.githooks/and configurecore.hooksPath. - Check that it rejects
assorted fixes, that it rejectsfeat: add the filter(the ticket is missing) and that it acceptsfeat(filters): add the filter by labelwithRefs: GT-134in the body. - Show that
--no-verifydodges 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()
- Which SemVer version should be released and why?
- Write the complete message for commit
9182b3cwith itsBREAKING CHANGEtrailer and its migration guide. - Write the
git logcommand that would extract only thefeatcommits from that range. - 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 mobileBody: "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 functionThe 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 sanitisingSpecify 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-1523.
And to locate the breaks specifically, since they may be in the body:
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
blameon 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.templatein the repository puts the reminder in front of your eyes at just the right moment. And writing in the editor withcommit.verboseinstead of with-mimproves quality more than any other single measure. - The convention is held up by the
commit-msghook 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-verifyexists. - 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
