How Git Works Internally: A Simple Guide

Most developers use Git every day git add, git commit, git push but have you ever wondered what actually happens behind the scenes? Understanding Git's internals transforms it from a mysterious tool into a logical system you can reason about. Let's peek under the hood.
Creating a Git Repository: The .git Folder
When you run git init in a directory, Git creates a hidden folder called .git. This folder is Git's brain—it contains everything Git needs to track your project's history.
Let's see this in action:
$ mkdir my-project
$ cd my-project
$ git init
Initialized empty Git repository in /path/to/my-project/.git/
That's it! Your directory now has version control. But what did Git actually create?
Inside the .git Folder
Let's explore what's inside:
$ ls -a
. .. .git
$ tree .git
.git/
├── HEAD
├── config
├── description
├── hooks/
├── info/
├── objects/
│ ├── info/
│ └── pack/
└── refs/
├── heads/
└── tags/
Let's understand the important parts:
Key Files and Folders
HEAD - A pointer to your current branch. Think of it as "You are here" marker.
$ cat .git/HEAD
ref: refs/heads/main
config - Repository-specific settings (your name, email, remote URLs, etc.)
objects/ - This is where Git stores all your data (files, folders, commits). Everything!
refs/ - References to commits (branches and tags are stored here)
refs/heads/- Your branches live hererefs/tags/- Your tags live here
index - The staging area (appears after your first git add)
Git Objects: The Building Blocks
Git stores everything as objects. There are only four types, but we'll focus on three main ones:
Blob - Stores file content
Tree - Stores directory structure
Commit - Stores commit information
Let's understand each with examples.
1. Blob Objects (File Content)
A blob stores the content of a file. Just the content—no filename, no permissions, nothing else.
Let's create our first blob:
$ echo "Hello, Git!" > README.md
$ git add README.md
Git just created a blob! Let's find it:
$ git ls-files --stage
100644 8d0e4... 0 README.md
That 8d0e4... is the SHA-1 hash of the blob. Let's look inside the objects folder:
.git/objects/
├── 8d/
│ └── 0e41234abcd... (this is our blob!)
├── info/
└── pack/
Git splits the hash: first 2 characters become the folder name (8d), the rest becomes the filename.
We can view the blob content:
$ git cat-file -p 8d0e4
Hello, Git!
Key Point: The blob only contains "Hello, Git!"—it doesn't know it's called README.md.
2. Tree Objects (Directory Structure)
Trees store directory information. They link blobs to filenames and can point to other trees (subdirectories).
A tree object looks like this internally:
100644 blob 8d0e4... README.md
100644 blob a1b2c... index.html
040000 tree 3d4e5... src
Each line says: "Here's a file/folder, its permissions, its type, its hash, and its name."
Let's create a more complex structure:
$ mkdir src
$ echo "console.log('Hello');" > src/app.js
$ git add .
Now if we could peek at the tree (we'll see how after we commit), it would look like:
100644 blob 8d0e4... README.md
040000 tree 9f8e7... src
And the src tree would contain:
100644 blob 6a7b8... app.js
Key Point: Trees connect blobs to filenames and organize them into a directory structure.
3. Commit Objects (Snapshots in Time)
A commit ties everything together. It points to a tree (the root directory at that moment) and stores metadata.
$ git commit -m "Initial commit"
[main (root-commit) a3f5b2c] Initial commit
A commit object contains:
tree 7d8e9f1...
author John Doe <john@example.com> 1704067200 +0000
committer John Doe <john@example.com> 1704067200 +0000
Initial commit
Let's visualize how these objects connect:
[Commit: a3f5b2c]
│
├─ tree: 7d8e9f1
├─ author: John Doe
├─ date: 2024-01-01
└─ message: "Initial commit"
│
└─> [Tree: 7d8e9f1]
│
├─ blob: 8d0e4... → README.md
└─ tree: 9f8e7... → src/
│
└─> [Tree: 9f8e7]
│
└─ blob: 6a7b8... → app.js
Key Point: A commit is a snapshot of your entire project at a specific moment. It points to the root tree, which points to all files and subdirectories.
What Happens When You Run Git Commands?
Now let's see what actually happens inside .git when you use common Git commands.
Step 1: Creating and Modifying a File
$ echo "# My Project" > README.md
What happened in .git? Nothing yet! Git doesn't track changes until you tell it to.
Working Directory: [README.md] ✓
Staging Area: [ empty ]
Repository: [ empty ]
Step 2: Running git add
$ git add README.md
What happened internally?
Git reads the file content: "# My Project"
Git compresses it and calculates its SHA-1 hash:
3b18e512...Git creates a blob object in
.git/objects/3b/18e512...Git updates the staging area (
.git/index) to record: "README.md → blob 3b18e512..."
.git/objects/
├── 3b/
│ └── 18e512... (contains "# My Project")
└── ...
.git/index
├── README.md → blob 3b18e512...
The file is now staged but not committed.
Working Directory: [README.md] ✓
Staging Area: [README.md → blob 3b18e512] ✓
Repository: [ empty ]
Step 3: Running git commit
$ git commit -m "Add README"
[main (root-commit) a3f5b2c] Add README
What happened internally?
Git reads the staging area (
.git/index)Git creates a tree object representing the current directory structure
Git creates a commit object pointing to that tree
Git updates the branch reference (
refs/heads/main) to point to the new commitGit updates
HEADto track this branch
.git/objects/
├── 3b/
│ └── 18e512... (blob: "# My Project")
├── 7d/
│ └── 8e9f1c... (tree: root directory)
└── a3/
└── f5b2c4... (commit: "Add README")
.git/refs/heads/main
└── a3f5b2c4... (points to commit)
.git/HEAD
└── ref: refs/heads/main
Now everything is committed:
Working Directory: [README.md] ✓
Staging Area: [README.md → blob 3b18e512] ✓
Repository: [Commit a3f5b2c] ✓
Step 4: Making Another Commit
Let's add another file:
$ echo "print('Hello')" > main.py
$ git add main.py
$ git commit -m "Add main.py"
What happened?
New blob created for
main.pyNew commit created, with parent pointing to previous commit
a3f5b2c
[Commit: b4c5d6e] "Add main.py"
│
├─ tree: 9e8f7a...
├─ parent: a3f5b2c ← Links to previous commit!
└─ message: "Add main.py"
│
└─> [Tree: 9e8f7a]
│
├─ blob: 3b18e5... → README.md
└─ blob: 6f7a8b... → main.py
The complete history chain:
[b4c5d6e] Add main.py
│
└─ parent
│
↓
[a3f5b2c] Add README
Step 5: Creating a Branch
$ git branch feature
What happened?
Git simply created a new file:
.git/refs/heads/feature
└── b4c5d6e... (points to same commit as main)
That's it! A branch is just a pointer to a commit. This is why branches in Git are so lightweight—they're just 41 bytes (40-character hash + newline).
HEAD → main
↓ ↓
[b4c5d6e] Add main.py
↑
feature
Step 6: Switching Branches
$ git checkout feature
What happened?
Git just updated HEAD:
.git/HEAD
└── ref: refs/heads/feature
main
↓
[b4c5d6e] Add main.py
↑
HEAD → feature
How Git Uses Hashes for Integrity
Every object in Git (blob, tree, commit) has a SHA-1 hash based on its content. This creates an amazing property: Git's entire history is tamper-proof.
If you change a file:
The blob hash changes
The tree containing that blob hash changes
The commit pointing to that tree hash changes
Every subsequent commit hash changes (because parent hash changed)
This is why Git can detect corruption instantly—if any hash doesn't match, something is wrong.
Understanding Git's Mental Model
Here's the key mental model to carry with you:
Git is a content-addressable filesystem with a version control system built on top.
Every file content is stored as a blob
Every directory structure is stored as a tree
Every commit is a snapshot pointing to a tree
Branches are just movable pointers to commits
The entire history is a directed acyclic graph (DAG) of commits
When you understand that:
git addcreates objects and updates the staging areagit commitcreates a commit object and moves a branch pointergit branchcreates a new pointergit checkoutmoves the HEAD pointer
...Git stops being mysterious and becomes logical.
Exploring Git Objects Yourself
Want to see this for yourself? Try these commands:
# See what's in the staging area
git ls-files --stage
# View any object (replace hash with actual hash)
git cat-file -p <hash>
# See the type of an object
git cat-file -t <hash>
# Find the hash of a file
git hash-object README.md
# See commit history as a graph
git log --oneline --graph --all
Conclusion
Git isn't magic it's just blobs, trees, and commits linked together with hashes. Branches are pointers. History is a graph. That's it.
Now when someone says "just rebase it" or you hit a merge conflict, you won't blindly copy Stack Overflow commands. You'll understand why things break and how to fix them. You'll think in objects and pointers, not mystery and fear.
So crack open that .git folder. Run git cat-file on everything. Break things in a test repo and watch what happens. Git's not mysterious anymore it's just data structures, and now you speak its language.