What git add and git commit actually do underneath — objects, refs, and the graph they form.
Beginner → Advanced
a tour of .git
Every git repo's entire state lives in one directory: .git/ at the repo
root. Nothing else — no server, no database — is required for git to work.
ls .git | https://git-scm.com/docs/gitrepository-layout | HEAD, config, objects/, refs/, and index are the pieces that matter most |'gi_ls1'
Path
What it is
objects/
every blob, tree, commit, and tag ever created — the actual database
refs/heads/
one file per local branch, each containing a commit hash
refs/tags/
one file per tag
HEAD
a pointer to whichever ref (or commit) you currently have checked out
index
the staging area — a binary snapshot of what the next commit will contain
config
this repo's local settings (see .gitconfig for the global equivalent)
the four object types
Git is, underneath everything else, a key-value store: content goes in, a hash comes
out, and that hash is forever the content's address. There are exactly four kinds of
objects, and everything in a repo's history is built from them.
Object
Stores
blob
the raw contents of one file — no filename, no permissions, just bytes
tree
a directory listing: mode, type, hash, and name for each entry (blob or sub-tree)
commit
a tree hash, one or more parent commit hashes, author/committer, and a message
tag
an annotated tag: the object it points to, a tagger, and a message (lightweight tags skip this and are just refs)
The filename is not part of the blob — it only exists in the tree that references it.
That's why renaming a file git already tracked doesn't create a new blob if the content is
unchanged: same content, same hash, same object, reused.
content-addressable storage
An object's name is a hash of its own content (SHA-1 by default — 40 hex
characters; newer git versions can init a repo with SHA-256 instead, but SHA-1 repos are
still the overwhelming majority in practice). Change one byte, and the hash changes; this
is also how git verify-commit/git verify-tag (signed commits/tags)
and git fsck (object-database integrity checking) detect tampering.
echo "hello" | git hash-object --stdincomputes the blob hash git would use for this content, without writing anything
git hash-object -w <file> | | computes the hash AND writes the blob into .git/objects |'gi_hash2'
plumbing: looking at objects directly
The commands you use daily (commit, log, merge...)
are "porcelain" — friendly wrappers. Underneath, "plumbing" commands operate on objects
directly, and are handy for actually seeing what git is doing.
git cat-file -t <hash> | https://git-scm.com/docs/git-cat-file | prints an object's type: blob, tree, commit, or tag |'gi_cat1'
git cat-file -p <hash> | https://git-scm.com/docs/git-cat-file | pretty-prints an object's contents, whatever type it is |'gi_cat2'
git ls-tree <tree-ish> | https://git-scm.com/docs/git-ls-tree | lists a tree's entries: mode, type, hash, name |'gi_lstree1'
git cat-file -p HEAD^{tree} | | shortcut: the tree object for whatever HEAD's commit points at |'gi_lstree2'
refs are just files
A branch is nothing more than a 40-character commit hash written to a text file.
Creating a branch is cheap for exactly this reason — it's a file write, not a copy of any
content.
cat .git/refs/heads/main | | the commit hash that "main" currently points to, in plain text |'gi_ref1'
cat .git/HEAD | | usually reads "ref: refs/heads/<branch>" — HEAD points at a ref, which points at a commit |'gi_ref2'
git symbolic-ref HEAD | https://git-scm.com/docs/git-symbolic-ref | the same thing, the porcelain way |'gi_ref3'
Checking out a commit hash directly (instead of a branch) puts a raw hash in
.git/HEAD instead of a ref name — that's exactly what "detached HEAD" means:
HEAD is no longer attached to a branch that would move forward with your next commit.
the index: what git add really does
git add does not touch your history. It reads the file, writes it as a
blob object, and updates one binary file — .git/index — to record that this
path should point at that blob next time you commit. No tree or commit object exists yet.
git ls-files --stage | https://git-scm.com/docs/git-ls-files | shows the index's raw contents: mode, blob hash, and path for every staged file |'gi_idx1'
Only git commit turns the current index into a tree object (recursively,
one per directory) and wraps it in a commit object with a pointer to the previous commit
as its parent.
the commit graph
A commit's only structural link to history is its parent hash(es). Follow "parent"
pointers backward and you get the whole history — a directed acyclic graph (DAG), not a
line. A normal commit has one parent; a merge commit has two (or more); the very first
commit in a repo has none.
git cat-file -p HEAD | | shows a raw commit object: its tree hash, parent(s), author, committer, and message |'gi_dag1'
git log --graph --oneline --all | https://git-scm.com/docs/git-log | draws the DAG in the terminal — every line here is a walk over parent pointers |'gi_dag2'
loose objects, packfiles, and gc
New objects start "loose" — one file per object under .git/objects/xx/
(the first two hash characters name the subdirectory). That's simple but wasteful at
scale, so git periodically compacts many loose objects into a single compressed
.pack file plus an index for fast lookup.
git count-objects -v | https://git-scm.com/docs/git-count-objects | shows how many objects are loose vs. already packed, and their disk size |'gi_gc1'
git gc | https://git-scm.com/docs/git-gc | packs loose objects and prunes ones that are no longer reachable from any ref |'gi_gc2'
"No longer reachable" is doing a lot of work in that sentence — it's also exactly why
deleting a branch doesn't truly delete its commits right away; see
Undoing Things in Git for how the reflog uses this to recover
"lost" work.
related topics
Git Cheat Sheet — the day-to-day commands built on everything above. Undoing Things in Git — reflog and recovery, which only make sense once you know refs are just files. .gitconfig Reference — the repo-local config file this page's directory tour includes.