Staging and Committing
Building good commits: the staging area, viewing changes with diff, staging only part of a file, ignoring files, writing clear commit messages and fixing the last commit.
The three areas
Git keeps three versions of your project in view at once. The
working tree is the files on disk. The
repository holds the commits, and the last commit
on your branch (HEAD) is the reference point. Between them sits the
staging area, also called the
index: a complete list of the files and contents that the
next commit will contain, stored in .git/index. Right
after a commit, the staging area matches the last commit; the
working tree matches too, unless you left changes unstaged.
git add copies the current content of a file from the
working tree into the staging area; git commit turns
exactly what is in the staging area into a commit. Edits you make
after git add stay in the working tree until you add
them again. That extra step is the point: you can change five files
while troubleshooting and still commit them as two separate,
meaningful changes.
Viewing changes with git diff
A diff is a line-by-line description of
the differences between two versions of files: which lines were
removed (-), which were added (+), with a
few unchanged lines around them for context. Git can compare any two
of the three areas:
| Command | Compares | Answers |
|---|---|---|
git diff |
Working tree ↔ staging area | What have I changed but not staged? |
git diff --staged (same as
--cached)
|
Staging area ↔ last commit | What will the next commit contain? |
git diff HEAD |
Working tree ↔ last commit | Everything I changed since the last commit |
Sam changes the NTP server in a router config and stages that, then adds a logging line without staging it:
git init -q net-tools
cd net-tools
cat > router.cfg <<'CFG'
hostname R1
interface Gi0/0
ip address 192.0.2.1 255.255.255.0
ntp server 198.51.100.10
CFG
git add router.cfg
git commit -q -m "Add base config for R1"
sed -i 's/198.51.100.10/198.51.100.20/' router.cfg
git add router.cfg
printf 'logging host 203.0.113.50\n' >> router.cfg
echo "--- git diff (working tree vs staging area)"
git diff
echo "--- git diff --staged (staging area vs last commit)"
git diff --staged
--- git diff (working tree vs staging area) diff --git a/router.cfg b/router.cfg index 9fd5d7f..cfea459 100644 --- a/router.cfg +++ b/router.cfg @@ -2,3 +2,4 @@ hostname R1 interface Gi0/0 ip address 192.0.2.1 255.255.255.0 ntp server 198.51.100.20 +logging host 203.0.113.50 --- git diff --staged (staging area vs last commit) diff --git a/router.cfg b/router.cfg index 69cb811..9fd5d7f 100644 --- a/router.cfg +++ b/router.cfg @@ -1,4 +1,4 @@ hostname R1 interface Gi0/0 ip address 192.0.2.1 255.255.255.0 -ntp server 198.51.100.10 +ntp server 198.51.100.20
The staged version (blob 9fd5d7f) appears in both
diffs: it is the "after" side of the staged diff and the "before"
side of the unstaged one. A changed line is shown as one removed and
one added line. The index line names the blobs on each
side, and 100644 is the file mode.
Hunks
Each block of a diff that starts with @@ is a
hunk: one contiguous region of change
with its surrounding context lines. The header
@@ -1,4 +1,4 @@ means "starting at line 1, four lines
of the old version; starting at line 1, four lines of the new
version". Git adds three lines of context on each side, so changes
more than six unchanged lines apart become separate hunks, and Git
appends the nearest preceding line that looks like a section heading
(hostname R1 above) to help you find your way. Hunks
are the unit you choose from when you stage part of a file.
Staging only what belongs together
The simplest way to build a focused commit is to name files:
git add router.cfg inventory.yml stages those two and
nothing else. git add . stages everything in the
current folder and below — convenient, but check
git status first so nothing unexpected comes along.
Sometimes one file contains two unrelated changes. Sam renamed the
router (hostname R1-AMS, which needs a change request)
and fixed the NTP server (urgent) in the same file.
git add -p (patch mode) walks through the hunks and
asks about each one. It is interactive, so here is what the session
looks like:
$ git add -p
diff --git a/router.cfg b/router.cfg index b422df9..6676515 100644 --- a/router.cfg +++ b/router.cfg @@ -1,4 +1,4 @@ -hostname R1 +hostname R1-AMS ! interface Gi0/0 description Uplink to ISP (1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n @@ -10,5 +10,5 @@ interface Gi0/1 ip address 198.51.100.1 255.255.255.0 no shutdown ! -ntp server 198.51.100.10 +ntp server 198.51.100.20 logging host 203.0.113.50 (2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]? y
| Answer | Meaning |
|---|---|
y / n |
Stage / do not stage this hunk |
s |
Split the hunk into smaller ones (when possible) |
e |
Edit the hunk by hand in your editor |
a / d |
Stage / skip this and all later hunks in the file |
q |
Quit; hunks answered so far keep their answer |
? |
Show help for all answers |
The result: the same file is now both staged and modified. The
example below feeds the answers n and
y through a pipe only so that it can run unattended; at
a terminal you type them.
git init -q net-tools
cd net-tools
cat > router.cfg <<'CFG'
hostname R1
!
interface Gi0/0
description Uplink to ISP
ip address 192.0.2.1 255.255.255.0
no shutdown
!
interface Gi0/1
description Office LAN
ip address 198.51.100.1 255.255.255.0
no shutdown
!
ntp server 198.51.100.10
logging host 203.0.113.50
CFG
git add router.cfg
git commit -q -m "Add base config for R1"
sed -i 's/hostname R1/hostname R1-AMS/; s/198.51.100.10/198.51.100.20/' router.cfg
printf 'n\ny\n' | git add -p >/dev/null 2>&1
git status --short
git diff --staged
git commit -q -m "Use the new NTP server"
git diff
MM router.cfg diff --git a/router.cfg b/router.cfg index b422df9..9d1af9e 100644 --- a/router.cfg +++ b/router.cfg @@ -10,5 +10,5 @@ interface Gi0/1 ip address 198.51.100.1 255.255.255.0 no shutdown ! -ntp server 198.51.100.10 +ntp server 198.51.100.20 logging host 203.0.113.50 diff --git a/router.cfg b/router.cfg index 9d1af9e..6676515 100644 --- a/router.cfg +++ b/router.cfg @@ -1,4 +1,4 @@ -hostname R1 +hostname R1-AMS ! interface Gi0/0 description Uplink to ISP
MM in the short status means "modified in the staging
area" (left column) and "modified again in the working tree" (right
column). The NTP fix is committed; the hostname change waits in the
working tree for its own commit. Graphical tools, including the
Source Control view in Visual Studio Code, offer the same choice for
selected lines.
Ignoring files with .gitignore
A .gitignore file lists patterns
of untracked files that Git should ignore: they no longer appear in
git status and git add . skips them. It is
an ordinary file, committed with the project so the whole team
shares the rules. Typical candidates are build output, caches, logs,
editor settings and local files with secrets.
git init -q net-tools
cd net-tools
mkdir -p scripts/__pycache__ backups
printf 'print("checking links")\n' > scripts/check_links.py
printf 'compiled\n' > scripts/__pycache__/check_links.cpython-311.pyc
printf 'hostname R1\n' > backups/r1-2026-03-01.cfg
printf 'DB_PASSWORD=example-only\n' > .env
printf 'run started\n' > run.log
git status --short --untracked-files=all
echo "--- add .gitignore"
cat > .gitignore <<'IGN'
# Python bytecode
__pycache__/
*.pyc
# Local secrets, never commit
.env
# Logs and local backups
*.log
/backups/
IGN
git status --short --untracked-files=all
git check-ignore -v .env run.log backups/r1-2026-03-01.cfg
?? .env ?? backups/r1-2026-03-01.cfg ?? run.log ?? scripts/__pycache__/check_links.cpython-311.pyc ?? scripts/check_links.py --- add .gitignore ?? .gitignore ?? scripts/check_links.py .gitignore:5:.env .env .gitignore:7:*.log run.log .gitignore:8:/backups/ backups/r1-2026-03-01.cfg
git check-ignore -v explains which rule — file, line
number and pattern — ignores a path: the tool to use when a file is
unexpectedly missing from git status.
Patterns
| Pattern | Matches |
|---|---|
*.log |
Any file ending in .log, in any folder |
build/ |
A folder named build anywhere (trailing
/: folders only)
|
/backups/ |
Only the backups folder at the top (leading
/: relative to the .gitignore)
|
docs/*.pdf |
PDFs directly in docs (a / in the
middle also anchors the pattern)
|
**/temp |
temp in any folder at any depth |
!keep.log |
Exception: do not ignore keep.log after all
(not possible inside an ignored folder)
|
# text |
A comment |
Rules can live in several places: a .gitignore in any
folder (applies to that folder and below), the personal, uncommitted
.git/info/exclude of one repository, and a global file
set with core.excludesFile for things only you produce,
such as your editor's backup files. GitHub maintains a public
collection of .gitignore templates per language, which
it offers when you create a repository.
git init -q net-tools
cd net-tools
printf 'run started\n' > run.log
git add run.log
git commit -q -m "Add run log"
printf '*.log\n' > .gitignore
printf 'run finished\n' >> run.log
git status --short
git rm -q --cached run.log
git status --short
M run.log ?? .gitignore D run.log ?? .gitignore
After committing that deletion, run.log is untracked
and ignored. Note that this does not remove it from older commits —
which matters if the file contained a secret: see
Securing Repositories.
Writing good commit messages
A commit message is the text
stored with a commit that describes the change: a short subject
line, optionally followed by a blank line and a body. It is written
for the person who reads git log or
git blame a year from now — often yourself — trying to
understand why a line is the way it is. The diff already shows
what changed; the message must say why.
Use the new NTP server on all routers The old server 198.51.100.10 is being decommissioned on 15 March. Point R1 to the new pool address so time keeps syncing and log timestamps stay comparable across devices. Refs: CHG-1042
The widely used conventions, and why they exist:
-
Subject of about 50 characters, no period. Tools
show only the first line:
git log --oneline, the GitHub commit list, email subjects. Short subjects stay readable there. - Imperative mood: "Add", "Fix", "Use", not "Added" or "Fixes". Read it as "this commit will … use the new NTP server". Git's own generated messages ("Merge branch …", "Revert …") follow the same style.
- A blank line, then a body wrapped at about 72 characters, explaining the reason, the context and any side effects. Git treats everything before the first blank line as the subject, and many tools do not wrap long lines.
- References to a ticket, change or issue number, so the commit can be traced back to the decision behind it.
Write a message with a body by running
git commit without -m, which opens your
editor; or give -m twice — each one becomes a
paragraph. Many teams add a structured format on top, such as
Conventional Commits (fix: use the new NTP server),
which lets tools generate changelogs and version numbers — see
Branching Strategies and Releases.
Atomic commits
An atomic commit contains exactly one logical change — complete, and nothing else. "Use the new NTP server" is atomic; "NTP, hostname, some cleanup and a fix for the backup script" is not. Atomic commits pay off later:
- Reverting one change undoes exactly that change, without taking unrelated work with it.
- Reviewing a small, focused diff is faster and catches more mistakes.
-
Finding the cause of a problem with
git bisectpoints to one small commit instead of a mixed bag (see Exploring History). - Cherry-picking a fix to another branch takes only the fix.
"Complete" matters as much as "one": each commit should leave the
project in a working state — the config should be valid and the
tests should pass — so that any commit can be checked out, deployed
or bisected. The staging area, selective git add and
git add -p are the tools for building such commits even
after a messy work session.
Fixing the last commit with --amend
To amend is to replace the most recent
commit with a corrected one, using git commit --amend:
whatever is staged is added to the last commit's content, and you
can give a new message. Sam forgot a file and made a typo in the
message:
git init -q net-tools
cd net-tools
printf 'hostname R1\n' > r1.cfg
git add r1.cfg
git commit -q -m "Add R1 config"
printf 'hostname R2\n' > r2.cfg
printf 'hostname R3\n' > r3.cfg
git add r2.cfg
git commit -q -m "Add R2 confg"
git log --oneline
echo "--- forgot r3.cfg, and a typo in the message"
git add r3.cfg
git commit -q --amend -m "Add R2 and R3 configs"
git log --oneline
git show --stat --format='%h %s' HEAD
632599a Add R2 confg 2c259e0 Add R1 config --- forgot r3.cfg, and a typo in the message b67ded4 Add R2 and R3 configs 2c259e0 Add R1 config b67ded4 Add R2 and R3 configs r2.cfg | 1 + r3.cfg | 1 + 2 files changed, 2 insertions(+)
The commit 632599a was not edited — it was
replaced by a new commit, b67ded4, with the
same parent. Commits are immutable; their hash is computed from
their content, so a different content or message must be a different
commit. The old one is no longer on any branch and is eventually
cleaned up. Use git commit --amend --no-edit
to add staged changes while keeping the message.
The git commit -a shortcut and its trap
git commit -a (or -am "message")
automatically stages every tracked file that was modified
or deleted, then commits. It saves typing for small changes. The
trap is in the word "tracked": new files are not included.
git init -q net-tools
cd net-tools
printf 'hostname R1\n' > r1.cfg
git add r1.cfg
git commit -q -m "Add R1 config"
printf 'ntp server 198.51.100.20\n' >> r1.cfg
printf 'hostname R2\n' > r2.cfg
git commit -a -m "Add NTP to R1, add R2"
git status --short
[main 52fc0a5] Add NTP to R1, add R2 1 file changed, 1 insertion(+) ?? r2.cfg
"1 file changed": only r1.cfg went in. The message
promises R2, but r2.cfg is still untracked
(??) — and if nobody notices before pushing, the
colleague who pulls gets a commit that claims to add a file it does
not contain.