Engineering Toolkit, lesson 3
Commits and the staging area
Save work in small, named steps you can always go back to.
Git keeps every saved version of a project, with who changed what and why, so you can go back to any of them. This lesson is the daily loop: start a repository, save changes as commits, read the history, and keep out the files that don't belong. You need the shell from "Files and folders in the shell".
By the end, you can:
Part 1. A repository and its first commit
Turn a folder into a repository and save its first commit.
Maybe you've had a file called final_v2_really_final.py. Git replaces that habit. It keeps a project's whole history in a hidden .git folder inside it. Here's a small script to put under git for the next few lessons: it prints how many words a text file has, and its three most common words.
import sys
from collections import Counter
text = open(sys.argv[1]).read().lower()
words = text.split()
print("words:", len(words))
for word, n in Counter(words).most_common(3):
print(word, n)Tell git who you are, once per computer, using your own name and email. They go into every commit you make. The last line names the first branch main; branches are the next lesson.
$ git config --global user.name "Ana Lima"
$ git config --global user.email "ana@example.com"
$ git config --global init.defaultBranch main$ cd textstats
$ git init
Initialized empty Git repository in /home/ana/textstats/.git/
$ git status --short
?? README.md
?? count.py
$ git add count.py README.md
$ git commit -m "Count words in a text file"
[main (root-commit) 26f2494] Count words in a text file
2 files changed, 11 insertions(+)
create mode 100644 README.md
create mode 100644 count.py
$ git log --oneline
26f2494 (HEAD -> main) Count words in a text fileExample 1.1
The first commit
The textstats folder holds count.py and README.md. Make it a repository and save both files as its first commit.
- Make the repository
git initCreates the hidden .git folder. Nothing is saved yet.
- See what git notices
git status --short→??twice??means untracked: git sees the files but isn't keeping them. - Choose what goes in
git add count.py README.mdPuts both files, as they are right now, into the next commit.
- Save the commit
git commit -m→ 26f2494Saves a snapshot with your message, and names it with an id.
- Read the history
git log --onelineOne commit so far.
HEAD -> mainmarks where you are.
AnswerOne commit, 26f2494, holding both files.
A commit's id is a hash of everything in it: the files, your name and email, the time, the message and the commit before it. Change any of those and the id changes, so yours will differ from 26f2494. Git shows the first seven characters, and more if that's needed to keep them unique.
Repository and commit
A repository is a project whose history git keeps, in a .git folder inside it. A commit is a saved snapshot of the files you added, with an author, a time, a message and a link to the commit before it.
git add the files, then git commit -m "message"
git add . adds everything in the current folder that git isn't told to ignore. It's quick, and it's also how secrets and data files end up in a repository, as part 4 shows. Name the files instead, at least until the project has a .gitignore.
Your turn
In a brand-new repository, you run git commit -m "First version" straight away. What happens?
git add. Until then your files are untracked, and git commit has nothing to save. That extra step is there on purpose, as part 2 shows.Part 2. The staging area
Split your changes into separate commits with git add.
You fix a bug in count.py: "the" and "the," were counted as different words. While you're there, you add a usage line to the README. Those are two separate changes. If one turns out wrong you'll want to undo it alone, so each gets a commit of its own.
import re
import sys
from collections import Counter
text = open(sys.argv[1]).read().lower()
words = re.findall(r"[a-z']+", text)
print("words:", len(words))
for word, n in Counter(words).most_common(3):
print(word, n)$ git status --short
M README.md
M count.py
$ git diff README.md
diff --git a/README.md b/README.md
index 737ddaf..6cbb648 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
# textstats
Count the words in a text file.
+
+Usage: python3 count.py notes.txt$ git add count.py
$ git status --short
M README.md
M count.py
$ git commit -m "Ignore punctuation when counting"
[main 0f2361e] Ignore punctuation when counting
1 file changed, 2 insertions(+), 1 deletion(-)
$ git add README.md
$ git commit -m "Show how to run it in the README"
[main ba52d05] Show how to run it in the README
1 file changed, 2 insertions(+)Example 2.1
Two changes, two commits
count.py and README.md have both changed. Save them as two commits, the bug fix first.
- Check what changed
git status --shortAn M in the right-hand column for both files: changed on disk, not staged.
git diffshows the lines,+added and-taken out. - Stage the fix only
git add count.pycount.py's M moves to the left-hand column: it's staged for the next commit.
- Commit it→ 0f2361e
Only what's staged goes in. README.md is still changed and unstaged.
- Stage and commit the README→ ba52d05
The second change gets a commit of its own.
AnswerTwo commits: 0f2361e for the fix, ba52d05 for the README.
git add copies a file, as it is at that moment, into a holding area for the next commit. git commit saves what's in that area, and nothing else. So you decide what each commit holds, whatever else you've changed on disk. git diff --staged shows what's waiting there.
git add count.py copies count.py into the staging area, and git commit saves it as 0f2361e. README.md stays changed in the working tree, ready for a commit of its own.Staging area
The changes that go into the next commit, also called the index. git add puts a file's current version there, and git commit saves it. The files on disk, with all your edits, are the working tree.
working tree → git add → staging area → git commit → repository
Read git status --short as two columns: the left one is the staging area, the right one the working tree. An M on the right is changed but not staged, an M on the left is staged, and ?? is untracked. A file can be in both columns at once, as the next question shows.
Your turn
You edit count.py and README.md, run git add count.py, edit count.py once more, then run git commit. What does the commit contain?
git add copies the file as it is at that moment. Later edits stay in the working tree until you add them again, and README.md was never added. git status --short would show count.py as MM: staged, and modified again since.Part 3. Reading the history
Read the history, and name earlier commits with HEAD~1 and HEAD~2.
History is only worth keeping if you can read it. git log lists commits, newest first, and git show shows one in full.
$ git log --oneline
ba52d05 (HEAD -> main) Show how to run it in the README
0f2361e Ignore punctuation when counting
26f2494 Count words in a text file
$ git show --stat HEAD~1
commit 0f2361ebe6c44f6cfb3082886bafb79ca2a4a721
Author: Ana Lima <ana@example.com>
Date: Wed Nov 4 09:31:00 2026 +0000
Ignore punctuation when counting
count.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)Example 3.1
Naming a commit by counting back
Which commit is HEAD~1, and what did it change?
- Find HEAD
(HEAD -> main)→ ba52d05HEAD is the commit you're on, here the newest on main.
- Count back one
HEAD~1→ 0f2361e~1is the commit before, its parent.HEAD~2would be 26f2494. - Show it
git show --stat HEAD~1The full id, the author, the time and the message, then the files it changed.
- Read the change
count.py | 3 ++-Three lines touched in count.py: 2 added and 1 taken out.
AnswerHEAD~1 is 0f2361e, the punctuation fix, which changed count.py.
Each commit records the one before it, so history is a chain you walk back from HEAD. ~1 takes one step back and ~2 two. They work wherever git expects a commit: git diff HEAD~2 HEAD shows everything the last two commits changed.
HEAD
Git's name for the commit you're on now, usually the newest commit of your branch. HEAD~1 is its parent, and HEAD~2 the commit before that.
HEAD~n = n commits back from where you are
A message is for whoever reads the history a year from now. The diff already shows what changed, so say what the commit does and why. Git's own manual suggests a first line of at most 50 characters, then a blank line and the details. git commit without -m opens your editor for exactly that.
Your turn
git log --oneline lists five commits, newest first: 7c1e2a9 (HEAD -> main), 41b09fd, c3d8e20, 9a0f5b1 and 2e7714c. Which commit does git show HEAD~3 show?
Part 4. What to leave out
Keep secrets, environments and data out with a .gitignore.
Some files should never be committed. Virtual environments are big and can be rebuilt. Python's cache files are rebuilt on every run. Data and model files are big and often private. And anything holding a password or an API key must stay out.
$ git status --short
?? .env
?? .venv/
?? __pycache__/
?? data/# Python
.venv/
__pycache__/
*.pyc
# Secrets and local settings
.env
# Data and model files: too big for git, and often private
data/
*.pt$ git status --short
?? .gitignore
$ git check-ignore -v .env
.gitignore:7:.env .env
$ git add .gitignore
$ git commit -m "Leave out environments, secrets and data"
[main a24cc41] Leave out environments, secrets and data
1 file changed, 11 insertions(+)
create mode 100644 .gitignoreExample 4.1
A .gitignore for a Python project
git status lists .env, .venv/, __pycache__/ and data/. Hide all four from git, and find out what hides .env.
- Write the patterns
.gitignoreOne pattern per line, and
#starts a comment..venv/matches that folder, and*.pycany name ending in .pyc. - Check again
git status --short→?? .gitignoreOnly the .gitignore itself is left, and it belongs in the repository.
- Ask what hides a file
git check-ignore -v .envLine 7 of .gitignore, the pattern .env, is hiding it.
- Commit the rules→ a24cc41
Now everyone with a copy of the project leaves out the same files.
AnswerAll four are hidden, and the .gitignore is committed.
A .gitignore lists untracked paths for git to act as if it can't see, so git status stays readable and git add . can't pick them up. A pattern that ends in / matches only folders. A pattern with no / at its start or in its middle matches at any depth, so *.pt hides model files in every folder.
.gitignore
A file of patterns, one per line, for paths git should leave out. It only affects files git isn't tracking yet. A file that's already committed stays tracked until git rm --cached takes it out of the index.
So a .gitignore can't hide a secret that's already committed. git rm --cached .env stops tracking the file and keeps your copy, but the old commit still holds the key, and anyone with a copy of the repository can read it. Treat the key as leaked: revoke it and make a new one.
Your turn
A .gitignore holds two lines, data/ and *.pt. Which of these files does git status still list?
data/ matches folders called data at any depth, so data/raw/prices.csv and notes/data/old.csv are hidden, and *.pt hides models/best.pt. notes/data.md is a file whose name only starts with data, so no pattern matches it.Where you'll meet it
"What's the difference between git add and git commit?" is a fair opening question. Answer with the three places: add copies a change into the staging area, and commit saves what's staged as a snapshot in the repository. Then say why it helps: one commit per change, so each can be read, reviewed or undone alone.
GitHub's secret scanning checks a repository's whole history, on every branch, for keys and passwords. Push protection, on by default for your account, stops you pushing known kinds of key to a public repository. Its docs give part 4's advice: revoke a leaked key, since scrubbing it from the history is slow and, once it's revoked, often unneeded.
$ git log --oneline -- count.py
0f2361e Ignore punctuation when counting
26f2494 Count words in a text fileA path after -- limits the log to the commits that changed that file: the README and .gitignore commits drop out.
Check yourself
Four questions from across the lesson. Try each one before you look back.
1
git status --short shows MM count.py. What does that mean?
git add. A commit now takes the staged version; run git add again to include the rest.2
Which commit message helps most when someone reads the history a year from now?
git log --oneline. A longer reason can follow after a blank line.3
Last week you committed .env, with an API key in it. Today you add .env to .gitignore and commit that. What's true now?
git rm --cached .env stops tracking it and keeps your copy on disk. The old commit still holds the key, and anyone with a clone can read it, so treat the key as leaked: revoke it and make a new one.4
A nightly script runs under set -e. One line is git commit -m "Nightly results", and on some nights nothing has changed. What happens on those nights?
git commit says so and returns 1, and set -e stops at any code that isn't 0. If a quiet night is normal, say so: git commit -m "Nightly results" || true.What to remember
1A repository and its first commit
git initmakes a repository;git addpicks the files andgit commit -msaves them as a commit with an id, like 26f2494.2The staging area
git commitsaves only what's staged, andgit addstages a file as it is at that moment; ingit status --short, an M on the left is staged and an M on the right isn't.3Reading the history
HEAD is the commit you're on;
HEAD~1is the one before it andHEAD~2the one before that.4What to leave out
A .gitignore hides only untracked files; a secret that's already committed stays in the history, so revoke it.
Recap and going further
The lesson on one page
git config --global user.nameinit.defaultBranch main
- Working tree: your files
- Staging area: the next commit
- Repository: every commit
git status --shortandgit diffgit addwhat belongs togethergit commit -m
git log --onelineHEAD,HEAD~1,HEAD~2git show --stat
- Environments, caches, data, models
- Secrets, always
- Committed already?
git rm --cached, and revoke the key
Try this
Put one of your projects under git
Pick a folder you work in, even a folder of notebooks. Run git init, write a .gitignore before your first git add, and check with git status --short that only the files you mean are listed. Then make three small commits that each do one thing, and read them back with git log --oneline and git show --stat HEAD~1.
You need: Git. git --version checks; a Mac offers to install it the first time you type git, and on Windows it comes with Git for Windows.
Look for
- Files
git statuslists that you didn't expect, such as.DS_Storeor.ipynb_checkpoints. - What
git diffshows for a notebook (.ipynb) file, and why notebooks make noisy diffs. - How
git status --shortchanges aftergit addand aftergit commit. - Whether each of your messages says why, and not only what.
The words, in plain language
- Repository and commit
- A repository is a project whose history git keeps, in a
.gitfolder inside it. A commit is a saved snapshot of the files you added, with an author, a time, a message and a link to the commit before it. - Staging area
- The changes that go into the next commit, also called the index.
git addputs a file's current version there, andgit commitsaves it. The files on disk, with all your edits, are the working tree. - HEAD
- Git's name for the commit you're on now, usually the newest commit of your branch.
HEAD~1is its parent, andHEAD~2the commit before that. - .gitignore
- A file of patterns, one per line, for paths git should leave out. It only affects files git isn't tracking yet. A file that's already committed stays tracked until
git rm --cachedtakes it out of the index.
Read next
- Recording Changes to the RepositoryPro Git, chapter 2Tracked and untracked files, staging, short status and .gitignore, with diagrams.
- Viewing the Commit HistoryPro Git, chapter 2Every useful way to filter and format
git log, including the history of one file. - gitignoreGit documentationEvery rule for patterns, including
!to make an exception and/to anchor a path. - github/gitignoreGitHub, on GitHubReady-made .gitignore files for Python and most other languages, to start from.
Engineering Toolkit overviewSpotted a mistake? Tell us.