What git actually stores: blobs, trees and commits named by their hashes, branches and HEAD as tiny pointer files, the staging area, merge vs rebase, reset, and how the reflog gets back work you thought was lost. With real commands and real output.
⏱ ~50 min read14 sections8 interactive widgetsShell · Go · JavaScript
FoundationsPractitionerStaff-level
Foundations§01
The 30-second version
In one sentenceGit is a box of snapshots. Every commitCommit (git)A saved snapshot of every file in the repo, plus a pointer to the previous snapshot. Old commits are never changed; a new commit only adds a new snapshot on top. is a full picture of your project, named by a fingerprint of its contents. A branchBranch (git)A movable name that points at one commit. Making a new commit on a branch moves the name forward to it; the commits themselves never move. is just a sticky note with one commit's name on it.
Most git confusion comes from picturing it the wrong way: as a list of changes, or as folders of files that get edited. Inside, it's much simpler. Git only ever does three things:
Adds snapshots. A commit is written once and never changed. "Changing" history really means making new commits and moving a sticky note.
Names everything by its content. Each file version, folder listing and commit gets an id computed from its bytes, like 522d933. Same content, same id; one byte different, a completely different id.
Moves sticky notes.main, feature, HEAD (which means "where you are now"): each is a tiny file holding one commit id. Committing, merging, resetting and rebasing (§07–§09) mostly come down to "write some new snapshots, then move a note".
Three commits, three snapshots, shared files
Each commit points at a complete snapshot (a tree is a folder listing, a blob is one file's contents; §03 opens them up). Files that didn't change are not copied: the trees point at the same blob. Arrows between commits point backwards, to the parent.
Why bother knowing the inside?
Because the scary commands stop being scary. Once you can picture snapshots and pointers, you can predict what merge, rebase and reset --hard will do before you press Enter, and you know how to undo them when they go wrong.
What this topic covers
§02 is a short primer: the commands and words you need, if git is new to you.
§03–§06 open up .git: blobs, trees and commits; why ids are hashes; the staging area; branches and HEAD.
§07–§09 cover combining and undoing work: merge, rebase, reset.
§10 is the rescue kit: the reflog. §11–§12 go deep: storage, garbage collection, and rewriting history that others already have.
Foundations§02
Primer: the everyday commands
If you already commit and push every day, skip to §03. Otherwise, here is everything the rest of the page assumes.
Running commands
Every example is typed into a TerminalTerminal (tty)The window you type commands into and read their output from. A program running in it reads your keystrokes as its input and its output appears as text. (Under the hood the OS presents it as a special file such as /dev/pts/0.), the text window where you run programs by name. A line starting with $ is a command you type (don't type the $ itself); the lines under it are what the program printed. Text after # is a comment for you, not part of the command. Blocks that show only commands, with no output, leave the $ out.
terminal
$ pwd # "print working directory": which folder am I in?
/tmp/git-internals/demo
$ ls# list the files and folders here
README.md src
$ cat README.md # print a file's contents# Notes app
A tiny notes app.
A repository, and the files you see
A repositoryRepository (repo)A project folder tracked by git, plus its hidden .git folder, which stores every commit, branch and setting. ("repo") is a normal project folder plus one hidden folder, .git, where git keeps every saved version. The ordinary files you open in your editor are the working treeWorking treeThe ordinary files you see and edit in a git project folder, as opposed to the snapshots stored inside .git.. Git never changes them unless you run a command that says so.
Every repo starts with one branchBranch (git)A movable name that points at one commit. Making a new commit on a branch moves the name forward to it; the commits themselves never move., usually called main: think of it as a name for your latest commit. §06 shows what a branch really is.
The loop you'll repeat a thousand times
terminal
git init # once: turn this folder into a repo (creates .git)git status # what changed since the last commit?git add README.md # choose which changes go into the next commitgit commit -m "Describe the app"# save them as a new snapshot, with a message (-m)git log --oneline # list the snapshots, newest first, one line each
Here's that last command in the small demo repo used throughout this page. Each line is one commit: a short id, then its message.
terminal (real output)
$ git log --oneline
5fe3c9c Describe the app
3fdb89c First commit
Sharing: remotes, push and pull
Your repo usually also exists on a server such as GitHub. That other copy is a remoteRemote (origin)Another copy of the repository that yours knows by name, usually on a server like GitHub. git clone names it origin. git fetch downloads its commits; git push uploads yours and moves its branch., named origin by default. git clone <url> makes your own full copy (a cloneCloneA complete copy of a repository, with all its history: git clone <url>. Every clone has every commit, so each teammate's laptop holds a full backup of the project.), git push sends your new commits up, and git pull brings other people's down. Everything in §03–§11 happens entirely on your machine; only §12 is about what happens once other people have copies.
Foundations§03
Commits are snapshots: blobs, trees and commits
In one sentenceEverything git remembers is built from three kinds of objects: a blob is a file's contents, a tree is a folder listing, and a commit points at one tree plus its parent commit.
A common mental picture is that a commit stores "what changed". It doesn't. A commit points at a complete snapshot of every file. Git computes the changes (a "diff") on the fly, whenever you ask for one, by comparing two snapshots.
The three object types
Every git objectGit objectOne item in git's storage inside .git/objects: a blob (a file's contents), a tree (a folder listing) or a commit (a snapshot record). Objects never change once written; each is named by the hash of its content. lives in .git/objects and is one of these:
1
Blob
The bytes of one file. No name, no path, no date. Two files with the same bytes share one blobBlob (git)A git object that holds the contents of one file, and nothing else: no file name, no permissions, no date. Two files with identical contents share a single blob..
2
Tree
One folder: a list of entries, each with a name, a type, and the id of a blob (file) or another treeTree (git object)A git object that lists one folder: for each entry, its name, its type (file or sub-folder) and the id of the blob or tree that holds it. (sub-folder).
3
Commit
The id of the top-level tree, the id of the parent commit(s), author, time and message. That's all.
The links between them are ids: 40-character names like 5fe3c9c24e7d9735b709b14c21f6810fd21a43ae. §04 shows exactly how an id is computed. For now, treat it as the object's unique name.
Look for yourself
git cat-file -p <id> prints any object (-p = "pretty-print"). Here's the demo repo after its second commit. Want to follow along? Build the exact same repo first:
Build the demo repo (paste into a terminal, in an empty folder; it prints the two commits)
The export lines and the GIT_…_DATE=… prefixes fix the author name and the time (as Unix timeUnix timeA time written as the number of seconds (or milliseconds) since 1970-01-01 00:00:00 UTC, “the epoch”. Date.now() in JS and time.Now().UnixMilli() in Go both return it in milliseconds.), and -c commit.gpgsign=false turns off commit signing for these two commands. With them, your ids will match ours exactly. Without them, your blob and tree ids still match (they depend only on file contents), but your commit ids differ, because your name and the time are part of each commit. That's §04 in action.
Now start at the commit main points to and follow the ids. (cat prints a file; §06 explains the two small files it prints here.)
terminal (real output)
$ cat .git/HEAD # where am I? (§06)
ref: refs/heads/main
$ cat .git/refs/heads/main # which commit is main?
5fe3c9c24e7d9735b709b14c21f6810fd21a43ae
$ git cat-file -p 5fe3c9c # the commit
tree 1a1b0515c025f4506d43688acd6ed7e4ad6985aa
parent 3fdb89c95cbcc03e1f8e987c33c834fc4a22e4dc
author Ada <ada@example.com> 1790848800 +0000
committer Ada <ada@example.com> 1790848800 +0000
Describe the app
$ git cat-file -p 1a1b051 # its tree: the top folder100644 blob 20b449d66c75edc829bae815eb57e3b139228b6b README.md
040000 tree f901409b49cc802742d3af473cc02868967d683b src
$ git cat-file -p 20b449d # a blob: just the file's bytes# Notes app
A tiny notes app.
Now explore the same objects as a picture. The first commit is in there too. Notice which boxes both commits point to:
▶Object-store explorer
The real objects of a 2-commit repo. Click any box, or any id in the output, to open it.
$ git cat-file -p 5fe3c9ctype: commit
tree
parent
author Ada <ada@example.com> 1790848800 +0000
committer Ada <ada@example.com> 1790848800 +0000
Describe the app
stored in .git/objects/5f/e3c9c24e7d9735b709b14c21f6810fd21a43ae
What you're looking at
The second commit. It names ONE tree (the whole project at that moment) and its parent commit. The two big numbers are the time in seconds since 1970 (Unix time).
Why snapshots don't waste space
The second commit only edited README.md. Its tree points at a new README blob, but src/ didn't change, so it points at exactly the samesrc tree (f901409) as the first commit, and through it the same app.js blob. Storing a snapshot costs only the objects that changed, plus the trees above them on the path up to the top folder.
terminal (real output)
$ find .git/objects -type f | wc -l
8
find .git/objects -type f lists every file under .git/objects, one per line. The pipePipe ( | )Connects one program's standard output to the next program's standard input, so small tools can be chained: cat log | grep ERROR | wc -l.| feeds that list into wc -l, which counts lines. So: 8 object files.
Eight objects for two commits: 2 commits, 3 trees (two top folders, one shared src), 3 blobs (two READMEs, one shared app.js). Nothing was copied.
Practitioner§04
Ids are fingerprints: content addressing
In one sentenceAn object's id is a hash of its content. So the id is both its name and a tamper seal: change one byte and you get a different object with a different name.
A Hash functionHash functionA function that turns any input (a string, a file) into a fixed-size number or fingerprint. The same input always gives the same output, and small changes give very different outputs — used to spread keys across buckets or servers, and (with cryptographic hashes like SHA-256) to detect tampering. turns any input into a short, fixed-size fingerprint. The same input always gives the same fingerprint, and a tiny change gives a completely different one. Git uses one called SHA-1MD5 / SHA-1Older hash functions (1992 / 1995) that are now broken: researchers can create two different files with the same hash. Fine as a non-security checksum, never for signatures, integrity against attackers, or passwords., which produces 20 bytesByte8 bits — enough to store a number from 0 to 255, or one ASCII character. Network speeds are usually quoted in bits per second, file sizes in bytes., written as 40 HexadecimalHexadecimal (hex)Base-16 numbers using the digits 0–9 and a–f. One hex digit is exactly 4 bits, so a byte is two hex digits: ff = 255. MAC and IPv6 addresses are written this way. characters. If hash functions are new to you, Hashing & Encryption §03 explains them with a live demo.
The recipe
To get a blob's idObject id (hash)The 40-hex-character name of a git object, such as 522d9335c44b…: the SHA-1 hash of the object's type, size and content. Usually shortened to the first 7 characters. SHA-256 repositories use 64 characters., git hashes a short header followed by the file's bytes:
"blob" + " " + <size in bytes> + <a zero byte> + <the file's bytes>
For a README.md containing # Notes app plus a newline (12 bytes), that's blob 12, a zero byte, then # Notes app\n. Let's compute it ourselves and compare with git:
blobid.go
package main
import (
"crypto/sha1""fmt"
)
funcmain() {
content := []byte("# Notes app\n")
header := fmt.Sprintf("blob %d\x00", len(content)) // "blob 12" + a zero byte
sum := sha1.Sum(append([]byte(header), content...)) // ← hash header + content together
fmt.Printf("%x\n", sum) // %x prints the 20 bytes as 40 hex characters
}
terminal (real output, Go 1.24 / Node 22)
$ go run blobid.go
522d9335c44ba4265f6e2bf0602d1981227d7b8d
$ node blobid.mjs
522d9335c44ba4265f6e2bf0602d1981227d7b8d
$ printf '# Notes app\n' | git hash-object --stdin # ask git to hash the same bytes
522d9335c44ba4265f6e2bf0602d1981227d7b8d
All three agree, and it's the very blob id you saw in the first commit's tree in §03. Any computer, anywhere, hashing those 12 bytes gets that id.
On disk: compressed, filed by id
Git compresses each object with zlibzlib (deflate)A standard, widely used compression format (the same one inside .zip and gzip files). Git compresses every object with it before writing it to disk. and saves it under .git/objects/, using the first two hex characters as a folder name (so no folder holds millions of files). Unpack one and the header is right there:
inflate.mjs
import { readFileSync } from"node:fs";
import { inflateSync } from"node:zlib";
const raw = readFileSync("demo/.git/objects/52/2d9335c44ba4265f6e2bf0602d1981227d7b8d");
console.log(raw.length, "bytes on disk");
console.log(JSON.stringify(inflateSync(raw).toString())); // JSON.stringify shows \u0000 for the zero byte
terminal (real output)
$ node inflate.mjs
28 bytes on disk
"blob 12\u0000# Notes app\n"
Trees and commits use exactly the same recipe, with tree <size> or commit <size> in the header. The content of a commit is the text you saw in §03, including the tree id and the parent id.
Why this design is so powerful
This is content-addressed storageContent-addressed storageStorage where each item's name (its address) is computed from its content, usually with a hash. Same content always gets the same name, and changed content always gets a new one.: an object's address is computed from what it contains. Three consequences fall out of it for free:
Deduplication. Same bytes → same id → stored once. Ten copies of the same license file cost one blob.
Objects can't change. "Editing" an object would change its id, which makes it a different object. So every object is ImmutableImmutableCan't be changed after it's created. To “change” an immutable value you make a new one. Immutable objects can be shared freely, because nobody can modify them under you., and git can cache, copy and share them without ever checking whether they're stale.
One id vouches for everything below it. A commit contains its tree's id; the tree contains its blobs' ids. Change one byte in one file and that blob's id changes, so the tree's content changes, so the tree's id changes, so the commit's id changes. That's a Merkle treeMerkle tree (hash tree)A tree where every leaf holds the hash of a data block and every parent holds the hash of its children's hashes. The root hash fingerprints all the data: if any block changes, the root changes, and you can find which block by following the differing hashes down.: a single 40-character commit id is a fingerprint of the entire project and all of its history (because the parent id is inside too).
See it happen. This widget builds the demo repo's second commit from scratch with real SHA-1, in your browser. Change one character of the README and watch the change ripple upwards, while src/ stays put:
▶One byte changes every id above it
Edit README.md. The ids are real SHA-1 hashes of real git objects, computed in your browser.
README.mdtry adding one “!”
✓ these are exactly the ids in the demo repo (§03)
commitDescribe the app
5fe3c9c24e7d9735b709b14c21f6810fd21a43ae
contains the id above, so it changes too
tree /top folder
1a1b0515c025f4506d43688acd6ed7e4ad6985aa
contains the id above, so it changes too
blobREADME.md
20b449d66c75edc829bae815eb57e3b139228b6b
hash of "blob 30\0" + the file
tree src/+ blob app.js
f901409b49cc802742d3af473cc02868967d683b
unchanged → same id → shared
The commit keeps the same parent, author, time and message as the real one; only the README changes. Arrows of trust point up: the commit holds the tree's id, the tree holds the blob's id.
Foundations§05
Working tree, index, repository
In one sentenceBetween your files and your commits sits a draft of the next commit, called the index (or staging area). git add copies a file into the draft; git commit saves the draft as a snapshot.
Three places a file version can live
Arrows to the right save a version into git. Arrows to the left copy a saved version back out. git restore overwrites your file, so an edit you never added can't be recovered afterwards.
The indexIndex (staging area)A file, .git/index, that lists the exact file versions (blob ids) that will go into your next commit. git add updates it; git commit turns it into a tree. is a real file, .git/index. It holds one entry per tracked fileTracked fileA file git knows about because it's in the index: you added it at some point (or it came with a clone). A brand-new file stays untracked, and is ignored by commit, until you git add it. (any file you've added at least once): its path and the blob id of the version that will go into the next commit. The file is binary, so opening it in an editor shows gibberish; print it with this command instead:
(The 0 is a "stage number". It's always 0 except in the middle of a merge conflict, where §07 shows it being 1, 2 and 3.)
Step through add and commit
This is the real second commit of the demo repo. Watch which of the three places changes at each step, and notice when the new blob is written:
▶What add and commit actually write
Real ids from a real repo. Watch which of the three places changes at each step.
Code
1echo"A tiny notes app." >> README.md
2git status --short
3git add README.md
4git status --short
5git commit -m "Describe the app"
6git status --short
Working treeyour files
README.md · same as index
src/app.js · same as index
Index.git/index
README.md → 522d933
src/app.js → d914c60
Repository.git/objects + refs
main → commit 3fdb89c
trees b09015d, f901409
blobs 522d933, d914c60
The starting point: one commit, 3fdb89c. All three places agree. The index (also called the staging area) is a list of file name → blob id: the exact versions your next commit will contain.
1 / 7
What to take from that
git add already saves the content. It writes the blob into .git/objects right away. That's why even an add-ed-but-never-committed version can sometimes be dug up later (§10).
git commit only uses the index. Changes you didn't add are not in the commit, even though they're in your files. That's the point: you can commit part of your work, such as one fix out of three edits.
git status compares the three places. The short form, git status --short (or -s), shows two columns: left is index vs last commit ("staged"), right is working tree vs index ("not staged yet"). Plain git status says the same in words, under the headings "Changes to be committed" and "Changes not staged for commit".
Foundations§06
Branches and HEAD are just pointers
In one sentenceA branch is a file containing one commit id. Committing on a branch writes a new commit and overwrites that file with the new id. HEAD is a file that says which branch you're on.
A branch costs one tiny file
terminal (real output)
$ git branch search # create a branch called "search"
$ cat .git/refs/heads/search # it's just a file with a commit id in it
5fe3c9c24e7d9735b709b14c21f6810fd21a43ae
$ git switch search # make it the current branch
Switched to branch 'search'
$ cat .git/HEAD # HEAD now names that branch
ref: refs/heads/search
Creating a branch copied nothing: no files, no commits. It wrote 41 bytes (40 hex characters and a newline). That's why git users make branches for every small experiment. A name that points at a commit like this is called a refRefA name that points at a commit, stored as a small file or a line in .git/packed-refs. Branches (refs/heads/main), tags (refs/tags/v1.0) and remote branches (refs/remotes/origin/main) are all refs.; branches live under refs/heads/, tags under refs/tags/.
HEADHEAD (git)Git's name for “where you are now”: usually it points at a branch name, which points at a commit. sits one step further back: HEAD points at a branch name, and the branch points at a commit. When you commit, git:
writes the new commit with parent = the commit the current branch points at;
overwrites the branch file (refs/heads/search) with the new commit's id.
HEAD itself doesn't change: it still says "I'm on search". The branch moved forward and took HEAD along.
Try it
Start from two commits with main and feature both pointing at the second one. Commit a few times, switch branches, commit again, and watch which sticky note moves. (The merge and rebase buttons are for §07 and §08.)
▶Commit-graph playground
Two branches, main and feature. Try merge, then ⟲ and try rebase on the same start. Watch the ids.
on branch main
● made on main● made on feature● merge commit (2 parents)● rebased copy (new id)◌ reachable only from the reflog
You're on main. Press “git commit” and watch which label moves. Then “git switch feature” and commit again.
git log --onelinefrom HEAD
4214ac7 Describe the app
0c6d2d1 First commit
Ids here come from a small stand-in hash so the demo runs anywhere; real git uses SHA-1 (§04). The rule is the same: change a commit's parent and you get a new id.
Things to notice:
Only the branch HEAD points at moves when you commit.
When you switch and commit on the other branch, history forks: two commits with the same parent. Nothing special happened; there are just two sticky notes on two different photos.
The git log box lists only what's reachable from HEAD by following parents backwards. Switch branches and the list changes, though no commit was created or deleted.
Detached HEAD
You can also point HEAD directly at a commit instead of at a branch, for example to look at an old version:
terminal (real output)
$ git checkout 5fe3c9c
Note: switching to '5fe3c9c'.
You are in'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
…
HEAD is now at 5fe3c9c Describe the app
$ cat .git/HEAD
5fe3c9c24e7d9735b709b14c21f6810fd21a43ae
This time .git/HEAD holds a commit id, not ref: refs/heads/….
That's a detached HEADDetached HEADThe state where HEAD points straight at a commit id instead of at a branch. You can look around and even commit, but no branch moves forward, so new commits are easy to lose when you switch away.. Nothing is broken. But if you commit here, no branch moves to remember the new commits. Switch away and they have no name; only the reflog (§10) still knows them. If you made commits you want to keep, give them a name before leaving: git switch -c my-experiment.
Practitioner§07
Merging: fast-forward and three-way
In one sentenceA mergeMerge (git)Combining two branches by making a new commit that has both branch tips as its parents. Existing commits are left untouched. combines two lines of work. If one side has nothing new, git just slides a pointer (fast-forward). Otherwise it compares both sides with their common ancestor and writes a commit with two parents.
Case 1: fast-forward
You branched feature off main, made three commits, and nobody touched main meanwhile. main's commit is an ancestorAncestor (commit)Any commit you can reach from another commit by following parent links backwards: its parent, its parent's parent, and so on. If A is an ancestor of B, then B already contains all of A's history. of feature's tipTip (of a branch)The newest commit on a branch: the one the branch name points at. Committing on the branch makes the new commit the tip. (its newest commit): you can walk from feature's tip back to it, so everything on main is already in feature. There is nothing to combine, so git merge feature just moves the main pointer forward to feature's commit. No new commit is created. That's a fast-forwardFast-forwardA merge where your branch has no commits of its own since the other branch split off, so git just moves your branch name forward to the other tip. No new commit is created..
Case 2: both sides moved
In the demo repo, search got two commits while main got one ("Say hello"). History has forked:
terminal (real output)
$ git log --oneline --graph --all
* 1399b36 Say hello
| * 22ba1cc Trim the query
| * f3b77a7 Add search
|/
* 5fe3c9c Describe the app
* 3fdb89c First commit
$ git merge search -m "Merge branch 'search'"
Merge made by the 'ort' strategy.
src/search.js | 1 +
1 file changed, 1insertion(+)
create mode 100644 src/search.js
$ git cat-file -p HEAD | head -3# the new commit: TWO parent lines
tree b5be7ccb5490952d11e24cd19a5d7ccd150d5549
parent 1399b367ccaf5a56892ceca5f46f765b2986b41e
parent 22ba1ccfbef2b71bebd4cbd319e47eeabe1b7270
A merge commit is an ordinary commit with two parent lines. Its tree is the combined snapshot. No existing commit changed: f3b77a7 and 22ba1cc keep their ids and their place in history.
How git combines the files: three-way merge
To merge, git needs three snapshots, not two:
Ours: the tip of the current branch (1399b36).
Theirs: the tip of the branch being merged (22ba1cc).
The base: the most recent commit both share, here 5fe3c9c. That's the merge baseMerge baseThe most recent commit that two branches have in common: the point where they split. git merge-base main feature prints it. A three-way merge compares both sides against it.; git merge-base main search prints it.
Why the base? Comparing only ours and theirs, git would see that a line is different but couldn't tell who changed it. With the base it can: if only one side differs from the base, that side made the change, so take it. This is a three-way mergeThree-way mergeCombining two versions of a file by also looking at the common ancestor (the merge base). A change made on only one side is taken automatically; a change both sides made to the same lines is a conflict.. Try the three cases (all real merges):
▶Three-way merge: base, ours, theirs
Real merges run with git 2.43. Changed lines (vs the base) are highlighted.
Basecommon ancestor
# Notes app
A tiny notes app.
License: MIT
OursHEAD (your branch)
# Notes
A tiny notes app.
License: MIT
Theirsbranch being merged
# Notes app
A tiny notes app.
License: Apache-2.0
↓ git merge
README.md after the mergemerged cleanly
# Notes← ours
A tiny notes app.
License: Apache-2.0← theirs
Line 1 changed only on our side, line 3 only on theirs, with an untouched line between them. For each change git asks: who changed it since the base? Only one side → take that side. Merged cleanly, no questions asked.
When it's a conflict
When both sides changed the same block of lines, git can't decide. It merges every file it can, then stops with a conflictMerge conflictWhen both sides changed the same (or neighbouring) lines differently since the merge base, git can't choose, so it stops, writes both versions into the file between <<<<<<< and >>>>>>> markers, and asks you to edit the result.:
terminal (real output)
$ git merge wording
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.
$ cat README.md
# Notes app
<<<<<<< HEAD
A tiny, fast notes app.
=======
A tiny notes app for teams.
>>>>>>> wording
Between <<<<<<< HEAD and ======= is our version; between ======= and >>>>>>> is theirs. To resolve: edit the file into what you actually want (maybe "A tiny, fast notes app for teams."), delete the marker lines, then git add README.md and git commit. Changed your mind? git merge --abort puts everything back as it was before the merge.
Practitioner§08
Rebase: replaying commits as new copies
In one sentenceA rebaseRebase (git)Replaying your commits one by one on top of another commit. The replayed commits are new copies with new ids; the originals stay in the reflog for a while. takes your branch's commits and replays them one by one on top of another commit. Each replayed commit is a new copy with a new id. The originals are left behind.
Why new ids? Because of §04: a commit's content includes its parent's id. Replaying "Add search" on top of a different parent produces a commit with different content, so it's a different commit with a different name. Git can't move a commit; it can only make a new one.
The same fork, rebased instead of merged
Same fork as in §07, but in a fresh copy of the repo, before the merge: search has two commits, main has one new one. This time, on search, we rebase onto main:
terminal (real output)
$ git switch search
$ git rebase main
Successfully rebased and updated refs/heads/search.
$ git log --oneline --graph --all
* dd021b4 Trim the query
* 9c91175 Add search
* 1399b36 Say hello
* 5fe3c9c Describe the app
* 3fdb89c First commit
Look at the ids. Before, the search commits were f3b77a7 and 22ba1cc. Now they're 9c91175 and dd021b4: same messages, same changes, different commits. History is now a straight line, and git switch main && git merge search would be a fast-forward.
Same starting point, two results (real ids)
git merge search (on main)
git rebase main (on search)
Both end with the same files (both final commits point at tree b5be7cc). Merge keeps the true history and adds one commit with two parents. Rebase rewrites search as new copies (9c91175, dd021b4); the originals are left behind, reachable only from the reflog.
The old commits weren't deleted. The branch's own reflog (§10) still lists them:
terminal (real output)
$ git reflog show search
dd021b4 search@{0}: rebase (finish): refs/heads/search onto 1399b367ccaf5a56892ceca5f46f765b2986b41e
22ba1cc search@{1}: commit: Trim the query
f3b77a7 search@{2}: commit: Add search
5fe3c9c search@{3}: branch: Created from main
See the difference yourself
Same start as the diagram. Press git merge feature (you're on main). Then press ⟲, press git switch feature, then git rebase main, and compare: the rebased copies get purple dots and new ids, and the originals fade to "reflog only". Then try git reset --hard feature@{1} to undo the rebase: it moves feature back to where it pointed one move ago, before the rebase. (reset --hard moves the current branch and makes your files match; §09 covers it properly.)
▶Commit-graph playground
Two branches, main and feature. Try merge, then ⟲ and try rebase on the same start. Watch the ids.
on branch main
● made on main● made on feature● merge commit (2 parents)● rebased copy (new id)◌ reachable only from the reflog
You're on main. Press “git merge feature” to combine the branches, then ⟲ and press “git switch feature” → “git rebase main” to compare.
git log --onelinefrom HEAD
01f6567 Say hello
4214ac7 Describe the app
0c6d2d1 First commit
Ids here come from a small stand-in hash so the demo runs anywhere; real git uses SHA-1 (§04). The rule is the same: change a commit's parent and you get a new id.
Merge or rebase?
git merge feature (on main)
git rebase main (on feature)
Existing commits
untouched, same ids
replaced by new copies with new ids
New commits
one merge commit (two parents), unless fast-forward
one copy per replayed commit
History shape
forks and joins, shows what really happened
one straight line
Safe on a shared branch?
yes
no: others still have the old commits
Conflicts
resolved once, in the merge commit
may come up once per replayed commit
Many teams combine them: rebase your own unshared branch to bring it up to date, then merge it into main through a pull requestPull request (PR)A proposed set of changes to a shared code base, shown as a diff for teammates to review. CI runs the tests on it, and it's merged only when they pass and a reviewer approves..
Conflicts during a rebase
A rebase is a series of small "apply this commit's changes here" steps, so a conflict can stop it at any one of them. Fix the file, git add it, then git rebase --continue. git rebase --abort puts the branch back exactly where it was. During a rebase, "ours" and "theirs" are swapped compared with a merge: "ours" is the branch you're replaying onto, "theirs" is your commit being replayed.
Cherry-pick: rebase for one commit
git cherry-pick <id>Cherry-pickgit cherry-pick <commit>: take the changes one commit introduced and apply them as a new commit on your current branch. The copy gets a new id, just like a rebased commit. takes the changes of one commit and applies them as a new commit on your current branch. It's the same operation rebase performs for each commit, and the same rule applies: the copy has a new id. Typical use: a bug fix landed on main and you also need it on a release-1.4 branch.
Practitioner§09
Undoing: reset, restore and revert
In one sentencegit reset <commit>moves the current branch to another commit. The flag decides how much else follows: --soft nothing, --mixed the index, --hard the index and your files.
Every undo command in git is one of the moves from the three-places picture in §05: point a branch somewhere else, copy a snapshot into the index, copy a snapshot into the working tree. Once you see which places a command touches, you know whether it can lose work.
reset: three strengths
In the demo repo we commit one change to src/app.js and then reset to its parent. Toggle the modes and watch which of the three places change:
▶reset: which of the three places move?
Real results from git 2.43, after committing one change to src/app.js.
$ git commit -am "Greet notes" # -a = stage every changed tracked file first
main points atHEAD
c475cb0 Greet notes
index has app.jsstaging
v3 · "hello, notes"
your app.js fileworking tree
v3 · "hello, notes"
$ git status --short → (nothing: all clean)
You just committed c475cb0, which changed src/app.js from v2 to v3. Now you run git reset --… HEAD~1 (HEAD~1 = “the parent of the current commit”).
(HEAD~1 means "the parent of the current commit"; the naming rules are in §08.)
Command
Branch moves
Index reset
Files overwritten
Typical use
git reset --soft HEAD~1
yes
no
no
Undo the last commit but keep it staged, to recommit differently
git reset HEAD~1 (--mixed)
yes
yes
no
Undo the last commit and unstage; keep all edits in your files
git reset --hard HEAD~1
yes
yes
yes
Throw away the last commit and any uncommitted edits
git reset --hard (no commit)
no
yes
yes
Throw away all uncommitted edits
Only --hard can destroy work, and only work that was never committed. The commit you reset away is still in the object store, reachable from the reflog. Uncommitted edits it overwrote are not.
restore and switch: the file-level tools
git restore <file> overwrites one file in your working tree with the index version. Unstaged edits to it are lost (and unrecoverable, they were never hashed).
git restore --source=HEAD~2 <file> brings back a file as it was two commits ago, without moving any branch.
git switch <branch> moves HEAD to another branch and updates index and files to match. It refuses if that would overwrite uncommitted changes, which makes it much safer than reset --hard.
revert: undo by adding, not removing
reset rewrites where a branch points, so the undone commit disappears from that branch's history. That's fine on your own branch, but on main, which everyone has pulled, it causes the same problem as a rebase (§08): others still have the commit.
git revert <id>Revert (git)git revert <commit>: make a new commit that undoes the changes of an earlier one. History only grows, so it's the safe way to undo something already shared. instead makes a new commit whose changes are the exact opposite of <id>'s. History only grows; nobody's copy is out of date; the mistake and its fix are both on record.
git revert 1399b36 # new commit: "Revert "Say hello""git revert -m 1 f8abff5 # reverting a MERGE: -m 1 means "keep parent 1 (main's side)"
Practitioner§10
The reflog: recovering lost work
In one sentenceCommits are almost never deleted; they only lose their name. The reflogReflog (git)Git's local diary of where your branch pointed over time. Even after an amend or reset, the old commits are listed there (by default for 30 to 90 days) and can be brought back. is git's diary of every commit HEAD and each branch pointed at, so you can find a nameless commit and name it again.
Recall §03: git log starts at HEAD and walks parent links backwards. A commit that no branch leads to is simply not visited. It's still in .git/objects, intact, with its full snapshot. You only need its id.
Git records one line in the reflog every time HEAD or a branch moves: each commit, switch, reset, merge, rebase. It lives in .git/logs/ and is local only: it's never pushed or cloned.
terminal (real output, the demo repo's whole history)
$ git reflog
f8abff5 HEAD@{0}: merge search: Merge made by the 'ort' strategy.
1399b36 HEAD@{1}: commit: Say hello
5fe3c9c HEAD@{2}: checkout: moving from search to main
22ba1cc HEAD@{3}: commit: Trim the query
f3b77a7 HEAD@{4}: commit: Add search
5fe3c9c HEAD@{5}: checkout: moving from main to search
5fe3c9c HEAD@{6}: commit: Describe the app
3fdb89c HEAD@{7}: commit (initial): First commit
Read it top to bottom as "most recent first". HEAD@{3} means "where HEAD pointed 3 moves ago". Every id in that list can be passed to git show, git switch -c, git branch or git reset.
Rescue 1: after reset --hard
▶Reflog rescue
A real session: lose a commit with reset --hard, then get it back.
Code
1git commit -m "Add Markdown export"
2git reset --hard HEAD~1
3git log --oneline -3
4git reflog -3
5git show --stat HEAD@{1}
6git reset --hard 7e7bcd0
Commits that existobjects
1399b36 Say hello
f8abff5 Merge branch 'search'
7e7bcd0 Add Markdown export ← main
Outputwhat you see
[main 7e7bcd0] Add Markdown export
1 file changed, 1 insertion(+)
create mode 100644 src/export.js
Reflog.git/logs/HEAD
7e7bcd0 HEAD@{0}: commit: Add Markdown export
You commit a new file, src/export.js. main moves to 7e7bcd0, and git appends a line to the reflog: its local diary of every place HEAD has pointed.
1 / 6
The recipe, every time: find the id in git reflog, check it with git show, then name it, with git branch rescue <id> (safe: just adds a sticky note) or git reset --hard <id> (moves your current branch there).
Rescue 2: a deleted branch
terminal (real output)
$ git branch -D search
Deleted branch search (was 22ba1cc).
$ git branch search 22ba1cc # recreate it at the same commit
Git even prints the id when it deletes. Scrolled away? Deleting a branch also deletes that branch's reflog, but HEAD's reflog still has every commit you made on it. Look for the line just below checkout: moving from search to …: that's where search was when you left it (22ba1cc in the listing above).
Rescue 3: a rebase that went wrong
A rebase moves the branch once, at the end, so the branch's own reflog has one entry for it. The entry before is the old tip:
git reflog show search # search@{1} is the pre-rebase tip (22ba1cc in §08)git reset --hard search@{1} # put the branch back exactly as it was
Before a reset, rebase or merge, git also saves the old HEAD as ORIG_HEADORIG_HEADA ref git sets before risky commands (reset, rebase, merge) to where HEAD was just before. git reset --hard ORIG_HEAD undoes the last one, if you haven't run another since., so git reset --hard ORIG_HEAD right after a bad rebase works too, as long as you haven't run another such command since.
Rescue 4: added, but never committed
git add writes a blob immediately (§05). If you then lost the file with reset --hard, the blob is still in .git/objects, just with nothing pointing at it: a danglingDangling objectAn object nothing points to: not a branch, not another object. A file you git add-ed and then threw away leaves a dangling blob. git fsck lists them until garbage collection removes them. object. git fsck can find it. (This run is in a fresh copy of the demo repo, where main is at f8abff5.)
terminal (real output)
$ printf 'draft one\n' > notes.txt && git add notes.txt # staged…
$ printf 'draft two (never added)\n' > notes.txt # …then edited again
$ git reset --hard
HEAD is now at f8abff5 Merge branch 'search'
$ git fsck --lost-found
dangling blob b4681a290f47f5946d10206612c4b08d2657a3ff
$ cat .git/lost-found/other/b4681a290f47f5946d10206612c4b08d2657a3ff
draft one
The added version came back. "draft two" did not, and never will: it was never added, so git never stored it. Blobs have no file names (§03), so with many dangling blobs you'll be reading through them by content.
Staff-level§11
Under the hood: gc, packfiles and walking history
In one sentenceLoose objects are eventually packed into one compressed file, with similar versions stored as small deltas. Objects nothing can reach are deleted, but only after generous waiting periods.
When objects really disappear
An object survives as long as it is reachableReachable (commit)A commit you can get to by starting at some branch, tag or HEAD and following parent links. git log shows only reachable commits, and garbage collection eventually deletes the unreachable ones.: you can get to it from a branch, a tag, HEAD, the stash, or a reflog entry, by following links. Reflog entries expire, and then git gcgit gc (garbage collection)Housekeeping that git also runs automatically now and then: it packs loose objects into packfiles and deletes unreachable objects once they are older than the expiry windows (by default 2 weeks, after their reflog entries have expired). may delete what was only reachable through them. The default timers (all configurable):
Setting
Default
Meaning
gc.reflogExpire
90 days
Reflog entries for commits still on the branch are dropped after this
gc.reflogExpireUnreachable
30 days
Entries for commits no longer on the branch (reset or rebased away)
gc.pruneExpire
2 weeks
An unreachable object must also be this old before it's deleted
You rarely run git gc yourself: commands like commit and fetch trigger git gc --auto, which does nothing until enough loose objects pile up (about 6,700 by default). Here's the commit 7e7bcd0 from §10 (after resetting it away again, so only the reflog points at it) surviving a normal gc, then being destroyed by force:
terminal (real output)
$ git gc -q # normal housekeeping
$ git cat-file -t 7e7bcd0 # still there? (-t prints the object's type)
commit
$ git reflog expire --expire=now --all # wipe every reflog entry
$ git gc -q --prune=now # and delete unreachable objects now
$ git cat-file -t 7e7bcd0
fatal: Not a valid object name 7e7bcd0
The first cat-file still finds the commit, because a reflog entry protects it. After the reflog is wiped, it's gone for good.
Packfiles: where the space savings come from
Each new object starts as a "loose" zlib file (§04). git gc, git push and git clone bundle objects into a packfilePackfileOne big file (.git/objects/pack/*.pack) holding many git objects compressed together, with an .idx file to find each one fast. Similar objects are stored as deltas. Clones and pushes send packfiles over the network., .git/objects/pack/pack-<id>.pack, plus an .idx index to find each object inside it quickly. Inside a pack, git can store an object as a deltaDelta (compression)Storing one version of a file as “that other version, plus these small edits” instead of in full. Git uses deltas inside packfiles, so two versions of a 32 KB file can cost about 4.8 KB in total. against a similar one.
So "git stores snapshots" (§03) is true at the level of objects. On disk, packs get most of the space efficiency of storing diffs anyway. Here's a 2,000-line, 32,902-byte file committed, then committed again with one line edited:
Notice which version is the full one: ae19ae6 is the newer file (HEAD:big.txt); the older one is the delta. Git prefers to keep recent versions whole because that's what you check out most often, and older versions pay the cost of rebuilding. Deltas are chosen by similarity, not by history: any two similar objects can pair up, even across unrelated files.
How git log walks the graph
git log starts with the commit HEAD points to and puts it in a queue. Then it repeats: take the newest commit from the queue (by commit date), print it, and add its parents. That's a graph walk, and it's why a merge's two sides appear interleaved by date. Options change the walk:
git log --first-parent follows only the first parent of each merge, which on main shows one line per merged pull request.
git log main..feature shows commits reachable from feature but not from main: "what's on my branch that main doesn't have yet". Rebase uses exactly this set to decide what to replay.
git log --all starts from every ref instead of just HEAD.
On huge repos, opening each commit object to read its parents is slow. Git can write a commit-graph file (.git/objects/info/commit-graph) that stores every commit's parents and a "generation number" (its distance from the root) in one compact table. Hosting services and git maintenance keep it up to date; with it, questions like "is this commit an ancestor of that one?" can stop walking early.
Staff-level§12
Staff-level: shared history, force pushes and SHA-256
In one sentenceEvery cloneCloneA complete copy of a repository, with all its history: git clone <url>. Every clone has every commit, so each teammate's laptop holds a full backup of the project. holds its own copy of every commit. Rewriting history only changes your copy and the server's pointer; everyone else still has the old commits, and git will try to bring them back.
Why a rewritten branch breaks other people's clones
git push normally only allows a fast-forward of the server's branch: the commit you push must have the server's current commit somewhere in its history. That guarantees nobody's commits get dropped. After a rebase or amend, your branch no longer contains the server's commits (they were replaced by copies), so a plain push is rejected. (Git says non-fast-forward in that case, or fetch first when, as below, someone else also pushed.)
A force pushForce pushgit push --force: replace the branch on the server with your version, even if that throws away commits. It changes the branch pointer, but copies of the old commits can survive in clones, forks and caches. overrides that check and moves the server's branch to your rewritten commits. Now picture your teammate Sam, who had pulled feature before:
Sam's feature still contains the old commits (f3b77a7, 22ba1cc). The server now has the copies (9c91175, dd021b4).
Sam runs git pull. The two sides have diverged (each has commits the other lacks), and since Git 2.33 a plain pull stops right there with fatal: Need to specify how to reconcile divergent branches. and asks Sam to choose merge, rebase or fast-forward only.
If Sam answers "merge" (git pull --no-rebase, or pull.rebase=false in their config, which many people set once and forget), git merges the two histories. Now Sam has both the originals and the copies: every change twice.
If Sam pushes that merge, the commits you meant to replace come right back.
Here is that story, really run in a tiny test repo with a local server (so the ids differ from the ones above):
▶What a force push does to a teammate's clone
A real session in a tiny test repo with a local server. Same story as the text, smaller ids.
Code
1# you, on feature:
2git rebase main
3git push --force-with-lease
4# Sam, on feature:
5git pull
6git pull --no-rebase
7# the fix, instead of that merge:
8git fetch
9git rebase origin/feature
Server (origin)feature
8b6895b a
5121862 b ← feature
Your clonefeature
8b6895b a
5121862 b ← feature
Sam's clonefeature
8b6895b a
5121862 b ← feature
Everyone has the same feature: commit a, then b (5121862). Meanwhile main on the server got a new commit c (df7fc25).
1 / 6
Sam's terminal (real output, hints trimmed)
$ git pull
hint: You have divergent branches and need to specify how to reconcile them.
…
fatal: Need to specify how to reconcile divergent branches.
$ git pull --no-rebase -q
$ git log --oneline --graph
* a359302 Merge branch 'feature' of /tmp/git-internals/r into feature
|\
| * 8ac3e1a b
| * df7fc25 c
* | 5121862 b
|/
* 8b6895b a
Two commits called b with different ids: the original and your copy, now both in Sam's history. The right move for Sam was git fetch then git rebase origin/feature: git recognizes that 5121862 makes the same changes as the copy and skips it (warning: skipped previously applied commit 5121862). But that takes everyone on the branch knowing what happened. That's why the rule is: rewrite only branches that just you push to, and protect main against force pushes in your hosting settings.
Force push safely: --force-with-lease
Plain --force means "make the server's branch equal mine, whatever is there". If Sam pushed a commit five minutes ago, it's silently thrown away. --force-with-lease--force-with-leaseA safer force push: “replace the branch on the server, but only if it still points where I last saw it.” If a teammate pushed in the meantime, the push is refused instead of deleting their commits. adds a condition: only overwrite the server's branch if it still points where your last fetch saw it.
Here's a real session with a local test server. Sam pushed a commit to feature; meanwhile I reworded my last commit with git commit --amend and want to push it:
terminal (real output, trimmed to the key lines)
$ git push
! [rejected] feature -> feature (fetch first) # ← plain push: refused
$ git push --force-with-lease
! [rejected] feature -> feature (stale info) # ← the lease protects Sam
$ git fetch # (an editor might do this for you)
$ git push --force-with-lease
+ ef2594d...ab39406 feature -> feature (forced update) # ← Sam's commit is gone!
"Lease" means "I'm overwriting the version I last saw, not a newer one". The second command is the lease doing its job. The last one shows its weak spot, explained next.
What a hosting service sees
A force push moves the server's branch, but the old commits don't vanish there either. They stay reachable from open pull requests, forks and the server's own logs, and other people's clones keep them indefinitely. Combined with §11, this gives two practical rules:
Secrets: once pushed, consider them public. Rewriting history (git filter-repo) tidies the repo; rotating the key is what actually protects you.
Ids in tickets and release notes refer to commits that only stay findable if nobody rewrites the branch. Another reason main is merge-only and protected.
SHA-1 and the move to SHA-256
Git was designed in 2005 around SHA-1. After the 2017 SHAttered collision (§04), two things changed:
Hardened SHA-1 (Git 2.13, 2017). Git uses a SHA-1 implementation that detects the known collision technique and refuses such objects. That's what protects ordinary repos today.
SHA-256 repositories. Added as experimental in Git 2.29 (2020). Git 2.42 (2023) stopped calling them experimental, though they still can't exchange commits with SHA-1 repos. You choose the format when you create a repo, and ids become 64 hex characters:
terminal (real output, git 2.43)
$ git init -q --object-format=sha256 s256 && cd s256
$ printf '# Notes app\n' | git hash-object --stdin
c8195cc25bee046631836415d8ba70964cf72f8613a31144f26c580888d05206
$ printf 'blob 12\0# Notes app\n' | sha256sum # same recipe as §04, different hash
c8195cc25bee046631836415d8ba70964cf72f8613a31144f26c580888d05206 -
The same 12-byte README now has a SHA-256SHA-256The standard cryptographic hash function today: it turns any data into a 32-byte (64 hex characters) fingerprint, and changing one byte of the input changes the fingerprint completely. Part of the SHA-2 family (2001); no practical attacks are known. id. Same recipe, only the hash function changed.
Practitioner§13
Check yourself
Predict the answer before you click. Each one is covered above: Q1 in §03–§04, Q2 in §06, Q3 in §08, Q4 in §09–§10, Q5 in §07, Q6 in §12.
QUESTION 1 / 6
Two files, one blob?
Your repo has docs/LICENSE and LICENSE with exactly the same bytes. How many blobs does git store for them?