Git Rescue Commands
The git commands you only look up when something has already gone wrong.
Everyone knows add, commit, push. This is the other list, the one you open
when git is refusing to do the obvious thing and standup is in ten minutes.
Undo things
# Undo the last commit, keep the changes staged
git reset --soft HEAD~1
# Forgot a file in the commit you just made? Amend without touching the message
git add forgotten-file.ts
git commit --amend --no-edit
# Throw away local changes to one file
git restore src/app.ts
# Unstage a file but keep the edit
git restore --staged src/app.ts
--amend rewrites the commit, so only use it on commits you have not pushed,
or be ready to force-push.
Nothing is ever really lost: the reflog
A bad rebase, a reset --hard, a branch you deleted because it was “already
merged”. All of it is still in the reflog for about 90 days.
git reflog # every position HEAD has been in, newest first
git reset --hard HEAD@{4} # go back to how things were four moves ago
Recovering a deleted branch is the same trick, just pointed at a name:
git reflog # find the last commit that was on the branch
git branch recovered-work a1b2c3d
Git also saves the pre-rebase and pre-merge position in ORIG_HEAD, so
git reset --hard ORIG_HEAD is the quick “put it back” after a merge or rebase
you regret.
Committed on a detached HEAD and now panicking? The commits are fine. Give them a name before you check anything else out:
git branch my-work # while still detached
error: cannot lock ref ‘refs/remotes/origin/feature/branch-name’
This one is filesystem trivia. Git stores refs as files, so
refs/remotes/origin/feature cannot exist as a file while
refs/remotes/origin/feature/branch-name needs feature to be a directory.
Someone pushed both feature and feature/thing, and your local refs can’t
represent both at once.
git update-ref -d refs/remotes/origin/feature # delete the ref that's in the way
git fetch --prune # then re-fetch cleanly
If the stale ref is packed inside .git/packed-refs instead, git gc --prune=now clears it.
Stashes
git stash push -m "half-done navbar" src/Navbar.tsx # stash specific files only
git stash -u # include untracked files
git stash show -p # diff of the newest stash
git stash show -p stash@{2} # diff of a specific stash
git stash apply stash@{2} # apply and keep it in the list
git stash branch fix-navbar stash@{2} # pop it onto a fresh branch
The quirk: plain git stash leaves untracked files behind. So “I stashed
everything and the bug is still there” is almost always a new file that never
got stashed. Use -u.
Archaeology: who wrote this, and why
# Find the commit that added or removed a string, the "pickaxe"
git log -S "LEGACY_TIMEOUT" --oneline
# Same, but regex, and shows the diff
git log -G "use[A-Z]\w+Timeout" -p
# Full history of one function, following it as it moved around the file
git log -L :handleSubmit:src/form.ts
# Blame that ignores whitespace and follows code moved between files
git blame -w -C -C src/form.ts
If a mass reformat is polluting every blame, list those commit SHAs in a file and teach git to skip them permanently:
echo "a1b2c3d4 # prettier everything" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
Still can’t find it? Let the test suite find it for you:
git bisect start HEAD v1.4.0
git bisect run npm test -- --silent
git bisect reset
Force-push without ruining someone’s afternoon
git push --force-with-lease
--force overwrites the remote branch no matter what. --force-with-lease
refuses if someone else pushed since your last fetch. There is very little
reason to ever type the first one.
Two branches at once, without stashing
Reviewing a PR in the middle of your own work is what worktrees are for. You get
a second checkout of the same repo, sharing one .git:
git worktree add ../repo-review origin/some-branch
cd ../repo-review # separate node_modules, separate dev server
git worktree remove ../repo-review
Checking out a PR from a fork without adding a remote:
git fetch origin pull/123/head:pr-123
git switch pr-123
Small quirks that cost a whole afternoon
Renaming a file to change only its case. macOS and Windows filesystems are case-insensitive, so git sees no change at all:
git mv --force Navbar.tsx navbar.tsx
Fixing the same conflict on every rebase. Turn on rerere and git remembers how you resolved it last time:
git config --global rerere.enabled true
Cleaning untracked junk. Always dry-run first, because git clean deletes files
that have never been committed and there is nothing to recover from:
git clean -nd # show what would be deleted
git clean -fd # actually do it (add -x to also remove gitignored files)
A file that should never have been committed. Remove it from the index without deleting it from disk:
git rm --cached .env
echo ".env" >> .gitignore
That only stops future commits. If it carried a secret, the secret is still in history and in every clone anyone made, so rotate it. Deleting the file is not enough.
.gitignore being “ignored”. Once a file is tracked, gitignore no longer applies
to it. Clear the index and re-add:
git rm -r --cached . && git add .
Review-round commits. Instead of “fix review comments”, attach the fix to the commit it belongs to:
git commit --fixup a1b2c3d
git rebase -i --autosquash origin/main
Getting back to where you were. - means “wherever I just was”:
git switch -