Git Pond
Nothing gets lost
Git with the lights on. Type real commands and watch what they do: branches are sticky notes, HEAD is a duck, and the commits you think you destroyed are still there, sunk below the line, where the reflog can find them. Twenty-six lessons from git init to bisect and pull requests, twelve challenges, and a bonus: a simulator for how many branches your team should keep.
How this works, and how it's checked
Every command runs in your browser, through a small git written for this page. It stores blobs, trees, commits and tags the way git does and names them with the same SHA-1 hashes, so a commit here has the hash real git would give it. Everyone commits as you, one minute per command from 08:00 UTC on 20 October 2026. That's why your hashes match everyone else's who typed the same thing.
To make sure the drawings tell the truth, the site's checks replay every lesson, every challenge, over a hundred tricky cases and thousands of random command sequences through real git 2.43. After every command, the two must agree on every hash, branch, tag, reflog entry, staged file, conflict stage and working file, on bisect's marks, and on origin. In the latest run of 3,000 random sequences, real git and this page disagreed on 4, about one in a thousand, all in rare corners (things like a cherry-pick stopped over files you've since changed). Every lesson and challenge matches exactly.
The limits are deliberate: one folder with no subfolders, one-line files, no text editor (the rebase todo list gets its own panel), no stash -u, no bisect skip, one remote. Inside those limits it's real git behavior, warnings and refusals included.
If you like this, Oh My Git! by bleeptrack and blinry and Learn Git Branching come at the same ideas as games.
Snapshots
Three places a file lives
Your folder, a staging area, and the commits. Most of git is moving changes between them.
Git keeps your work in three places. The working tree is the folder you see: files you open and change. The staging area (git also calls it the index) is the next commit as it stands right now. And commits are snapshots git has saved for good.
git add copies a file from the working tree into the staging area. git commit saves everything in the staging area as a snapshot. The table next to the terminal shows all three places side by side, so you can watch a change travel across.
echo "text" > file. Real git works line by line in files of any size; one line each keeps every merge on this page easy to read.A commit is a snapshot with a name
Not a list of changes. A complete picture of every file, named by a hash of its own contents.
A commit is a few lines of text: which snapshot it saved (a tree), which commit came before it (its parent), who made it, when, and the message. Its name, the 40-character hash, is the SHA-1 of exactly that text.
The tree lists file names and the hashes of their contents. The contents are blobs. Nothing is stored twice: if app.js didn't change, both commits' trees point at the same blob.
Because the name comes from the contents, changing anything changes the name. One letter in README.md makes a new blob, so a new tree, so a new commit hash. That's why history can't be edited quietly, and why two people with the same commit see the same hash.
ls .git/objects.Staging picks what goes in
You don't have to commit everything you changed. Stage the part that's ready.
The staging area lets you build a commit piece by piece. git status tells you what changed and where: "Changes to be committed" are staged, "Changes not staged" exist only in your working tree.
Stage app.js and commit. The README.md edit stays in your working tree, untouched. Then git restore README.md copies the staged version back over your working file.
That one really does throw work away. An edit that was never staged or committed has nowhere to come back from.
git restore --staged file does the opposite: it unstages and leaves your working file alone. git diff shows what you haven't staged; git diff --staged shows what you're about to commit.Branches
A branch is a sticky note
Making a branch copies nothing. It writes one hash into one small file.
A branch is a name that points at a commit, and that's all it is. git branch feature creates the file .git/refs/heads/feature and writes the current commit's hash in it: 41 bytes. No files are copied, which is why branches in git are instant and free.
HEAD is a file too. Usually it doesn't hold a hash; it holds a branch name: ref: refs/heads/main. That's how git knows which note to move when you commit.
Since a branch is only a note, you can peel it off and stick it somewhere else. git branch -f does exactly that. Watch the commits while you do it: none of them change.
origin/main are remote-tracking notes (see origin), and flag-shaped ones are tags: notes that never move. Click a note to see its file.HEAD is the duck
The duck sits on the note that moves when you commit.
On this page HEAD is a duck. It sits on one note at a time. When you commit, the new commit's parent is wherever the duck is, and the note under the duck moves up to the new commit. Every other note stays put.
git switch login moves the duck to login and changes your files to match that commit. Commit on login, switch to main, commit again, and the history forks. Nothing was copied: both branches share every commit from before the fork.
git switch - goes back to the branch you were just on, like cd - in a shell. Older guides use git checkout for all of this. switch does the branch half of checkout's job, with fewer surprises.Detached HEAD is not broken
It means the duck is sitting on a commit instead of on a note.
git switch --detach HEAD~2 puts the duck straight onto the commit two steps back (HEAD~2 means the parent of the parent of HEAD). Git's message sounds alarming, but this is a useful state: look at an old version, run it, try something.
You can even commit. But no note follows you, because the duck isn't on one. Switch away and git warns you: the commit you made is now attached to nothing. The graph shows it sinking.
It isn't gone. The reflog remembers where HEAD was a moment ago, so HEAD@{1} is that commit. Put a branch on it and it's safe.
git branch name <hash> works just as well. Git does clean up commits that nothing points at, but only with git gc, and by default not for about 30 days.Combining work
Merging: slide the note, or tie a knot
If one branch is simply further along, git slides the note. If both moved, it makes a commit with two parents.
main hasn't moved since style branched off, so style's history already contains all of main. Merging needs no new commit: git slides main up to where style is. That's a fast-forward.
login is different. main has commits login doesn't, and login has commits main doesn't. Git finds the newest commit they share (the merge base), works out what each side changed since then, and combines both into a new snapshot. The merge commit records two parents, so both histories stay reachable from it.
git merge --no-ff makes a merge commit even when a fast-forward would do. Some teams prefer that, because history then shows where each branch came in.A conflict is a question, not an error
When both sides changed the same line, git stops and asks which version you want.
Git merges line by line. Changes to different files, or to different lines, combine on their own. When both branches changed the same line in different ways, git can't know which one you want. So it stops halfway through the merge and writes both versions into the file, between markers:
<<<<<<< HEAD render(header) ======= render(footer) >>>>>>> footer
The top half is yours (HEAD), the bottom is theirs. Make the file say what it should: one side, the other, or something new. git add marks the file as settled, and git commit finishes the merge with the message git already wrote.
git merge --abort puts everything back as it was before the merge. The prompt says MERGING while you're partway through one.Rebase makes copies
It doesn't move your commits. It makes new ones on top of main and moves your note to them.
A commit can't change: its hash comes from its contents, and its parent is part of the contents. So rebase can't move a commit. What it does is replay each of your commits on top of main, one at a time, making a new commit each time: same change, same message, same author, new parent, new hash. Then it moves the login note to the last copy.
The originals don't disappear. Nothing points at them any more, so the graph shows them sunk, and the reflog still lists them.
Now main can fast-forward to login, and history is a straight line.
Undoing things
Reset moves the note (and maybe your files)
--soft moves only the note. --mixed also resets the staging area. --hard also overwrites your files.
git reset moves the branch under the duck to another commit. The three modes differ in how much else they touch:
--soft: only the note moves. Everything the skipped commit changed is still staged, ready to commit again.--mixed, the default: the note moves and the staging area is reset to match. The changes are still in your files, unstaged.--hard: the note moves, and the staging area and your files are overwritten to match.
Watch the table as you go. After all three, three commits look destroyed.
--hard is the only one that can lose work, and only work that was never committed. Committed work is in the reflog. That's next.The reflog remembers everything
Every time HEAD or a branch moves, git writes down where it was.
git reflog lists every place HEAD has been, newest first: each commit, switch, merge, rebase and reset, and what did it. HEAD@{0} is now, HEAD@{1} is one move ago, and so on.
Read down to the entry just before the first reset: HEAD@{3}. Reset to it and the three commits come back exactly as they were, with the same hashes. The same trick works after a bad rebase, a deleted branch, or a detached HEAD you wandered away from. If you committed it, it's in there.
reset --hard or restore, git never had them. Commit early; you can tidy up afterwards. Also handy: git reset --hard ORIG_HEAD undoes the last reset, merge or rebase, because each of them saves the old position there first.Bring back one file
You don't have to roll back a whole commit to get one file the way it was.
First, find out what happened. git log -- style.css lists only the commits that changed that file. git blame style.css shows, for each line, the commit and author that last touched it.
git show HEAD~3:style.css prints the file as it was three commits ago without touching anything. When you know which version you want, git restore --source=HEAD~3 style.css writes it into your working tree. Everything else stays as it is, and nothing is committed until you say so.
The same works for a file that was deleted: restore it from a commit where it still existed.
git checkout HEAD~3 -- style.css is the older spelling, and it also stages the file. With restore, add --staged --worktree for the same effect.Fix the last commit
--amend replaces the last commit with a corrected one.
Forgot a file, or made a typo in the message? Stage what's missing and run git commit --amend. Git makes a new commit with the same parent, your corrected snapshot, and the message you give it (or the old message, with --no-edit). The note moves to the new commit and the old one sinks.
It feels like editing, but it's a replacement, and the graph shows it: the hash changes.
Squash three commits into one
Reset --soft back three commits, then commit once.
--soft moves the note and leaves every change staged. Move it back three commits and the staging area holds the combined changes of all three. One commit saves them all.
The three "wip" commits sink, and the reflog keeps them in case you change your mind.
Rewrite history with rebase -i
A todo list of your recent commits. Reorder it, merge lines together, rename, or delete, and git replays it.
git rebase -i HEAD~4 opens a list of the last four commits, oldest first, each with an action. Git then works down the list, replaying each commit the way rebase does, and doing what the action says:
- pick: keep it as it is.
- reword: keep it, with a new message.
- squash: melt it into the commit above it, and keep both messages.
- fixup: melt it into the commit above, and throw its message away. For "fix typo" commits.
- drop: leave it out entirely.
Moving a line moves the commit. A commit that lands where it already was is kept as is, not copied. Everything after the first change gets a new hash, and the graph shows the old ones sinking.
git rebase --abort puts everything back, and the reflog has the originals regardless.Undo a commit other people already have
Revert adds a new commit that does the opposite. History only grows.
"turn on debug mode" is already on origin, and other people may have pulled it. Resetting it away would rewrite history they share. git revert works out the opposite of that commit's change and commits it on top. Everyone else just pulls one more commit.
Revert can undo any commit, not only the newest. If later commits touched the same lines, you get a conflict to settle, the same way as in a merge.
Juggling work
Stash: park your work
Put half-finished changes aside, do something else, then take them back.
git switch won't leave if the other branch would overwrite your uncommitted changes. You could commit half-done work. Or you can stash it.
git stash saves your changes as commits, and the graph shows them: one for your working tree, whose second parent holds what you had staged, with a note called stash on it. Then it puts your files back to match HEAD. Make the fix on main, come back, and git stash pop applies the changes again and removes the note.
git stash -u in real git, but not on this page), so add them first.A shelf of stashes
Stashes stack. Name them, pick one out, or turn one into a branch.
Every git stash goes on top of a stack: stash@{0} is the newest, stash@{1} the one before. git stash push -m "…" gives an entry a name you'll recognize in git stash list.
git stash branch name stash@{1} is the tidy way out for an idea that grew up. It makes a branch at the commit you were on when you stashed, applies the stash there (so it can't conflict with anything that happened since), puts back what you had staged, and drops the entry.
git stash apply applies without removing the entry; git stash drop removes without applying. Dropped stashes are commits like any other, and stay in the object store for a while.Cherry-pick: copy one commit
Take one commit's change from anywhere and apply it here as a new commit.
Cherry-pick replays one commit on top of HEAD, the same way rebase replays each of its commits: same change, same message, same author, new parent, new hash. The original stays where it was.
experiments~1 means one commit back from the tip of experiments. The hash from git log works too, and so does a list: git cherry-pick a b c.
Find the commit that broke it
Tell git one good commit and one bad one. It halves the suspects each time you answer.
Something worked at v1.0 and doesn't now. Reading nine commits is slow; testing three is fast. git bisect start, then git bisect bad (now is broken) and git bisect good v1.0 (that was fine). Git puts HEAD on the commit halfway between and waits.
Test it, answer good or bad, and the suspects halve. The graph keeps score: ✓ good, ✗ bad, and commits that are no longer suspects fade. Nine commits take at most four answers; a thousand would take ten.
If the test is a command, git can run it for you: git bisect run grep -q footer app.js marks each commit by the command's exit code. git bisect reset takes you back where you started.
Working with others
origin is just another repository
Push and fetch copy commits between repositories. Remote-tracking notes record what you saw last time.
origin is a copy of the repository on a server, with its own commits and its own branches. The top band of the graph is origin, the bottom is your laptop, and a commit sits in the same column in both.
origin/main on your laptop is a note too, but you can't commit on it. It records where origin's main was the last time you talked to origin, and only push, fetch and pull move it.
That's why Sam's push doesn't show up for you: nothing on your laptop changes until you fetch. Fetch downloads the new commits and moves origin/main. Your own main stays put until you merge.
git status says "ahead by 1" or "behind by 1", it's comparing with origin/main, which is what you last fetched, not what's on the server right now.Pull when you have both diverged
Pull is fetch, then merge or rebase. When both sides have new commits, git makes you choose which.
Your push is refused: origin has a commit you don't have, and taking your push would throw it away. You need Sam's commit first.
A plain git pull now stops and asks how to combine the two histories (git has done this since 2.33). git pull --rebase replays your commit on top of Sam's, for a straight line. git pull --no-rebase makes a merge commit. Both are fine; teams usually pick one.
Rebasing is safe here because your commit was never pushed. Nobody else has the original.
git config pull.rebase true (or false for merges), and a plain git pull will use it.The pull request round trip
Branch, push, review, merge on the server, pull, clean up. Most teams do this loop many times a day.
A pull request isn't a git feature; it's the hosting site's page for "please merge my branch". Under it is plain git. You push a branch. When someone approves, the site makes the merge commit on origin, on its own copy of main. Nothing on your laptop changes.
So you come home: switch to main and pull, and the merge commit (with Sam as its author) arrives. Your branch is fully merged now, so git branch -d deletes it without complaint.
Sam also deleted the branch on origin, but your origin/dark-mode note doesn't know that. Notes for remote branches are only updated when you talk to origin, and a plain fetch never removes one. git fetch --prune does.
git branch -d refuses and you need -D. That's expected, not lost work.Force push: the one that can lose work
A force push moves origin's note without asking. Anything only origin had is gone from origin.
After the rebase, your login branch holds copies, and origin's login still holds the originals. A normal push is refused, because it would drop commits from origin's branch.
git push --force pushes anyway and moves origin's note to your copies. If someone pushed to that branch meanwhile, their commit is now attached to nothing on the server, and servers keep no reflog. As far as origin is concerned, it's gone. It still exists on their laptop, if they notice in time.
--force-with-lease is the safe version. It only overwrites origin's branch if it still points where your origin/login note says. Sam pushed after your last fetch, so the lease fails. Fetch, keep Sam's work by cherry-picking it onto your rebased branch, and then the lease holds.
--force-with-lease a habit and keep plain --force off shared branches. In the sandbox at the top, you can try a plain --force and watch Sam's commit sink on the server.The mess
Two classic accidents at once. Everything you need is in the lessons above.
You made two commits on main that belong on a branch of their own. And before that, a reset --hard sank the password-reset commit, which main needs.
For the first: put a note on your work before you move main away from it. git branch login, then reset main back two commits. The work stays safe on login.
For the second: the reflog has the password reset's hash. Cherry-pick it onto main.
Practice
Challenges
No instructions this time. Make your repo look like the target, in as few moves as you can.
Each challenge shows a target graph: the notes you should end up with, on the commits they should point at. Commits are matched by what they are (message, snapshot and parents), not by hash, since hashes depend on how many commands you typed. Par is the shortest solution we know. Commands that only look, like status, log and reflog, don't count as moves.
Bonus
Branch strategy: how many branches is too many?
You know what a branch is now. Here's what a team's branches cost.
A team, twelve weeks of work, and two ways to branch. The same people build the same features and fight the same production fires under both. Only the road to production changes. Pick two strategies, press play, and watch where the time goes: conflicts, waiting, and changes lost in rushed merges.
How this works, and what it assumes
The work. It's generated once from the batch number, and both strategies get exactly the same stream. Each developer has a queue of features (a day or longer; the average is a knob, and long ones are rarer) and small fixes of two to eight hours. They commit every four hours of work. Production hotfixes (three hours) and bugs testers find (two hours) arrive at random, at the rates you set, and whoever is next on the rota drops what they're doing.
The code. It's modelled as a number of places where two changes can collide. Think "the same few lines of the same file", not "the same file". A few places are hot and most are quiet. Each feature touches two to six of them, and each commit touches one or two.
The history is a real commit graph. A merge finds the merge base the way git does, then merges three ways, place by place. If only one side changed a place since the base, that side wins. If both sides changed it differently, it's a conflict. A cherry-pick applies one commit's change to another branch as a brand new commit. Cherry-picking a change that's already there does nothing, as in git.
Conflicts cost time. Each conflicting place costs 6 minutes to look at, 3 more for every change on either side since the merge base, and about a minute more for every pair of changes (one from each side) that have to be reconciled with each other, up to 4 hours. That last part is why conflicts grow faster than the branches that cause them: twice the changes on each side means four times the pairs. The developer whose branch it is pays. Promotions and back-merges are paid by whoever's on the rota. That time comes out of building.
Some conflicts are settled badly. A small share (a knob) are settled by keeping one side whole. The other side's changes in that place are gone, and later merges won't bring them back, because as far as git is concerned they were merged.
The one number that was tuned: with the default knobs, a trunk-based team of six sees about three conflicts a week, most of them small. Everything else follows from the rules above. Nothing was set per strategy.
The strategies.
- Trunk-based. Each developer merges the day's work into main every evening, finished or not (assume unfinished work is behind a feature flag). Main deploys every evening.
- GitHub flow. One branch per feature off main, merged after the review wait, deployed on merge.
- GitFlow. Features merge into develop. On the release cadence a release branch is cut from develop, tested (bugs found are fixed on it), merged into main and deployed, then merged back into develop. Hotfixes branch from main, merge into main, then go down into develop and any open release.
- Environment branches, merge up. Features merge into dev. Dev is merged into the next branch every few days (a knob), middle branches weekly, and the last one into main on the release cadence. Bugs testers find are fixed on the branch being tested and merged back down.
- Environment branches, cherry-pick up. Features branch off main and merge into dev. At each promotion window, each change waiting on a branch is signed off with the chance you set, and the signed-off ones are cherry-picked up one commit at a time, oldest first. Hotfixes and tester fixes are cherry-picked back down.
What it leaves out: testing time, review quality, flaky deploys, how much easier small changes are to review, the cost of feature flags, and the way people adapt to whatever hurts. It isn't a forecast for your team. Use it to compare roads on the same work, and to see why they differ.
How it's checked. The site's build checks run every strategy over many batches and confirm that:
- the same batch always plays out the same way;
- every conflicting place obeys the three-way rule, recomputed from scratch;
- with bad settlements switched off, no strategy ever loses a change;
- every commit's change is accounted for;
- every cherry-pick is a copy of a change that already existed;
- the patterns described in B6 hold in every batch checked.
Same work, two roads
Branching strategy is usually argued in the abstract. Here it's an experiment with a control.
Each timeline above is one strategy. The lanes are the long-running branches, with production (main) at the top. Under them, one thin row per developer shows each branch they worked on: the solid part is work, the dotted tail is waiting for review. Ticks above main are deploys.
Press play and watch both at once. Rust rings are conflicts: the bigger the ring, the longer it took to untangle. The line under each timeline counts what's finished but still waiting to reach production, and anything production has that the branches below it don't.
The default pits four environment branches (dev, qa, uat, main), promoted by cherry-picking each change as it's signed off, against trunk-based development. That's the question this bonus started from.
Long-lived branches drift
A branch is a bet that nobody else will change the code it touches before it merges back.
The longer a branch lives, the more changes pile up on both sides of its merge base, and the more of them land in the same places. When it finally merges, each conflict is also bigger, because there are more changes to understand at once.
Trunk-based teams merge every day, so each bet is small. Count conflicts and they can look similar to a feature-branch team: trunk merges more often, so it has more chances to collide. Look at the hours instead. Small, fresh conflicts are cheap.
A long-running branch is the same bet that never gets settled. Develop drifts away from main between releases, and a release branch drifts away from develop while it's in test.
Cherry-pick promotion makes copies
The same change, with a different commit id on every branch, applied in a different order on each.
Promote environment branches by merging, and everything on dev goes up together, ready or not. So most teams with environment branches promote by cherry-picking only the changes that were signed off.
Every cherry-pick is a new commit holding the same change. Git can no longer tell from the history that qa and dev share it. It has to compare content, place by place. Changes also go up in a different order than they were built, so a change often lands on a branch that's missing something it was built on top of. That's a conflict even if nobody else touched the code.
Try it with a single developer, no review wait and no interruptions. There's nobody to collide with, and cherry-pick promotion still produces conflicts. Merge promotion produces none.
The hotfix that has to go down every branch
Production breaks. The fix goes on main and ships. Now every branch below main is missing it.
Someone has to carry the fix down to uat, then qa, then dev, one branch at a time. Miss one, and that branch keeps testing code that production no longer runs. Testers chase a bug that's already fixed, or sign off on a build that's about to lose the fix.
With one long-running branch there's nothing to carry down. Every extra branch is one more place to forget. The blue X marks on the timeline are back-merges nobody did, and the line under each timeline counts branches still without a production fix.
Git doesn't lose changes. Tired people do.
A merge never drops a change by itself. Settling the fortieth conflict of the week by keeping your side does.
Set "conflicts settled by keeping one side" to zero and nothing is ever lost, in any strategy. The build checks confirm that. Changes vanish when someone faced with a conflict keeps one side whole to get it over with.
Git then records the merge as done. The next time those branches merge, git sees the dropped change as already merged, so it won't come back. Nobody finds out until a customer does.
Every strategy gets the same small chance of a bad settlement per conflict. The more conflicts a strategy produces, the more of those chances it takes.
How many branches is too many?
Every strategy here, on the same work as the simulator above. It follows your knobs.
With the default knobs, two patterns hold in every batch the build checks run. Each extra environment branch adds its promotion delay to every change, so the wait for production climbs with every lane. And how changes move between branches matters as much as how many branches there are. Merging up mostly costs waiting. Cherry-picking up costs the same waiting plus at least twice the conflict time, at every branch count, and on average that conflict time grows with every lane too.
Change the knobs and see what holds. A tiny team with rare releases looks different from twenty developers shipping weekly.
When environment branches make sense, and what to do instead
Environments are places to run code. They don't have to be branches.
Long-running branches do have legitimate uses:
- A regulator or a contract requires a named, frozen release to be signed off before it ships. A release branch or a tag does that. A permanent qa branch isn't needed.
- You support several released versions at once. That's what release branches are for, and they live long on purpose. Fixes flow one way, from main into each release, cherry-picked and labelled. Nothing flows back.
What usually works better than one branch per environment:
- One main branch, merged into often.
- Build once, then promote the build. The exact artifact that passed qa is the one that goes to uat and production. Code isn't promoted between branches.
- Sign-off is a gate in the deploy pipeline, not a merge.
- Unfinished work hides behind a feature flag, so it can merge early without shipping.
- A release branch or tag when something needs to be frozen.
If you have four environment branches today, the simulator is a cheap way to show your team what they cost, using numbers close to your own: set the team size, the release cadence and the hotfix rate, then copy the link.
Undo anything
The whole page on one card.
| I want to… | Run |
|---|---|
| Unstage a file | git restore --staged file |
| Throw away edits to a file (gone for good) | git restore file |
| Get one file back from an old commit | git restore --source=<commit> file |
| Fix the last commit | git add … then git commit --amend |
| Undo the last commit, keep its changes | git reset --soft HEAD~1 |
| Squash, reorder, reword or drop recent commits | git rebase -i HEAD~N |
| Undo a commit that's already pushed | git revert <commit> |
| Undo the merge, rebase or reset you just did | git reset --hard ORIG_HEAD |
| Get back a "lost" commit | git reflog, then git branch rescue <hash> |
| Get out of a merge conflict | git merge --abort |
| Get out of a rebase | git rebase --abort |
| Find the commit that broke something | git bisect start, bad, good <commit> |
| Move the last N commits off main onto a branch | git branch new && git reset --hard HEAD~N |
| Push after a rebase or amend | git push --force-with-lease |
| Drop notes for branches deleted on origin | git fetch --prune |
A branch is a note. A commit never changes. The reflog remembers. Everything else is detail.