GitGroundworkGit
15 / 18 written chapters

G15 · Part 4 · Mastery

Commit hygiene

Small commits, messages that explain why, hooks that catch mistakes early, and tags that mark what shipped.

9 min read2 diagrams5 sections
05On this page
  1. Atomic commits
  2. Anatomy of a good message
  3. Conventional Commits
  4. Hooks
  5. Tags and releases
Fresher Mid Senior

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".

In one minute

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 calcTotal to computeTotal" 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-revs so blame skips it. Mixed into a real change, it can't.
  • Green at every step. git bisect assumes every commit builds. A broken middle commit forces you to git bisect skip and 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

fix(cart): apply coupon before tax Tax was calculated on the pre-discount subtotal, so customers paid too much on percentage coupons. The order now matches the finance spec. Refs: PAY-231 Co-authored-by: Sam Lee <sam@example.com> Signed-off-by: Ana Ruiz <ana@example.com> subject: 50, imperative blank line, required body: wrap at 72, why trailers: key: value
Tools depend on this shape. 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 --oneline output 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 log indents 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.

TrailerMeaningHow 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 projectsAdded 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.

CommitSemantic version bumpExample
fix:PATCH, 2.4.0 to 2.4.1fix(auth): refresh token before expiry
feat:MINOR, 2.4.0 to 2.5.0feat(cart): allow stacking two coupons
feat!: or a BREAKING CHANGE: footerMAJOR, 2.4.0 to 3.0.0feat(api)!: drop the v1 orders endpoint
Anything elseNo release on its owndocs: 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-msg hook 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.
Squash merges and the PR title

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.

GIT COMMIT pre-commit lint staged files prepare- commit-msg commit-msg check the message commit written objects stored post-commit notify only red: can abort · git commit --no-verify skips pre-commit and commit-msg GIT PUSH pre-push fast tests network pack sent pre-receive reject the push update once per ref post-receive CI, deploy, notify your machine the server: runs for every push, no --no-verify can skip it
Client hooks are a convenience that anyone can skip. Server hooks, and the host's branch rules and required checks built on the same idea, are the only real enforcement.
Client hookFiresTypical use
pre-commitBefore the message is asked for. Non-zero aborts.Lint and format staged files, block debug statements, scan for secrets
prepare-commit-msgAfter the default message is built, before the editor opensInsert the ticket ID from the branch name
commit-msgAfter you save the message. Non-zero aborts.commitlint, require a ticket reference
post-commitAfter the commit existsNotifications. It cannot undo anything.
pre-merge-commitBefore a merge commit is createdSame checks as pre-commit for merges
pre-rebaseBefore a rebase starts. Non-zero aborts.Refuse to rebase published branches
post-checkout, post-mergeAfter switching branches or a successful merge or pullReinstall dependencies when the lockfile changed
post-rewriteAfter commit --amend and rebaseCarry notes or metadata to the new commits
pre-pushAfter 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
ToolConfigInstall stepFits
Plain core.hooksPathScripts in a folder you commitEach person runs the git config onceSmall teams, any language
Husky.husky/pre-commit and friendsA prepare script in package.json sets core.hooksPath on npm installJavaScript projects
Lefthooklefthook.ymllefthook installPolyglot repos and monorepos. Single binary, runs commands in parallel.
pre-commit.pre-commit-config.yamlpre-commit installPython 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.

Keep hooks fast, and back them up in CI

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.

LightweightAnnotated
What it isJust a ref in refs/tags/ pointing at a commitA ref pointing at a tag object, which points at the commit
StoresNothing extraTagger, date, message, optional signature
Creategit tag v2.4.0git tag -a v2.4.0 -m "Coupon stacking"
Seen by git describeOnly with --tagsYes, by default
Pushed by --follow-tagsNoYes, if it points at a pushed commit
Use forPrivate bookmarksEvery 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.