git

Git from the inside: objects, refs, the reflog, and why history is so hard to truly delete

How Git stores data as hash-addressed objects (blob, tree, commit, tag), what refs and the reflog are, how to recover 'lost' commits, why deleted commits can still be found, the SHA-1 risk, and how to sign commits with an SSH key.

  • git
  • security
  • digital-forensics

Most developers use Git every day through a handful of commands: add, commit, push, pull. That's enough until something goes wrong: a commit vanishes after reset --hard, a secret gets pushed, or a teammate asks whether a commit under your name really came from you. At moments like that, understanding how Git stores data is far more useful than memorizing commands.

This post opens up the .git/ folder: how data is stored, what actually happens when we "delete" something, and what that means for security.

A short history

Linus Torvalds wrote Git in April 2005, after the free BitKeeper license used for Linux kernel development was revoked. The goals were clear: fast, fully distributed, and hard to corrupt silently. Within weeks Git was hosting the Linux kernel, and maintenance was handed to Junio Hamano, who still leads the project today.

That "hard to corrupt silently" goal shapes Git's entire design: every piece of data is named after the hash of its content.

Four kinds of objects

Inside .git/objects/ there are only four kinds of objects:

ObjectContents
blobthe contents of one file, with no filename and no permissions
treethe listing of one directory: names, modes, and the hashes of the blobs or trees inside
committhe hash of one tree (a project snapshot), parent commit hashes, author, committer, timestamps, and message
tagan annotated tag: points at another object, with tagger name, message, and an optional signature

Each object's name is the SHA-1 hash of the header <type> <size>\0 followed by its content. We can prove it ourselves:

Blob hash = SHA-1 of header + content
$ printf 'halo\n' | git hash-object --stdin
1185b465e5d77cbaf348effaf07b801e63998d17
 
$ printf 'blob 5\0halo\n' | sha1sum
1185b465e5d77cbaf348effaf07b801e63998d17  -

Anyone, on any machine, who stores a file containing halo gets the same hash. This is content-addressable storage: the address is determined by the content.

Now make a commit and look inside with git cat-file:

Dissecting a commit
$ git cat-file -p HEAD
tree 17825fadd92192535884f4f84a3b8aee40b3bb50
author Delfi <delfi@example.com> 1793674800 +0700
committer Delfi <delfi@example.com> 1793674800 +0700
 
Tambah salam
 
$ git cat-file -p HEAD^{tree}
100644 blob 1185b465e5d77cbaf348effaf07b801e63998d17	salam.txt
 
$ find .git/objects -type f
.git/objects/3f/1712761f2d9d3162b67061ff21c1b1146f876c
.git/objects/17/825fadd92192535884f4f84a3b8aee40b3bb50
.git/objects/11/85b465e5d77cbaf348effaf07b801e63998d17

Three objects: a commit, a tree, and a blob. Each is stored zlib-compressed in a folder named after the first two characters of its hash. The first commit has no parent line; the next one has one, and a merge commit has two or more.

This chain makes Git history a Merkle DAG. A commit's hash depends on its tree's hash, which depends on every blob's hash, and also on its parent commit's hash. Changing one byte in an old file changes the hash of its blob, its tree, the commit that holds it, and every commit after that. That's why a single commit hash is enough to guarantee the integrity of all history behind it, and why "rewriting history" in Git always means creating new commits.

One thing that's often missed: author and committer are just text the committer writes themselves. Anyone can run git -c user.name="Linus Torvalds" -c user.email=... commit. The hash guarantees the content hasn't changed, not who wrote it. We'll come back to this in the signing section.

Refs, HEAD, and branches that are just files

A 40-character hash is hard to remember, so Git names it through refs. A branch is just a text file containing one hash:

A branch is a pointer
$ cat .git/HEAD
ref: refs/heads/main
 
$ cat .git/refs/heads/main
3f1712761f2d9d3162b67061ff21c1b1146f876c

HEAD points to the active branch, and the branch points to a commit. Creating a branch means writing a 41-byte file, which is why branches in Git are so cheap. When you commit, Git creates a new commit object and moves the branch pointer to it. Rarely changing refs can be moved into .git/packed-refs, and newer Git versions also support the reftable ref storage format, but the concept is the same.

This leads to an important idea: an object that can't be reached from any ref is called unreachable. It doesn't disappear right away; it just no longer shows up in git log.

The reflog: your local safety net

Every time HEAD or a branch moves, through a commit, checkout, reset, rebase, or amend, Git records it in the reflog:

The trail of HEAD movements
$ git commit --amend -m "Tambah salam!"
$ git reflog
db49bbc HEAD@{0}: commit (amend): Tambah salam!
3f17127 HEAD@{1}: commit (initial): Tambah salam

Commit 3f17127 was "replaced" by the amend, but the object is still there and the reflog still remembers it. The same applies after a mistaken reset --hard or a botched rebase:

Recovering a commit after reset --hard
git reflog                    # find the position before the mistake
git branch rescue HEAD@{1}    # put a new branch there
# or go straight back:
git reset --hard HEAD@{1}

If the reflog doesn't help either, say for a dropped stash or a branch deleted long ago, look for orphaned objects with git fsck:

Finding commits no ref points to
git fsck --unreachable --no-reflogs | grep commit
git fsck --lost-found         # copies them into .git/lost-found/
git show <hash>               # inspect them one by one

The reflog is local: it isn't pushed and doesn't exist in a fresh clone. By default, reachable entries are kept for 90 days and unreachable ones for 30 days (gc.reflogExpire and gc.reflogExpireUnreachable). Once the reflog expires, git gc prunes orphaned objects older than two weeks (gc.pruneExpire). So you have a generous window to rescue work, but not forever.

Packfiles: where objects live long-term

Storing every version of every file in full sounds wasteful. Git solves this with packfiles: when git gc runs or data is sent over the network, loose objects are combined into a single .pack file and similar objects are stored as deltas of other objects.

Looking inside the object store
git count-objects -vH
git verify-pack -v .git/objects/pack/pack-*.idx | sort -k3 -n | tail -5   # 5 largest objects

The second command is handy for finding large files that were once committed and then deleted: they're gone from the working tree, but still make clones heavy because they live in the history.

Why "deleting" in Git almost never truly deletes

Everything above leads to the same conclusion: Git is designed not to lose data. That's great for day-to-day work, but bad when what needs deleting is an API key or a private file.

  • A new commit that removes a file doesn't touch the old blob. The file can still be retrieved with git show <old-commit>:path/to/file.
  • git reset or rebase followed by push --force removes commits from the branch, but the objects still exist in your local reflog, in the clone of anyone who fetched, in forks, in CI caches, and in mirrors such as Software Heritage.
  • On GitHub, commits no longer referenced by any branch can often still be opened directly by their hash. In 2024 Truffle Security showed that because forks share a single object storage network, commits from deleted forks, and even from private repos that were later made public, can still be accessed through the parent repo. GitHub stated that this behavior is by design.

So the order of operations is always the same, as covered in OPSEC for developers: revoke and rotate the secret first, then clean the history with git filter-repo. Cleaning history tidies the repo; revoking the secret is what makes the leak harmless.

SHA-1, SHAttered, and SHA-256

All the integrity guarantees above rely on the strength of the hash function. In February 2017, researchers from Google and CWI Amsterdam announced SHAttered, the first practical SHA-1 collision: two different PDFs with the same hash. In theory, a collision like that allows two different Git objects with the same name.

Git responded on two levels:

  1. SHA-1DC. Since Git 2.13 (2017), Git uses a SHA-1 implementation that detects known collision-attack patterns and rejects suspicious objects. The output is still SHA-1, so all existing repos remain compatible.
  2. The SHA-256 object format. Since Git 2.29, repos can be created with git init --object-format=sha256, and the Git project has planned SHA-256 as the default in Git 3.0. The obstacle is the ecosystem: not every hosting service and tool supports SHA-256 repos yet, and a SHA-1 repo can't be converted in place.

For most projects, SHA-1DC is enough. But it's also a reminder that cryptographic signatures, not hashes alone, are what prove who made a commit.

Signing commits with an SSH key

Since Git 2.34, commits and tags can be signed with an SSH key you most likely already have, without setting up GPG:

~/.gitconfig
[user]
    signingkey = ~/.ssh/id_ed25519.pub
[gpg]
    format = ssh
[gpg "ssh"]
    allowedSignersFile = ~/.config/git/allowed_signers
[commit]
    gpgsign = true
[tag]
    gpgsign = true

For Git to verify signatures locally, list the trusted public keys in an allowed signers file:

Registering a key and verifying
echo "delfi@example.com $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers
git log --show-signature -1
git verify-commit HEAD

The signature is stored in the commit object's gpgsig header, so it's part of what determines the commit hash. On GitHub or GitLab, upload the same key as a signing key so commits get the Verified label. For teams, enable a branch rule requiring signed commits, so commits impersonating someone else stand out immediately.

Keep the limits in mind: a signature proves that the key holder approved the commit, not that the code is safe. If the private key on a laptop is stolen, the attacker can sign too. Protect the key with a passphrase, or keep it on a hardware security key (ssh-keygen -t ed25519-sk).

Summary

  • Git is a hash-addressed object database: blobs (file contents), trees (directories), commits (snapshot + parents + metadata), and tags.
  • Branches and HEAD are just pointers to hashes. Rewriting history means creating new objects and moving pointers.
  • The reflog and git fsck can recover almost any local mistake, as long as expiry and git gc haven't kicked in.
  • Anything that was ever pushed should be considered copied. For secrets: revoke first, clean history after.
  • Hashes guarantee content, not identity. For identity, sign your commits and tags.