Making Good Commits
A commit is more than a save point. It is a small historical claim: this exact project state belongs together, and the message explains why a future reader should care.
Commit as snapshot plus explanation
Git commits are snapshots, not file-by-file save commands. When you run git commit, Git records the content staged in the index as the next point in history. The working tree can contain other unfinished edits; those are not part of the commit unless they were staged first.
A commit is a named object in Git history that points to a project tree, remembers its parent commit or commits, stores author and committer metadata, and carries a message. In everyday work, it is the unit you inspect, review, revert, cherry-pick, and share.
That last sentence is the practical reason to make good commits. You are not only preserving code; you are creating handles for future action. A commit that says "fix stuff" and changes eight unrelated files gives the future reader very little to grab. A commit that says Validate email before saving profile and contains only that change can be reviewed, blamed, reverted, and understood without spelunking.
The most useful mental model is: stage the story you want to tell, then commit that story with a title and context. This keeps Git from feeling like a mysterious filing cabinet. The index becomes your drafting table.
Atomic commits
An atomic commit makes one coherent change. "One" does not necessarily mean one file, one line, or one tiny patch. It means one reason. If all the changed lines must travel together for the project to make sense, they probably belong in the same commit. If two changes could be reviewed, reverted, or explained independently, they probably want separate commits.
Add password reset token expiry
- Database migration adds
expires_at. - Model validates the expiry.
- Tests cover expired tokens.
Update auth and clean dashboard
- Password reset behavior changes.
- Dashboard CSS is renamed.
- A logging library is upgraded.
Atomic commits help in four ordinary ways. Reviewers can check intent against implementation. You can bisect history when a bug appears. You can revert one bad decision without removing unrelated work. And you can read the history later as a sequence of design choices rather than a pile of timestamps.
If the commit message needs the word "and" to be honest, pause. Sometimes the "and" is natural because one change has several parts. Often it is a clue that the staging area contains two stories.
Staging as composition
git add is better understood as "add this content to the next commit" than "add this file forever." The distinction matters because the index stores the version of a file as it looked when you staged it. If you edit the file again afterward, Git can show the same file as both staged and unstaged: one version is ready for the commit, and a later version is still only in the working tree.
git status
git diff # what is still unstaged
git diff --staged # what will be committed
git add -p # choose hunks interactively
git commit
The command git add -p is the workhorse for good commits. It lets you review hunks and choose which ones enter the index. When a file contains both a real fix and a drive-by cleanup, patch staging lets you commit the fix now and leave the cleanup for a separate commit.
| Question | Command | Why it helps |
|---|---|---|
| What have I changed? | git diff |
Shows working tree changes that are not staged yet. |
| What am I about to record? | git diff --staged |
Shows the exact staged content that git commit will use. |
| Can I split this file? | git add -p |
Lets you stage selected hunks instead of the whole file. |
| Did I stage too much? | git restore --staged <path> |
Removes content from the index without deleting your working tree edits. |
Think of staging as an editorial step. You are not just telling Git that files changed. You are deciding which exact lines belong in the next historical unit.
Message shape
A commit message has two jobs. The subject line names the change when history is skimmed. The body, when needed, explains the context a diff cannot show: why the change was made, what alternatives were rejected, what side effects matter, and how to verify it.
Validate email before saving profile
Profiles were accepting malformed email addresses when imported from
older CSV exports. That made password reset delivery fail later, far
from the original import error.
Reject invalid addresses at the profile boundary and add coverage for
the legacy importer path.
The subject line should be short enough to scan in git log --oneline. Imperative phrasing usually reads well because it describes what applying the commit does: Add retry timeout, Reject expired tokens, Remove unused avatar cache. The body is optional. Use it when the diff answers "what" but not "why."
changes
The reader has to open the diff to learn even the topic of the commit.
Normalize imported profile emails
The reader immediately knows the domain, action, and scope.
Do not stuff every detail into the subject. A compact subject plus a body is easier to search, easier to review, and kinder to the person reading the history during an incident.
Commit anatomy
A commit object is small but information-dense. It ties a snapshot to history and explains who did what. You do not need the internals every day, but knowing the shape clarifies several confusing behaviors: why amending changes the commit ID, why author and committer can differ, and why two commits with the same patch are still distinct objects.
- Tree: the top-level project snapshot.
- Parent or parents: the previous commit for ordinary commits, or multiple parents for merges.
- Author: the person who originally wrote the change.
- Committer: the person who created this commit object in this repository.
- Message: the human explanation of the change.
git cat-file -p HEAD
The author and committer are often the same person. They diverge when someone applies a patch written by someone else, rebases or cherry-picks work, or uses options such as --author or --reset-author. In a team, that distinction is not trivia. It preserves credit for the original change while also recording who integrated it into the current history.
The commit hash is derived from the commit object's content and metadata. Change the tree, the parent, the dates, the author, or the message, and you have a different object with a different ID. That is why history editing is powerful locally and disruptive after publication.
Amend, empty commits, and published history
git commit --amend replaces the tip of the current branch with a new commit. It is perfect when the latest local commit has a typo, missed file, or weak message. It is not a magic edit-in-place operation; the old commit is abandoned and a new commit takes its place.
git add forgotten-test.js
git commit --amend
Use amend freely before you share the commit. After you push or otherwise publish it, amending rewrites history that someone else may already have based work on. That can still be acceptable in coordinated workflows, but it is no longer a private cleanup. You are changing the name of a commit other people may be using.
Empty commits are a special case. Git usually prevents a commit whose tree is identical to its parent because that is often a mistake. git commit --allow-empty bypasses that guard. Use it only when a history event matters without a content change: triggering a deployment, marking a migration boundary, or recording a coordination point in a repository-driven process.
git commit --allow-empty -m "Trigger staging deployment"
Local history is your draft. Published history is a shared document. Clean up the draft; coordinate before rewriting the shared document.
Common pitfalls
Committing whatever happens to be staged. The index is sticky. Run git diff --staged before committing so the commit contains the story you think it contains.
Mixing formatting, renames, and behavior changes. These are hard to review together. Commit mechanical cleanup separately from logic changes whenever possible.
Writing messages that repeat the diff. "Changed validation.js" says what Git already knows. Explain the reason: what problem does this change solve?
Amending after publication as if nothing happened. Amend creates a new commit. If the old one was pushed, tell collaborators before rewriting or force-pushing that branch.
Worked examples
Example 1: Split one messy working tree into two commits
You changed email validation and also renamed a CSS class while you were in the area. Start by reviewing the full diff, then stage only the validation hunks.
git diff
git add -p src/profile/validation.js test/profile-validation.test.js
git diff --staged
git commit -m "Validate imported profile emails"
Now stage the mechanical CSS rename as a separate commit.
git add src/profile/profile.css src/profile/ProfileView.jsx
git diff --staged
git commit -m "Rename profile form email class"
Example 2: Turn a vague message into a useful one
Suppose the diff updates retry handling after a production timeout. This message is too vague:
fix api
A better message gives the domain and the reason:
Bound checkout API retries
Checkout requests could retry indefinitely when the payment gateway
timed out before returning a response. Cap retries so failed payments
surface to the user and stop holding worker slots.
Example 3: Amend a local commit that missed a test
You committed the behavior change, then realized the regression test was still unstaged. If the commit is still local, amend it.
git status
git add test/checkout-retry.test.js
git commit --amend
The editor opens with the existing message as a starting point. Keep it if the message still describes the finished commit; improve it if the test changes the story.
Example 4: Decide whether an empty commit is justified
Empty commits should feel unusual. If you are triggering a CI workflow intentionally, make the message explicit so history does not look broken.
git commit --allow-empty -m "Trigger production smoke test"
If there is no external system watching commits, an empty commit is probably noise. Prefer a tag, issue comment, release note, or actual content change depending on what you need to record.
Example 5: Judge local cleanup versus published rewrite
You made three local commits on a feature branch and have not pushed. It is reasonable to amend, reorder, or squash them before sharing. Once you push, switch mental modes.
Clean the story. Use --amend for the latest commit and interactive rebase when you have learned the right commit boundaries after the fact.
Coordinate first. A rewritten commit ID can make collaborators reconcile their branches with your new version of history.
Sources & further reading
-
The canonical introductory chapter for tracked files, the staging area,
git add, status, and the commit loop. -
Precise behavior for
git commit, including--amend,--author,--reset-author, and--allow-empty. -
Reference for staging content, especially interactive and patch modes used to compose atomic commits.
-
A deeper look at blobs, trees, and commit objects, useful when commit metadata and rewritten IDs stop feeling intuitive.
-
A practical commit-message style guide, especially strong on subject/body separation and explaining why rather than restating the diff.