Git internals: objects, hashes, refs, and the index
Mid / SeniorWhat is really stored in .git — blobs, trees, commits, tags, content-addressed hashes, branches as pointers, HEAD, and the staging area.
Created 15 October 2026
8 concepts · detail hidden
01Glossary — terms used in this page
#c-glossary-terms-used-in-this-page- Object — an immutable, compressed record in
.git/objects, identified by its hash: a blob, tree, commit, or annotated tag. - SHA-1 / SHA-256 — cryptographic hash functions; Git uses SHA-1 (40 hex chars) by default, SHA-256 (64 hex chars) in repositories created with
--object-format=sha256. - Content-addressable storage — storage where the key of a piece of data is derived from the data itself (its hash).
- Ref — a human-readable name (
refs/heads/main,refs/tags/v1.0) that points at an object hash. - HEAD — the ref that says “where you are now”: usually a pointer to a branch, sometimes directly to a commit.
- Index / staging area — the binary file
.git/indexdescribing the snapshot that the next commit will contain. - Working tree — the actual files on disk you edit.
- DAG (Directed Acyclic Graph) — a graph with one-way edges and no cycles; commit history is one (commits point to parents).
- Packfile — a single file bundling many objects with delta compression, produced by
git gcand network transfers. - Plumbing / porcelain — low-level commands (
hash-object,cat-file,update-ref) vs user-facing ones (commit,switch,merge).
- Object — an immutable, compressed record in
02Git as a content-addressable key-value store
#c-git-as-a-content-addressable-key-value-storeAt its core Git is not a diff-tracking system. It is a key-value database where:
- the value is a piece of content (a file’s bytes, a directory listing, a commit record), and
- the key is the hash of that content.
Everything else — branches, merges, history — is built on top of that store. You can use it directly with plumbing commands:
$ echo 'hello' | git hash-object -w --stdin ce013625030ba8dba906f756967f9e9ca394464a $ git cat-file -t ce0136 # type blob $ git cat-file -p ce0136 # pretty-print content hello-wwrote the object to.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a— the first two hex chars become a directory, the remaining 38 the file name (this fan-out avoids having millions of files in a single directory). The file is zlib-compressed.Consequences of content addressing
- Deduplication is free. Two identical files anywhere in history (any branch, any commit) are stored once — same content, same hash, same object.
- Objects are immutable. Changing one byte gives a different hash, i.e. a different object. Git never edits an object; “rewriting history” means creating new objects and moving pointers.
- Integrity is built in. If a disk bit flips, the content no longer matches its name;
git fsckdetects it. - Cheap equality checks. Comparing two directories of any size = comparing two tree hashes. This is why
git statusandgit diffbetween commits are fast.
03The four object types — blob, tree, commit, tag
#c-the-four-object-types-blob-tree-commit-tagType Stores Points to blob Raw file bytes. No file name, no permissions. nothing tree A directory listing: entries of (mode, name, hash)blobs (files) and trees (subdirectories) commit A snapshot + metadata exactly one tree, zero or more parent commits tag (annotated) Tag name, tagger, message, optional signature usually a commit A commit for a tiny project looks like this:
commit a1b2c3... tree 9f8e7d... ┌──────────────────────────┐ ┌───────────────────────────────┐ │ tree 9f8e7d │ ──────▶ │ 100644 blob 3b18e5 README.md │ │ parent 5d4c3b │ │ 040000 tree 7c6b5a src │──┐ │ author Ana <a@x> 169... │ └───────────────────────────────┘ │ │ committer ... │ ▼ │ │ tree 7c6b5a │ Add parser │ ┌───────────────────────────────┐ └──────────────────────────┘ │ 100644 blob e69de2 main.py │ │ parent │ 100755 blob 2f1a9c run.sh │ ▼ └───────────────────────────────┘ commit 5d4c3b ...Inspect a real commit:
$ git cat-file -p HEAD tree 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432 parent 5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a author Ana Kowalska <ana@example.com> 1760500000 +0200 committer Ana Kowalska <ana@example.com> 1760500000 +0200 Add parser $ git cat-file -p HEAD^{tree} 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad README.md 040000 tree 7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a3 srcKey insights
- A commit is a full snapshot, not a diff. It points to the complete root tree. Diffs are computed on demand by comparing two trees. (On disk, packfiles do store deltas between similar objects — but that is a compression detail, invisible in the model.)
- File names live in trees, not blobs. Renaming a file creates a new tree entry pointing at the same blob. Git does not record renames at all;
git log --followandgit diff -Mdetect them heuristically by content similarity. - Empty directories cannot be tracked — a tree only has entries for blobs and non-empty subtrees. Hence the
.gitkeepconvention. - The mode records only “regular file” (
100644), “executable” (100755), symlink (120000), directory (040000), or submodule (160000). No ownership, no timestamps. - Lightweight tags are just refs pointing at a commit; annotated tags (
git tag -a) are real objects with their own message, author, and optional GPG signature. Use annotated tags for releases.
04What a commit hash is and how it is computed
#c-what-a-commit-hash-is-and-how-it-is-computedThe hash of any object is
SHA-1(header + content), where the header is"<type> <size-in-bytes>\0". You can reproduce Git’s result without Git:import hashlib data = b"hello\n" header = b"blob %d\0" % len(data) print(hashlib.sha1(header + data).hexdigest()) # -> ce013625030ba8dba906f756967f9e9ca394464a (same as git hash-object)A commit hash is the same formula applied to the commit’s text: the
treeline, everyparentline, author name+email+timestamp+timezone, committer name+email+timestamp+timezone, and the message (plus a signature, if signed).Why this matters
Because the commit text contains the parent hash, and the parent’s text contains its parent hash, a commit hash transitively fingerprints the entire history behind it — a Merkle chain (like a blockchain, minus the consensus). Change anything anywhere upstream and every descendant hash changes.
That is exactly why:
- Rebase, amend, and cherry-pick always produce new hashes — even when the code diff is identical, the parent or committer timestamp is different.
- Two people who share a commit hash are guaranteed to have byte-identical history up to that point.
- Rewriting a pushed commit forces everyone else to reconcile (their branches still point at the old hashes).
Short hashes and revision syntax
Any unique prefix (min 4 chars, Git shows 7+ by default) works:
git show a1b2c3d. Git also understands relative expressions:Expression Meaning HEAD~1,HEAD~3first parent, walking back 1 / 3 generations HEAD^2the second parent (only meaningful on a merge commit) main@{2}where mainpointed two moves ago (from the reflog)main@{yesterday}where mainpointed yesterday (reflog, local only)HEAD^{tree}the tree object of the commit A..Bcommits reachable from B but not from A (“what B adds”) A...Bcommits reachable from either but not both (symmetric difference) SHA-1 vs SHA-256
SHA-1 has known collision attacks (SHAttered, 2017). Git mitigates with a hardened SHA-1 implementation that detects the known collision technique, and supports SHA-256 repositories (
git init --object-format=sha256). Interop between SHA-1 and SHA-256 repositories is still limited, so most hosting today still uses SHA-1.05A branch is a 41-byte file — refs and how they move
#c-a-branch-is-a-41-byte-file-refs-and-how-they-moveA branch is not a container of commits. It is a ref: a file containing one commit hash.
$ cat .git/refs/heads/main a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 $ git rev-parse main # the same thing, via plumbing a1b2c3d4e5f60718293a4b5c6d7e8f9012345678(“41 bytes” = 40 hex chars + newline.) Many refs are also stored in
.git/packed-refsfor efficiency, and newer Git versions can use thereftablebackend — but the model is the same: name → hash.Where refs live
.git/ ├── HEAD "ref: refs/heads/main" ├── refs/ │ ├── heads/ local branches │ │ ├── main │ │ └── feature/login │ ├── remotes/origin/ remote-tracking branches (updated by fetch) │ │ ├── main │ │ └── HEAD │ ├── tags/ tags │ │ └── v1.2.0 │ └── stash the stash stack ├── packed-refs ├── objects/ the object database ├── index the staging area ├── config repo config: remotes, branch upstreams └── logs/ reflogsWhat “the commits on a branch” means
The commits “on”
featureare simply all commits reachable by walking parent pointers from the commitfeaturepoints to. A commit can be on many branches at once. “Branch history” is a graph-walk result, not stored data.C---D <- feature / A---B---E---F <- mainAandBare on both branches.git log main..feature→C D.How a commit moves a branch
git commitdoes four things:- Writes blobs for staged file content (actually done earlier, by
git add). - Writes tree objects from the index.
- Writes a commit object with
parent = <current HEAD commit>. - Moves the current branch ref to the new commit hash (and appends a reflog entry).
Creating a branch (
git branch x) is writing one tiny file — O(1), no copying. Deleting a branch deletes that file; the commits remain until garbage collected (if nothing else reaches them).- Writes blobs for staged file content (actually done earlier, by
06HEAD, detached HEAD, and the reflog
#c-head-detached-head-and-the-reflogHEAD answers “what is checked out?” Normally it is a symbolic ref — it points at a branch, which points at a commit:
HEAD ──▶ refs/heads/main ──▶ a1b2c3dWhen you commit, Git follows HEAD to the branch and moves the branch. HEAD itself stays “on main”.
Detached HEAD
If you check out a commit hash, tag, or remote-tracking branch directly, HEAD contains a hash instead of a branch name:
$ git switch --detach v1.2.0 # or: git checkout v1.2.0 $ cat .git/HEAD 3f4e5d6c7b8a99...HEAD ──▶ 3f4e5d6 (no branch involved)New commits made here move only HEAD. As soon as you switch away, nothing points at them — they become unreachable and will eventually be pruned. Fix:
git switch -c rescue-branchbefore leaving (or recover via the reflog, below). Detached HEAD is normal and useful for inspecting old versions,git bisect, and CI checkouts.The reflog — Git’s local undo history
Every time HEAD or a branch tip moves (commit, reset, rebase, checkout, merge, pull), Git logs the old and new hash in
.git/logs/:$ git reflog a1b2c3d (HEAD -> main) HEAD@{0}: reset: moving to HEAD~2 9f8e7d6 HEAD@{1}: commit: Add validation 5d4c3b2 HEAD@{2}: commit: Add parser ... $ git reset --hard HEAD@{1} # undo the reset- The reflog is local only — never pushed or fetched.
- Entries expire (defaults: 90 days for reachable entries, 30 days for unreachable).
- It is the safety net for almost every “I lost my commits” situation: a botched rebase,
reset --hard, a deleted branch.
07The three trees — HEAD, index, and working tree
#c-the-three-trees-head-index-and-working-treeMost Git commands are best understood as moving content between three places:
“Tree” Where Role HEAD The commit HEAD points to The last committed snapshot Index (staging area) .git/indexThe proposed next commit snapshot Working tree Files on disk Your sandbox working tree ──git add──▶ index ──git commit──▶ HEAD (new commit, branch moves) ▲ ▲ │ └── git restore <f> ───┘ │ └─ git restore --staged / git reset <f> ─┘The index is a full snapshot, not a list of changes
A common misconception is that the index contains “only the changes you staged”. In fact, right after a commit, the index is identical to HEAD’s tree — it lists every tracked file with its blob hash, plus cached stat data (size, mtime, inode) used to quickly tell whether a working file changed.
git add fhashesf, writes the blob to the object store immediately, and updatesf’s entry in the index.git committurns the index into tree objects — it does not look at the working tree at all.git statusreports two diffs: HEAD vs index (“Changes to be committed”) and index vs working tree (“Changes not staged”).git diff= index vs working tree;git diff --staged= HEAD vs index;git diff HEAD= HEAD vs working tree.
Why the staging area exists
It lets you craft a commit from a subset of your edits — even part of a file with
git add -p— so commits are atomic and reviewable even when your working tree is messy.Pitfall: “add then edit”
echo v1 > config.txt && git add config.txt echo v2 > config.txt git commit -m "config" # commits v1 — the version that was staged!git statuswould have shownconfig.txtboth staged and modified.08Packfiles and garbage collection
#c-packfiles-and-garbage-collectionStoring every version of every file as a separate zlib blob (“loose objects”) would be huge. Periodically (and automatically via
git gc --auto) Git:- Packs loose objects into
.git/objects/pack/*.packwith an.idxindex, storing similar objects as deltas against each other (often the newer version is kept whole and older ones become deltas, since recent versions are accessed more). - Prunes unreachable objects older than a grace period (default 2 weeks), after reflog entries pointing at them have expired.
Network transfers (
fetch,push,clone) also send packfiles: the two sides negotiate which commits each has, and the sender builds a pack of exactly the missing objects.git count-objects -vH # loose vs packed size git gc # pack + prune now git fsck --lost-found # list dangling commits/blobs (recovery of lost work)Pitfall: large binaries
Every version of a 200 MB model file lives in history forever (and in every clone) and deltas poorly. Use Git LFS, which commits a small pointer file and stores the real content on a separate server — or keep artifacts out of Git entirely (object storage, model registry).
- Packs loose objects into