← Back to the library
FoundationsHow it works · Applied · 13 min

Git internals: objects, refs and the index

What is Git actually storing, what does a branch or a rebase change on disk, and how do you get lost work back?

Git is a content-addressed object store (blobs, trees, commits, tags) plus a small set of movable pointers called refs. Commits are immutable snapshots; branches, HEAD, merge and rebase only create new objects and move pointers, which is why almost nothing is truly lost until garbage collection.

THE MENTAL MODEL

Picture an append-only database of snapshots where every record is named by the hash of its contents: change one byte in a file and its blob gets a new ID, so does every tree above it, and so does the commit that points to that tree. Nothing in that store is ever edited. On top sit refs: branch names, tags and HEAD, which are just names pointing at commit IDs and are the only mutable part. The index is a staging snapshot between your working tree and the next commit. With that model the scary commands become plain: commit writes objects and moves the current branch; merge writes a commit with two parents; rebase writes new copies of commits on a new base and moves the branch to the copies, leaving the originals unreferenced but still on disk. The reflog records every pointer move, so 'lost' work is usually one git branch rescue HEAD@{1} away.

HOW IT FITS TOGETHER

From working tree to a branch pointerEverything left of the commit is staging; everything from the commit right is immutable objects plus movable names.
  1. Working treeOrdinary files you edit. Git compares them to the index using cached stat data to find what changed.
  2. Index (staging area).git/index: a sorted list of path, mode and blob ID for the next commit. git add writes the blob and updates the entry.
  3. Blob and tree objectsBlobs hold file contents (no name). Trees map names and modes to blob or subtree IDs. Identical content is stored once.
  4. Commit objectPoints to one root tree and zero or more parents, plus author, committer and message. Its ID covers the entire history behind it.
  5. Branch refrefs/heads/main holds a commit ID (a file or a reftable entry). Committing moves it forward.
  6. HEADUsually a symbolic ref to the current branch; 'detached HEAD' means it holds a commit ID directly, so new commits belong to no branch.
  7. ReflogLocal log of every value HEAD and each branch has had. Kept 90 days for reachable entries, 30 for unreachable, then gc may prune.
Merge vs rebase vs squash mergeAll three can produce the same final tree; they differ in the history graph and in which commit IDs survive.
Merge commitRebase (then fast-forward)Squash merge
History shapeBranches and joins preservedLinear; branch replayed on the new baseLinear; one commit per branch or PR
Rewrites existing commitsNo; adds one commit with two parentsYes; every rebased commit gets a new IDNot the branch, but its commits never reach main
Conflict resolutionOnce, inside the merge commitPer replayed commit; rerere helpsOnce, against the combined diff
git bisect granularityFine; --first-parent to skip branch noiseFine, if each commit builds and passesCoarse: one PR per step
Reverting a featuregit revert -m 1 <merge>Revert each commit or a rangegit revert <one commit>
Safe on shared branchesYesOnly on branches nobody else has pulledYes for main; delete the source branch after
Typical fitLong-lived branches, release integrationKeeping a feature branch current, curating commitsSmall focused PRs into a linear main

KEY TERMS

Objects and content addressing
Each object is stored as type, size, a NUL byte and the content, hashed (SHA-1 by default) to give its ID and zlib-compressed under .git/objects. Four types: blob (bytes), tree (directory listing of mode, name, ID), commit (tree, parents, author, committer, message) and annotated tag (points to an object with a tagger and message, optionally signed). Same content always yields the same ID, which gives deduplication and integrity checks for free.
Refs and HEAD
A ref is a name for an object ID: refs/heads/* are branches, refs/tags/* tags, refs/remotes/* your last-known copy of the remote's branches. A branch is nothing more than a pointer that advances when you commit on it, which is why creating one is instant. HEAD names what is checked out. Refs live as loose files, in packed-refs, or in the newer reftable backend.
The index
The index is the proposed next commit, not a diff. git add hashes the file into a blob and records it; git commit turns the index into trees and a commit. That is why you can stage part of a file (add -p), why git diff shows working tree vs index and git diff --staged shows index vs HEAD, and why a merge conflict lives in the index as stages 1-3 until you resolve it.
What rebase rewrites
Rebase replays each commit's change onto a new base, creating new commits with new parents and therefore new IDs, then moves the branch to the last copy. The originals still exist and are reachable from the reflog and ORIG_HEAD. Anyone who based work on the old IDs now has diverged history, which is why rebasing published branches forces others into recovery; push with --force-with-lease, never plain --force.
Reflog and recovery
Every time HEAD or a branch moves (commit, reset, checkout, rebase, merge, pull), Git appends the old and new values to that ref's reflog. HEAD@{2} means where HEAD was two moves ago, main@{yesterday} works too. Reflogs are local and not pushed. Commits unreachable from any ref or reflog are removed only when gc prunes them, after the expiry windows.
Packfiles and gc
New objects start as loose files. git gc (often run automatically) and fetch/push pack them into packfiles, storing similar objects as deltas against each other plus a .idx for lookup, which is why a repository with thousands of versions of a file stays small. Partial clones mark packs from a promisor remote so missing objects can be fetched on demand.
SHA-256 transition
Git has supported SHA-256 repositories (git init --object-format=sha256) for years, and the planned Git 3.0 changes make it the default for new repositories along with main as the default branch and reftable as the default ref storage. Git 3.0 has no release date yet, interoperability between SHA-1 and SHA-256 repositories is still being built, and forge support varies, so existing projects should not switch on their own.

IN YOUR STACK

Bash · git plumbing Client

git init -q -b main demo && cd demo
echo 'hello' > a.txt
git hash-object a.txt           # ce013625030ba8dba906f756967f9e9ca394464a = sha1("blob 6\0hello\n")
git add a.txt                   # writes the blob into .git/objects, records it in the index
git ls-files --stage            # 100644 ce0136... 0  a.txt   (mode, blob ID, stage, path)
git commit -qm "first"

git cat-file -t HEAD            # commit
git cat-file -p HEAD            # tree <id>, author, committer, message; no parent line: root commit
git cat-file -p 'HEAD^{tree}'   # 100644 blob ce0136...  a.txt
git cat-file -p ce0136          # hello

cat .git/HEAD                   # ref: refs/heads/main   (a symbolic ref)
git rev-parse main              # the commit ID the branch points to; that is the whole branch
git for-each-ref --format='%(refname) %(objectname:short)'

git count-objects -v            # loose objects before packing
git gc -q && git count-objects -v   # now in a packfile
git verify-pack -v .git/objects/pack/*.idx | tail -3
  • Plumbing commands (hash-object, cat-file, ls-files, rev-parse, for-each-ref) have stable output and are what scripts and tools should use; porcelain output can change.
  • Abbreviated IDs work as long as they are unambiguous in the repository; scripts should use full IDs.
git plumbing documentation (opens in new tab)

Bash · git reflog Client

# Oops: a hard reset threw away two commits.
git reset --hard HEAD~2
git reflog -n 4
# 1a2b3c4 HEAD@{0}: reset: moving to HEAD~2
# 9f8e7d6 HEAD@{1}: commit: add payment retries
# 5c4d3e2 HEAD@{2}: commit: extract client
git branch rescue HEAD@{1}      # pin it with a ref first, so gc can never prune it
git reset --hard rescue         # or cherry-pick only the commits you want

# A rebase went wrong: the branch's own reflog has its pre-rebase tip.
git reflog show feature -n 3
git reset --hard feature@{1}    # ORIG_HEAD also works right after the rebase

# Deleted branch: its reflog is gone, but HEAD's reflog remembers checkouts.
git reflog | grep 'checkout: moving from old-branch'
# Last resort: list commits no ref or reflog reaches.
git fsck --unreachable --no-reflogs | grep commit
  • Reflogs are local and expire (90 days reachable, 30 unreachable by default). Recover soon, and before running gc --prune=now.
  • Uncommitted changes discarded by reset --hard were never objects unless they were staged; staged blobs may still be found with git fsck --lost-found.
git reflog documentation (opens in new tab)

Bash · git rebase -i Client

# While working: record a fix aimed at an earlier commit.
git commit --fixup=3f2a1bc            # message: "fixup! add retry policy"

# Before opening the PR: curate history onto the latest main.
git fetch origin
git rebase -i --autosquash --update-refs origin/main

# The todo list Git opens (top runs first):
pick   3f2a1bc add retry policy
fixup  9c0d4e2 fixup! add retry policy     # folded in, message dropped
reword 51ab7e0 handle 429 responses        # stop to edit the message
exec   pnpm test                           # the rebase stops here if tests fail
# The stacked branch follows its commit (no trailing comment on update-ref lines):
update-ref refs/heads/retry-part-1
pick   7d8e9f0 expose limiter metrics
drop   0a1b2c3 debug logging

# Then publish without clobbering anyone else's push:
git push --force-with-lease origin retry-part-2
  • --autosquash reorders fixup!/squash!/amend! commits next to their targets; --update-refs moves other branches that pointed into the rebased range (stacked PRs).
  • If it goes wrong mid-way, git rebase --abort restores the original branch; after it finishes, the reflog does.
  • Only rebase commits nobody else has based work on, or coordinate first.
git rebase -i documentation (opens in new tab)

Bash · git bisect Client

# HEAD is bad, v2.4.0 was good. Git checks out the midpoint each step.
git bisect start HEAD v2.4.0 --

# Exit codes: 0 = good, 1-127 (except 125) = bad, 125 = skip (cannot test).
cat > /tmp/check.sh <<'EOF'
#!/bin/sh
pnpm install --frozen-lockfile >/dev/null 2>&1 || exit 125   # unbuildable: skip
pnpm vitest run tests/checkout.spec.ts                        # the regression test
EOF
chmod +x /tmp/check.sh

git bisect run /tmp/check.sh     # about log2(N) steps: 1,000 commits in ~10 tests
git bisect log > bisect.log      # keep the evidence for the PR or incident notes
git bisect reset                 # back to where you started

# Merge-heavy history: only test the commits that landed on main.
git bisect start --first-parent HEAD v2.4.0 --
  • Keep the test script outside the working tree; bisect checks out old commits, which may not contain it.
  • Flaky tests make bisect lie. Run the check several times inside the script, or bisect on a deterministic reproduction.
  • Squash-merged histories bisect to a whole PR; merge or rebase histories can narrow it to one commit.
git bisect documentation (opens in new tab)

Bash · git worktree Client

# Urgent fix while a feature branch is half-done: no stash, no second clone.
git fetch origin
git worktree add -b hotfix/login-loop ../app-hotfix origin/main
cd ../app-hotfix
pnpm install && pnpm test
git commit -am "fix: stop login redirect loop" && git push -u origin hotfix/login-loop

# Review a colleague's PR next to your own work:
git worktree add ../app-review origin/feature/payments   # detached at that commit

git worktree list
# /work/app          4e1f2a3 [feature/search]
# /work/app-hotfix   8b9c0d1 [hotfix/login-loop]
# /work/app-review   77aa12c (detached HEAD)

cd /work/app
git worktree remove ../app-hotfix    # refuses if it has uncommitted changes
git worktree prune                   # clean up entries whose directories were deleted
  • All worktrees share one object store and set of refs, so they cost little disk and see each other's commits immediately.
  • A branch can be checked out in only one worktree at a time; each worktree has its own HEAD, index and working files.
  • Ignored files (node_modules, .env) are per worktree; install dependencies in each.
git worktree documentation (opens in new tab)

Bash · partial clone + sparse-checkout Client

# Blobless clone: all commits and trees, file contents fetched on demand.
git clone --filter=blob:none --sparse https://github.com/acme/monorepo.git
cd monorepo                                  # only top-level files checked out so far

# Cone mode (the default): choose directories, not glob patterns.
git sparse-checkout set apps/web packages/ui
git sparse-checkout add packages/config
git sparse-checkout list

git log --oneline -- apps/web | head         # fast: history is local
git blame apps/web/src/app.tsx               # may fetch old blobs lazily (needs network)

# CI that only builds the tip: shallow and treeless.
git clone --depth=1 --filter=tree:0 https://github.com/acme/monorepo.git

# Change the filter later: store it, then refetch everything under it in one go.
git config remote.origin.partialclonefilter blob:limit=1m
git fetch --refetch origin
  • blob:none is the best default for developers: full history, small clone. tree:0 is smaller still but makes most history commands fetch constantly, so keep it for throwaway CI checkouts.
  • Partial clone needs server support for the filter capability and a reachable remote whenever a missing object is needed.
  • git fetch --refetch --filter=<spec> applies a new filter to that one fetch only; later fetches keep using remote.origin.partialclonefilter, so change the config.
  • Non-cone sparse patterns are deprecated; cone mode is faster and works with the sparse index.
partial clone + sparse-checkout documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Merge commit

Use when Integrating long-lived branches (release, environment) where knowing when and what was merged matters, or when commits are already shared.

Avoid when Every small PR, where merge bubbles bury the useful history and make git log --graph unreadable.

Rebase, then fast-forward

Use when Updating your own feature branch onto main and curating commits so each one builds and tells a story.

Avoid when The branch is shared and others have pulled it; rebasing rewrites the IDs they built on.

Squash merge

Use when Small, focused PRs into a linear main where the PR is the unit of review, revert and changelog (Conventional Commit per PR).

Avoid when A large branch with meaningful intermediate commits you want to bisect or revert individually, or stacked branches that will then conflict with the squashed copy.

WHERE IT BITES

  • Force-pushing a rebased branch with plain --force and overwriting a teammate's commits. Use --force-with-lease, and avoid rewriting shared branches.
  • Assuming git reset --hard or a deleted branch destroyed commits and starting over; the reflog almost always still has them.
  • Committing secrets and 'removing' them in a later commit. Every old blob is still in history and in every clone; rotate the secret, then rewrite history if needed.
  • Building on a detached HEAD and switching away: those commits belong to no branch and survive only in the reflog.
  • Bisecting a history where many commits do not build, without exit 125 for skips, and getting a confidently wrong answer.
  • Checking large binaries into Git: every version stays in every full clone forever. Use Git LFS or artifact storage, and partial clone for existing giant repos.

CLOSE THE AI. EXPLAIN THIS.

Explain what changes in .git when you commit, when you create a branch, and when you rebase a three-commit branch onto a newer main, then show how you would recover the pre-rebase branch an hour later.

SOURCES

Explainer reviewed