Team training · Block 2 of 6
Undo and recovery
Destroying your work four ways, and getting it back
Frame it honestly: this is the block that exists because we have lost work before. Everything here is still local — nothing you do can affect a colleague yet. That's deliberate; you learn to break things where the blast radius is zero.
Promise up front: we will destroy commits four different ways and recover from all but one. Make them notice which one we can't recover, because that's the whole argument for commandment 1.
~49 minutes.
2.1
The safety net
~8 minutes, concept. This slide set changes how people feel about git, which matters more than any single command.
Git is almost append-only
Commands that look destructive usually just move a pointer .
The commit object stays on disk. What changes is whether anything still points at it.
A commit nothing points to is unreferenced — invisible to git log, but very much still there.
The central claim of the block. Say it plainly and let it land before demonstrating.
Tie back to Block 1: commits only point backwards to their parents. Nothing points forwards. So when a branch label moves off a commit, there's no remaining path to reach it by walking the graph — and git log walks the graph. It's invisible, not deleted.
Analogy that works: deleting a file on disk usually just removes the directory entry. The bytes are still there until something overwrites them. Same idea.
The label moves — the reflog remembers
91ac5d3
e91e334
c584509
f6c6987
main ← HEAD
main ← HEAD
unreferenced — still on disk
$ git reflog
e91e334 HEAD@{0}: reset: moving to HEAD~2
f6c6987 HEAD@{1}: commit: Add end-to-end invoice_total <- the tip that just fell off
c584509 HEAD@{2}: commit: Add tax calculation
e91e334 HEAD@{3}: commit: Add README
91ac5d3 HEAD@{4}: commit: Add discount helper
The label moved and two commits fell off the walk — but git reflog still lists every one by the same id. Matching colours tie each node to its journal line: f6c6987 is orphaned in the graph, yet sitting right there at HEAD@{1}.
This slide carries the whole safety-net argument, so build it in three beats.
Beat 0 — the graph: four commits, main (and HEAD) on the tip f6c6987. Point at where main is; that's the thing about to move.
Beat 1 — the reset: ask first "if I move main back two commits, what happens to the two on the right?" People predict "deleted". Advance: main jumps back to e91e334, and c584509 + f6c6987 get the dashed ring — orphaned, not gone. Say the garbage-collection fact honestly: unreachable objects survive ~90 days (and reflog keeps them reachable), so there's no rush.
Beat 2 — the reflog: git log walks the graph and would never show the orphaned pair; git reflog reads a local journal of where HEAD has been, so it still lists them. The colours are the point of the redesign — the amber f6c6987 node is the amber f6c6987 at HEAD@{1}. Trace one across with your finger.
HEAD@{1} means "one HEAD movement ago", not "one commit back" (HEAD~1). People conflate them constantly — call it out. And note e91e334 appears twice in the journal, same colour both times: same commit, two moments.
Two caveats: the reflog is local only, never pushed or fetched, and a fresh clone starts nearly empty. It saves you from your own mistakes, not a colleague's.
2.2
reset
~16 minutes. The command with the worst reputation in git, mostly because three very different operations share one name.
One command, three depths
Working dir
Staging
Repository
your edits
what's queued
branch pointer
--soft
--mixed
--hard · reaches all the way into your working directory
All three move the branch pointer. How far left they reach is the only difference.
Ask before advancing: given the three trees, how many distinct useful behaviours could a reset have? The answer falls out — one per tree.
Advance through soft, mixed, hard. Each bar extends further left.
The single sentence to leave them with: every reset moves the branch pointer; the flag decides how much of your current state gets dragged along with it.
--mixed is the default. If someone types git reset with no flag, that's what they get.
--soft — uncommit, keep everything staged
$ git reset --soft HEAD~1
$ git status
Changes to be committed:
modified: invoice.py
The commit is gone from the branch. Its contents are sitting in staging, ready to recommit.
Use it for: "I committed too early" or "these two commits should be one".
The gentlest of the three and the most underused. Nothing is at risk.
Demo: commit something, soft reset, show status still holds everything staged, then recommit with a better message. That round trip makes it click.
Note this is one legitimate way to squash — though interactive rebase in Block 3 is usually easier for more than a couple of commits.
--mixed — uncommit and unstage
$ git reset HEAD~1 # --mixed is the default
$ git status
Changes not staged for commit:
modified: invoice.py
Commit undone, staging cleared. Your edits are still in the working directory, untouched.
Use it for: "undo that commit, let me restage things differently".
Still completely safe — no edits are lost, they just move back one tree to the left.
Point out the flag is optional. That's worth knowing because most Stack Overflow answers say "git reset HEAD~1" without explaining that mixed is implied.
Pair it with add -p from Block 1: mixed reset then selectively restage is the standard way to split one commit into two.
--hard — the one that bites
$ git reset --hard HEAD~2
HEAD is now at e91e334 Add README
$ git status
nothing to commit, working tree clean
Two commits off the branch and your working directory overwritten to match.
The commits are recoverable. Uncommitted edits it overwrote are not.
Slow down here. This is the command in the incident reports.
The crucial split, and the reason this slide is fragmented: the COMMITS survive as unreferenced objects and we recover them on the next slide. Any working-directory changes that were never committed are simply gone — git never had a copy, so there is nothing to restore from.
That asymmetry is the entire argument for commandment 1. Say it explicitly: commit early and often means "keep your work inside the recoverable zone".
Recovering the commits
$ git reflog
e91e334 HEAD@{0}: reset: moving to HEAD~2
f6c6987 HEAD@{1}: commit: Add end-to-end invoice_total <- there it is
$ git branch rescue HEAD@{1} # non-destructive: just a new label
$ git log --oneline --all --graph
$ git reset --hard rescue # once you've verified
Create a label first, verify, then move. Don't fix a panic with another --hard.
Run this live against demos/reflog. The tested sequence: setup.sh builds five commits, reset --hard HEAD~2 destroys two, reflog shows all six entries, branch rescue HEAD@{1} recovers.
Push the "branch first, verify, then move" habit hard. Recovering with another reset --hard works but gives you no chance to check you grabbed the right commit — and you're already flustered.
Optional flourish: git cat-file -p HEAD@{1} between the panic and the rescue proves the object was never deleted. That's the actual lesson, rather than "there's a magic undo command".
When it looks broken
Commandment 09
When it looks broken, stop. Check git reflog, then ask. Improvising turns a recoverable mess into a lost afternoon.
Nearly every unrecoverable situation started as a recoverable one that someone kept typing at.
The most important non-technical rule in the course.
The failure pattern is always the same: something unexpected happens, the person tries three half-remembered commands from a search result, and each one moves pointers further from the recoverable state. The commits are usually still there after step one; by step four the reflog is cluttered and the person can't say what they did.
Give explicit permission: stopping and asking is the senior move, not the junior one. Nobody is judged for it.
2.3
Detached HEAD
~4 minutes. Mostly about defusing the scary message.
It's a state, not an error
$ git switch --detach c584509
Note: switching to 'c584509'.
You are in 'detached HEAD' state. You can look around, make
experimental changes and commit them...
91ac5d3
c584509
f6c6987
main
← HEAD
HEAD → main → commit
HEAD
HEAD → commit — no branch in between
Normally HEAD → branch → commit. Detached means HEAD → commit directly, with no branch in between.
Commits you make here belong to no branch. Switch away and they become unreferenced.
Callback to Block 1's two-levels-of-indirection slide. Detached HEAD is simply that indirection removed.
Drive the graph as you say it. Start state: main on the tip f6c6987, and HEAD hanging off main — the normal two-hop "HEAD → main → commit". Point at the two arrows.
Advance 1 — git switch --detach c584509: HEAD lets go of main and lands directly on the older commit c584509 (amber ring). main hasn't moved; it's still on f6c6987. The middle hop is gone — HEAD now names a commit, not a branch. That's the whole of what "detached" means.
Completely legitimate for looking at an old state — checking out a commit to test whether a bug existed then, for instance.
Advance 2 — the risk: commits made from here have nothing pointing at them once you leave, because main never came along. And by now the room knows how that story ends: reflog.
Getting out
$ git switch -c experiment # keep the work: give it a branch
$ git switch main # discard it: just leave
If you made commits and left without branching, git reflog still has them.
Two exits, and the choice is simply whether you want what you did.
Demo both. Making a commit while detached, switching away, then recovering it via reflog is a nice reinforcement of 2.1 — same mechanism, different cause.
2.4
revert versus reset
~12 minutes. The distinction that decides whether an undo is safe to share.
Two ways to undo
reset — move the label back
$ git reset --hard HEAD~1
91ac5d3
f6c6987
c584509
main ← HEAD
main ← HEAD
orphaned
revert — add an undoing commit
$ git revert 91ac5d3
91ac5d3
f6c6987
c584509
main ← HEAD
8b3f1a9
reverted — kept
main ← HEAD
Two mechanisms, not two spellings. reset moves the label back and c584509 falls off the branch — history rewritten. revert 91ac5d3 rewrites nothing: the commit stays put and a new 8b3f1a9 is stacked on top to cancel it — and revert can target any commit, not just the tip.
Run the two halves as a predict-then-reveal, one fragment each.
Advance 1 — reset: the command appears and main (with HEAD) slides back one commit; c584509 drops off into the dashed, orphaned state. Say it plainly: the commit isn't gone, but it's no longer on the branch — this only rewrites history, which is why it's for private work.
Advance 2 — revert: the command appears and instead of moving anything back, a brand-new commit 8b3f1a9 is appended at the tip; 91ac5d3 (the commit being reverted) stays exactly where it is, and so does everything else. Nothing existing changed identity, so nobody's history is invalidated.
Worth calling out here: revert takes the commit you want undone — 91ac5d3, an older one — not the tip. It can cancel any commit in history, and it does so by adding a commit, never by removing one.
The single contrast to land: reset removes a commit from the branch (a rewrite); revert leaves every commit in place and adds one that cancels an earlier change. Opposite effects on history.
Frame the choice as audience, not preference — has anyone else seen this commit? Reverting is the right call on a shared branch even though it leaves the mistake visible; that visibility is a feature.
revert in practice
$ git revert c584509
[main 8b3f1a9] Revert "Add tax calculation"
$ git log --oneline
8b3f1a9 Revert "Add tax calculation"
f6c6987 Add end-to-end invoice_total
c584509 Add tax calculation
The original commit is still there. Nobody's history is invalidated, so nobody needs to do anything.
Emphasise the last line — that's the whole point. If a colleague has already pulled c584509, a revert costs them nothing: they pull one more commit. If you'd reset and force pushed instead, their history and yours would have diverged and they'd have to sort it out.
Note revert takes the commit you want undone, not the one you want to return to. That's the opposite of reset and it catches people out.
The rule
Commandment 06
Revert in public, reset in private.
Undo shared mistakes by adding a commit. Undo local ones by moving the pointer.
"Public" means anyone else could have pulled it. On our setup: has it been pushed?
The gloss on the fragment matters — "public" is subtler than it sounds and the definition needs to be operational.
Our working test: pushed to origin means treat it as public. Local-only means do what you like.
This is the first half of the shared-history rule. Block 4 completes it once remotes actually exist, so flag that this is a partial picture and we'll return to it.
Where we got to
Situation Reach for
Committed too early reset --soft
Want to restage differently reset (mixed)
Throw away local commits entirely reset --hard
Undo something already pushed revert
Lost a commit somehow reflog, then branch
Not sure what happened Stop. Ask.
Recoverable: commits, branches, rebases, bad merges. Not recoverable: edits you never committed.
This table is worth screenshotting for the team wiki — it's the practical index to the block.
Close by restating the one asymmetry. Everything git has ever recorded can be recovered. The gap is between "changed on disk" and "recorded", and commit is what closes it.
Tease Block 3: now that they know nothing is really lost, rewriting history becomes far less frightening — which is exactly the confidence needed for rebase.