What Git really is: snapshots and the three areas
Most Git confusion comes from one wrong picture: that Git stores a list of edits. It stores snapshots, and every command moves files between three places on your laptop. Once you see those three places, git status becomes a dashboard you can read at a glance, and the rest of Git starts to make sense.
By the end you can
- Explain what version control does for a team, and why Git stores snapshots instead of edits
- Say what a commit contains, and why its ID changes if anything in it changes
- Move changes between the working folder, the staging area and the repository with
add,commitandrestore - Read every line of
git status, in both long and short form - Set up Git, make three commits, look inside one to prove it is a snapshot, and model the staging area in code
The everyday picture
Imagine a store manager who keeps a photo album of the shop floor. Before each photo, staff arrange the shelves: move the festival gift boxes to the front, refill the cooking oil, fix a crooked price card. That is the shop floor as it is right now, messy and changing.
Then the manager decides what goes into the next photo. Aisle 3 is ready; aisle 5 is still half done, so she frames the photo to leave it out. That framing is a separate step from the arranging. Finally she takes the photo, writes the date and a caption on the back, "Festival display, aisle 3", and puts it in the album.
Old photos never change. If someone moves the gift boxes tomorrow, last week's photo still shows them where they were. To see what changed between two days, you lay two photos side by side. Git works exactly like this: the working folder is the shop floor, the staging area is the framing, and a commit is the photo in the album.
What version control is for
This is the first lesson of Git from the Inside, and it assumes no Git experience at all. You need Git installed, a terminal and an editor such as VS Code; the Your Developer Setup course walks through all three if you have not set them up yet.
Before Git, teams shared code as zip files and folders called final, final-v2 and final-v2-fixed. Nobody could say which version was on the store handhelds, who changed the discount logic, or how to get last Tuesday's working copy back. Version control answers those questions. It gives you four things:
- History: every saved version, with who made it, when, and a message saying why.
- Undo: return any file, or the whole project, to any earlier version.
- Parallel work: several people change the same project at once and combine their work later (Branches and merging).
- Proof: an exact record of what shipped, which is how you answer "when did this behaviour change?" (Reading code like a detective).
Git is the version control tool almost every team uses today. It runs entirely on your laptop. The whole history lives in a hidden folder called .git inside your project. No server, no account and no internet are needed for anything in this lesson. Sharing through GitHub comes later, in GitHub: remotes, clone, push and pull.
Snapshots, not edits
Many people imagine Git as a list of edits: "line 4 changed, then line 9 was added". It is not. Each time you save a version, Git records a snapshot: what every tracked file looked like at that moment. When you ask "what changed?", Git compares two snapshots and computes the difference on the spot.
Storing every file every time sounds wasteful, so Git uses a trick. It names each file's content by a fingerprint of that content (a hash, explained below). If a file did not change, its fingerprint is the same, so the new snapshot points to the copy Git already has. A commit that changes one file in a project of 2,000 files stores one new file and reuses the other 1,999. Later, Git also compresses similar contents together on disk (Inside the .git folder). You get the simple model, full pictures, at the cost of small changes.
This is why Git is fast at things that feel heavy: switching to last month's version means reading one snapshot, not replaying a month of edits.
Quick check
You ask Git to show the project as it was 200 commits ago. What does it do?
Every commit has a complete snapshot, so Git reads that one picture directly. Undoing changes one by one is how an edit-list tool would work, which is the wrong mental model. Old versions are in your local .git folder, so no network is involved.
What a commit is
A commit is one photo in the album. It holds:
- a pointer to the snapshot of all files (Git calls it a tree),
- the author and committer, each with a name, email and timestamp,
- the message: one line saying what and why, optionally followed by more detail,
- a pointer to its parent: the commit that came just before it. The very first commit has none.
Git feeds all of that into a hash function, SHA-1 by default, and gets a 40-character commit ID such as e5b14539196777869d5c87ddc86561e127094891. You normally see the first 7 characters, e5b1453, which are enough to identify it in a project. The ID is a fingerprint of the content, so change anything, even one letter of the message, and you get a different ID. That is why old commits never change: "editing" a commit really creates a new commit with a new ID (Rewriting history safely). Git plans to move new repositories to the stronger SHA-256 hash in Git 3.0; the idea stays the same.
Because every commit points to its parent, the commits form a chain going back in time. Your project's history is that chain, read from the newest commit backwards.
Quick check
Which of these is not part of what a commit's ID is calculated from?
A commit stores its snapshot, parent, author, committer and message, and the ID is a hash of exactly those. Branch names are not stored in commits at all; a branch is a separate label that points at a commit, as Branches and merging shows. That is why renaming a branch never changes any commit ID.
index.js did not change in the newest commit, so both snapshots point to the same stored content e5a5bd6.The three areas
Your project lives in three places at once, and almost every Git command moves changes between them.
The working folder (Git's docs say working tree) is the shop floor: the files you see in VS Code and edit. The staging area, also called the index, is the framing: the exact content of every file that will go into the next commit. The repository, the .git folder, is the album: every commit ever made.
git add index.js copies the current content of index.js into the staging area. It does not mark the file for later; it takes a copy now. git commit turns the whole staging area into a new snapshot and adds it to the repository. Two commands go the other way. git restore --staged index.js puts the last commit's version back into the staging area, which "unstages" your change; your edits in the working folder stay. git restore index.js copies the staged version over your working file, which throws away your unstaged edits with no undo. Older tutorials use git reset HEAD index.js and git checkout -- index.js for these two; git restore replaced them in Git 2.23 because one command name was doing too many jobs.
Why have a middle step at all? Because real work is messy. While fixing a bug you also add a debug line, rename a variable and start a second idea. The staging area lets you choose: put only the fix in this commit, and the rest in another commit, or in none. Small, focused commits are much easier to review, understand and undo (Everyday Git).
Quick check
You edit index.js, run git add index.js, then run git restore --staged index.js. Where is your edit now?
With --staged, restore only resets the staging area to the last commit's version. Your working file is untouched, so the edit is still there, now listed under "Changes not staged for commit". Only plain git restore (without --staged) overwrites the working file.
git add copies a file's current content into the staging area; git commit saves the staging area as a new snapshot. Going back, git restore --staged unstages by copying the last commit's version into staging, and git restore overwrites your working file with the staged version. git status reports the differences between neighbouring areas.Think first: you run git add index.js, then edit index.js again, then git commit. Which version is in the commit?
The version from the moment you ran git add. The commit is made from the staging area, and add copied the file then. Your later edit is still in the working folder, and git status shows index.js under "Changes not staged for commit". Short status shows MM: modified in staging, and modified again in the working folder. Run git add again to include the newer edit. Build it step 6 shows exactly this.
git status is your dashboard
Run git status before and after every Git command until you no longer need to. It compares the three areas and puts every changed file into one of three groups:
- Changes to be committed: the staging area differs from the last commit. These go into the next commit.
- Changes not staged for commit: your working file differs from the staging area. These do not go into the next commit unless you add them.
- Untracked files: files Git has never been told about. Git ignores them completely until you add them once. After that they are tracked.
Status also prints a hint under each heading with the exact command to move files, such as (use "git restore --staged <file>..." to unstage). Those hints are correct and worth reading. git status -s gives the same information in two columns: the left letter is the staging area compared with the last commit, the right letter is the working folder compared with the staging area.
| Short status | Meaning | Goes into next commit? |
|---|---|---|
?? | Untracked: Git does not know this file | No |
A (A, space) | New file, staged | Yes |
M (space, M) | Modified, not staged | No |
M (M, space) | Modified and staged | Yes |
MM | Staged, then modified again | Only the staged version |
D / D | Deleted, not staged / staged | Only if staged |
Quick check
git status -s prints M README.md (a space, then M). What happens to that change if you run git commit now?
The left column is empty, so nothing about README.md is staged. The right column M says the working file changed since the staging area. A commit takes only the staging area; the edit stays behind for later. Git only refuses when nothing at all is staged.
Try the three areas in the simulator below. It starts the way a real day often does: two commits exist, index.js is edited and prices.json is a new file. Stage just one of them and commit, then edit a staged file again and watch git status list it twice. Try git restore on an edit and read the warning.
- The
git statusoutput under the columns updates after every click and matches what real Git prints for that state. - A commit only takes what is in the staging area. Files left in the working folder stay there, still changed, for the next commit.
Build it
Set up Git, make three commits on a tiny project, and read git status and git log at each step. All outputs are from Git 2.55. Your commit IDs will differ, because they include your name and the time. When you are done, the Practice section has a small model of the staging area to write and test in your browser.
-
Tell Git who you are, once per laptop
Every commit records a name and email, and Git refuses to commit without them. Use the email you will use on GitHub, so your commits link to your account later.
--globalsaves the settings in~/.gitconfigfor every repository on this laptop. The other two lines name the first branchmainand make VS Code the editor for longer commit messages.Terminalgit config --global user.name "Your Name" git config --global user.email "you@example.com" git config --global init.defaultBranch main git config --global core.editor "code --wait" git config --global --listOutputuser.name=Your Name user.email=you@example.com init.defaultbranch=main core.editor=code --wait -
Create a repository
git initcreates the hidden.gitfolder. That folder is the repository; delete it and the history is gone, while your files stay. Without theinit.defaultBranchsetting, Git 2.55 prints a long hint and names the branchmaster.Terminalmkdir -p ~/work/price-check && cd ~/work/price-check git initOutputInitialized empty Git repository in /Users/you/work/price-check/.git/ -
Add two files and read the dashboard
Create
index.jsandREADME.mdin VS Code. Prices are stored as whole cents (integers), so $4.25 is425; whole numbers never pick up the rounding errors that decimals like 0.1 + 0.2 do. "Article" is the data name for a product in this project.JavaScript// index.js: print the shelf price of one article const prices = { 'A-1001': 425, 'A-2040': 189 }; // cents const code = process.argv[2] ?? 'A-1001'; console.log(`${code}: $${(prices[code] / 100).toFixed(2)}`);Markdown# price-check Print the shelf price of an article.Terminalgit statusOutputOn branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) README.md index.js nothing added to commit but untracked files present (use "git add" to track) -
Stage both and make the first commit
Use a Conventional Commit message, a type such as
featordocs, a colon and a short summary (Everyday Git).Terminalgit add index.js README.md git status git commit -m "feat: print the price of one article" git log --oneline git statusOutputOn branch main No commits yet Changes to be committed: (use "git rm --cached <file>..." to unstage) new file: README.md new file: index.js [main (root-commit) 9f18d2a] feat: print the price of one article 2 files changed, 7 insertions(+) create mode 100644 README.md create mode 100644 index.js 9f18d2a (HEAD -> main) feat: print the price of one article On branch main nothing to commit, working tree clean"root-commit" means it has no parent.
(HEAD -> main)says you are on the branchmain, which points at this commit (Branches and merging). "working tree clean" means all three areas agree: nothing is waiting anywhere. -
Change one file, add another
Move the prices into their own file. Create
prices.jsonand changeindex.jsto load it, then run the program and check the dashboard.JSON{ "A-1001": 425, "A-2040": 189, "A-0007": 150 }JavaScript// index.js: print the shelf price of one article const prices = require('./prices.json'); // cents, one entry per article code const code = process.argv[2] ?? 'A-1001'; console.log(`${code}: $${(prices[code] / 100).toFixed(2)}`);Terminalnode index.js A-0007 git status git status -sOutputA-0007: $1.50 On branch main Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: index.js Untracked files: (use "git add <file>..." to include in what will be committed) prices.json no changes added to commit (use "git add" and/or "git commit -a") M index.js ?? prices.json -
Stage, then edit again
Stage both files, then change the comment in
index.jsto// prices in cents, keyed by article codeand save. The file now has one version staged and a newer one in the working folder.Terminalgit add index.js prices.json git status -s # edit the comment in index.js and save, then: git status -s git statusOutputM index.js A prices.json MM index.js A prices.json On branch main Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: index.js new file: prices.json Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: index.jsindex.jsappears in both groups because the staging area and the working folder each hold a different version. The hint under the first heading also changed: before the first commit it saidgit rm --cached; now there is a commit to restore from, so it saysgit restore --staged. -
Move things back with restore
Unstage
prices.json, then discard the newer comment edit inindex.js.Terminalgit restore --staged prices.json git status -s git restore index.js git status -sOutputMM index.js ?? prices.json M index.js ?? prices.jsonUnstaging a new file makes it untracked again; the file is still on disk.
git restore index.jscopied the staged version over the working file, so the comment edit is gone for good and the earlier staged change remains. That is the only command in this lesson that can lose work, which is why the simulator asks before it runs it. -
Make two more commits
Stage
prices.jsonagain and commit. Then add a usage line to the README and commit it with-a, which stages every changed tracked file first. It is safe here becausegit statuswould show onlyREADME.md.Terminalgit add prices.json git commit -m "feat: load prices from prices.json" # add a usage line to README.md and save, then: git commit -am "docs: show usage in README" git log --onelineOutput[main 3624f79] feat: load prices from prices.json 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 prices.json [main e5b1453] docs: show usage in README 1 file changed, 2 insertions(+) e5b1453 (HEAD -> main) docs: show usage in README 3624f79 feat: load prices from prices.json 9f18d2a feat: print the price of one articlegit log --onelinelists commits newest first: short ID and the first line of the message. -
Look inside a commit
git cat-file -pprints a stored object as Git keeps it. The newest commit is plain text: its snapshot (tree), itsparent, author, committer and message.git ls-treelists a snapshot;HEADmeans the current commit andHEAD~1its parent.Terminalgit cat-file -p HEAD git ls-tree HEAD git ls-tree HEAD~1Outputtree aedee8e6ea9e5feead2f8fc6ac0e164fbe5f7134 parent 3624f79ef247ebceecff27e79a9272e5aed49d34 author Your Name <you@example.com> 1791357076 +0600 committer Your Name <you@example.com> 1791357076 +0600 docs: show usage in README 100644 blob 65866ea2fead7b24a97ce77840793a544832c1d7 README.md 100644 blob e5a5bd6143675b12c300ff774dd4e6348808d861 index.js 100644 blob 27f31c052ed4ad43f3a71618dc7631f02194434b prices.json 100644 blob 6ecc521332e5791bd10f72cb8c42b677699ef54b README.md 100644 blob e5a5bd6143675b12c300ff774dd4e6348808d861 index.js 100644 blob 27f31c052ed4ad43f3a71618dc7631f02194434b prices.jsonBoth snapshots list all three files: they are full pictures, not changes. Only
README.mdhas a new ID;index.jsandprices.jsonpoint to the same stored content as before. The timestamp is seconds since 1970, and+0600is the author's time zone, six hours ahead of UTC. Inside the .git folder explores these objects properly. -
Read an old photo
The first commit is still there, unchanged.
git show COMMIT:FILEprints a file as it was in any commit, without touching your working folder.Terminalgit show HEAD~2:index.jsOutput// index.js: print the shelf price of one article const prices = { 'A-1001': 425, 'A-2040': 189 }; // cents const code = process.argv[2] ?? 'A-1001'; console.log(`${code}: $${(prices[code] / 100).toFixed(2)}`);
Bring it to your team
These questions are about habits, and you can ask them without reading any code.
- "Does each commit in this PR do one thing? Can you show me the file list with
git show --stat?" - "Is our commit email the same as our GitHub email, so history links to real people?"
- "Do we have a
.gitignoreand a.gitattributes, so new files and line endings behave the same on Windows and macOS?" - "If this laptop is lost tonight, which work exists only in its working folder and has never been committed and pushed?"
- "When you say 'it's in Git', do you mean committed, or just saved in the editor?"
Red flags
- Commit messages like "update", "fix", "wip" on every commit.
- One giant commit per day, mixing a bug fix, a refactor and config changes.
- Someone deleting
.gitto "start fresh" when Git gets confusing. - Commits by "root" or "user@laptop", because nobody set
user.nameanduser.email.
Practice
Model the staging area in code
A repository is three maps from file name to file content: head (the last commit), index (the staging area) and work (the working folder). Like real Git, the index starts as a copy of the last commit. Write two functions. add(repo, file) copies the file's current content from work into index. status(repo) returns the three groups that git status prints, each sorted by name:
staged: files whose content inindexdiffers fromhead(this includes new files),notStaged: files that are inindexand whose content inworkdiffers from it,untracked: files inworkthat are not inindex.
// repo = { head: {}, index: {}, work: {} }, each mapping file name to content
function add(repo, file) {
// copy the file's current content from the working folder into the staging area
}
function status(repo) {
// return { staged: [...], notStaged: [...], untracked: [...] }, each sorted by name
return { staged: [], notStaged: [], untracked: [] };
}
const fresh = () => ({
head: { 'README.md': 'v1', 'index.js': 'v1' },
index: { 'README.md': 'v1', 'index.js': 'v1' },
work: { 'README.md': 'v1', 'index.js': 'v2', 'prices.json': 'v1' },
});
test('an edited file is not staged and a new file is untracked', () => {
expect(status(fresh())).toEqual({ staged: [], notStaged: ['index.js'], untracked: ['prices.json'] });
});
test('add stages a change and a new file', () => {
const repo = fresh();
add(repo, 'index.js');
add(repo, 'prices.json');
expect(status(repo)).toEqual({ staged: ['index.js', 'prices.json'], notStaged: [], untracked: [] });
});
test('add copies the content at that moment (editing again gives MM)', () => {
const repo = fresh();
add(repo, 'index.js');
repo.work['index.js'] = 'v3';
expect(repo.index['index.js']).toBe('v2');
expect(status(repo)).toEqual({ staged: ['index.js'], notStaged: ['index.js'], untracked: ['prices.json'] });
});
test('a clean repo has nothing in any group', () => {
const repo = { head: { 'a.js': '1' }, index: { 'a.js': '1' }, work: { 'a.js': '1' } };
expect(status(repo)).toEqual({ staged: [], notStaged: [], untracked: [] });
});
Show a solution
function add(repo, file) {
repo.index[file] = repo.work[file]; // a copy of the content now, not a reference to the file
}
function status(repo) {
const names = [...new Set([
...Object.keys(repo.head), ...Object.keys(repo.index), ...Object.keys(repo.work),
])].sort();
return {
staged: names.filter((f) => repo.index[f] !== repo.head[f]),
notStaged: names.filter((f) => f in repo.index && repo.work[f] !== repo.index[f]),
untracked: names.filter((f) => f in repo.work && !(f in repo.index)),
};
}Read a dashboard and plan the commit
You fixed a stock count bug in the Store app. git status -s prints the four lines below. Write down (1) exactly what git commit would save right now, and (2) the commands that give you one commit containing the fix in both source files, in their newest versions, and nothing else.
M src/stock.js
MM src/count.js
?? .env
?? notes.txt- You say the commit would hold
src/stock.jsand the staged version ofsrc/count.js, not its newest edit - You run
git add src/count.jsagain before committing - You never stage
.envornotes.txt, and you suggest adding.envto.gitignore - You check with
git status(orgit diff --staged) before runninggit commit
Show a model answer
(1) Right now the commit would save src/stock.js and the version of src/count.js that was staged earlier. The newer edit to count.js (the right-hand M) would stay behind, and the two untracked files are ignored by commit.
git add src/count.js # stage the newest version
git status -s # expect: M src/stock.js, M src/count.js, ?? .env, ?? notes.txt
git commit -m "fix: round loose items to 0.01 kg in stock count"
echo ".env" >> .gitignore # so git add . can never pick it upCheck yourself
You change one file in a project of 500 files and commit. What does Git store for the new commit?
Every commit is a full snapshot, but unchanged files have the same content fingerprint, so the snapshot points to content Git already has. Line-by-line differences are computed when you ask for them. Commits are stored locally the moment you make them; pushing only copies them elsewhere.
git status -s shows MM src/stock.js. If you run git commit now, what is committed for that file?
The left M means a change is staged; the right M means the working file changed again after that. A commit is made only from the staging area. Run git add src/stock.js again if you want the newer edit included.
A teammate changes one word in the message of an old commit. Why does that commit's ID change?
A commit ID is a fingerprint of the snapshot, parent, author, dates and message. Change any of them and the fingerprint changes, so Git has really made a new commit. That is also why the commits after it get new IDs too: each one's parent pointer changed (Rewriting history safely).
Flashcards
Key takeaways
- Git stores snapshots of every tracked file. Unchanged content is stored once and reused, and differences are computed on demand.
- A commit is a snapshot plus author, committer, dates, message and parent. Its ID is a hash of all of that, so commits never change.
- Three areas: the working folder (what you edit), the staging area (the next commit) and the repository (all commits).
addandcommitmove forward;restore --stagedandrestoremove back. git statusis the dashboard: to be committed, not staged, untracked. Read it before and after every command.- Everything in this lesson is local, in the
.gitfolder. Committed is not the same as backed up; that comes with pushing in GitHub: remotes, clone, push and pull.