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.
Four object types, three trees, and a reflog that keeps 90 days of everything you thought you deleted.
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.
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.
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:
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.
Git maintains three states of your project simultaneously, and almost every command is defined by which of them it touches:
git log starts from.git add copies
from working tree to index.Once you hold that model, the reset modes stop being magic. They differ only in how far down they push HEAD's new position:
| Command | HEAD | Index | Working tree | Use when |
|---|---|---|---|---|
git reset --soft X | → X | unchanged | unchanged | Recommit differently; keep everything staged |
git reset --mixed X | → X | → X | unchanged | Default. Unstage but keep your edits |
git reset --hard X | → X | → X | → X | Throw the work away. Nothing else does this |
git checkout X | → X | → X | → X | Move to another commit (detaches HEAD) |
git switch B | → B | → B | → B | Move to a branch. The safe, modern spelling |
git restore F | — | — | → index | Discard working-tree edits to one file |
git restore --staged F | — | → HEAD | — | Unstage one file, keep the edit |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Two situations that come up constantly, and the shortest correct path through each.
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.
| Command | What it does |
|---|---|
git reflog | Every position HEAD has held. Your undo button |
git log --oneline --graph --all | The actual shape of your branches |
git log -S'string' | Commits that added or removed that string — pickaxe search |
git log -p -- path/to/file | Full history of one file, with diffs |
git blame -w -C file | Blame ignoring whitespace and following moved code |
git bisect run ./test.sh | Automatic binary search for the breaking commit |
git switch -c branch | Create and move to a branch (safe checkout -b) |
git restore --staged file | Unstage without touching your edits |
git worktree add ../dir branch | Second working directory, one object store |
git stash push -m 'msg' -- path | Stash only specific paths, with a label |
git rebase -i --autosquash HEAD~5 | Tidy a branch before it lands |
git commit --fixup HASH | Mark a fix for autosquash to fold in later |
git diff --staged | Review exactly what you are about to commit |
git fsck --lost-found | Find dangling objects when the reflog can't help |
git count-objects -vH | Repo size, packed and loose |
git push --force-with-lease | Force push that refuses if someone else pushed first |