git-firedrill
A CLI practice game that drills you on realistic, messy git rescue scenarios — in throwaway sandbox repos, with real git, automated state verification, and explain-why feedback when you take a wrong turn.
Most git tutorials teach clean branch-graph topology. Almost nothing drills the
situations that actually burn people: a dirty index on a diverged remote, a stash
pop that quietly flattens your staged/unstaged split, two commits vaporized by a
hard reset, a rebase stopped mid-conflict. git-firedrill sets up each of those
messes for real and lets you fight your way out.
Why deliberate practice, why now
As AI agents take over routine git operations — many teams now run "prepare, don't execute" policies where the agent stages the work and a human signs off — the human half of that arrangement only works if the human stays fluent. Skills you don't exercise decay, and self-assessment decays faster:
- In a randomized controlled trial with experienced open-source developers, AI assistance made them 19% slower on their own repositories — while they estimated it had made them roughly 20% faster (METR, arXiv:2507.09089). Perceived competence and actual competence drift apart quietly.
- Aviation hit this problem decades ahead of us. The FAA's SAFO 13002 ("Manual Flight Operations") warns that autoflight reliance degrades hand-flying skills, and SAFO 17007 directs operators to build deliberate manual-flying practice into line operations. Pilots don't hand-fly the approach because autopilots are bad; they do it so that the day the automation disconnects, the skill is there.
git-firedrill is that periodic hand-flown approach, for git. Ten minutes of
drill keeps the recovery reflexes — reflog, --index, revert-not-reset — warm
for the day your agent (or your past self) leaves the repo in a state nobody
prepared for.
Quickstart
Requires Node >= 20 and a system git (>= 2.30 recommended; one scenario wants >= 2.35).
pnpm add -g git-firedrill # or: npm i -g git-firedrill
firedrill list # see the drills
firedrill start rescue-diverged-pull
# ... a subshell opens inside a throwaway repo; fix it with real git ...
firedrill verify # state-based check + diagnosis
Or without installing: npx git-firedrill list.
Every command:
| command | what it does |
|---|---|
firedrill list |
scenario table with tiers and your solved marks |
firedrill start [slug] |
build the broken sandbox, print the brief, drop you into a subshell there (no slug: first unsolved) |
firedrill status |
active scenario, sandbox path, attempts/hints |
firedrill verify |
check the end state; on failure, diagnose the likely wrong turn (--json for scripts) |
firedrill hint |
reveal the next hint (nudge → concept → near-solution) |
firedrill solution |
print one canonical path, annotated (marks the run unscored) |
firedrill reset |
wipe and rebuild the current scenario |
firedrill next |
start what practice needs next: unsolved → failed retries → stalest solved |
firedrill clean |
reclaim sandbox disk — keeps progress + the active scenario (--all wipes everything) |
start, next, and reset accept --no-shell to print the sandbox path
instead of spawning a subshell.
Replay value. Some drills vary within bounds on every start/reset — the
bisect culprit moves, the hard reset's depth changes, the lost branch's stack
grows or shrinks — so repeating a drill retests the skill, not the memorized
answer. Verification always reads the recorded state, so any run is checked
against what its setup actually did. And once all drills are solved, next
becomes a due queue: drills you failed come back first, then whatever you
solved longest ago. Skills decay quietly; the queue doesn't let them.
The scenarios (50)
| slug | tier | what it drills |
|---|---|---|
unstage-without-losing |
1 | unstage without overwriting the worktree (restore --staged vs the destructive spellings) |
amend-forgot-file |
1 | fold a forgotten file into HEAD with --amend, not an "oops" commit |
rescue-diverged-pull |
2 | flagship: dirty index + diverged remote; preserve the staged/unstaged split through the update |
stash-pop-conflict |
2 | a conflicted stash pop keeps the stash entry — resolve, stage, and stash drop |
shelve-staged-only |
2 | stash push --staged vs plain stash vs --keep-index (git >= 2.35) |
wrong-branch-commit |
2 | move a commit off main: branch first, reset second |
untrack-ignored-file |
2 | .gitignore came too late — rm --cached, keep the working copy |
restore-from-tag |
2 | pull one file's old version forward; restore --source vs the silently-staging checkout <ref> -- |
stash-with-untracked |
2 | plain stash abandons new files; stash -u takes everything |
amend-wrong-author |
2 | fix HEAD's author without a new commit: commit --amend --reset-author |
hook-blocks-commit |
2 | a broken pre-commit hook is down for everyone; --no-verify on purpose, not by habit |
clean-untracked-safely |
2 | sweep untracked junk (dirs included) without losing the one file worth keeping |
reflog-reset-recovery |
3 | resurrect commits lost to reset --hard via the reflog / ORIG_HEAD |
squash-wip-commits |
3 | interactive-rebase squash that leaves the tree byte-identical |
rebase-conflict-continue |
3 | drive a rebase through a conflict: resolve → add → --continue |
revert-pushed-commit |
3 | undo a pushed commit's effect without rewriting shared history |
recover-deleted-branch |
3 | branch -D killed the branch reflog; HEAD's reflog still remembers |
cherry-pick-conflict |
3 | pick exactly one commit through a conflict; --continue, not commit |
fixup-autosquash |
3 | commit --fixup + rebase -i --autosquash: fix a commit in place |
bisect-regression |
3 | binary-search eight suspects, end the session, revert the culprit |
worktree-parallel |
3 | hotfix main from a second worktree while your WIP stays dirty |
squash-merge-feature |
3 | merge --squash: land content without history; no merge bubble |
commit-half-your-edits |
3 | add -p: commit one hunk, keep the draft hunk in flight |
reword-middle-commit |
3 | reword one commit three back with rebase -i, every tree byte-identical |
ours-theirs-conflict |
3 | two conflicted files, two different resolutions: checkout --ours/--theirs |
apply-emailed-patch |
3 | land a format-patch file with the original author's identity intact (am, not apply) |
revert-a-range |
3 | back out three consecutive commits in one move, history untouched |
resurrect-deleted-file |
3 | find the commit that deleted a file (--diff-filter=D) and bring it back |
pickaxe-hunt |
3 | hunt the commit that introduced a bad line by content, not by bisecting a test |
unshallow-for-history |
3 | a --depth 1 clone can't see the commit you need to revert — fetch --unshallow |
sync-fork-upstream |
3 | fast-forward a stale fork onto upstream, then push it back out, no merge bubble |
rename-branch-everywhere |
3 | rename a branch locally, on origin, and in its tracking config — all three, not two |
revert-the-revert |
3 | re-land a rolled-back feature by reverting the revert, no cherry-pick, no rewrite |
stash-branch-rescue |
3 | a stale stash conflicts on pop; stash branch replays it where it was born |
orphan-branch-docs |
3 | start a history-free branch (switch --orphan) with a clean, minimal tree |
submodule-pointer-sync |
3 | pulled the parent, but the submodule pointer never checked out — submodule update |
detached-head-rescue |
4 | branch-park a commit made on a detached HEAD before it goes orphan |
split-commit |
4 | split one mixed commit into two clean ones, tree byte-identical |
unmerge-revert-m |
4 | revert a merge: -m 1, parent numbering, and the -m 2 inversion |
force-with-lease |
4 | amend-after-push meets a teammate's push; the lease seatbelt |
fsck-lost-found |
4 | reflog expired, branch deleted — fsck --lost-found still finds it |
upstream-rewritten |
4 | a teammate rebased and force-pushed a shared branch out from under your local commits |
purge-committed-secret |
4 | drop a secret-bearing commit from unpushed history before it ever ships |
undo-bad-rebase |
4 | un-rebase a branch via its own reflog / ORIG_HEAD — not the commit reflog |
rebase-skip-duplicate |
4 | a rebase halts on a commit already upstream in disguise — recognize the empty-commit stop, --skip |
recover-staged-blob |
4 | a file was only ever staged, never committed, then reset --hard — the blob survives as dangling |
rebuild-lost-index |
4 | the index itself is gone; status claims mass deletion that never happened — reset rebuilds it |
skip-worktree-mystery |
4 | an edit is invisible to git status because something set --skip-worktree on the path |
fetch-from-bundle |
4 | update a network-less repo from a git bundle file — it quacks like a remote |
ambiguous-refname |
4 | a tag and a branch share a name; git resolves the collision one way — disambiguate it for good |
A recording of the full walkthrough below (built from real CLI output) lives at
demo/rescue-diverged-pull.cast — play it
with asciinema play demo/rescue-diverged-pull.cast.
Walkthrough: rescue-diverged-pull (and its favorite trap)
The setup: a local bare "origin" moved one commit ahead while you have a.txt
staged and b.txt unstaged. The goal is the remote commit in your
history with the staged/unstaged split intact and no stash entries left.
The reflexive attempt:
$ git stash
$ git pull --rebase
$ git stash pop # ← the trap
$ firedrill verify
check expected actual
───────────────────────────────── ────────────────────────────── ──────────────────────────────
✅ remote commit reachable from HEAD yes yes
❌ staged files a.txt (none)
❌ a.txt edit is in the index contains "LOCAL-STAGED-EDIT" missing "LOCAL-STAGED-EDIT"
❌ unstaged files b.txt a.txt, b.txt
✅ b.txt edit is in the worktree contains "LOCAL-UNSTAGED-EDIT" contains "LOCAL-UNSTAGED-EDIT"
✅ stash entries left over 0 0
✅ merge/rebase in progress no no
Likely cause: You popped the stash without `--index`, so everything came back
unstaged. `git stash pop --index` (or `git stash apply --index`) preserves the
staged/unstaged split. Fix it now with `git add a.txt`.
Nothing was lost — but the index was silently flattened, which is exactly how a carefully-staged partial commit turns into a kitchen-sink commit. The clean run:
$ git stash push
$ git pull --rebase
$ git stash pop --index # --index restores the staged set exactly
$ firedrill verify # all ✅
(git pull --rebase --autostash fails this drill the same way — an autostash is
always reapplied without --index — and verification tells you that, because
the two mistakes leave different reflog fingerprints. And if you noticed the
remote commit doesn't touch your files at all, a plain git pull fast-forwards
under your dirty tree and passes too. Any real solution passes.)
How verification works
State predicates, not command matching. After you act, the scenario asserts
on the end state of the repo: index contents (git diff --cached), worktree
status, commit-graph shape (rev-list, merge-base --is-ancestor), tree shas,
stash count, reflog entries, and — for shared-history drills — the state of the
local bare origin. Any sequence of git commands that reaches the goal passes;
there is no script to match.
Content markers. Setup writes distinctive lines (LOCAL-STAGED-EDIT,
REMOTE-EDIT, …) so checks can assert which change ended up where, not just
which paths changed. Recorded shas/trees live in an expected.json outside the
repo, where your git never sees them.
Traps. You're allowed to fail — trap commands run for real. On a failed verify, the scenario's trap rules run in order against the wreckage and the first match prints a diagnosis of the probable wrong turn ("a conflicted pop does NOT drop the stash entry…"), usually with the recovery route — which is usually the reflog.
Sandbox safety. Every scenario lives in a throwaway directory under
$TMPDIR/git-firedrill/. The tool is built to be incapable of touching a real
repository: every internal git invocation asserts (symlink-resolved) that its
working directory is inside the sandbox session, and that guard is itself under
test. Remotes are local bare repos — nothing ever touches the network.
Contributing
Scenario ideas are very welcome — the bar is "a mess a real developer has
actually been in". CONTRIBUTING.md is the full
authoring guide: the five-rule constitution, the verify-semantics-empirically
rule, scenario anatomy, trap design, and the test requirements (canonical +
trap paths; pnpm test must stay green). DESIGNING-SCENARIOS.md
walks the actual design method behind every drill in this repo — how to
start from a real mess instead of a feature, prove subtle git behavior
empirically before encoding it, design a goal state that can't be false-passed,
and attack + playtest a drill before it ships.
pnpm install
pnpm test # all canonical + trap + guard tests
pnpm build # dist/cli.js
node dist/cli.js list