Foundation · Core

Git & Version Control

Git as a content-addressed object store — the four object types, the three trees, and the recovery paths that mean you have almost certainly not lost that work.

24 min read Level: core → advanced Foundation 10 / 10
The model

WHAT GIT ACTUALLY STORES

Four object types, three trees, and a reflog that keeps 90 days of everything you thought you deleted.

YOUR EDITSWorking treeUntracked filesSTAGINGIndex / staging areagit addgit restore --stagedOBJECT STOREblob = contenttree = directorycommit = snapshottag = named commitREFSHEADrefs/heads/*refs/remotes/*refs/tags/*SAFETY NETreflog — 90 daysgit fsck --lost-foundREMOTEoriginfetch / pushpackfiles

Nothing in the object store is ever modified — objects are immutable and named by their own hash. Every operation that looks destructive is really just moving a pointer.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is actually in .git?
.GITworking treefetch from remotecheckout → workingtreepush → remote refsOBJECTS, CONTENT-ADDRESSEDblobfile contents, no nametreenames + modes → blobscommitone tree + parentstagannotated onlyPOINTERSrefs/heads/*branchesHEADwhere you arerefs/remotes/*last known remotereflogwhere HEAD has beenWORKING STATEindex (.git/index)the staging areapackfilesdeltas, after gcloose objectsbefore gc
A blob has no filename — the tree supplies it. That is why moving a file costs nothing, why git cannot track a rename as such, and why identical files anywhere in history are stored exactly once.
ConnectionWhere does a change live at each step?
edit a fileworking treeuntracked changegit addblob written NOWindextree stagedgit committree + parentobject storecommit writtenbranch ref movesreflog records itgit pushremote ref updated
The blob is written at `git add`, not at commit. That is why a change you staged and then overwrote is still recoverable, and why reflog can bring back a commit you have 'lost' for up to ninety days.
Core

CORE CONCEPTS

The model that makes every confusing command obvious.

Git is not a diff engine. It stores snapshots, addressed by the hash of their content, in four object types:

  • blob — the bytes of one file. No name, no permissions, just content. Two identical files anywhere in history are one blob.
  • tree — a directory listing: names, modes, and the hashes of the blobs and trees inside it.
  • commit — a pointer to one root tree, plus parent commit hashes, author, committer and message.
  • tag — an annotated pointer to a commit, with its own message and signature.

Every object's name is the SHA of its content, which makes the whole history tamper-evident: change one byte in one file in one old commit and every commit hash after it changes too. That is also why rewriting published history is antisocial — everyone else's hashes stop matching.

looking inside
$ git cat-file -p HEAD
tree 9d4f2a1c8e7b3f0a5d6c2e1b4a8f7d3c9e0b1a2f
parent 3c1e8a9f2b7d4c6e0a5f8b3d1c9e7a2f4b6d8c0e
author Vishal Abhinav <...> 1789012345 +0530
Fix the writeback stall under load
$ git cat-file -p HEAD^{tree}
100644 blob a3f9... README.md
040000 tree 7b2c... src
A commit is one tree plus metadata. That is genuinely all it is.

Git maintains three states of your project simultaneously, and almost every command is defined by which of them it touches:

  • HEAD — the commit you're on. What git log starts from.
  • Index (staging area) — the proposed next commit. git add copies from working tree to index.
  • Working tree — the files on disk you're actually editing.

Once you hold that model, the reset modes stop being magic. They differ only in how far down they push HEAD's new position:

CommandHEADIndexWorking treeUse when
git reset --soft X→ XunchangedunchangedRecommit differently; keep everything staged
git reset --mixed X→ X→ XunchangedDefault. Unstage but keep your edits
git reset --hard X→ X→ X→ XThrow the work away. Nothing else does this
git checkout X→ X→ X→ XMove to another commit (detaches HEAD)
git switch B→ B→ B→ BMove to a branch. The safe, modern spelling
git restore F→ indexDiscard working-tree edits to one file
git restore --staged F→ HEADUnstage one file, keep the edit
switch and restore exist now

git checkout was overloaded to do four unrelated jobs, which is why it was so easy to lose work with it. Since 2.23 the jobs are split: git switch changes branches, git restore changes files. Use them and a whole category of accidents disappears.

A branch is a file under .git/refs/heads/ containing one 40-character hash. Making a branch writes 41 bytes. Deleting one deletes 41 bytes. Nothing is copied, which is why Git branching is instant while it was expensive in the tools Git replaced.

HEAD is a file containing ref: refs/heads/main — a pointer to a pointer. Committing updates the branch that HEAD names. Detached HEAD just means HEAD holds a hash directly instead of a ref: commits you make there belong to no branch, and nothing but the reflog remembers them.

branches are files
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
3c1e8a9f2b7d4c6e0a5f8b3d1c9e7a2f4b6d8c0e
$ git update-ref refs/heads/hotfix 3c1e8a9
That is exactly what 'git branch hotfix' does. No copying.

Merge creates one new commit with two parents. Nothing is rewritten, every original hash survives, and the history records that two lines of work existed in parallel.

Rebase replays your commits onto a new base, one at a time. Each replayed commit is a new object with a new hash. The originals are orphaned (still in the reflog). History becomes linear and the parallel work is no longer recorded.

Squash collapses a branch into one commit on the target. Simplest log, most information destroyed.

The property that actually matters

Choose on the basis of git bisect. Bisect needs every commit in history to build and run. A rebased or squashed branch gives you commits that were tested as a unit — good. A merge of a branch whose intermediate commits were broken gives you bisect runs that fail to compile, and you spend the session marking them skip. That is the real argument for tidying a branch before it lands, and it has nothing to do with the log looking pretty.

Never rebase a branch someone else has pulled

Rebasing makes new commits with new hashes. Anyone who already has the old ones will merge both copies back in on their next pull, and the branch grows a duplicate of every commit. The rule is simple and absolute: rebase only what lives solely on your machine.

Advanced

ADVANCED

Recovery, bisection, repository weight, and the features worth turning on.

Every time HEAD moves — commit, checkout, reset, rebase, merge — Git appends the old position to the reflog. Objects stay in the store until garbage collection, and gc will not touch anything reachable from a reflog entry. Default expiry is 90 days for reachable entries and 30 for unreachable ones.

This means git reset --hard almost never actually destroys a commit. It moves a pointer. The commit is still there, and the reflog knows where.

recovering from a hard reset
$ git reset --hard HEAD~3
HEAD is now at 3c1e8a9 Fix the writeback stall
...three commits of work apparently gone.
$ git reflog
3c1e8a9 HEAD@{0}: reset: moving to HEAD~3
8f2b4d1 HEAD@{1}: commit: Add retry budget to the client
a91c3e7 HEAD@{2}: commit: Wire up the circuit breaker
5d0f8b2 HEAD@{3}: commit: Extract the transport interface
$ git reset --hard 8f2b4d1
All three back. The commits never went anywhere.
Reflog gone too (fresh clone, or expired)? Objects may still exist:
$ git fsck --lost-found --no-reflogs
dangling commit 8f2b4d1c9e0a7f3b2d5c8e1a4f6b9d0c3e7a2f5b

You know it worked in the release three weeks ago and it's broken now. Between them are 4,000 commits. Bisect does binary search: log₂(4000) ≈ 12 builds to find the exact commit that introduced the problem.

The manual form is fine, but the payoff is git bisect run with a script that exits 0 for good and non-zero for bad. Then it's fully automatic — go and do something else while it narrows down.

automated bisect
$ git bisect start
$ git bisect bad HEAD
$ git bisect good v2.14.0
Bisecting: 1994 revisions left to test after this (roughly 11 steps)
exit 0 = good, 1 = bad, 125 = skip (can't build this one)
$ git bisect run ./scripts/reproduce.sh
running ./scripts/reproduce.sh
...
8f2b4d1c is the first bad commit
Add retry budget to the client
$ git bisect reset
Make the reproducer fast before you start

Bisect runs your script a dozen times. A five-minute test suite is an hour; a ten-second targeted reproducer is two minutes. Time spent narrowing the test down to the one failing case pays for itself immediately.

New objects are written loose — one zlib-compressed file each. git gc packs them into a packfile with delta compression, storing similar objects as diffs against one another. A repo with 200,000 loose objects and the same repo packed can differ by an order of magnitude on disk.

The thing that catches everyone

Deleting a large file in a new commit does not shrink the repository. The blob is still reachable from every commit that contained it, so it ships with every clone forever. A 500 MB accidental binary from 2019 is still in everybody's clone today.

Removing it means rewriting history — git filter-repo (the maintained successor to filter-branch) — followed by a force push and every collaborator re-cloning. Doing it right is disruptive; the answer is to not commit large binaries in the first place, which is what Git LFS is for.

finding what's making the repo heavy
$ git count-objects -vH
count: 1204
size-pack: 3.82 GiB
$ git rev-list --objects --all |
$ git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
$ awk '$1=="blob"' | sort -k3 -n -r | head -5
blob 7f2a9c1 498237440 assets/demo-recording.mov
475 MB in one blob, in every clone, forever.

Hooks

Scripts in .git/hooks/ that fire at defined points. pre-commit for formatting and lint, pre-push for the fast test subset, commit-msg for message conventions. They are local and not versioned — which is why teams use a manager like pre-commit to install them from a config that is versioned. Server-side hooks are how a platform enforces signed commits or protected paths.

Worktrees

git worktree add ../hotfix release-2.14 gives you a second working directory on a different branch, sharing one object store. No stashing, no second clone, no re-downloading 4 GB to fix one line on a release branch while your feature build is still running.

rerere

Reuse recorded resolution. Turn it on and Git remembers how you resolved a given conflict, then replays that resolution automatically when the same conflict appears again — which it will, on every rebase of a long-running branch.

worth putting in your global config
$ git config --global rerere.enabled true
$ git config --global pull.rebase true
$ git config --global fetch.prune true
$ git config --global diff.algorithm histogram
$ git config --global rebase.autosquash true
$ git config --global init.defaultBranch main
fetch.prune alone removes a whole class of 'why is this dead branch
still in my tab completion' confusion.
In practice

TWO COMMON MESSES

Two situations that come up constantly, and the shortest correct path through each.

You committed to the wrong branch

move the last commit to where it belongs
$ git log --oneline -1
8f2b4d1 Add retry budget to the client
This should have been on feature/retries, not main.
$ git branch feature/retries # point a new branch here
$ git reset --hard HEAD~1 # rewind main
$ git switch feature/retries # the commit is safely over here

You need one commit from another branch

cherry-pick, and what to watch for
$ git cherry-pick 8f2b4d1
Makes a NEW commit with a new hash and the same change.
Both branches now carry the change under different hashes, so the
eventual merge may conflict. -x records the origin in the message:
$ git cherry-pick -x 8f2b4d1
(cherry picked from commit 8f2b4d1c9e0a7f3b2d5c8e1a4f6b9d0c3e7a2f5b)
The one habit worth building

Before any command that rewrites history — reset --hard, rebase, filter-repo — run git reflog once and note the current hash. It takes two seconds and turns every subsequent mistake into a one-line recovery.

Reference

CHEATSHEET

CommandWhat it does
git reflogEvery position HEAD has held. Your undo button
git log --oneline --graph --allThe actual shape of your branches
git log -S'string'Commits that added or removed that string — pickaxe search
git log -p -- path/to/fileFull history of one file, with diffs
git blame -w -C fileBlame ignoring whitespace and following moved code
git bisect run ./test.shAutomatic binary search for the breaking commit
git switch -c branchCreate and move to a branch (safe checkout -b)
git restore --staged fileUnstage without touching your edits
git worktree add ../dir branchSecond working directory, one object store
git stash push -m 'msg' -- pathStash only specific paths, with a label
git rebase -i --autosquash HEAD~5Tidy a branch before it lands
git commit --fixup HASHMark a fix for autosquash to fold in later
git diff --stagedReview exactly what you are about to commit
git fsck --lost-foundFind dangling objects when the reflog can't help
git count-objects -vHRepo size, packed and loose
git push --force-with-leaseForce push that refuses if someone else pushed first