npm.io
0.5.0 • Published 3d agoCLI

git-firedrill

Licence
MIT
Version
0.5.0
Deps
3
Size
411 kB
Vulns
0
Weekly
0

git-firedrill

CI MIT License Node >= 20

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

License

MIT

Keywords