Reframe sweep M7-27 + capstone (AI drives git, lesson=theory, de-slop) (#93)
Sync course wiki / sync-wiki (push) Successful in 11s

Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
This commit was merged in pull request #93.
This commit is contained in:
2026-06-22 21:58:36 -04:00
committed by Claude (agent)
parent a29823f4b3
commit 513d7e7ac8
38 changed files with 1735 additions and 1424 deletions
+97 -63
View File
@@ -1,8 +1,8 @@
# Module 12 — When It Goes Wrong: Revert, Reset, and Recovery
> **A bad change already shipped. Now what?** Recovery is its own skill — and knowing the *right*
> undo for the situation is the difference between a clean five-second fix and force-pushing over
> your teammates' work.
> **A bad change already shipped. Now what?** Recovery is its own skill. Knowing the *right* undo for
> the situation is the difference between a clean five-second fix and force-pushing over your
> teammates' work.
---
@@ -81,7 +81,7 @@ nobody has to force-anything. On a branch other people (or agents) share, `rever
the correct answer.
This also maps straight back to the Module 2 reframe: the repo is durable memory. A `revert` commit
is *more* informative than a silent erase — six months later, `git log` tells you the feature was
is *more* informative than a silent erase. Six months later, `git log` tells you the feature was
tried and pulled, and the message says why. You're writing the project's memory, not editing it.
### Reverting a bad **merge** — the headline case
@@ -110,9 +110,9 @@ feature got merged into main," it's almost always `-m 1`. You can confirm the pa
git show <merge-sha> --format="%P" --no-patch # prints the two parent SHAs, in order
```
**The gotcha you must know about (honesty up front):** reverting a merge tells Git "the content of
**The gotcha you must know about:** 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 and decides those commits are already accounted for so it brings in **nothing**,
*reverted* merge and decides those commits are already accounted for, so it brings in **nothing**,
or only the new commits, silently leaving your fix half-applied. The fix is counterintuitive: to
re-merge a branch whose merge you reverted, **revert the revert** first (`git revert <revert-sha>`),
then add your new work on top, then merge. This is a real, recurring source of "why didn't my merge
@@ -148,7 +148,7 @@ The rule, stated plainly:
> **Already shared? Use `revert`. Only ever local? `reset` is fine.** When unsure, assume shared.
### `git reflog` — the net under the net
### `git reflog` — recovering commits you thought you destroyed
Here's the reassuring part. `reset --hard` *feels* like it nukes commits permanently. It almost
never does. Git keeps a private, local log of **everywhere `HEAD` has ever pointed** — every commit,
@@ -167,12 +167,11 @@ git branch recovered a1b2c3d
```
This is the answer to "an agent ran `git 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 most people don't know it exists until the day they desperately
need it.
the work was *committed at some point*, the reflog can almost certainly get it back. Most people
don't know it exists until the day they need it.
Two honest limits, because they matter: the reflog is **local only** (it's not pushed; a fresh clone
has an empty reflog), and entries **expire** — unreachable ones are garbage-collected after roughly
Two limits, because they matter: the reflog is **local only** (it's not pushed; a fresh clone
has an empty reflog), and entries **expire**. Unreachable ones are garbage-collected after roughly
30 days by default, reachable ones after about 90. The reflog is a recovery net for *recent* mistakes
on *your* machine, not an archive. (And it can only recover what was *committed* — see "Where it
breaks.")
@@ -231,43 +230,54 @@ do them once on purpose now.
**You'll need:**
- The `tasks-app` Git repo from Module 2 (with a few commits in its history).
- Git installed, and your AI assistant available.
- The starter file `lab/bad-clear-snippet.py` from this module — a deliberately broken `clear`
- Git installed, and your agent in the repo. We use **Claude Code** as the worked example
(`claude # sub your own agent`); the directing-and-verifying pattern is the same for any of them.
- The starter file `lab/bad-clear-snippet.py` from this module, a deliberately broken `clear`
command, so everyone produces the *same* bad merge instead of relying on the AI to misbehave on cue.
> **A note on realism.** By now (postModule 4) your AI edits files directly. We hand you the exact
> broken snippet anyway so the lab is deterministic — the point is practicing the *recovery*, not
> waiting for a model to break something on demand.
### Part A — Merge a bad change, then revert the merge
You direct the agent to do the git work and you verify the result. The whole point of this lab is
that *you* hold the judgment: which undo, which parent, whether it actually worked.
1. Make sure you're on a clean `main`:
1. Get the repo onto a clean `main`. Tell your agent:
> Make sure `~/ai-workflow-course/tasks-app` is on a clean `main` — switch to it and confirm
> there's nothing uncommitted.
Verify before you go further:
```bash
cd ~/ai-workflow-course/tasks-app
git switch main
git status # should be clean
git status # should be clean, on main
```
2. Branch, and add the broken `clear` command. Open `cli.py`, and inside `main()`'s command dispatch
(next to the other `elif command == ...` branches), paste the block from
`lab/bad-clear-snippet.py`. It *looks* reasonable and even "works" once — the bug is that it
corrupts the saved state so the **next** command crashes.
2. Stage the broken change. The snippet in `lab/bad-clear-snippet.py` *looks* reasonable and even
"works" once; the bug is that it corrupts the saved state so the **next** command crashes. Hand it
to your agent:
> Create a branch `bad-clear`. Add the `elif command == "clear"` block from
> `lab/bad-clear-snippet.py` into `cli.py`'s command dispatch inside `main()`, next to the other
> `elif command == ...` branches. Commit it with the message `Add clear command`.
Verify the agent did exactly that, on the branch:
```bash
git switch -c bad-clear
# ...paste the snippet into cli.py, save...
git add cli.py
git commit -m "Add clear command"
git log --oneline -1 # "Add clear command", on bad-clear
git show HEAD -- cli.py | grep clear # the clear branch is in the diff
```
3. Merge it into `main` with a real merge commit (the `--no-ff` forces a merge commit even though a
fast-forward was possible — this is what a merged PR looks like):
3. Merge it into `main` as a real merge commit (a merged PR is a merge commit, not a fast-forward):
> Switch to `main` and merge `bad-clear` with a real merge commit (no fast-forward), message
> `Merge branch 'bad-clear'`.
Verify the shape:
```bash
git switch main
git merge --no-ff bad-clear -m "Merge branch 'bad-clear'"
git log --oneline --graph -3
git log --oneline --graph -3 # a merge commit sitting on main
```
4. **Now feel the bug.** It passes the first skim:
@@ -279,29 +289,39 @@ do them once on purpose now.
```
This is the AI plausibility trap made concrete: the change reviewed fine and "worked," and broke
the *next* command. It's merged on `main`. You need it gone safely, because in a real team
the *next* command. It's merged on `main`. You need it gone, and safely, because in a real team
others may have already pulled.
5. Try the naive revert and watch it refuse, because a merge has two parents:
5. Direct the agent to undo the bad merge, and watch the trap. Reverting a merge is fiddly: a naive
`git revert HEAD` refuses, because a merge has two parents and Git won't guess which side to keep.
Tell your agent:
```bash
git revert HEAD # error: ... is a merge but no -m option was given
> The merge we just put on `main` is bad. Undo it safely on shared history. Note that it's a merge
> commit.
A naive revert hits this, and a competent agent recognizes it:
```
error: commit ... is a merge but no -m option was given
fatal: revert failed
```
6. Confirm the parents, then revert the merge properly, keeping the `main` side (`-m 1`):
The correct move keeps the `main` side, which is parent 1:
```bash
git show HEAD --format="%P" --no-patch # two SHAs: parent 1 is main, parent 2 is bad-clear
git revert -m 1 HEAD # writes a NEW commit that undoes the whole merge
git log --oneline -3 # you'll see a "Revert ..." commit on top
git revert -m 1 <merge-sha> # writes a NEW commit that undoes the whole merge
```
> `git revert` drops you into your text editor with a pre-filled "Revert …" message — save and
> close it (in vim, type `:wq` then Enter; in nano, Ctrl-O then Ctrl-X). Or add `--no-edit` to
> keep that default message and skip the editor entirely: `git revert -m 1 HEAD --no-edit`. Either
> way you end up with the same "Revert …" commit.
6. **Verify and decide — this is the part you own.** Don't take "I reverted it" on faith. Confirm the
agent kept the *right* parent: parent 1 is the old `main` tip, parent 2 is `bad-clear`, and `-m 1`
keeps parent 1. If it had used `-m 2` it would have kept the broken side.
7. Prove you're recovered — and notice nothing was erased:
```bash
git show <merge-sha> --format="%P" --no-patch # two SHAs: parent 1 is main, parent 2 is bad-clear
git log --oneline -3 # a "Revert ..." commit on top
```
7. Prove you're recovered, and notice nothing was erased:
```bash
rm -f tasks.json # drop the corrupted state file the bug wrote
@@ -319,16 +339,20 @@ do them once on purpose now.
### Part B — "Lose" a commit, recover it with the reflog
1. Make a small real commit you'd be sad to lose:
1. Make a small real commit you'd be sad to lose. Tell your agent:
> Add a trivial `version` command to `cli.py` that prints a version string, and commit it with the
> message `Add version command`.
Verify it's there:
```bash
# with your AI, add a trivial "version" command to cli.py that prints a version string, then:
git add cli.py
git commit -m "Add version command"
git log --oneline -1 # note this commit exists
git log --oneline -1 # "Add version command"
python cli.py version # prints the version
```
2. Now destroy it the way an over-eager cleanup (or an agent) would — a hard reset:
2. Now destroy it the way an over-eager "clean up the history" cleanup (or an agent) would, with a
hard reset. Run this one yourself so you feel the floor drop out:
```bash
git reset --hard HEAD~1
@@ -338,26 +362,36 @@ do them once on purpose now.
It's not in `log`. It feels permanently lost. It isn't.
3. Find it in the reflog and bring it back:
3. Direct the agent to recover it from the reflog. You need to know the reflog exists so you can ask
for it and check the result:
> My last commit was destroyed by a `git reset --hard`. Find it in the reflog and restore the
> branch to it. Show me the reflog line you used before you reset.
Then verify. The commit is back, and the app works again:
```bash
git reflog # find the line: "... commit: Add version command"
git reset --hard <that-sha> # branch pointer back to the recovered commit
# (or, more cautiously: git branch recovered <that-sha> then inspect before resetting)
git log --oneline -1 # it's back
git log --oneline -1 # "Add version command" is back
python cli.py version # works again
```
You just recovered a commit that `log` swore was gone. **That's the net under the net.** Note that
step 2's `--hard` would have *also* eaten any uncommitted edits in the working tree at the time
and the reflog could **not** have saved those, because they were never committed. Recovery covers
committed history, not unsaved scratch work.
You just recovered a commit that `log` swore was gone. Note the honest limit: step 2's `--hard`
would have *also* eaten any uncommitted edits in the working tree at the time, and the reflog could
**not** have saved those, because they were never committed. Recovery covers committed history, not
unsaved scratch work.
### Part C (optional) — Drop a named recovery point
Before you hand the agent something sweeping, have it tag the current known-good state:
> Tag the current commit as `known-good`, an annotated tag, message "Clean state at end of Module 12
> lab".
Confirm the anchor exists:
```bash
git tag -a known-good -m "Clean state at end of Module 12 lab"
git diff known-good # later, this shows everything that changed since this anchor
git tag # known-good is listed
git diff known-good # later, this shows everything that changed since this anchor
```
Get in the habit of tagging before you hand an agent something sweeping.
@@ -397,8 +431,8 @@ like one is how people lose data they thought was safe.
re-merging that branch later quietly does nothing useful until you *revert the revert*. Forget this
and you'll burn an afternoon wondering why your fix won't merge.
The honest summary: Git is a near-perfect time machine for the *text you committed*, and nothing more.
Know that boundary and you'll trust it exactly as far as it deserves.
The boundary in one line: Git is a near-perfect time machine for the *text you committed*, and nothing
more. Know that boundary and you'll trust it exactly as far as it deserves.
---