Skip to content
Blog | sirlisko | Luca Lischetti Blog | sirlisko | Luca Lischetti Blog | sirlisko | Luca Lischetti
Go back

Commit Messages & PR Titles


I’ve been writing commit messages for almost two decades. And yet, recently, I found a new reason to think carefully about them.

I was writing an ADR for my team about consistent analytics event naming. As I drafted the convention, I reached for past tense. Events describe things that already happened, so it made sense. That small choice made me pause. If we care enough about consistency in event names to document it formally, shouldn’t we apply the same rigour to our commit messages and PR titles?

Especially since a PR title, when squash-merged, becomes the commit on main. It’s not just a label on a code review. It’s permanent history.

That reflection turned into this guide. It’s opinionated and it works for me, but the most important thing isn’t that you follow this exact format. It’s that your team has one format, documented somewhere, and everyone follows it.

Why This Matters

Good commit messages and PR titles:

The Format

I use Conventional Commits, which looks like this:

<type>(<scope>): <subject>

<body>

<footer>

A real example:

feat(auth): add two-factor authentication

Implemented TOTP-based 2FA to reduce account compromise risk.
Users can generate backup codes on initial setup.

Closes #234

The type describes what kind of change it is. I use these:

The scope (optional but worth using) is the module or domain affected: auth, api, ui, db, validation, and so on. Pick 8–12 scopes for your project and stick to them.

The subject is the short summary. Keep it under 50 characters, use imperative mood (“add filter” not “added filter”), and don’t put a period at the end.

What Actually Makes a Good Commit Message

The format is the easy part. The hard part is knowing what to say.

I think about it as a 2x2 matrix. Every meaningful commit message should cover four things:

WhatWhy
High-levelIntent — What does this accomplish?Context — Why does this code exist?
Low-levelImplementation — What did you do?Justification — Why was this change necessary?

Intent goes in the subject line:

fix(auth): prevent concurrent token refresh race condition

Context, implementation, and justification go in the body:

fix(auth): prevent concurrent token refresh race condition

When users have multiple tabs open, token refresh requests can race.
Both requests see an expired token, both initiate refresh, and one
ends up with an invalidated token, causing 401 errors in the other tab.

Implemented request deduplication using a Promise stored on auth state.
Subsequent refresh requests await the first one instead of initiating
new refreshes.

Reduces token-related 401 errors by ~95% in multi-tab sessions.

Fixes #892

Not every commit needs all four. A simple refactor might just need intent and a one-liner of context. But when you’re writing a body, ask yourself: am I explaining the why, or just restating what the diff already shows?

✗ Change email validation by adding a regex and checking it multiple times.
✓ Add stricter email validation to reduce bounce rate on password resets.

The code shows the how. The message explains the why.

The Mistakes Worth Avoiding

Vague subjects. The subject should tell someone what changed without reading the diff:

✗ fix bugs
✗ update code
✓ fix(auth): prevent null reference in token refresh

Repeating the type in the subject. The type already says what kind of change it is, the subject should describe what was fixed, not restate it:

✗ fix(date): fix use of update date
✓ fix(date): use updated_at instead of created_at

Mixing unrelated changes. If your subject contains “and”, that’s usually a sign you have two commits:

✗ feat(auth): add 2FA and update dependencies and fix typo in docs

Split it. Easier to revert and understand.

Subjects that are too long. Keep it under 50 characters. Put the rest in the body, not the subject:

✗ feat(api): implement pagination support for user list endpoint with limit and offset params
✓ feat(api): add pagination to user list endpoint

Forgetting to close issues. Link it in the footer:

Fixes #892

GitHub auto-closes the issue on merge. Free changelog entry.

Squash vs. Keep

This comes up in almost every team I’ve worked with, and I’ve seen both camps argue passionately.

My default is squash. Almost always. A single, well-written commit per PR is clean, easy to revert, and forces you to articulate the change clearly. It also keeps git log readable, which is the whole point.

The only time I keep multiple commits is when the feature is large enough that the commits genuinely tell a story worth preserving: each one is a meaningful, self-contained step, and collapsing them into one would lose context that matters when reading the history later. That’s the exception, not the rule.

If you’re unsure: squash. A history full of “fix linting”, “oops”, and “WIP” commits helps nobody.

Before You Submit

My workflow: commit freely while coding. Rough messages, frequent commits. When I’m ready to open a PR, I run:

git rebase -i main

Then I reorder, squash, and reword into a clean narrative. The key operations you’ll use most:

Don’t stress about commit quality while you’re in the flow. Rebase is where you turn your working history into something a reviewer (and future you) will actually appreciate.

Which brings to mind a famous quote:

Write drunk, edit sober — as Hemingway (allegedly) put it. The rebase is the sober part.

PR Titles

PR titles follow the same format as commit messages, because they often end up as the merge commit message. Same rules apply:

feat(auth): add two-factor authentication
fix(api): handle null responses in user endpoint
refactor(state): migrate from Redux to Zustand

Keep it under 60 characters. The body is where context lives, not the title.

One thing worth noting: use imperative mood in the title (“add two-factor authentication”), but past tense in the body (“implemented TOTP-based 2FA…”). The title describes what the PR does; the body narrates what you did.

Here’s the PR body template I use:

## Description
Briefly summarize what was implemented or changed.

## Motivation
Explain why this change was needed. What problem does it solve?

## Implementation Details
Describe how you solved it. Link to relevant code sections if helpful.
- Used X library for Y reason
- Refactored the Z module to improve performance
- Added validation for edge case

## How to test
[Describe how a reviewer can verify this change works as expected]

## Screenshots (if UI-related)
[Add screenshots for visual changes]

## Breaking Changes
[If applicable, describe what changed and migration path]

Tooling

If you want to enforce this automatically, the setup takes about ten minutes:

npm install --save-dev husky commitlint @commitlint/config-conventional
npx husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'

Create commitlint.config.js:

module.exports = {
  extends: ['@commitlint/config-conventional'],
};

Now every commit gets validated before it’s created. Fail fast, fix fast.

You can also set a global commit template so your editor opens with the format pre-filled:

# Save this as ~/.gitmessage:
# <type>(<scope>): <subject>
#
# <body>
#
# <footer>

git config --global commit.template ~/.gitmessage

For Teams: CONTRIBUTING.md Template

Once you’ve settled on a convention, document it where people will actually find it: in the repo. Here’s a ready-to-paste CONTRIBUTING.md section:

## Commit Messages

We follow [Conventional Commits](https://www.conventionalcommits.org/).

### Format
\`\`\`
<type>(<scope>): <subject>
\`\`\`

### Types
- `feat` — New feature
- `fix` — Bug fix
- `refactor` — Code refactoring
- `perf` — Performance improvement
- `test` — Test additions/changes
- `docs` — Documentation
- `chore` — Maintenance
- `ci` — CI/CD changes

### Scopes
Use one of: `auth`, `api`, `ui`, `db`, `hooks`, `validation`, `state`, `types`

### Rules
- Use imperative mood ("add" not "added")
- Keep subject under 50 characters
- Explain *why* in the body, not *how*
- Link issues: `Closes #123`

### Example
\`\`\`
feat(auth): add two-factor authentication

Implemented TOTP-based 2FA to reduce account compromise risk.
Users can generate backup codes on initial setup.

Closes #234
\`\`\`

## Pull Requests

- Title follows commit format
- Body uses past tense (what you did)
- Link related issues
- Add tests for new features

Adjust the scopes list to match your project. That’s the one section where copy-paste won’t cut it.

Bonus: Changelog Automation

One side benefit of consistent commit messages: you can auto-generate changelogs from your history.

npm install --save-dev conventional-changelog-cli
npx conventional-changelog -p angular -i CHANGELOG.md -s

This reads your commit history and produces a structured changelog grouped by features, fixes, and breaking changes. It only works well if your commits are clean, which is another reason the discipline pays off.

Quick Checklist

Before committing:

Before creating a PR:

References

Consistency Over Perfection

The thing I kept coming back to while putting this together: none of this matters if the rules change depending on who’s committing. A slightly imperfect convention that everyone follows is worth more than a perfect one that nobody does.

Pick a format. Document it in CONTRIBUTING.md. Set up commitlint if you want guardrails. Then stop thinking about it and get back to writing code.



Next Post
Ode to ADRs