Skip to content
Software Atlas

Git, From the Inside

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 commit is a full picture of your project, named by a fingerprint of its contents. A branch 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:

  1. Adds snapshots. A commit is written once and never changed. "Changing" history really means making new commits and moving a sticky note.
  2. 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.
  3. 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
commit AFirst commitcommit Bedit READMEcommit Cadd search.jstreesnapshot Atreesnapshot Btreesnapshot CREADME v1blobapp.jsblobREADME v2blobsearch.jsblob
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 Terminal, 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 repository ("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 tree. Git never changes them unless you run a command that says so.

Every repo starts with one branch, 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 commit
git 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 remote, named origin by default. git clone <url> makes your own full copy (a clone), 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 object 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 blob.
2

Tree

One folder: a list of entries, each with a name, a type, and the id of a blob (file) or another tree (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)
setup.sh
export GIT_AUTHOR_NAME=Ada GIT_AUTHOR_EMAIL=ada@example.com
export GIT_COMMITTER_NAME=Ada GIT_COMMITTER_EMAIL=ada@example.com
git init -q -b main demo && cd demo
echo "# Notes app" > README.md
mkdir src && echo 'console.log("hi");' > src/app.js
git add .
GIT_AUTHOR_DATE=1790845200 GIT_COMMITTER_DATE=1790845200 git -c commit.gpgsign=false commit -q -m "First commit"
echo "A tiny notes app." >> README.md
git add README.md
GIT_AUTHOR_DATE=1790848800 GIT_COMMITTER_DATE=1790848800 git -c commit.gpgsign=false commit -q -m "Describe the app"
git log --oneline

The export lines and the GIT_…_DATE=… prefixes fix the author name and the time (as Unix time), 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 folder
100644 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.
HEADmainrefs: tiny filesparenttreetreeREADME.mdsrcsrcREADME.mdapp.jscommit 5fe3c9cDescribe the appcommit 3fdb89cFirst committree 1a1b051/ (in commit 2)tree b09015d/ (in commit 1)tree f901409src/blob 20b449dREADME.md v2blob 522d933README.md v1blob d914c60app.js
$ 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 same src 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 pipe | 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 function 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-1, which produces 20 bytes, written as 40 Hexadecimal characters. If hash functions are new to you, Hashing & Encryption §03 explains them with a live demo.

The recipe

To get a blob's id, 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"
)

func main() {
	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 zlib 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 storage: an object's address is computed from what it contains. Three consequences fall out of it for free:

  1. Deduplication. Same bytes → same id → stored once. Ten copies of the same license file cost one blob.
  2. Objects can't change. "Editing" an object would change its id, which makes it a different object. So every object is Immutable, and git can cache, copy and share them without ever checking whether they're stale.
  3. 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 tree: 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
Working treethe files you editIndex.git/index · next commitRepository.git/objects · all commitsgit addgit commitgit restore fgit restore --staged fgit switch / reset --hard copy a whole commit into both index and working tree
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 index is a real file, .git/index. It holds one entry per tracked file (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:

terminal (real output)
$ git ls-files --stage        # show the index: mode, blob id, stage number, path
100644 20b449d66c75edc829bae815eb57e3b139228b6b 0	README.md
100644 d914c6066c43346e10107448c111670dbfd43a4f 0	src/app.js

(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 ref; branches live under refs/heads/, tags under refs/tags/.

HEAD sits one step further back: HEAD points at a branch name, and the branch points at a commit. When you commit, git:

  1. writes the new commit with parent = the commit the current branch points at;
  2. 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
mainfeature0c6d2d1 First commit0c6d2d1First commit4214ac7 Describe the app4214ac7Describe th…HEAD → mainfeature
● 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 HEAD. 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 merge 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 ancestor of feature's 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-forward.

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, 1 insertion(+)
 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:

  1. Ours: the tip of the current branch (1399b36).
  2. Theirs: the tip of the branch being merged (22ba1cc).
  3. The base: the most recent commit both share, here 5fe3c9c. That's the merge base; 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 merge. 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 conflict:

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 rebase 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)
5fe3c9cDescribe1399b36Say hellof3b77a7Add search22ba1ccTrim queryf8abff5Merge (2 parents)main
git rebase main (on search)
5fe3c9cDescribe1399b36Say hello9c91175Add searchdd021b4Trim queryf3b77a7reflog only22ba1ccreflog onlysearchmain
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
mainfeature0c6d2d1 First commit0c6d2d1First commit4214ac7 Describe the app4214ac7Describe th…cca2d10 Add searchcca2d10Add search1781c2a Trim the query1781c2aTrim the qu…01f6567 Say hello01f6567Say helloHEAD → mainfeature
● 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 commitsuntouched, same idsreplaced by new copies with new ids
New commitsone merge commit (two parents), unless fast-forwardone copy per replayed commit
History shapeforks and joins, shows what really happenedone straight line
Safe on a shared branch?yesno: others still have the old commits
Conflictsresolved once, in the merge commitmay 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 request.

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> 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.)

CommandBranch movesIndex resetFiles overwrittenTypical use
git reset --soft HEAD~1yesnonoUndo the last commit but keep it staged, to recommit differently
git reset HEAD~1 (--mixed)yesyesnoUndo the last commit and unstage; keep all edits in your files
git reset --hard HEAD~1yesyesyesThrow away the last commit and any uncommitted edits
git reset --hard (no commit)noyesyesThrow 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> 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 reflog 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_HEAD, 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 dangling 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 reachable: 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 gc may delete what was only reachable through them. The default timers (all configurable):

SettingDefaultMeaning
gc.reflogExpire90 daysReflog entries for commits still on the branch are dropped after this
gc.reflogExpireUnreachable30 daysEntries for commits no longer on the branch (reset or rebased away)
gc.pruneExpire2 weeksAn 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 packfile, .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 delta 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:

terminal (real output)
$ git verify-pack -v .git/objects/pack/*.idx | grep blob
ae19ae63acf6d62fc6982f9b7af0eac12b83e232 blob   32902 4782 259
5527f08830602c2f6f4a138a18c3068d2e11bada blob   14 25 5132 1 ae19ae63acf6d62fc6982f9b7af0eac12b83e232

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 clone 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 push overrides that check and moves the server's branch to your rewritten commits. Now picture your teammate Sam, who had pulled feature before:

  1. Sam's feature still contains the old commits (f3b77a7, 22ba1cc). The server now has the copies (9c91175, dd021b4).
  2. 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.
  3. 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.
  4. 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 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:

  1. 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.
  2. 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-256 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?

QUESTION 2 / 6
What does a branch cost?
git branch experiment
cat .git/refs/heads/experiment
QUESTION 3 / 6
Rebase and ids

On feature you have commits f3b77a7 and 22ba1cc. You run git rebase main, and main has one new commit. What happens to those two ids?

QUESTION 4 / 6
Which reset loses work?

You edited app.js but never ran git add. Which command throws that edit away for good?

QUESTION 5 / 6
Merge or fast-forward?

main hasn't moved since you branched feature off it and made 3 commits. On main you run git merge feature. What does git create?

QUESTION 6 / 6
Safe force push

You rebased your own branch, already pushed once, and must push again. A teammate might have pushed to it too. What should you run?

Answered 0 / 6
Foundations§14

Cheat sheet

📸
Commits are snapshotsA commit points at one tree (the whole project) and its parent(s). Diffs are computed on demand.
🔑
Ids are content hashesBlob, tree and commit ids are SHA-1 hashes of their content. Same content, same id; changed content, new object.
🏷️
Branches are pointersA branch is a file with one commit id. HEAD names the current branch. Detached HEAD = HEAD holds an id directly.
📦
Index = next commitgit add writes the blob and stages it. git commit saves exactly what's staged.
🔀
Merge adds, rebase copiesMerge: new commit with two parents (or a fast-forward). Rebase: new copies with new ids. Don't rebase shared commits.
⏪
reset strengths--soft moves the branch. --mixed also resets the index. --hard also overwrites your files.
🛟
Reflog firstLost a commit? git reflog, git show <id>, then git branch rescue <id>. Kept 30–90 days by default.
🕳️
What's truly goneEdits never added or committed, once overwritten. Commit early; stash before risky commands.
I want to…Command
See what an object containsgit cat-file -p <id> (-t for its type)
See the whole graphgit log --oneline --graph --all
Unstage a file, keep my editsgit restore --staged <file>
Throw away my edits to a filegit restore <file> (unrecoverable if never added)
Redo the last commitgit reset --soft HEAD~1, or git commit --amend
Bring my branch up to date with maingit rebase main (unshared branch) or git merge main
Undo a commit that's already pushedgit revert <id>
Get back a commit after reset --hardgit reflog, then git reset --hard <id>
Undo a rebasegit reset --hard <branch>@{1} (or ORIG_HEAD right after)
Recreate a deleted branchgit branch <name> <id> (id from the delete message or the reflog)
Find a lost staged filegit fsck --lost-found, then look in .git/lost-found/other/
Push a rebased branchgit push --force-with-lease --force-if-includes
Show the merge base in conflictsgit config --global merge.conflictStyle zdiff3
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Developer FoundationsThe Command Line
Read →