The 7 Rules of Great Git Commit Messages
The most widely cited commit message guidelines were first articulated by Chris Beams in 2014 and remain accurate today. The seven rules are: separate subject from body with a blank line; limit the subject line to 50 characters; capitalise the subject line; do not end the subject line with a period; use the imperative mood in the subject line; wrap the body at 72 characters; use the body to explain what and why, not how.
The 50-character subject limit is a discipline constraint, not a technical one — git does not enforce it, but GitHub, GitLab, and most git GUI tools truncate subject lines beyond approximately 72 characters with an ellipsis, and the terminal output of git log --oneline wraps at the terminal width. Keeping subjects short forces you to name the change clearly rather than dumping details into the subject. The details belong in the body.
The imperative mood rule is the one developers most frequently misapply. 'Fixed a bug' and 'Fixes a bug' are both wrong — the correct form is 'Fix a bug' or more precisely 'Fix null pointer in user session handler'. The convention mirrors git's own generated messages: 'Merge pull request #42', 'Revert changes to authentication flow', 'Initial commit'. A practical test: read your subject line as a completion of 'If applied, this commit will...' — 'If applied, this commit will Fix null pointer in user session handler' reads naturally. 'If applied, this commit will Fixed a bug' does not.
The body section is often skipped but becomes essential when debugging production issues six months later. A good body explains the motivation for the change and contrasts the new behaviour with the old behaviour. Code comments explain how — commit bodies explain why the how was chosen over alternatives.
The Conventional Commits Specification: feat, fix, and Beyond
The Conventional Commits specification (conventionalcommits.org) formalises a structured prefix system for commit subjects that has become the de facto standard in open-source and enterprise projects alike. The format is: type(scope)!: description. The type is required; scope and the breaking-change exclamation mark are optional.
The core types defined by the specification are: feat (a new feature that adds capability to the codebase), fix (a bug fix that repairs broken behaviour), docs (changes to documentation only — no production code), style (formatting, whitespace, or linting changes with no logic change), refactor (code restructuring that neither adds a feature nor fixes a bug), test (adding or correcting tests), chore (maintenance tasks like updating dependencies, changing build scripts, or modifying tooling configuration). Many teams extend this list with perf (performance improvement), ci (changes to CI/CD configuration), and build (changes to the build system).
The scope is an optional parenthetical noun that names the subsystem the commit affects: feat(auth): add OAuth2 PKCE flow, fix(payments): correct ABA QR payload encoding, docs(api): add rate limit examples to README. Scopes are project-specific conventions — they should name meaningful domains like modules, packages, or major features rather than generic terms. A scope of 'backend' in a backend-only repository adds no information.
The exclamation mark variant and the BREAKING CHANGE footer are the most important mechanisms in the spec for release automation. feat!: rename user endpoint from /api/user to /api/v2/users signals a breaking API change. Alternatively, any commit type can include a footer starting with BREAKING CHANGE: followed by a description. Both signal to automated tools that the next release must increment the major version number.
Automated Changelogs and Semantic Versioning with Conventional Commits
The primary reason Conventional Commits has displaced ad-hoc commit styles in professional projects is that it makes the entire release process automatable. Tools like semantic-release, release-please (Google), and changesets read git history, group commits by type, determine the appropriate next version number under Semantic Versioning, generate a CHANGELOG.md, tag the release, and publish packages — all without human intervention.
Semantic Versioning (semver.org) defines three version components: MAJOR.MINOR.PATCH. A PATCH bump (1.2.3 to 1.2.4) is triggered by fix commits — backwards-compatible bug corrections. A MINOR bump (1.2.3 to 1.3.0) is triggered by feat commits — backwards-compatible new features. A MAJOR bump (1.2.3 to 2.0.0) is triggered by any commit with BREAKING CHANGE in the footer or a ! after the type. chore, docs, style, refactor, and test commits produce no version bump on their own.
The generated CHANGELOG.md from a Conventional Commits history is significantly more useful than a manually maintained one. Each release section groups entries under 'Features', 'Bug Fixes', 'Performance Improvements', and 'BREAKING CHANGES' headings automatically. The commit scope appears as a bold prefix, making it easy to scan which subsystem changed. GitHub issue and PR numbers referenced in commit bodies are automatically linked. A project that has maintained Conventional Commits for a year has a complete, structured record of every intentional change — invaluable when a downstream user asks 'when did this behaviour change and why?'
For teams not yet using full automation, even manually reading a Conventional Commits history is faster than reading arbitrary messages. Scanning for feat commits shows what was added; scanning for BREAKING CHANGE shows what requires migration. This signal is completely absent from history full of messages like 'wip', 'fix stuff', and 'changes'.
Enforcing Standards with Husky and Commitlint
Agreeing on commit message conventions as a team is worthless if developers can push non-compliant messages without friction. Commitlint is a Node.js tool that validates commit messages against a configurable ruleset. Husky is a tool that installs git hooks into a project so those hooks are shared with every team member via the repository, not just configured once on one machine.
The typical setup involves three packages: husky (git hook management), @commitlint/cli (the validation runner), and @commitlint/config-conventional (the preset that implements the Conventional Commits ruleset). Installation in a Node.js project follows: npm install --save-dev husky @commitlint/cli @commitlint/config-conventional, then npx husky init to create the .husky directory, then echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg to wire commitlint to the commit-msg hook. A commitlint.config.js file in the project root containing module.exports = { extends: ['@commitlint/config-conventional'] } completes the setup.
With this in place, running git commit -m 'changed stuff' will fail immediately with a clear error explaining that the message does not match the required format, before the commit is even created. The developer sees the violation at the earliest possible point — their own machine, before the code ever reaches a branch, a pull request, or CI. Teams using this setup consistently report fewer review comments about commit quality and cleaner git histories on main branches.
For non-Node.js projects, pre-commit (Python) offers a cross-language alternative with a commitlint mirror hook. Alternatively, a server-side hook in a self-hosted GitLab or Gitea instance can enforce the same rules at push time rather than commit time — this is the right approach when the team includes contributors who work in languages without a good Node.js toolchain available.
Interactive Rebase: Cleaning Up Before the Pull Request
Even developers who write careful commit messages in isolation produce messy local histories during active development. Work-in-progress commits ('wip', 'trying something', 'revert last change it broke tests'), debugging commits that revert immediately, and accidental multi-concern commits that touch unrelated parts of the codebase are all common. Interactive rebase is the tool for cleaning this up before the branch becomes public.
The command git rebase -i HEAD~N opens an editor showing the last N commits with action verbs in front of each. The default action is pick (keep the commit as-is). Changing pick to squash (or s) merges that commit into the previous one, prompting to combine their messages. Changing to fixup (or f) squashes the commit and discards its message. Changing to reword (or r) keeps the commit but opens an editor to rewrite its message. Changing to edit (or e) pauses the rebase at that commit so you can amend it or split it. Deleting a line drops the commit entirely.
Splitting a commit that touches multiple unrelated concerns is slightly more involved: mark it with edit, then when the rebase pauses run git reset HEAD~ to unstage those changes back to the working tree, then use git add -p (patch mode) to selectively stage only the first concern, commit it with a proper message, then git add the remaining files and commit them as a second commit, then git rebase --continue to finish.
A commonly misunderstood safety rule: interactive rebase rewrites history, meaning commit hashes change. This is safe and appropriate on local branches or branches that exist only on your fork — rewriting shared branches that others have based work on causes divergence and requires everyone to force-reset their local copy. The clean-up workflow is: develop on a local feature branch, rebase interactively before pushing for the first time, then push normally. Never rebase a branch after it has been pushed and reviewed unless the entire team is coordinated.
More in developer tools
View all developer tools guides →