1. Git Is a Content-Addressed Key-Value Store

Here is the whole mental model: Git is a key-value database where the key is the SHA-1 hash of the content, and the value is the content itself. Every file you have ever committed is stored somewhere in .git/objects/ as a compressed file, and its name is the SHA-1 hash of its contents.

This has an important implication: if two files in different commits have the same content, Git stores them exactly once. If you change one line in a 1000-line file, Git stores both versions in full — but because SHA-1 packs identical content into the same object, large-scale duplication is avoided through the object model itself.

SHA-1 to SHA-256

Git is moving from SHA-1 to SHA-256 due to theoretical collision vulnerabilities. The transition is gradual — existing repos use SHA-1, new repos can be initialized with SHA-256 using git init --object-format=sha256. For practical purposes in 2026, the object model is identical; only the hash algorithm changes.

2. The Four Object Types

Everything in Git is made of exactly four object types. Every single Git feature — branches, tags, merges, rebases, stashes — is built on top of these four:

Blob: A File's Contents

A blob stores the raw contents of a file. No filename, no permissions — just bytes. The filename is handled by the tree object (more on that shortly). This means if two files in your project have identical contents but different names, they share one blob object. Space-efficient by design.

# Create a blob manually (git's low-level "plumbing" command)
echo "hello, world" | git hash-object --stdin -w
# → 8ab686eafeb1f44702738c8b0f24f2567c36da6d

# Read it back
git cat-file -p 8ab686eafeb1f44702738c8b0f24f2567c36da6d
# → hello, world

# Find it on disk
ls .git/objects/8a/
# → b686eafeb1f44702738c8b0f24f2567c36da6d
# (first 2 chars = directory, rest = filename)

Tree: A Directory Snapshot

A tree object is a directory listing: it maps filenames to blob hashes (for files) or other tree hashes (for subdirectories). Think of it as a directory inode in a filesystem — it contains the structure but delegates the content to other objects.

# After a commit, inspect its tree
git cat-file -p HEAD^{tree}
# Output:
100644 blob a1b2c3d4...  README.md
100644 blob e5f6a7b8...  package.json
040000 tree 9c0d1e2f...  src
040000 tree 3a4b5c6d...  tests

# "100644" = regular file permissions
# "040000" = directory
# "100755" = executable file

# Drill into src/
git cat-file -p 9c0d1e2f...
# → 100644 blob ...  index.js
     100644 blob ...  utils.js

Commit: A Snapshot + Metadata

A commit object is surprisingly simple. It contains: a pointer to a tree (the root directory snapshot), one or more parent commit hashes, author name/email/time, committer name/email/time, and the commit message. That's it.

# Read a commit object
git cat-file -p HEAD
# Output:
tree 9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d
parent 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
author Debarshi Chaudhuri <dev@example.com> 1717833600 +0530
committer Debarshi Chaudhuri <dev@example.com> 1717833600 +0530

Fix: handle null user gracefully in payment flow

Notice what is NOT in a commit object: a diff, a branch name, the files themselves. A commit is just a snapshot pointer with metadata. The "diff" you see in git show is computed on the fly by comparing the commit's tree against its parent's tree.

Tag: A Named Pointer

An annotated tag is an object that points to another object (usually a commit) with extra metadata: the tagger's name, a message, and optionally a GPG signature. Lightweight tags are not objects at all — they are just refs (pointers), as we'll see.

3. Refs: The Names for Your Objects

Objects are addressed by their hash (40-character hex string). Humans need names. That is what refs are: plain text files that contain a SHA-1 hash.

# A branch is literally just a file containing a hash
cat .git/refs/heads/main
# → a3f2c1b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0

# HEAD is a ref that usually points to another ref
cat .git/HEAD
# → ref: refs/heads/main

# Detached HEAD: HEAD points directly to a hash, not a branch
git checkout a3f2c1b4
cat .git/HEAD
# → a3f2c1b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
Detached HEAD explained — and why it's not scary

"Detached HEAD" just means .git/HEAD contains a raw commit hash instead of a branch name. If you make commits in this state, they are stored correctly in the object store — but no branch ref is updated to point to them. When you switch branches, those commits become "dangling" and eventually garbage-collected. Fix: git checkout -b new-branch-name before you switch away.

4. What Happens When You Commit

Let's trace exactly what git commit -m "fix auth bug" does:

  1. Write blobs: For every file in the staging area (index), Git computes its SHA-1 and writes a blob object if it doesn't already exist.
  2. Write trees: Git recursively builds tree objects representing the directory structure, referencing the new blobs and existing unchanged blobs.
  3. Write commit: Git writes a commit object pointing to the root tree, with the current HEAD as parent, and your name/email/time from git config.
  4. Update the branch ref: Git updates .git/refs/heads/your-branch to contain the new commit's hash.
  5. Update HEAD: If HEAD is ref: refs/heads/your-branch, it now transitively points to the new commit.

The entire history of your project is now in a DAG (directed acyclic graph) of commit objects, each pointing to its parent(s). Your branch is literally just a sticky note that says "this is the current tip of this branch."

5. What a Merge Actually Does

A merge commit is just a commit with two parents. That's it. When you do git merge feature-branch:

  1. Git finds the merge base — the most recent common ancestor commit of both branches.
  2. Git computes the diff from merge-base → main and from merge-base → feature-branch.
  3. If those diffs don't overlap, Git applies both automatically.
  4. Git creates a new commit with two parents: the tip of main and the tip of feature-branch.
# After a merge, the commit object has two parents
git cat-file -p HEAD
# tree   f1e2d3c4...
# parent a1b2c3d4...  ← was the tip of main
# parent e5f6a7b8...  ← was the tip of feature-branch

6. What a Rebase Actually Does

Rebase creates new commit objects. It does not move existing commits. This is the thing that surprises people: after a rebase, your commits have new SHA-1 hashes even if the changes are identical, because the parent hash is part of the commit object, and changing the parent changes the hash.

# Before rebase
main:    A → B → C
feature:         C → D → E

# git rebase main (from feature branch)
# Creates NEW commits D' and E' with:
  - Same changes as D and E
  - New parent chain: based on C (tip of main)
  - New SHA-1 hashes (because parent hash changed)

main:    A → B → C
feature:         C → D' → E'

# The old D and E still exist in .git/objects
# but no refs point to them — they're "dangling"
# git reflog can recover them for ~30 days

This is why you should never rebase commits that you have already pushed to a shared branch — you're rewriting history, and everyone else's local copy now has commit hashes that no longer exist in your history.

7. git push: What Goes Over the Wire

When you git push origin main, here is what happens:

  1. Git connects to the remote and asks "what commit is your main at?"
  2. Git finds all the commits, trees, and blobs that you have locally that the remote does not.
  3. Git packages them into a "packfile" (a compressed binary format combining all the objects with delta compression).
  4. Git sends the packfile.
  5. The remote unpacks the objects and updates its refs/heads/main to point to your new commit.

"Refusing to push non-fast-forward" means: the remote's tip is not an ancestor of your new tip. It would have to throw away existing commits to accept yours. Either you need to pull first (fetch + merge/rebase), or if you're sure, force-push with --force-with-lease (safer than --force because it checks that no one pushed since you last fetched).

--force-with-lease vs --force

--force overwrites the remote with whatever you have, period. --force-with-lease adds a safety check: "only overwrite if the remote tip is still what I last fetched." It prevents accidentally overwriting someone else's push that happened between your fetch and your push. Always prefer --force-with-lease when you must force-push.

8. Practical Powers Unlocked by Understanding the Model

Once you understand that Git is just a content-addressed object store with refs on top, a lot of advanced Git operations become obvious:

  • git reflog: A log of every position HEAD has been at, with timestamps. Since objects are never deleted immediately, you can recover "lost" commits by finding their hash in the reflog and creating a branch: git checkout -b recovered-work abc123f
  • git cherry-pick: Creates a new commit whose changes are the diff between a target commit and its parent, applied to the current HEAD. It does not copy the commit — it applies the patch and creates a new object.
  • git bisect: Binary search through the commit DAG. You mark a commit as "good" and one as "bad", and Git checks out the midpoint for you to test. Repeat until the bug-introducing commit is isolated.
  • git stash: Stores your uncommitted changes as a special commit object and resets your working tree. git stash pop applies that commit and drops the stash ref. It is not magic — it is just commits that refs/stash points to.

9. The One Command You Should Run Right Now

Open any Git repository and run this:

find .git/objects -type f | head -20

# Pick one hash (e.g. from .git/objects/ab/cdef...)
# Combine dir + filename to get full hash: abcdef...

git cat-file -t abcdef1234...   # → blob / tree / commit / tag
git cat-file -p abcdef1234...   # → contents

Poke around. Read your most recent commit. Follow the tree pointer. Read the blob contents. Five minutes of this will do more for your Git intuition than 20 hours of reading about it. The objects are just files. The model is just a graph. The rest is UI built on top of that.

The mental model in one paragraph

Git stores every version of every file as a blob, every directory as a tree of blobs and other trees, every commit as a pointer to a root tree + parent commits + metadata. Branches are files that contain a commit hash. HEAD is a file that contains a branch name or a commit hash. Everything else is plumbing built on top. Nothing is lost until garbage collection runs. The reflog is your undo stack. Rebase creates new objects; it does not move existing ones.

JSON Validator & Formatter

Frequently validating API responses or config files? Use our client-side JSON validator — works offline, nothing leaves your browser.

Open JSON Validator