Most Git commit best practices come down to one habit: decide what goes into a commit before you type git commit, not after. Part 1 explained that the staging area is a proposed next commit that you build deliberately. This part puts that to work in the loop you repeat dozens of times a week: stage precisely with git add -p, review what you staged, write a message someone can use in a year, keep junk out of the repository with .gitignore, and move around with git switch and git restore instead of the overloaded git checkout.
Every command below was run against Git 2.43.0. git switch and git restore need Git 2.23 or later, so check with git --version if you are on an old distribution.
What a good commit looks like
A commit should be one logical change that you could explain in a single sentence, and that you could revert on its own without breaking something unrelated. "Fix rounding in invoice total" is a commit. "Fix rounding, rename a helper, and bump a dependency" is three commits that happen to share a working tree.
The practical test: if you would struggle to write the subject line without the word "and", the change is probably two commits. Small, focused commits make code review faster, make git revert safe, and make tools like git bisect (Part 8) land on the real culprit instead of a 40-file blob.
Stage part of a file with git add -p
You rarely finish work in neat order. Say you fixed a greeting format in a PHP file and, in the same file, started changing how invoice totals are calculated:
git diff
diff --git a/InvoiceService.php b/InvoiceService.php
--- a/InvoiceService.php
+++ b/InvoiceService.php
@@ -2,10 +2,10 @@
function greet(string $name): string
{
- return 'Hello ' . $name;
+ return "Hello, {$name}!";
}
function total(array $items): float
{
- return array_sum($items);
+ return array_sum(array_map(fn ($i) => $i->price, $items));
}
git add InvoiceService.php would stage both changes. To stage only the first, use patch mode:
git add -p InvoiceService.php
Git shows one hunk at a time and asks what to do. Here the two edits are close enough that Git groups them into a single hunk, so press s to split it:
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.
@@ -2,8 +2,8 @@
function greet(string $name): string
{
- return 'Hello ' . $name;
+ return "Hello, {$name}!";
}
function total(array $items): float
{
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
@@ -6,6 +6,6 @@
}
function total(array $items): float
{
- return array_sum($items);
+ return array_sum(array_map(fn ($i) => $i->price, $items));
}
(2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]? n
Now git status -s shows MM InvoiceService.php: the first column says part of the file is staged, the second says there are still unstaged edits. That is the two-column output from Part 1 doing exactly its job. Commit the staged half, then repeat for the rest.
| Key | What it does |
|---|---|
y |
Stage this hunk |
n |
Do not stage this hunk |
s |
Split the hunk into smaller hunks (only offered when Git can split it) |
e |
Manually edit the hunk, for when even a split hunk mixes two changes |
a / d
|
Stage / skip this hunk and all later hunks in the file |
q |
Quit; this hunk and the remaining ones are not staged |
? |
Print help for the keys available at that prompt |
The exact list of keys changes with the hunk (you can see j, K, g and / appear above for navigating between hunks), so press ? whenever you are unsure.
One limit catches people: git add -p only works on files Git already knows about. A brand-new untracked file does not appear in the prompts. Run git add -N newfile.txt first. It records an "intent to add" entry, the file shows as A in git status -s, and Git can then treat its content as a diff.
Review before you commit
Patch mode makes it easy to stage the wrong hunk, so look at what you built:
git diff --staged # index vs last commit: what will be committed
git diff # working tree vs index: what will be left behind
If you prefer to see the diff while writing the message, git commit -v appends the staged diff below the message template (it is not saved into the message). Setting git config --global commit.verbose true makes that the default. Passing -v twice also shows the unstaged changes.
Write commit messages people can use
The Git documentation describes the convention directly: begin with a single short line of no more than 50 characters summarizing the change, then a blank line, then a fuller description. It is not enforced, but it matters because the text up to the first blank line is treated as the title throughout Git. git log --oneline, email patches and most web UIs show only that line.
Round invoice totals to two decimals before tax
Totals were summed from unrounded line items, so a 3-line invoice
could be off by one cent against the payment gateway. Round each
line first, then sum.
Fixes the mismatch reported in #482.
Three habits that make logs readable:
- Imperative mood in the subject. "Add retry to webhook client", not "Added" or "Adds". This is a widely used convention, including in Git's own project; read it as completing the sentence "If applied, this commit will...".
-
Wrap the body around 72 characters. This is convention rather than a Git rule, and it keeps
git logreadable in a terminal. - Explain why, not what. The diff already shows what changed. The message should say why it was needed and what you ruled out.
Compare these two log lines six months from now:
a1b2c3d fix stuff
e4f5a6b Retry webhook delivery on 5xx responses
If your team uses a prefix convention such as feat: or fix:, Part 9 covers enforcing it with hooks and CI. For now, a clear subject matters more than the format.
Fix the last commit with --amend
Forgot a file, or a typo in the message? --amend replaces the tip of the current branch with a new commit:
# add a forgotten change and keep the existing message
git add newfile.txt
git commit --amend --no-edit
# or just reword the message
git commit --amend -m "Add newfile.txt with sample content"
The important detail is that the result is a new commit with a new hash; the old one is not edited. In my test run the hash changed from d38c758 to 285763d after the first amend and to 62515a9 after the second. That is harmless on a commit only you have, and a problem once someone else has pulled it, because you are now rewriting shared history. Part 4 covers when that is safe.
.gitignore: keep generated files and secrets out
A .gitignore file lists patterns for untracked files Git should pretend are not there. A typical starting point:
.env
*.log
build/
vendor/
node_modules/
!keep.log
The rules that matter, per the gitignore documentation:
- A trailing slash (
build/) matches only directories. Without it, the pattern matches files and directories. - A slash at the beginning or middle makes the pattern relative to the
.gitignorefile's own directory./hello.*matcheshello.txtbut nota/hello.java; without a leading slash, the pattern can match at any depth. -
**/foomatchesfooanywhere, anda/**/bmatchesa/b,a/x/b,a/x/y/band so on. - A leading
!negates a pattern, as with!keep.logabove. - You cannot re-include a file if a parent directory of that file is excluded. Git does not look inside excluded directories, so a negation pattern for a file in there has no effect.
Find out why a file is (or isn't) ignored
When a pattern does not behave, ask Git which rule applied:
git check-ignore -v logs/a.log build/o.js keep.log
.gitignore:2:*.log logs/a.log
.gitignore:3:build/ build/o.js
.gitignore:4:!keep.log keep.log
Each line is source:line:pattern followed by the path. Notice that keep.log matches the negation on line 4, which is why it still shows up as untracked in git status even though *.log is ignored.
The mistake everyone makes: ignoring a file that is already tracked
The documentation is explicit: files already tracked by Git are not affected by ignore rules. If you committed .env and then added it to .gitignore, Git keeps tracking it. Stop tracking it while leaving the file on disk:
git rm --cached .env
git commit -m "Stop tracking .env"
After that, git status is quiet about the file and git ls-files no longer lists it. This only fixes the future. The old commit still contains the file's contents, so if it held a real credential, treat the credential as leaked and rotate it. Removing it from history is a separate job, covered in Part 10.
Where else ignore rules can live
| Location | Use it for |
|---|---|
.gitignore in the repo |
Rules the whole team shares: build output, vendor/ and node_modules/, env files |
.git/info/exclude |
Rules for this clone only that you don't want to commit |
Global file (core.excludesFile) |
Personal rules for every repo: editor swap files, OS clutter. Default path is $XDG_CONFIG_HOME/git/ignore, or ~/.config/git/ignore if that variable is unset |
Keep editor and OS files out of the shared .gitignore and in your global file instead, so a project's ignore list describes the project and not everyone's tooling.
git switch and git restore instead of git checkout
git checkout does two unrelated things: it changes branches and it overwrites files. Git 2.23 split these into git switch and git restore. The old command still works, but the new ones are harder to misuse.
| Task | Command |
|---|---|
| Move to an existing branch | git switch main |
| Create a branch and move to it | git switch -c feature/x |
| Go back to the previous branch | git switch - |
| Inspect an old commit without a branch | git switch --detach HEAD~1 |
| Unstage a file | git restore --staged InvoiceService.php |
| Discard unstaged edits in a file | git restore InvoiceService.php |
| Discard only some edits, hunk by hunk | git restore -p InvoiceService.php |
| Restore a file from another commit | git restore --source=HEAD~1 InvoiceService.php |
git switch refuses to treat a typo as a new branch. git switch nosuch fails with fatal: invalid reference: nosuch, whereas creating a branch always requires the explicit -c. If you have uncommitted changes that don't conflict with the target branch, they are carried across, and Git lists them (M InvoiceService.php) when it switches.
Be careful with git restore InvoiceService.php. It overwrites your working copy with the staged version and the unstaged edits are gone; as Part 1 noted, the reflog cannot bring back changes that were never committed or staged. When unsure, use patch mode, which asks before discarding each hunk (Discard this hunk from worktree) and lets you quit with q. Note that git restore --source=HEAD~1 InvoiceService.php writes the older version into your working tree and leaves it as an unstaged change, so review it with git diff before committing.
Common problems
| Symptom | Cause and fix |
|---|---|
git add -p skips my new file |
It only lists tracked files. Run git add -N file first. |
s isn't offered at the prompt |
The hunk can't be split further. Use e to edit it by hand. |
A file is in .gitignore but still shows as modified |
It is already tracked. Use git rm --cached <file>. |
A negation (!) pattern has no effect |
A parent directory is ignored. Ignore the directory's contents instead of the directory. |
| I committed to the wrong branch | Don't reach for amend. This is a reset or cherry-pick job (Parts 5 and 6). |
git switch says "command not found" or "not a git command" |
Git is older than 2.23. Upgrade, or use git checkout. |
Before you move on
Run git diff --staged every time before committing until it is reflex. Split mixed changes with git add -p rather than committing everything and hoping. Put a real reason in the message body when the change isn't obvious. Add secrets and build output to .gitignore before the first commit, because ignoring them afterwards doesn't undo anything. And treat git restore on unstaged work as irreversible.
References: git-add, git-commit, gitignore, git-switch and git-restore in the official Git documentation.
Related reading: this series starts with Part 1: Git Staging Area Explained.
Originally published on DEV Talk.
Top comments (0)