How Git Stores Your Work
What is inside the .git folder: blobs, trees and commits, the hashes that name them, and the references — branches, tags and HEAD — that point to them.
The .git folder
Everything Git knows about a project lives in the hidden
.git folder at its top. Here it is straight after
git init, and again after the first commit:
git init -q net-tools
ls net-tools/.git
printf '# Network tools\n' > net-tools/README.md
git -C net-tools add README.md
git -C net-tools commit -q -m "Add README"
echo "--- after the first commit"
ls net-tools/.git
HEAD branches config description hooks info objects refs --- after the first commit COMMIT_EDITMSG HEAD branches config description hooks index info logs objects refs
| Entry | What it holds |
|---|---|
objects/ |
The object database: every version of every file, folder and commit |
refs/ |
Branches (refs/heads/), tags
(refs/tags/) and remote-tracking branches
(refs/remotes/)
|
HEAD |
Which branch is checked out |
index |
The staging area, a binary file (created by the first
git add)
|
config |
The local configuration of this repository |
logs/ |
The reflog: where each branch and HEAD pointed over time (see Undoing Things) |
hooks/ |
Sample scripts that Git can run at events such as pre-commit; inactive until renamed |
COMMIT_EDITMSG |
The message of the last commit you wrote |
info/, description,
branches/
|
A local exclude file, a description used by some web viewers, and a legacy folder that Git 3.0 plans to drop |
The object database
The object database is the
store in .git/objects where Git keeps all content and
history as objects, each one named by the hash of its content. It
holds only four kinds of object: blobs, trees, commits and annotated
tags. Make one commit with a README.md and a file in a
configs folder, and five objects appear:
git init -q net-tools
cd net-tools
mkdir configs
printf '# Network tools\n' > README.md
printf 'hostname R1\n' > configs/r1.cfg
git add .
git commit -q -m "Add README and R1 config"
find .git/objects -type f | sort
git cat-file -t HEAD
git cat-file -p HEAD
.git/objects/03/7acefe82c92ee6002835c088dac5ab484d641c .git/objects/06/5bb8206b8c5a1dff0993ed9bc0612b4f3ac46a .git/objects/21/e39505034afc806dbf4b6e798f65fdadb659a3 .git/objects/87/feea5e57670f811429150943919bf08403e3df .git/objects/af/a136fadb7d81b6f2ddf6c15ede937ae067c5f4 commit tree 21e39505034afc806dbf4b6e798f65fdadb659a3 author Sam Rivera <sam@example.com> 1772442000 +0000 committer Sam Rivera <sam@example.com> 1772442000 +0000 Add README and R1 config
Each object is a compressed file whose path is its hash: the first
two hexadecimal characters become a folder name (so no folder gets
too large), the other 38 the file name.
git cat-file is the tool to look inside:
-t prints the type of an object,
-p pretty-prints its content. Any revision works as the
name — here HEAD, the current commit.
Commit objects
The commit is a short text: the hash of one tree (the snapshot of
the whole project), the author and the committer with a time stamp
in seconds since 1 January 1970 and a time zone offset (1772442000
is 2 March 2026, 09:00 UTC), a blank line, and the message. A commit
after the first one also has one or more
parent lines — more on those
below. The author wrote the change; the
committer recorded it. They differ when, for example, someone
applies a colleague's patch or a commit is rebased.
Trees and blobs
A tree object is a folder
listing: for each entry, a file mode, an object type, a hash and a
name. A blob (binary large object) holds
the content of one file — only the bytes, without the file name or
any date. Names live in the tree that points to the blob. The
^{tree} suffix asks for the tree of a commit, and
HEAD:path names the object at a path in that commit:
git init -q net-tools
cd net-tools
mkdir configs
printf '# Network tools\n' > README.md
printf 'hostname R1\n' > configs/r1.cfg
git add .
git commit -q -m "Add README and R1 config"
git cat-file -p 'HEAD^{tree}'
git cat-file -p HEAD:configs
git cat-file -t HEAD:configs/r1.cfg
git cat-file -p HEAD:configs/r1.cfg
100644 blob 87feea5e57670f811429150943919bf08403e3df README.md 040000 tree 065bb8206b8c5a1dff0993ed9bc0612b4f3ac46a configs 100644 blob afa136fadb7d81b6f2ddf6c15ede937ae067c5f4 r1.cfg blob hostname R1
Mode 100644 is an ordinary file, 100755 an
executable file, 040000 a subfolder (another tree) and
120000 a symbolic link. Git stores no other
permissions, no owner and no modification time. It also does not
store empty folders: a folder exists only as a tree with at least
one entry, which is why projects add a placeholder file such as
.gitkeep to keep an empty folder.
Content-addressable storage
Git is built on content-addressable storage: each object is stored and found under a name computed from its content (its hash), instead of under a name or location someone chose. Two consequences follow directly. The same content always gets the same name, so it is stored only once. And if one byte changes, the name changes.
git hash-object computes the name Git would give a
file's content, without storing it. Two files with the same content
get the same hash — the one we saw for r1.cfg above —
and one changed character gives a completely different one:
printf 'hostname R1\n' > r1.cfg
printf 'hostname R1\n' > backup-of-r1.cfg
git hash-object r1.cfg backup-of-r1.cfg
printf 'hostname R2\n' | git hash-object --stdin
printf 'blob 12\0hostname R1\n' | sha1sum
afa136fadb7d81b6f2ddf6c15ede937ae067c5f4 afa136fadb7d81b6f2ddf6c15ede937ae067c5f4 5e1f4bd672583f3ddfbf1dff2d4b747bd2dd9bb2 afa136fadb7d81b6f2ddf6c15ede937ae067c5f4 -
The last line shows there is no magic: an object's name is the SHA-1 hash of a short header — the type, a space, the size in bytes and a zero byte — followed by the content. The file name is not part of it, which is why a renamed file needs no new blob, and why a hundred copies of the same config in a repository cost the space of one.
Content addressing also gives Git its integrity. A commit names its
tree by hash, the tree names its blobs by hash, and a commit names
its parent by hash. A single commit hash therefore vouches for the
complete snapshot and all history before it: change any byte
anywhere, and the hashes no longer match. Git notices corruption on
disk or in transfer, and git fsck checks a whole
repository.
Commit hashes, SHA-1 and SHA-256
A commit hash is the identifier of a commit: the hash of the commit object, written as 40 hexadecimal characters with SHA-1 (64 with SHA-256). Because the commit object includes the tree, the parents, author, committer, both time stamps and the message, changing any of them — even only the message — gives a different commit with a different hash. You can abbreviate a hash to any unique prefix; Git shows seven characters by default and uses more when a repository needs them to stay unambiguous.
Git has used SHA-1, a hash function from 1995, since the start. In 2017 researchers produced the first practical SHA-1 collision (two different files with the same hash). Git responded by switching to a hardened SHA-1 implementation that detects that kind of attack, and by adding a second object format:
-
Git 2.29 (2020) added SHA-256 repositories (
git init --object-format=sha256), marked experimental. - Git 2.42 (2023) toned the warning down: SHA-256 repositories are not expected to change incompatibly.
- Since Git 2.45, work has been under way to let SHA-1 and SHA-256 repositories exchange history; as of 2026 that interoperability is not finished.
- Git 3.0 (no release date yet) plans to make SHA-256 the default for new repositories.
Until then, a SHA-256 repository cannot push to or fetch from a SHA-1 repository, and support among hosting services is not universal. For everyday work today, SHA-1 repositories remain the norm; what matters is knowing that hashes are content fingerprints, not random IDs.
Refs and HEAD
Hashes are not made for people. A
ref (reference) is a human-readable name
that points to an object — almost always a commit. A branch is a ref
under refs/heads/, a tag a ref under
refs/tags/. In the default storage format a ref is
literally a small text file containing a hash:
git init -q net-tools
cd net-tools
mkdir configs
printf '# Network tools\n' > README.md
printf 'hostname R1\n' > configs/r1.cfg
git add .
git commit -q -m "Add README and R1 config"
cat .git/HEAD
cat .git/refs/heads/main
git rev-parse main
git rev-parse --short HEAD
ref: refs/heads/main 037acefe82c92ee6002835c088dac5ab484d641c 037acefe82c92ee6002835c088dac5ab484d641c 037acef
The branch main is a 41-byte file holding the hash of
the latest commit. That is why creating a branch in Git is instant:
it writes one small file. When you commit, Git creates the new
commit object and then simply overwrites the branch file with the
new hash. git rev-parse translates any revision into
its full hash and is the safe way to read refs: Git often moves refs
into a single packed-refs file (git gc and
git clone do this), and reftable, a new binary format
available with git init --ref-format=reftable since Git
2.45, is planned as the Git 3.0 default, so reading files directly
is not reliable.
HEAD
HEAD is the special ref that records
what is currently checked out. Normally it is a
symbolic ref: it does not hold a hash but the name of a
branch (ref: refs/heads/main), so "HEAD" means "the
latest commit of the current branch". A new commit becomes the child
of the commit HEAD resolves to, and moves that branch forward.
Switching branches rewrites HEAD to name another branch. When HEAD
holds a commit hash instead of a branch name, you are in a detached
HEAD state — see Branches.
Parent links: how history is formed
Make a second commit and look at it. It has a
parent line with the hash of the first commit:
git init -q net-tools
cd net-tools
mkdir configs
printf '# Network tools\n' > README.md
printf 'hostname R1\n' > configs/r1.cfg
git add .
git commit -q -m "Add README and R1 config"
printf 'hostname R2\n' > configs/r2.cfg
git add configs/r2.cfg
git commit -q -m "Add R2 config"
git cat-file -p HEAD
git cat-file -p HEAD:configs
git log --oneline
tree 297fbb67a66aad594cdbd33124bcdb65398c9223 parent 037acefe82c92ee6002835c088dac5ab484d641c author Sam Rivera <sam@example.com> 1772442000 +0000 committer Sam Rivera <sam@example.com> 1772442000 +0000 Add R2 config 100644 blob afa136fadb7d81b6f2ddf6c15ede937ae067c5f4 r1.cfg 100644 blob 5e1f4bd672583f3ddfbf1dff2d4b747bd2dd9bb2 r2.cfg 048f22e Add R2 config 037acef Add README and R1 config
Three things to notice:
-
The history is a chain of parent links running backward in time.
git logstarts at HEAD and followsparentlines until it reaches a commit without one (the root commit). A merge commit has two or more parents. -
Each commit points to a complete snapshot, not to a list of
changes. The diff you see in
git showis computed on the fly by comparing a commit's tree with its parent's. -
The snapshot is cheap:
r1.cfgdid not change, so the newconfigstree points to the very same blobafa136f. Unchanged files and folders are shared between commits, never copied.
Because each commit contains its parent's hash, changing an old commit — even its message — produces a new hash for it and for every commit after it. This is why "rewriting history" means creating new commits, and why rewriting commits that others already have causes trouble (see Rebasing and Rewriting History).
Packfiles and garbage collection
New objects are first written as loose objects: one
zlib-compressed file each, as above. That is fast to write but
wasteful for thousands of versions of the same files. Periodically
Git packs them into a packfile: one file in
.git/objects/pack/ where similar objects are stored as
deltas (differences) against each other, with an index file for fast
lookup. Packfiles are also what Git sends over the network during
clone, fetch and push.
git init -q net-tools
cd net-tools
mkdir configs
printf '# Network tools\n' > README.md
printf 'hostname R1\n' > configs/r1.cfg
git add .
git commit -q -m "Add README and R1 config"
printf 'hostname R2\n' > configs/r2.cfg
git add configs/r2.cfg
git commit -q -m "Add R2 config"
git count-objects -v | grep -E '^(count|in-pack|packs):'
git gc -q
git count-objects -v | grep -E '^(count|in-pack|packs):'
count: 9 in-pack: 0 packs: 0 count: 0 in-pack: 9 packs: 1
Two commits produced nine objects (two commits, four trees, three
blobs); git gc moved all nine into one pack.
gc stands for garbage collection: besides packing, it
removes objects that nothing refers to any more. The reflog counts
as a reference, so commits you "lost" recently stay recoverable for
at least 30 days by default. After that, unreferenced loose objects
get a further two-week grace period (gc.pruneExpire)
before they are deleted. You rarely need to run it: Git runs
git gc --auto by itself after some commands, and
git maintenance (added in Git 2.29; scheduled
background runs with git maintenance start since 2.30)
can schedule this and other housekeeping in the background for large
repositories.