How Git Works¶
What You'll Learn¶
- How Git stores your project as content-addressed objects
- Why a commit is a snapshot, not a diff
- What the working tree, staging area, and repository each hold
- Why branches are cheap, what
HEADpoints to, and how remotes fit in
Mental Model¶
Git is a database of immutable snapshots linked into a graph. Branches are movable name tags on that graph.
HEADsays which tag you're standing on. Almost every Git command either adds snapshots or moves tags.
Once this clicks, "dangerous" commands like reset and rebase become understandable: they move tags and create new snapshots, and the old snapshots are still there for a while.
The Object Database¶
Everything Git stores lives in .git/objects, named by a hash of its content.
| Object | Stores | Points to |
|---|---|---|
| blob | The contents of one file (no name, no permissions) | Nothing |
| tree | A directory listing: names, modes, and the hashes of blobs and subtrees | Blobs and trees |
| commit | Author, committer, message, timestamp | One tree (the snapshot) and zero or more parent commits |
| tag (annotated) | A named, optionally signed, pointer with a message | Usually a commit |
flowchart LR
C2["commit b7e41c<br/>Add retries"] -->|parent| C1["commit 3fa09d<br/>Initial API"]
C2 --> T2["tree"]
T2 --> B1["blob: README.md"]
T2 --> T3["tree: src/"]
T3 --> B2["blob: app.py (new version)"]
C1 --> T1["tree"]
T1 --> B1
T1 --> T4["tree: src/"]
T4 --> B3["blob: app.py (old version)"]
Unchanged files are shared between snapshots — README.md is stored once. That's why snapshots are cheap.
See it yourself¶
mkdir git-internals && cd git-internals && git init -q
echo "hello" > greeting.txt
git add greeting.txt
git commit -qm "Add greeting"
git cat-file -p HEAD # the commit object
tree 6d0cf2a3c1b56a1c3a0f4a0d0c7f3f3c5c0a4a2e
author Priya Shah <priya@example.com> 1757840400 +0000
committer Priya Shah <priya@example.com> 1757840400 +0000
Add greeting
git cat-file -p 'HEAD^{tree}' # the tree: one blob named greeting.txt
git cat-file -p HEAD:greeting.txt # the blob: hello
Because an object's name is the hash of its content, changing anything — a file, a message, a parent — produces a different hash. That's what makes history tamper-evident, and why rewriting one old commit changes every commit after it.
Those hashes are SHA-1 (40 hex characters) in almost every repository today. Git also supports SHA-256 repositories (git init --object-format=sha256), and the Git project plans to make SHA-256 the default in Git 3.0, alongside main as the default branch name. Existing repositories keep working either way; the change matters mostly for tools that assume a 40-character hash.
Three Places Your Changes Live¶
flowchart LR
W["Working tree<br/>files on disk"] -->|"git add"| I["Staging area (index)<br/>the next snapshot"]
I -->|"git commit"| R["Repository<br/>committed snapshots"]
R -->|"git restore / git switch"| W
I -->|"git restore --staged"| W
| Area | Holds | Inspect with |
|---|---|---|
| Working tree | Files as you're editing them | git status, git diff |
| Staging area | Exactly what the next commit will contain | git diff --staged |
| Repository | Every commit | git log |
The staging area lets you commit part of your changes:
git add -p app.py # stage selected hunks interactively
git commit -m "Add request timeout"
# the rest of your edits stay uncommitted
Branches Are Pointers¶
A branch is a file containing one commit hash:
Creating a branch writes a new 41-byte file. Committing on a branch moves that pointer to the new commit.
gitGraph
commit id: "3fa09d"
commit id: "b7e41c"
branch feature/retry
checkout feature/retry
commit id: "c91d02"
commit id: "e44a7b"
checkout main
commit id: "f10b3c"
mainpoints tof10b3c.feature/retrypoints toe44a7b.- Both share history up to
b7e41c, their merge base.
HEAD¶
HEAD is a pointer to what you have checked out — normally a branch, not a commit:
When you commit, Git writes the commit, then moves the branch HEAD points to.
Detached HEAD¶
If you check out a commit or tag directly, HEAD points at a commit instead of a branch:
You can look around and even commit, but no branch moves with you. When you switch away, those commits aren't on any branch. Keep them by creating one:
Remotes and Remote-Tracking Branches¶
A remote is another copy of the repository, usually named origin.
origin/main is your local record of where main was on the remote the last time you talked to it. It only updates when you fetch (or pull).
| Command | Does |
|---|---|
git fetch |
Download new commits and update origin/*. Never touches your branches or files. |
git pull |
fetch, then merge or rebase origin/<branch> into your current branch |
git push |
Upload your commits and move the branch on the remote |
Fetching is always safe, so git fetch followed by a look at git log origin/main is a good habit before changing anything.
Tags¶
git tag -a v2.4.0 -m "Release 2.4.0" # annotated: author, date, message; can be signed
git push origin v2.4.0
A tag is a pointer that doesn't move. Use annotated tags for releases, and never move a published release tag — CI systems, caches, and people depend on it meaning one exact commit.
What Most Engineers Miss¶
- A branch is a 41-byte file.
cat .git/refs/heads/mainprints a commit hash and a newline (or the ref lives in.git/packed-refs). Creating a branch costs nothing, which is why short-lived branches are cheap. - Git stores snapshots, then compresses them. Each commit points to a full tree; packfiles later store most objects as deltas against similar ones. "Git stores diffs" is wrong at the model level and right only inside packfiles.
- SHA-1 is hardened, not trusted. After Google and CWI published the first practical SHA-1 collision in 2017 (SHAttered), Git 2.13 switched to a SHA-1 implementation that detects collision attacks. Repositories can also use SHA-256 (
git init --object-format=sha256), but most hosting services, including GitHub, don't support SHA-256 repositories yet. - Git 3.0 changes defaults. The project's published breaking-changes plan makes SHA-256 the default for new repositories and
mainthe default branch name in Git 3.0, and makes Rust a required part of the build. No release date is set; setinit.defaultBranchexplicitly today so nothing depends on the default. - The empty tree has a famous hash.
git hash-object -t tree /dev/nullprints4b825dc642cb6eb9a060e54bf8d69288fbee4904in every SHA-1 repository, handy forgit diff 4b825dc HEADto see a whole project as one diff.
Common Mistakes¶
- Thinking of commits as diffs, and being confused when a rebase "changes every commit hash".
- Believing
git fetchchanges your working files, or thatorigin/mainupdates itself. - Committing in detached
HEADstate and losing track of the work when switching branches. - Staging everything with
git add .and committing unrelated changes, secrets, or build output together. - Moving or deleting published tags that pipelines and deployments reference.
Check Yourself¶
Deleting a branch
You delete a local branch that was never merged or pushed. What happened to its commits?
- They were deleted with the branch
- They still exist as unreachable objects until garbage collection prunes them, and the reflog can find them
- They moved to
main
A branch is only a pointer. git branch -D removes the pointer; the reflog still knows the hash.
Interview Questions¶
- What are the four types of Git objects, and how do they relate?
- What is a branch in Git, physically?
- What does
HEADpoint to, and what is a detachedHEAD? - What's the difference between
mainandorigin/main? - Why does rewriting an old commit change the hashes of every later commit?
Next¶
Continue to Everyday Workflow.