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.

Three boxes: the working tree holds router.cfg version 3, the staging area (the index) holds version 2, and the repository's last commit holds version 1. git add copies from the working tree to the staging area; git commit records the staging area in the repository. git diff compares the working tree with the staging area; git diff --staged compares the staging area with the last commit. Working tree files on disk router.cfg v3 Staging area the index router.cfg v2 Repository last commit (HEAD) router.cfg v1 git add git commit git diff git diff --staged
Each area can hold a different version of the same file. The two diff commands compare neighboring areas.

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 bisect points 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.