G15 · Part 4 · Mastery
Commit hygiene
Small commits, messages that explain why, hooks that catch mistakes early, and tags that mark what shipped.
A commit message is documentation written at the one moment you had the most context. Six months
later, a good message is the difference between understanding a line of code and rewriting it out
of fear. History is also a tool: bisect, revert, blame and
cherry-pick all work far better on clean commits than on a pile of "wip" and "fix".
Make each commit one change you could describe in a short sentence. Write that sentence as the
first line, in the imperative ("Fix tax rounding on coupons", not "fixed stuff"), keep it under
about 50 characters, and add a blank line and a few lines of why if the reason is not
obvious. Read git diff --staged before you commit. That is most of commit hygiene;
the rest of this page is tooling that makes it automatic.
Atomic commits
An atomic commit holds one logical change, complete on its own. The build passes and the tests pass at that commit. It can be reverted without dragging unrelated work out with it, and a reviewer can read it without holding three stories in their head at once.
- One reason to exist. If the subject line needs the word "and", you probably have two commits.
- Refactor apart from behaviour. "Rename
calcTotaltocomputeTotal" touches 40 files and changes nothing. Keep it away from the three-line bug fix, or the fix drowns in the rename. - Formatting on its own. A reformat commit can go into
.git-blame-ignore-revssoblameskips it. Mixed into a real change, it can't. - Green at every step.
git bisectassumes every commit builds. A broken middle commit forces you togit bisect skipand weakens the search.
You rarely write atomic commits in the order you work. That's what the staging area is for.
git add -p stages hunk by hunk, git commit --fixup=<sha> records a
correction aimed at an earlier commit, and git rebase -i --autosquash folds those
corrections in before you open the pull request.
git add -p src/cart.ts
git commit -m "fix(cart): apply coupon before tax"
git add src/cart.test.ts
git commit --fixup=HEAD
git rebase -i --autosquash main
Anatomy of a good message
git log --oneline, GitHub and email patches all treat the
first line as the title and everything after the first blank line as the body. Trailers are the
last block, one Key: value per line.
- Subject around 50 characters, 72 at most. Longer subjects get cut off in
--onelineoutput and in the host's UI. No full stop. - Imperative mood. "Add", "fix", "remove". It completes the sentence "If applied, this commit will…". Git's own generated messages ("Merge branch…", "Revert…") use the same form.
- Blank line after the subject. Without it, many tools read the whole message as one long title.
- Body wrapped at 72. Git does not wrap for you, and
git logindents the body by four spaces, so 72 still fits an 80-column terminal. - Why over what. The diff already shows what changed. The message should say why, what else you tried, and what a reader might get wrong.
- fixed stuff
- update
- Fixed the bug where the cart total was wrong sometimes
+ fix(cart): apply coupon before tax, not after
Trailers
Trailers are structured Key: value lines at the end of the message. Git can add, parse
and filter them, and hosts read several of them.
| Trailer | Meaning | How to add it |
|---|---|---|
Co-authored-by: | Credits a pair or mob partner. GitHub and GitLab show both avatars on the commit. | git commit --trailer "Co-authored-by: Sam Lee <sam@example.com>" |
Signed-off-by: | The Developer Certificate of Origin: you certify you have the right to submit the code. Linux and many CNCF projects require it. It is not a cryptographic signature. | git commit -s |
Refs:, Fixes: | Links to a ticket. Some hosts close issues from Fixes #123 in the message. | Type it, or use --trailer |
Reviewed-by:, Acked-by: | Records review in patch-based projects | Added by the maintainer |
git log --format="%h %s %(trailers:key=Co-authored-by,valueonly)"
git interpret-trailers --parse < message.txt
Conventional Commits
Conventional Commits is a small spec for the subject line that machines can parse. Once every
commit on main follows it, tooling can pick the next version number and write the
changelog for you.
<type>(<optional scope>)<optional !>: <short imperative summary>
<optional body>
<optional footers, such as BREAKING CHANGE: or Refs:>
Common types are feat, fix, docs, style,
refactor, perf, test, build, ci,
chore and revert. Only three things affect the version number.
| Commit | Semantic version bump | Example |
|---|---|---|
fix: | PATCH, 2.4.0 to 2.4.1 | fix(auth): refresh token before expiry |
feat: | MINOR, 2.4.0 to 2.5.0 | feat(cart): allow stacking two coupons |
feat!: or a BREAKING CHANGE: footer | MAJOR, 2.4.0 to 3.0.0 | feat(api)!: drop the v1 orders endpoint |
| Anything else | No release on its own | docs: fix install steps |
Before 1.0.0, most tools treat breaking changes as a minor bump, because semver says anything may change in 0.x. The usual tool chain:
- commitlint checks each message in a
commit-msghook and again in CI. - semantic-release runs in CI on every push to
main. It reads commits since the last tag, bumps the version, tags, writes release notes and publishes. - release-please keeps an open "release PR" that updates the changelog and version. Merging it cuts the release, so a human decides when.
- git-cliff and conventional-changelog only generate the changelog.
If your host squash-merges pull requests, the commit that lands on main usually takes
the PR title as its subject. Then the PR title is what must follow the spec. Lint the title in CI,
not only the individual commits.
Hooks
Hooks are executables Git runs at fixed points. A hook that exits non-zero aborts the operation, if
it runs early enough to stop it. They live in .git/hooks, which is not part of the
repository. A fresh clone gets only the .sample files, and nobody else sees the hooks
you write there.
| Client hook | Fires | Typical use |
|---|---|---|
pre-commit | Before the message is asked for. Non-zero aborts. | Lint and format staged files, block debug statements, scan for secrets |
prepare-commit-msg | After the default message is built, before the editor opens | Insert the ticket ID from the branch name |
commit-msg | After you save the message. Non-zero aborts. | commitlint, require a ticket reference |
post-commit | After the commit exists | Notifications. It cannot undo anything. |
pre-merge-commit | Before a merge commit is created | Same checks as pre-commit for merges |
pre-rebase | Before a rebase starts. Non-zero aborts. | Refuse to rebase published branches |
post-checkout, post-merge | After switching branches or a successful merge or pull | Reinstall dependencies when the lockfile changed |
post-rewrite | After commit --amend and rebase | Carry notes or metadata to the new commits |
pre-push | After the remote is contacted, before anything is sent. Non-zero aborts. | Fast tests, block direct pushes to main |
Sharing hooks with a team
Since you can't commit .git/hooks, you commit the hooks somewhere else and point Git at
them. Every option below does that in the end.
mkdir .githooks
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
| Tool | Config | Install step | Fits |
|---|---|---|---|
Plain core.hooksPath | Scripts in a folder you commit | Each person runs the git config once | Small teams, any language |
| Husky | .husky/pre-commit and friends | A prepare script in package.json sets core.hooksPath on npm install | JavaScript projects |
| Lefthook | lefthook.yml | lefthook install | Polyglot repos and monorepos. Single binary, runs commands in parallel. |
| pre-commit | .pre-commit-config.yaml | pre-commit install | Python teams. Large catalogue of ready-made hooks, each in its own environment. |
Pair any of them with lint-staged or an equivalent, so the hook only checks files
in this commit and not the whole repository. To test a hook without making a commit,
git hook run pre-commit runs it exactly as Git would, from wherever
core.hooksPath points.
A pre-commit hook that takes 30 seconds gets skipped with --no-verify within a week.
Aim for under two seconds. Leave the full test suite to CI, and run the same lint and commitlint
checks there, because a hook on a laptop is advice. A required status check on the server is a rule.
Tags and releases
A tag is a name for a commit that, unlike a branch, is not supposed to move. There are two kinds, and they are different things inside Git.
| Lightweight | Annotated | |
|---|---|---|
| What it is | Just a ref in refs/tags/ pointing at a commit | A ref pointing at a tag object, which points at the commit |
| Stores | Nothing extra | Tagger, date, message, optional signature |
| Create | git tag v2.4.0 | git tag -a v2.4.0 -m "Coupon stacking" |
Seen by git describe | Only with --tags | Yes, by default |
Pushed by --follow-tags | No | Yes, if it points at a pushed commit |
| Use for | Private bookmarks | Every release |
git tag -a v2.4.0 -m "Coupon stacking release"
git tag -s v2.4.0 -m "Coupon stacking release"
git tag -v v2.4.0
git show v2.4.0
git push origin v2.4.0
git push --follow-tags
git config --global push.followTags true
git tag -d v2.4.0
git push origin --delete v2.4.0
A plain git push sends no tags at all. git push --tags sends every tag you
have, including stray local ones, so --follow-tags is usually the better default.
-s signs the tag with your GPG or SSH key. tag -v checks it.
Never move a published tag. Anyone who has fetched v2.4.0 keeps the old
one, and git fetch will not overwrite an existing tag unless forced. Two people then
build different code from the same version name. If a release is bad, ship v2.4.1.
git describe
$ git describe
v2.4.0-3-g8f2c9d1
$ git describe --tags --dirty --always
v2.4.0-3-g8f2c9d1-dirty
Read it as: nearest annotated tag v2.4.0, 3 commits after it, at commit
8f2c9d1 (the g stands for git). Exactly on a tag, it prints just the tag.
--dirty flags uncommitted changes, and --always falls back to a short hash
when no tag is reachable. It makes a good build label, because it traces any binary back to an
exact commit.
Semantic versioning reads MAJOR.MINOR.PATCH: a breaking change, a new
backwards-compatible feature, a backwards-compatible fix. The Conventional Commits table above is
the bridge from commit messages to those three numbers.
Finished reading?
0 of 18 chapters read in this series