← Back to the journal

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 -