Files
ai-workflow-course/blog/13-revert-reset-recovery.md
2026-06-23 07:28:55 -04:00

12 KiB

Your AI Just Force-Pushed Over a Day of Work. Now What?

Let me paint you a picture I've actually lived. You hand an agent a tidy little instruction ("clean up the branch history before we open the PR") and walk off to refill your coffee. You come back, glance at git log, and a commit you definitely made an hour ago is just… not there. The agent decided "clean up" meant git reset --hard, helpfully threw away the thing you cared about, and reported success.

Your pulse does a thing.

Here's what I want you to take from this post: that moment is survivable, and which command you reach for next is the entire ballgame. Recovery is its own discipline: not a vibe, not Ctrl-Z mashing, but a small set of tools where picking the right one is the difference between a clean five-second fix and force-pushing your teammate's work into the void. This is the last stop in Unit 2 of The Workflow, my free course for IT folks who can already get an AI to write code but keep getting bitten by everything around it. Back in the earlier posts we installed the safety net: version control as undo for the AI. This is the day you learn to actually use the net when you fall.

Three undos, three blast radii

The first thing nobody tells you about Git is that it has more than one "undo," and the failure mode is using the wrong one. They differ on two questions: what do they touch, and are they safe once history is shared (i.e., once someone else has pulled it).

Command Undoes Rewrites history? Safe once shared?
git restore <file> Uncommitted edits in your working tree No Yes, nothing shared to break
git revert <commit> An already-committed change, by writing a new inverse commit No, it adds Yes, the team-safe undo
git reset <commit> Moves your branch pointer backward, un-committing Yes No, dangerous once others pulled

restore you've probably already met: it's for the mess that hasn't been committed yet. This post is about the bottom two rows, because the AI's worst messes are the ones that already made it into a commit, a merge, or a merged PR.

revert: undo by adding, not erasing

Mental model: a commit is a diff, a set of line changes. git revert <commit> computes the opposite diff and commits it. The bad change is still in your history, but a new commit immediately after it cancels it out.

git log --oneline
# a1b2c3d  Add "export to CSV" command   <- turned out to be broken
git revert a1b2c3d
# opens an editor for the message, then commits the inverse
git log --oneline
# 9f8e7d6  Revert "Add export to CSV command"
# a1b2c3d  Add "export to CSV" command

Why is this the one you reach for first? Because it never rewrites history. Anyone who already pulled a1b2c3d just pulls one more commit on top and they're back in sync with you. Nobody's clone breaks. Nobody has to force-anything. And, this is the part I love, your git log now tells the truth: "we tried this, then we deliberately pulled it, and here's why." Six months from now that's a gift to whoever's reading the history, human or agent. A revert writes the project's memory honestly instead of quietly editing the past.

Reverting a bad merge: the headline case

Here's the one that actually bites people, because it's exactly what a bad merged PR looks like. You don't have one bad commit; you have a merge commit that dragged in a whole branch's worth of them. Naively reverting it fails:

error: commit abc123 is a merge but no -m option was given.
fatal: revert failed

A merge commit has two parents (the branch you were on, and the branch you merged in) and Git won't guess which side is "the one to keep." You tell it:

git show <merge-sha> --format="%P" --no-patch   # prints the two parent SHAs, in order
git revert -m 1 <merge-sha>                      # keep parent #1 (main), undo what the other side brought in

For "a bad feature got merged into main," it's almost always -m 1.

Now the gotcha, up front, because honesty is the whole point of this section: reverting a merge tells Git the content of that branch is undone. If you later fix the branch and try to merge it again, Git looks at the reverted merge, decides those commits are already accounted for, and brings in nothing, silently leaving your fix half-applied. The counterintuitive cure is to revert the revert first (git revert <revert-sha>), then stack your new work on top, then merge. This is a real, recurring source of "why didn't my merge do anything," and now it'll never cost you an afternoon.

reset: moving the pointer (and why it's sharp)

git reset doesn't write an inverse commit. It moves your branch to point at an older commit, un-committing everything after. That's rewriting history, which is both its power and its danger. Three flavors:

git reset --soft  HEAD~1   # un-commit, KEEP changes staged   (redo the message / squash)
git reset --mixed HEAD~1   # un-commit, keep changes unstaged (the default)
git reset --hard  HEAD~1   # un-commit AND delete the changes (the one that ruins days)

reset is correct on exactly one kind of history: the kind you have not shared. Squashing three "wip" commits before you push, fixing a botched last commit: perfect, that's what it's for. But the instant a commit has been pushed and someone pulled it, reset becomes a way to rewrite history out from under them, and the only way to publish your rewrite is --force. On a shared branch, that's how you delete a teammate's (or an agent's) work. The rule, plainly:

Already shared? revert. Only ever local? reset is fine. When unsure, assume shared.

reflog: the net under the net

Now the reassuring part, the thing that saves the coffee-break disaster from the intro. reset --hard feels permanent. It almost never is. Git keeps a private, local log of everywhere HEAD has ever pointed (every commit, reset, checkout, merge) in the reflog. A commit you "lost" is no longer reachable from your branch, but it's still in the object database, and the reflog still knows its SHA.

git reflog
# 9f8e7d6 HEAD@{0}: reset: moving to HEAD~1
# a1b2c3d HEAD@{1}: commit: Add the feature I just "lost"   <- there it is
git branch recovered a1b2c3d    # cautious: park it on a branch and inspect
# or just snap straight back:
git reset --hard a1b2c3d

That's the answer to "an agent ran reset --hard and ate an hour of my commits." As long as the work was committed at some point, the reflog can almost certainly get it back. It's the single most reassuring command in Git, and almost nobody knows it exists until the day they desperately need it.

[insert a screenshot referencing a git reflog output with the "lost" commit highlighted here]

Tags: named recovery points

SHAs are unmemorable. A tag is a permanent, human-readable name pinned to a commit:

git tag -a v1.0 -m "Last known-good before the big AI refactor"
git push origin v1.0          # tags don't push by default
git diff v1.0                 # later: everything that changed since the known-good point

The habit worth building: before you turn an agent loose on a large, sweeping change, tag the known-good state. It turns "I think it was working yesterday" into a named anchor you can diff against in one command. On your git host, a release is the same idea dressed up: a tag plus notes and artifacts the whole team can point at. Tags are the durable, shareable recovery points the reflog is not.

Try it for real (the part that sticks)

Reading about this is nothing like doing it, so the course lab has you stage the disaster on purpose, on the little tasks-app we use throughout. The short version, abridged:

# Part A: merge a bad change, then revert the merge
git switch main
git merge --no-ff bad-clear -m "Merge branch 'bad-clear'"   # what a merged PR looks like
git revert HEAD                # refuses: "is a merge but no -m option was given"
git revert -m 1 HEAD           # writes a NEW commit undoing the whole merge
git log --oneline              # bad merge STILL there, revert sitting on top, history intact

# Part B: "lose" a commit, get it back
git reset --hard HEAD~1        # commit vanishes from the branch
git reflog                     # find: "... commit: Add version command"
git reset --hard <that-sha>    # fully recovered

Do it once, deliberately, while the stakes are zero. Then the day it happens for real, your hands already know the moves.

Where it breaks (the part that earns your trust)

This is the second half of a backup-and-recovery thread (pushing to a remote was the backup half, this is recovery) and the most valuable thing it teaches is where the analogy stops. Git gives you near-perfect point-in-time logical recovery for versioned text. It is emphatically not a general backup system, and treating it like one is exactly how people lose data they thought was safe.

  • Not a backup for your database, or any runtime state. Your app's data lives in a database, in object storage, on a running server. git revert rolls back code; it does nothing for the rows your buggy migration already mangled. Restoring data is a different discipline with different tools.
  • Not a backup for secrets, which shouldn't be in there anyway. And here's the trap: if a key did leak into a commit, revert does not remove it from history. The secret is still sitting in the old commit for anyone with the repo. A committed secret is a leaked secret: rotate it, don't just revert it. (There's a whole module on keeping them out in the first place; foreshadowing.)
  • It only recovers what was committed. reset --hard and git restore both destroy uncommitted edits, and the reflog cannot bring those back: there's no object to recover because nothing was ever committed. The defense is the one this whole course keeps repeating: commit often, so "uncommitted" is always a tiny window.
  • Poor backup for large binaries. Git versions text beautifully and binaries terribly: every change stores a whole new copy and the "diff" is useless noise. Datasets, video, model weights: real artifact storage, not your Git history.
  • The reflog is local and temporary. Not pushed, empty in a fresh clone, and garbage-collected in roughly 30 days. A net for recent local mistakes, not an offsite archive. The offsite durability comes from pushing to a remote, a different power. You need both.

The honest summary: Git is a beautiful time machine for the text you committed, and nothing more. Know that boundary and you'll trust it exactly as far as it deserves, which, used right, is pretty far.

You're done when

You can say, without looking, which undo fits an uncommitted mess, a bad change already pushed to a shared branch, and three local "wip" commits you want to squash, and why the wrong pick is wrong each time. You've reverted a real merge with -m 1 and watched both the bad merge and the revert sit in your log. You've "lost" a commit to reset --hard and pulled it back from the reflog. And you can name, in one breath, four things Git is not a backup for: your database, your secrets, your uncommitted changes, your large binaries.

That completes Unit 2: the whole team layer: hosting, issues, review, collaboration, and now recovery. Next up we start Unit 3, where we stop checking things by hand and let the machine do it: tests. Because the best recovery story is the one where the broken change never merges in the first place.

If you've got your own "the AI nuked my work and here's how I clawed it back" war story, or a recovery trick I didn't cover, drop it in the comments. I read them, and the scars you've collected are exactly what makes this stuff land for the next person.