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.

Lesson 007 min read#git#internals#beginner

On this page

  1. Words used in this lesson
  2. The three places your work lives
  3. A commit is a snapshot, not a change
  4. What a hash really is
  5. Branches and HEAD are just pointers
  6. Git compares by hash, so it is fast and honest
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

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 .git folder 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. HEAD is 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.

PlaceWhat it isHow data gets in
Working treeThe files on your diskYou edit them
Staging area (index)The list of changes picked for the next commitgit add
Repository (the .git folder)All commits, forevergit 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:

Shell
git --versiongit -c init.defaultBranch=main init democd demoecho "hello" > hello.txtgit add hello.txtgit commit -m "Add hello.txt"
TEXT
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.txt

The -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:

Shell
echo "draft" >> hello.txtgit add hello.txtecho "more" >> hello.txtgit status --short
TEXT
MM hello.txt

Two 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:

Shell
git show HEAD:hello.txt   # the last commitgit show :hello.txt       # the staging areacat hello.txt             # the working tree
TEXT
hellosecond 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:

TEXT
$ git diff@@ -1,3 +1,4 @@ hello second line draft+more$ git diff --staged@@ -1,2 +1,3 @@ hello second line+draft

If 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:

Shell
git cat-file -t HEADgit cat-file -p HEAD
TEXT
committree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7author Ada Lovelace <ada@example.com> 1767261600 +0000committer Ada Lovelace <ada@example.com> 1767261600 +0000Add hello.txt

A 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:

Shell
git cat-file -p 'HEAD^{tree}'
TEXT
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	hello.txt

A tree lists names and what they point to. The 100644 is the file mode (a normal file). The blob holds the content:

Shell
git cat-file -p HEAD:hello.txt
TEXT
hello

So 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:

Shell
git cat-file -p HEADgit cat-file -p 'HEAD^{tree}'
TEXT
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.txt

Now 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.

TEXT
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:

Shell
find .git/objects -type f | sort
TEXT
.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/e15ae62f48243ef3fa88ad8b93c295c7069d77

Eight 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:

Shell
printf 'hello\n' | git hash-object --stdin
TEXT
ce013625030ba8dba906f756967f9e9ca394464a

That 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.

Shell
cat .git/HEADcat .git/refs/heads/maingit log --oneline
TEXT
ref: refs/heads/main4f4e045da4b5e274b05d558a138c4b196d771cf44f4e045 Add second line and docsece15ae Add hello.txt
  • .git/HEAD says "I am on the branch main".
  • .git/refs/heads/main says "main points to commit 4f4e045".

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:

Shell
git branch featurecat .git/refs/heads/featuregit switch featurecat .git/HEADgit log --oneline --decorate --all
TEXT
4f4e045da4b5e274b05d558a138c4b196d771cf4Switched to branch 'feature'ref: refs/heads/feature4f4e045 (HEAD -> feature, main) Add second line and docsece15ae Add hello.txt

Both 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:

TEXT
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:

CommandWhat it does, in pointer language
git commitMake a new commit from the index; move the current branch to it
git branch nameAdd a new label on the current commit
git switch namePoint HEAD at another branch and update the files
git merge xMake a commit with two parents; move the current branch to it
git resetMove the current branch to another commit
git rebaseMake 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.

  1. Create a repository, make one commit with a file a.txt that contains one. Run git cat-file -p HEAD. Which line is missing compared with a later commit?
  2. Make a second commit. Find the parent line and check that it equals the first commit's hash (git rev-parse HEAD~1).
  3. Edit a.txt and run git add a.txt, then edit it again. Run git status --short. What two letters do you see, and what does each mean?
  4. Run printf 'one\n' | git hash-object --stdin. Which object in your repository has the same hash?
  5. Run git branch test, then cat .git/refs/heads/test. Which commit does it point to?
  6. Count the objects with find .git/objects -type f | wc -l. Then create b.txt with the same text one, add it and commit. How many new objects appear, and why fewer than you expect?

Answers

  1. The first commit has no parent line. The second has one.
  2. git rev-parse HEAD~1 prints the same hash as in the parent line. If it does not, check you ran it in the right repository.
  3. MM. The left M is "staged change versus last commit". The right M is "working tree change versus staged".
  4. The blob for the file content one plus newline. You can confirm with git cat-file -p <hash>.
  5. The same commit as main (the one you are on), because a new branch starts at the current commit.
  6. Adding b.txt with the same content as a.txt gives 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 for a.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 add moves data to the staging area and git commit saves 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. HEAD says which branch you are on. Moving branches never copies files.
  • git cat-file -p lets 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

Part of the study guide

Related series