Git's Object Model and Mental Model
Why this matters
Almost everyone who finds Git confusing is confused because they learned
the commands (add, commit, push) as magic incantations without ever
seeing the tiny, simple data model underneath them. That model is genuinely
small — four object types and some pointers — and once you can see it,
every later command stops being memorized and starts being obvious. This
module deliberately front-loads that model so the rest of the track (and
the CI/CD and GitOps tracks later, which live and die on Git) feels like
common sense instead of guesswork.
Concepts
Git is a content-addressed key-value store
Underneath everything, Git is a database that stores objects and looks them up by the SHA-1 hash of their content. Give Git some bytes and it hands you back a 40-character hex ID (a hash); give it that ID later and it hands the exact bytes back. Because the ID is the hash of the content, identical content always gets the same ID, and any change to the content produces a completely different ID. This is why Git can tell instantly whether two files are the same, and why history can't be silently altered: change one byte anywhere in the past and every ID from that point forward changes too. Everything else in Git is a thin layer of convenience over this "store bytes, get a hash back" core.
There are exactly four object types
That's the whole vocabulary of the Git database:
- A blob is the raw contents of a file — just the bytes, no filename, no permissions, no history. Two files with identical content anywhere in your project are stored as a single blob.
- A tree represents a directory. It's a list of entries, each pairing a
name and mode (like
100644 fileor40000 subdir) with the hash of a blob (a file) or another tree (a subdirectory). A tree is how filenames and directory structure get attached to otherwise-nameless blobs. - A commit is a snapshot plus context. It points at exactly one tree (the complete state of your project at that moment), lists zero or more parent commits (the commit[s] that came before), and records an author, a committer, timestamps, and your commit message.
- A tag object is a named, annotated pointer to another object (usually a commit), used for marking releases. You'll meet these properly in module 06.
Notice what a commit does not contain: diffs. Git stores whole snapshots
(deduplicated via shared blobs and trees), not "the change since last
time." The diffs you see in git diff and git log -p are computed on the
fly by comparing two snapshots.
Commits form a chain (actually, a graph) through parent links
Because each commit records its parent(s), commits link backward into a chain. Follow the parent pointers and you walk history from newest to oldest. Most commits have exactly one parent. A merge commit has two (or more) parents — that's literally what "merge" means at the data level: a commit that ties two lines of history back together. The very first commit in a repo has zero parents (a "root commit"). This backward-linked structure is a directed acyclic graph (a DAG) — "directed" because parent links point one way, "acyclic" because you can never loop back to a commit that descends from you.
Refs and HEAD: branches are just pointers
Forty-character hashes are unusable by humans, so Git keeps refs —
human-named pointers to commits, stored as tiny files under .git/refs/.
A branch is nothing more than a ref: a movable pointer holding one commit
hash. main is a file whose entire content is the hash of the latest
commit on that branch. When you commit, Git creates the new commit object
and then just rewrites that one file to point at it. This is why creating a
branch in Git is instant and cheap — it's writing 41 bytes to a file, not
copying anything.
HEAD is a special ref that answers "where am I right now?" Almost always
HEAD is a symbolic ref — it doesn't hold a commit hash, it holds the name
of a branch (e.g. "I am main"). So the chain is: HEAD → main → <commit hash> → tree → blobs. When HEAD points at a branch and you commit, the
branch moves and HEAD follows it for free. (When HEAD points directly at a
commit instead of at a branch, you're in "detached HEAD" state — a source of
much confusion that module 07 defuses completely.)
The three trees: working tree, index, and repository
Day-to-day Git shuffles content between three areas, and every basic command is really "move content from one of these to another":
- The working tree (or working directory) is the actual files on disk you edit with your editor — an ordinary checkout of one commit's snapshot that you're free to modify.
- The index (also called the staging area) is a middle zone: a
proposed next snapshot.
git addcopies the current content of a file from the working tree into the index. Nothing is committed yet — you're building up what the next commit will contain. - The repository (the
.gitdirectory) is the permanent object database — all the blobs, trees, and commits, plus the refs.git committakes whatever is currently in the index, writes it as a new tree + commit into the repository, and advances the current branch.
The staging area is the piece beginners most often resent and later rely on
most: it's what lets you commit some of your changes and not others, and
review exactly what you're about to record before you record it. git status is, at heart, a report on the differences between these three trees.
Everything lives in .git
All of this — the object database, the refs, HEAD, the index — sits inside
one hidden .git/ directory at the root of your project. Delete that
directory and you have plain files again with no history; copy it and you've
copied the entire history. There's no central server required for any of
this to work: your clone is a complete, standalone repository. Remotes
(module 04) are an optional convenience layered on top, not the source of
truth.
Command reference
The commands here are mostly inspection commands — plumbing that reveals the model. You won't type most of these daily, but seeing them once makes the everyday commands make sense.
| Command | What it does | Example |
|---|---|---|
git --version |
Prints the installed Git version | git --version |
git config --global <key> <value> |
Sets a global config value (identity, defaults) | git config --global user.name "Ada" |
git init |
Creates a new empty repository (a .git directory) |
git init |
git hash-object -w <file> |
Stores a file's content as a blob and prints its hash | git hash-object -w app.txt |
git cat-file -t <hash> |
Prints an object's type (blob/tree/commit/tag) | git cat-file -t 3b18e5 |
git cat-file -p <hash> |
Pretty-prints an object's content | git cat-file -p HEAD |
git log --oneline |
Lists commits, one per line, newest first | git log --oneline |
git cat-file -p HEAD^{tree} |
Shows the tree a commit points at | git cat-file -p HEAD^{tree} |
Flag breakdown for git config --global user.name "Ada":
config— read or write configuration.--global— write to your per-user config file (~/.gitconfig) so it applies to every repo for your user, instead of only the current repo (--local, the default) or the whole machine (--system).user.name "Ada"— the key and value; here, the name that will be stamped on every commit you author.
Flag breakdown for git hash-object -w app.txt:
hash-object— compute the object ID (hash) Git would give this content.-w— actually write the object into the database, not just compute and print the hash. Without-wit's a dry run.app.txt— the file whose bytes become a blob.
Flag breakdown for git cat-file -p HEAD:
cat-file— inspect an object in the database.-p— "pretty-print": format the object according to its type instead of dumping raw bytes. (-tinstead prints just the type;-sprints its size.)HEAD— the object to inspect; Git resolves the nameHEADto a commit hash for you.
Hands-on exercises
These exercises deliberately poke at the raw object database so you see the model before you start using the friendly commands in module 01. Run them all in your WSL2 Ubuntu terminal.
Confirm Git is installed (and install it if not).
git --versionIf it prints a version (e.g.
git version 2.43.0), you're set. If you get "command not found," install it and re-check:sudo apt update && sudo apt install -y git git --versionSet your identity. Git stamps every commit with a name and email; set them once, globally, so you're never nagged:
git config --global user.name "Your Name" git config --global user.email "you@example.com" git config --global init.defaultBranch main git config --list | grep -E 'user\.|init\.'The
init.defaultBranch mainline makes new repos start on a branch namedmain(not the older defaultmaster). Expect the last command to echo the three values back.Create a fresh repo and look at what
git initactually made:mkdir -p ~/learn-git/model && cd ~/learn-git/model git init ls -A ls .gitExpect a single hidden
.gitdirectory, and inside itHEAD,config,refs/,objects/, and more. This.gitdirectory is the repository.Read HEAD directly — see that it's just a pointer to a branch:
cat .git/HEADExpect exactly
ref: refs/heads/main. HEAD isn't a commit; it's a note saying "I am currently themainbranch." (There's nomainfile underrefs/heads/yet — it appears the moment you make your first commit.)Store a blob by hand and read it back. This is the raw key-value store from Concept 1, with nothing else involved:
echo "hello object model" > note.txt git hash-object -w note.txtCopy the 40-character hash it prints, then ask the database what type and content that hash holds (replace
<hash>with yours):git cat-file -t <hash> git cat-file -p <hash>Expect
bloband thenhello object model. You just wrote to and read from Git's object database with no commit anywhere in sight — proof that blobs are just content, addressed by hash.Prove content-addressing. Make a second file with identical content and hash it:
echo "hello object model" > copy.txt git hash-object copy.txtExpect the exact same hash as exercise 5 — same bytes, same ID, and Git would store only one blob for both. Now change one character and hash again:
echo "hello object modeL" > copy.txt git hash-object copy.txtExpect a completely different hash. One byte changed everything — that's the tamper-evidence property from Concept 1.
Make a real commit and walk the object chain. Now use the friendly commands (previewing module 01) to create an actual commit, then dissect it:
git add note.txt git commit -m "First commit" git log --onelineNote the short commit hash
git logprints. Now peel the layers apart:git cat-file -t HEAD git cat-file -p HEADExpect
commit, then a printout showing atree <hash>, anauthor, acommitter, and your messageFirst commit. Note there is noparentline — this is the root commit (Concept 3).Follow the pointer from commit → tree → blob. Take the tree hash from the commit you just printed and expand it:
git cat-file -p HEAD^{tree}Expect one line naming
note.txtand its blob hash — and that blob hash should match the one you got in exercise 5. You've now traced the full chainHEAD → commit → tree → blobby hand, exactly as Concept 4 describes.Watch a branch move on commit. See where
mainpoints, add a second commit, and see it move:cat .git/refs/heads/main echo "a second line" >> note.txt git add note.txt git commit -m "Second commit" cat .git/refs/heads/main git log --onelineExpect the hash in
refs/heads/mainto change between the twocats — the branch is a pointer, and committing rewrote that one small file to aim at the new commit (Concept 4).git lognow shows two commits, and the second one has aparentline if you inspect it withgit cat-file -p HEAD.Diagnose and fix: "I broke HEAD." (a controlled scare) Someone hands you a repo where
git statusprintsfatal: not a git repository (or any of the parent directories): .git. Reproduce and reason about it:mv .git .git-backup git statusExpect the
fatal: not a git repositoryerror — because all of Git's data lives in.git(Concept 6), moving it away leaves ordinary files with no repo behind them. Your working-tree files are untouched, though. Fix it by restoring the directory:mv .git-backup .git git status git log --onelineExpect
git statusto work again and both commits to reappear — nothing was lost, because the history was never in the files, it was in.git.
Independent challenge
No commands given here — reason it out from this module's concepts.
Task: Starting from an empty new repository, create two files with identical content but different names, commit them together in one commit, and then, using only the object-inspection commands from this module, prove three things about what Git actually stored: (a) that the commit points at a single tree, (b) that that tree lists both filenames, and (c) that despite being two files, they resolve to the same single blob in the object database — i.e. Git deduplicated the content. Explain in a sentence why that deduplication is a direct consequence of content-addressing rather than a special optimization Git had to be told to do.
Stuck? One hint
git cat-file -p HEAD^{tree} lists the tree's entries with a blob hash next
to each filename — compare the two hashes in that listing to each other, and
remember from exercise 6 that identical bytes always hash to the same ID.
Common mistakes & troubleshooting
- Thinking commits store diffs. They store full snapshots (trees of blobs), deduplicated by hash. Diffs are computed when you ask for them, not stored. This trips people up when reasoning about rebases and cherry- picks later — keep "snapshot, not patch" in mind.
- Believing a branch is a container of commits. A branch is a single movable pointer to one commit; the "commits on the branch" are just whatever you reach by following parent links backward from it. Two branches can share all their history and differ by one commit.
- Confusing HEAD with a branch. HEAD is usually a pointer to a branch
("I am on
main"), not a branch itself. Later, "detached HEAD" means HEAD points straight at a commit with no branch in between — unusual, not broken (module 07). - Deleting
.gitto "start over," then being surprised the history is gone. All history lives in.git. Removing it doesn't reset your repo — it destroys the repository entirely, leaving only the current files. - Assuming Git needs a server. Every clone is a full, independent repository with all history. Remotes are optional collaboration plumbing, covered in module 04 — Git works completely offline until then.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What are Git's four object types, and what does each one represent?
- Why do two files with identical content anywhere in a repository share a single blob, and what does that tell you about how Git addresses objects?
- A commit points at what, exactly — a diff or a full snapshot? What links it to the history before it?
- What is a branch, at the level of the actual data stored in
.git? - What is HEAD normally pointing at, and what does the chain from HEAD down to a file's bytes look like?
- Name the three "trees" content moves between, and say which command moves content into the middle one.
- If you
mv .git /tmp/somewhereand then rungit status, what happens and why — and are your files lost?
Show answers
- Blob = a file's raw content (bytes only, no name). Tree = a directory: a list of names+modes mapped to blob or tree hashes. Commit = a snapshot (one tree) plus parent link(s), author, committer, timestamps, and message. Tag = a named annotated pointer to another object (usually a commit).
- Because Git addresses objects by the hash of their content — identical bytes produce an identical hash, so they're literally the same object in the database. It tells you Git is a content-addressed store; dedup is a free consequence, not a feature bolted on.
- A commit points at a full snapshot — one tree representing the entire project state at that moment (not a diff). It's linked to history by its parent commit hash(es); a merge commit has two or more parents, a root commit has none.
- A branch is a single movable ref — a small file under
.git/refs/heads/whose entire content is one commit hash. Committing rewrites that file to point at the new commit. - HEAD normally points at a branch (a symbolic ref like
ref: refs/heads/main). The full chain isHEAD → branch → commit → tree → blob(s). - The working tree (files on disk), the index/staging area
(proposed next snapshot), and the repository (
.gitobject database).git addmoves content from the working tree into the index (the middle one). git statusfails withfatal: not a git repository, because all of Git's data lives in.gitand you moved it away. Your working-tree files are untouched; moving.gitback restores the full repository and history.
Next
Continue to 01-basic-local-workflow to turn the model you just dissected into the everyday edit → stage → commit loop you'll use constantly.