863435915c
Co-authored-by: claude <claude@jpaul.io> Co-committed-by: claude <claude@jpaul.io>
149 lines
12 KiB
Markdown
149 lines
12 KiB
Markdown
<!--
|
|
Suggested title: Your AI Just Force-Pushed Over a Day of Work. Now What?
|
|
Alt title: revert, reset, and the Net Under the Net
|
|
Slug: the-workflow-revert-reset-recovery
|
|
Meta description: Recovery is its own skill. Here's the right undo for every Git
|
|
disaster (revert vs reset vs reflog) and the hard truth about
|
|
where Git stops being a backup.
|
|
Tags: AI, developer workflow, git, revert, reset, reflog, recovery
|
|
-->
|
|
|
|
# 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](https://git.jpaul.io/justin/ai-workflow-course), 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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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](https://git.jpaul.io/justin/ai-workflow-course) has you stage the disaster on purpose, on the little `tasks-app` we use throughout. The short version, abridged:
|
|
|
|
```bash
|
|
# 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.
|