Skip to content

Git internals: objects, hashes, refs, and the index

Mid / Senior

What 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

  1. 01Glossary — 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/index describing 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 gc and network transfers.
    • Plumbing / porcelain — low-level commands (hash-object, cat-file, update-ref) vs user-facing ones (commit, switch, merge).
    #c-glossary-terms-used-in-this-page
  2. 02Git as a content-addressable key-value store

    At 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
    

    -w wrote 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

    1. Deduplication is free. Two identical files anywhere in history (any branch, any commit) are stored once — same content, same hash, same object.
    2. 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.
    3. Integrity is built in. If a disk bit flips, the content no longer matches its name; git fsck detects it.
    4. Cheap equality checks. Comparing two directories of any size = comparing two tree hashes. This is why git status and git diff between commits are fast.
    #c-git-as-a-content-addressable-key-value-store
  3. 03The four object types — blob, tree, commit, tag
    Type 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    src
    

    Key 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 --follow and git diff -M detect them heuristically by content similarity.
    • Empty directories cannot be tracked — a tree only has entries for blobs and non-empty subtrees. Hence the .gitkeep convention.
    • 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.
    #c-the-four-object-types-blob-tree-commit-tag
  4. 04What a commit hash is and how it is computed

    The 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 tree line, every parent line, 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~3 first parent, walking back 1 / 3 generations
    HEAD^2 the second parent (only meaningful on a merge commit)
    main@{2} where main pointed two moves ago (from the reflog)
    main@{yesterday} where main pointed yesterday (reflog, local only)
    HEAD^{tree} the tree object of the commit
    A..B commits reachable from B but not from A (“what B adds”)
    A...B commits 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.

    #c-what-a-commit-hash-is-and-how-it-is-computed
  5. 05A branch is a 41-byte file — refs and how they move

    A 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-refs for efficiency, and newer Git versions can use the reftable backend — 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/                        reflogs
    

    What “the commits on a branch” means

    The commits “on” feature are simply all commits reachable by walking parent pointers from the commit feature points 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          <- main
    

    A and B are on both branches. git log main..feature → C D.

    How a commit moves a branch

    git commit does four things:

    1. Writes blobs for staged file content (actually done earlier, by git add).
    2. Writes tree objects from the index.
    3. Writes a commit object with parent = <current HEAD commit>.
    4. 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).

    #c-a-branch-is-a-41-byte-file-refs-and-how-they-move
  6. 06HEAD, detached HEAD, and the reflog

    HEAD 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 ──▶ a1b2c3d
    

    When 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-branch before 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.
    #c-head-detached-head-and-the-reflog
  7. 07The three trees — HEAD, index, and working tree

    Most 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/index The 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 f hashes f, writes the blob to the object store immediately, and updates f’s entry in the index.
    • git commit turns the index into tree objects — it does not look at the working tree at all.
    • git status reports 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 status would have shown config.txt both staged and modified.

    #c-the-three-trees-head-index-and-working-tree
  8. 08Packfiles and garbage collection

    Storing every version of every file as a separate zlib blob (“loose objects”) would be huge. Periodically (and automatically via git gc --auto) Git:

    1. Packs loose objects into .git/objects/pack/*.pack with an .idx index, 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).
    2. 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).

    #c-packfiles-and-garbage-collection