Git & GitHub
The Mental Model: Snapshots, Commits and Branches
The working tree, the staging area and the repository, how a commit is a snapshot with a parent, and how branches and HEAD are just pointers.
On this page
In one line: Git does not store "changes". It stores snapshots of your whole project, called commits. Every commit points to its parent, and a branch is only a small label that points to one commit. If you hold this picture, every other Git command becomes easy to predict.
Most people learn Git as a list of magic commands and then panic when something goes wrong. This lesson does the opposite. We build a tiny repository, then open the .git folder and look at what is really inside. It has only a few kinds of objects and a few text files. After that, add, commit, branch, merge and reset are just different ways of moving pointers.
This is lesson 0 of a six-lesson series. You do not need to know Git before you start. The next lesson, the daily loop, uses the picture from here to explain status, add, commit, diff and log.
What I ran / checked. Every command in this lesson was run in a throwaway folder on Linux with git version 2.43.0 (git --version). I pasted the real output. Three things are different from a normal setup, on purpose, so that the output is repeatable: I ignored my own Git configuration (GIT_CONFIG_GLOBAL=/dev/null), I used the name "Ada Lovelace" with ada@example.com, and I fixed the commit date to 1 January 2026. Your hashes will not match mine, because a hash depends on the author, the date and the message. That is normal and is part of the lesson. I also read the object chapter of the Pro Git book source (progit/progit2, book/10-git-internals/sections/objects.asc). Nothing here talks to GitHub yet.
Words used in this lesson
- A repository (or "repo") is a project folder that Git tracks, plus a hidden
.gitfolder where Git keeps the history. - The working tree is the normal files and folders you see and edit.
- The staging area (also called the index) is a list of the changes you picked for the next commit. You fill it with
git add. - A commit is a saved snapshot of the whole project, with an author, a date, a message and a pointer to its parent commit.
- A parent is the commit that came just before. The first commit has no parent. A merge commit has two.
- A hash (or SHA) is the long ID of an object, such as
4f4e045da4b5e274b05d558a138c4b196d771cf4. Git computes it from the content. People usually write only the first 7 characters. - An object is one stored item in
.git/objects. There are four kinds. We meet three here: blob, tree and commit. - A blob stores the content of one file (not its name).
- A tree stores a folder: a list of names, each pointing to a blob or to another tree.
- A branch is a movable label that points to one commit.
HEADis a label that says "where I am now". It usually points to a branch. - A ref is any such label. Branches are refs. So are tags and remote branches.
The three places your work lives
Git has three places. Almost every command moves data from one place to the next.
| Place | What it is | How data gets in |
|---|---|---|
| Working tree | The files on your disk | You edit them |
| Staging area (index) | The list of changes picked for the next commit | git add |
Repository (the .git folder) | All commits, forever | git commit |
The staging area is the part that surprises beginners. Why not commit straight from the files? Because it lets you choose what goes into a commit. You may have changed five files, but only two belong to the same idea. You stage those two and commit them.
Let me prove that the three places hold three different versions. First, a new repository with one commit:
git --versiongit -c init.defaultBranch=main init democd demoecho "hello" > hello.txtgit add hello.txtgit commit -m "Add hello.txt"git version 2.43.0Initialized empty Git repository in .../demo/.git/[main (root-commit) ece15ae] Add hello.txt 1 file changed, 1 insertion(+) create mode 100644 hello.txtThe -c init.defaultBranch=main part names the first branch main. Without it, Git uses its own default, which depends on your version and settings. Output line (root-commit) tells us this commit has no parent.
Before the next step I had already made a second commit (it is shown in the next section) that added the line second line. Now I change the file in two steps. I add draft and stage it, then add more and do not stage it:
echo "draft" >> hello.txtgit add hello.txtecho "more" >> hello.txtgit status --shortMM hello.txtTwo letters, because there are two columns. The left column compares the staging area with the last commit. The right column compares the working tree with the staging area. M means modified. So MM says: "this file is changed in both comparisons".
Now the same file in the three places:
git show HEAD:hello.txt # the last commitgit show :hello.txt # the staging areacat hello.txt # the working treehellosecond line---hellosecond linedraft---hellosecond linedraftmore(I added the --- lines to separate the three outputs.) Three places, three versions. git diff shows working tree against the staging area. git diff --staged shows the staging area against the last commit:
$ git diff@@ -1,3 +1,4 @@ hello second line draft+more$ git diff --staged@@ -1,2 +1,3 @@ hello second line+draftIf you remember one thing about git diff: plain git diff does not show staged changes. Many people think their change is "missing" because they staged it and then ran git diff.
A commit is a snapshot, not a change
Many tools store the difference between versions. Git conceptually stores the full picture each time. (It saves space by sharing unchanged files between snapshots, so a snapshot of 10,000 files where one changed costs very little.)
Look at the first commit. git cat-file prints any object. -t shows its type and -p pretty-prints it:
git cat-file -t HEADgit cat-file -p HEADcommittree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7author Ada Lovelace <ada@example.com> 1767261600 +0000committer Ada Lovelace <ada@example.com> 1767261600 +0000Add hello.txtA commit is just a small text object with:
- a tree: the snapshot of the whole project (by hash),
- the author and committer, each with a date (the number is seconds since 1 January 1970),
- the message.
This first commit has no parent line. Now the tree:
git cat-file -p 'HEAD^{tree}'100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txtA tree lists names and what they point to. The 100644 is the file mode (a normal file). The blob holds the content:
git cat-file -p HEAD:hello.txthelloSo the chain is commit, then tree, then blob. The file name lives in the tree. The content lives in the blob. This is why renaming a file does not copy its content: the blob stays, only the tree entry changes.
The second commit has a parent
Next I added a second line to hello.txt and a new folder docs/ with notes.txt, then committed:
git cat-file -p HEADgit cat-file -p 'HEAD^{tree}'tree a77330f41e518d6e8ffadaf41383b88317e75c28parent ece15ae62f48243ef3fa88ad8b93c295c7069d77author Ada Lovelace <ada@example.com> 1767265200 +0000committer Ada Lovelace <ada@example.com> 1767265200 +0000Add second line and docs040000 tree 38d5be3806b7049721d52443472afe889b08af00 docs100644 blob 27019c35b7067ec5703ab99bca6ec41593b8e38a hello.txtNow there is a parent line. It holds the hash of the first commit. This is the whole idea of history: each commit names its parent, and the parent names its own parent, back to the first commit. git log simply walks this chain.
commit 4f4e045 (second) ----parent----> commit ece15ae (first) | | tree a77330f tree aaa96ce |-- docs/ (tree 38d5be3) |-- hello.txt (blob ce01362) `-- hello.txt (blob 27019c3)The folder docs is a tree inside a tree. Our first hello.txt blob (ce01362) is not in the second tree, because the file changed. It is still stored, because the first commit needs it. Nothing is lost.
You can see all the objects as files. Each object is a file named by its hash, in a folder named by the first two characters:
find .git/objects -type f | sort.git/objects/27/019c35b7067ec5703ab99bca6ec41593b8e38a.git/objects/38/d5be3806b7049721d52443472afe889b08af00.git/objects/4f/4e045da4b5e274b05d558a138c4b196d771cf4.git/objects/a7/7330f41e518d6e8ffadaf41383b88317e75c28.git/objects/aa/a96ced2d9a1c8e72c56b253a0e2fe78393feb7.git/objects/bf/a655111293037a5564088d1a9bbca4cbcf446b.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a.git/objects/ec/e15ae62f48243ef3fa88ad8b93c295c7069d77Eight objects: 2 commits, 3 trees (one for each root, plus docs), 3 blobs. Count them against the lists above.
What a hash really is
A hash is a fingerprint of the content. Git runs the content (with a small header) through a hash function and uses the result as the name. Two things follow.
First, the same content always gets the same hash. You can compute it yourself, without any repository, for the text hello plus a new line:
printf 'hello\n' | git hash-object --stdince013625030ba8dba906f756967f9e9ca394464aThat is exactly the blob hash from our first commit. If two files in a project have the same content, Git stores one blob. If you commit the same file on two different computers, both get the same hash.
Second, you cannot change an object without changing its hash. A commit contains its parent's hash. So if you change an old commit, its hash changes, so the next commit's parent line is wrong, so its hash must change too, all the way to the tip. This is why "editing history" (amend, rebase) really means making new commits, and why it is a problem when others already have the old ones. You will use this idea again in lesson 4 and lesson 5.
This repository uses SHA-1 hashes (40 hex characters). Git also has a SHA-256 mode. I tried git init --object-format=sha256 on this Git version and got 64-character hashes. SHA-1 is still the default, so stay with the default unless you have a reason.
A short hash works as long as it is unique in your repository. git log --oneline shows 7 characters. Git will tell you if a short hash is ambiguous.
The Pro Git book says Git is "a content-addressable filesystem": a simple key-value store where the key is the hash of the value. The commands in this lesson are the "plumbing" (low-level). add, commit and log are the "porcelain" (friendly) layer on top.
Branches and HEAD are just pointers
Here is the surprise: a branch is not a copy of your files. It is a text file with one hash in it.
cat .git/HEADcat .git/refs/heads/maingit log --onelineref: refs/heads/main4f4e045da4b5e274b05d558a138c4b196d771cf44f4e045 Add second line and docsece15ae Add hello.txt.git/HEADsays "I am on the branchmain"..git/refs/heads/mainsays "mainpoints to commit4f4e045".
When you commit, Git creates the new commit with the current commit as parent, then writes the new hash into the branch file. That is all "the branch moves forward" means.
Creating a branch costs almost nothing. It writes one more small file:
git branch featurecat .git/refs/heads/featuregit switch featurecat .git/HEADgit log --oneline --decorate --all4f4e045da4b5e274b05d558a138c4b196d771cf4Switched to branch 'feature'ref: refs/heads/feature4f4e045 (HEAD -> feature, main) Add second line and docsece15ae Add hello.txtBoth feature and main point to the same commit. HEAD -> feature means we are standing on feature. If we commit now, only feature moves, and main stays behind:
before a commit on feature: after a commit on feature: ece15ae <- 4f4e045 ece15ae <- 4f4e045 <- 9a8b7c6 ^ ^ ^ ^ main feature(HEAD) main feature(HEAD)(The hash 9a8b7c6 is made up for the drawing. It is not from a run.)
One more case: detached HEAD. If you check out a commit hash instead of a branch, HEAD holds the hash directly, with no branch name. You can look around and even commit, but no branch label follows you, so those commits are easy to lose. git switch main takes you back. We recover from this in lesson 4.
Why this picture helps
Now several commands become short sentences:
| Command | What it does, in pointer language |
|---|---|
git commit | Make a new commit from the index; move the current branch to it |
git branch name | Add a new label on the current commit |
git switch name | Point HEAD at another branch and update the files |
git merge x | Make a commit with two parents; move the current branch to it |
git reset | Move the current branch to another commit |
git rebase | Make new copies of commits on a new base; move the branch to them |
Git compares by hash, so it is fast and honest
Because every object has a content hash, Git can tell if two snapshots are the same by comparing two short strings. That is why git status is fast and why Git can detect a corrupted file (the content no longer matches its name). Packing also saves space: after many commits git gc bundles loose objects into compact "pack" files. You never need to manage this by hand.
Try it
Do these in an empty folder. Use git init with -c init.defaultBranch=main as above.
- Create a repository, make one commit with a file
a.txtthat containsone. Rungit cat-file -p HEAD. Which line is missing compared with a later commit? - Make a second commit. Find the
parentline and check that it equals the first commit's hash (git rev-parse HEAD~1). - Edit
a.txtand rungit add a.txt, then edit it again. Rungit status --short. What two letters do you see, and what does each mean? - Run
printf 'one\n' | git hash-object --stdin. Which object in your repository has the same hash? - Run
git branch test, thencat .git/refs/heads/test. Which commit does it point to? - Count the objects with
find .git/objects -type f | wc -l. Then createb.txtwith the same textone, add it and commit. How many new objects appear, and why fewer than you expect?
Answers
- The first commit has no
parentline. The second has one. git rev-parse HEAD~1prints the same hash as in theparentline. If it does not, check you ran it in the right repository.MM. The leftMis "staged change versus last commit". The rightMis "working tree change versus staged".- The blob for the file content
oneplus newline. You can confirm withgit cat-file -p <hash>. - The same commit as
main(the one you are on), because a new branch starts at the current commit. - Adding
b.txtwith the same content asa.txtgives two new objects, not three: a new commit and a new tree. The blob is reused, because it has the same hash as the one fora.txt.
Interview questions
Explain the difference between the working tree, the staging area and the repository. (Must know)
The working tree is the files I edit on disk. The staging area is the list of changes I picked for the next commit; I fill it with git add. The repository is the saved history in .git. A commit takes what is in the staging area, not what is on disk. That is why I can edit five files and still make two clean commits. I can check each place: git diff compares disk with the staging area, and git diff --staged compares the staging area with the last commit.
Does Git store changes or snapshots? (Must know)
Conceptually, snapshots. Each commit points to a tree that describes the whole project at that moment. Git saves space by reusing the same blob when a file did not change, and it packs objects later. When I run git show, it computes a difference for me to read, but that is not how it is stored. I mention that this is the key difference from older tools that think in file changes.
What is a commit made of? (Must know)
A commit is a small object with a pointer to a tree (the full snapshot), zero or more parent hashes, the author and committer with dates, and the message. The first commit has no parent and a merge commit has two or more. I can show it with git cat-file -p HEAD.
What is a branch? What is HEAD? (Must know)
A branch is a file containing one commit hash, so it is a movable label. When I commit, the branch moves to the new commit. HEAD says where I am now; normally it holds the name of a branch (ref: refs/heads/main). This is why creating a branch is instant and cheap: it is one tiny file, not a copy of the project.
Follow-up: "What is a detached HEAD?" HEAD holds a commit hash directly, not a branch name. New commits are not on any branch, so if I switch away I can lose track of them. I can still find them with the reflog (lesson 4), or I can give them a name with git switch -c new-branch.
How does Git make a hash, and why does it matter? (Senior signal)
A hash is computed from the object's content (and a short header). So the same content always has the same hash, and any change gives a different hash. Because a commit includes its parent's hash, changing an old commit changes every commit after it. That is why amend and rebase create new commits instead of editing old ones, and why rewriting history that others already have causes trouble. It also means Git can detect corruption, because a damaged object no longer matches its name.
Do two identical files take twice the space? (Senior signal)
No. A blob is named by its content hash, so two files with the same content share one blob, even in different folders or commits. I proved this with git hash-object: the hash of hello\n equals the blob hash in the repository. The file name is stored in the tree, not in the blob.
Where does Git store the name of a file? (Trap)
In the tree, not in the blob. A tree entry has a mode, a type, a hash and a name. A blob has only content. That is why a rename costs almost nothing in storage. Git also does not store "this file was renamed" as a fact. It only guesses renames later by comparing content, for example in git log --follow.
What does git commit actually change on disk? (Senior signal)
It writes new objects: blobs for new or changed files that were staged, trees for the changed folders (and the root), and a commit object. Then it writes the new commit hash into the file of the current branch (for example .git/refs/heads/main). The old commits are not touched. That is why old states are still reachable, and why a "lost" commit can often be found again.
Why is it dangerous to say "Git stores diffs"? (Trap)
Because it leads to wrong predictions. For example, people think git revert or git checkout replays diffs backwards. In reality Git compares two snapshots when it needs a diff. Also, a merge is not "applying someone's diff". It is a three-way comparison of snapshots: the two tips and their common ancestor. I check my own understanding by asking "what snapshots and what pointers are involved?"
How do you look inside a repository when something seems wrong? (Senior signal)
I start with high-level tools: git status, git log --oneline --graph --decorate --all, git branch -vv. If that is not enough I use the low-level ones: git cat-file -p <hash> to see an object, git rev-parse to turn a name into a hash, git ls-files --stage to see the staging area, and cat .git/HEAD to see where I am. These commands only read, so they are safe.
Key takeaways
- Git has three places: working tree, staging area, repository.
git addmoves data to the staging area andgit commitsaves it. - A commit is a snapshot (a tree) plus author, date, message and a parent pointer. History is the parent chain.
- Objects are named by the hash of their content. Same content, same hash. Change anything and the hash changes, along with all later commits.
- A branch is a text file with one hash.
HEADsays which branch you are on. Moving branches never copies files. git cat-file -plets you look at any object, and it is safe.
What's next
Next: the daily loop, Lesson 1. You will use status, add, commit, diff and log every day, write good commit messages, ignore files with .gitignore, and split changes with git add -p. Practise now: make a repository, look at each object with git cat-file -p, and draw the commit chain on paper.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Git, Team Workflow and Monorepos · Full-Stack Questions
- Node.js Internals Interviewers Ask About · Full-Stack Questions
Part of the study guide
Related series