DevFox Labs
HomeLearningToolsAboutContact
intermediate30 minby DevFox

Recovering Lost Work with the Reflog

A rescue manual organised by symptom: bad reset, deleted branch, botched rebase, lost amend, dropped stash — plus git fsck, and the one category of loss that is permanent.

  • Git

On this page

  • Git stops pointing, it does not delete
  • Reading the reflog
  • Narrowing the journal
  • Recovery, by symptom
  • I ran git reset --hard and lost commits
  • I deleted a branch
  • My rebase produced garbage
  • I amended and lost the original commit
  • I dropped a stash
  • I lost work that was never committed
  • When the reflog is not enough: git fsck
  • A pushed commit is a backed-up commit
  • Habits that make recovery boring
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    There is a specific silence that follows a bad git reset --hard. You stare at a working tree that is suddenly missing a day of work, and the commits you made are nowhere in git log. That feeling is almost always wrong. The work is still there.

    By the end of this lesson you can walk into that moment calmly and get it back — commits erased by a reset, a branch you deleted, a rebase that mangled everything, a commit you amended away, even a stash you dropped. You will also know the one kind of loss that no Git command can undo, so you never waste an hour hunting for something that was never there.

    Git stops pointing, it does not delete

    Here is the insight the whole lesson rests on. When you reset, rebase, amend, or delete a branch, Git does not erase the commits involved. It changes which commits are referenced — which ones a branch name, a tag, or HEAD currently points at. The commit objects stay on disk exactly as they were.

    The books on the shelves

    Commit objects. Not touched.

    git reset --hard and git branch -D do not shred a book. It sits there, unlisted, waiting for someone who remembers its shelf number.

    The cards in the catalogue

    Branch names, tags, HEAD.

    These are what those commands tear up. Losing the card is why the work looks gone.

    The reflog

    A ledger of every shelf number.

    A per-repository journal of every position HEAD and each branch has held, and what moved it there. Every commit, checkout, reset, merge, rebase step and pull writes a line.

    Your repository as a library. Almost every alarming Git command operates on the catalogue.

    The reflog is yours alone

    The reflog lives in .git/logs/ and is never pushed, never fetched, and never cloned. A colleague cloning your repository gets your commits but none of your reflog. It also expires: entries for reachable commits are kept 90 days, entries for unreachable ones 30 days, tunable with gc.reflogExpire and gc.reflogExpireUnreachable.

    Reading the reflog

    Run git reflog in any repository with some history and you get something like this:

    text
    0c8e2d4 HEAD@{0}: reset: moving to HEAD~2
    b42c0ae HEAD@{1}: commit: Add retry logic to the uploader
    19ff5c2 HEAD@{2}: commit: Extract upload client
    0c8e2d4 HEAD@{3}: rebase (finish): returning to refs/heads/api
    0c8e2d4 HEAD@{4}: rebase (pick): Tidy the config loader
    a1b9e77 HEAD@{5}: rebase (start): checkout main
    5e6c31b HEAD@{6}: checkout: moving from main to api

    Four things live on each line. The hash on the left is the commit HEAD pointed at after that action. HEAD@{n} is a positional index: HEAD@{0} is where you are now, HEAD@{1} is where you were one move ago. The action label — commit, checkout, reset, rebase (start), rebase (finish), pull — tells you which operation moved you. The rest is the message.

    Read it newest-first, top to bottom, like a flight recorder. The line above the disaster is where you were standing immediately before it. In the output above, the reset threw away b42c0ae and 19ff5c2; HEAD@{1} still names b42c0ae, which is the tip of everything the reset appeared to destroy.

    Counting entries versus counting hours

    An index like HEAD@{2} means two entries back; HEAD@{2.hours.ago} means where HEAD pointed two hours ago. The numeric index is positional, not stable — every new commit or checkout pushes it down by one. Resolve it to a real hash and work with that instead.

    Narrowing the journal

    git reflog shows the journal for HEAD. Each branch keeps its own, which is far quieter when you know which branch went wrong:

    bash
    git reflog show api        # only the api branch's positions
    git reflog show main

    Timestamps make the story readable when the action labels alone are ambiguous:

    bash
    git reflog --date=iso
    text
    0c8e2d4 HEAD@{2026-07-24 15:02:11 +0200}: reset: moving to HEAD~2
    b42c0ae HEAD@{2026-07-24 14:58:40 +0200}: commit: Add retry logic

    That is usually enough to pin down "the state just before lunch", which is how people actually remember their mistakes.

    Recovery, by symptom

    Each of these follows the same shape: find the hash of the good state, then point something at it. Nothing below rewrites anything until you decide it should.

    I ran git reset --hard and lost commits

    Find the entry immediately before the reset — the one whose message is your last real commit:

    bash
    git reflog

    You could move the branch straight back:

    bash
    git reset --hard b42c0ae   # branch jumps back to the old tip

    Safer, and what an experienced hand does under stress, is to look before you leap. Put the recovered state on a throwaway branch first and inspect it:

    bash
    git switch -c rescue b42c0ae   # new branch at the lost tip
    git log --oneline -5           # confirm it is what you want

    If it is right, go back and reset the real branch. If it is not, you have changed nothing. Prefer git switch over git checkout here: checkout overloads branch switching and file restoring in one command, and confusing the two is how people lose work in the first place.

    I deleted a branch

    git branch -D feature/checkout prints the tip hash as it deletes. If it is still in your scrollback, that is your answer. If not, the branch's own reflog is gone with it, but HEAD recorded every time you checked that branch out:

    bash
    git reflog | grep checkout

    Recreate the branch at the hash you find:

    bash
    git branch feature/checkout 9d41c7e

    If nothing turns up, fall back to the deeper net:

    bash
    git fsck --lost-found

    My rebase produced garbage

    Before a rebase, reset, or merge, Git stashes the previous HEAD in a ref called ORIG_HEAD. That is your one-step undo:

    bash
    git reset --hard ORIG_HEAD   # back to the pre-rebase state

    ORIG_HEAD is overwritten by the next such operation, so use it promptly. If it is already stale, find the rebase (start) line in the reflog — the entry directly above it is where the branch stood before the rebase began.

    bash
    git reflog | grep -n "rebase (start)"

    I amended and lost the original commit

    git commit --amend does not edit a commit; it builds a new one and moves the branch to it. The original is still on the shelf, recorded one entry back:

    bash
    git reflog
    git show HEAD@{1}                # the pre-amend commit
    git switch -c pre-amend HEAD@{1} # keep it if you need it

    It is also how you get back files you swept into an amend by accident.

    I dropped a stash

    Stashes are commits too, which is why they are recoverable. git stash drop prints the dropped stash's hash on the way out — copy it before you clear the screen. If it is gone, hunt for unreferenced commits:

    bash
    git fsck --unreachable | grep commit
    git show <hash>                  # is this the one?

    Then either apply it straight into your working tree or put it back on the stash list:

    bash
    git stash apply <hash>                 # drop the changes back in
    git stash store -m "recovered" <hash>  # or re-list it as a stash

    Use git stash apply, not git cherry-pick: a stash is a merge commit with two or three parents, and cherry-pick refuses merge commits without being told which parent to diff against.

    Copy the hash before you drop

    Both git stash drop and git branch -D print the hash of what they just unreferenced. That single line is the cheapest recovery tool Git gives you, and it costs nothing to paste it into a note before you carry on.

    I lost work that was never committed

    Here is the honest answer: the reflog cannot help you. It is a record of positions HEAD has held, and uncommitted edits were never a position. If you ran git restore ., git checkout --, or git reset --hard over changes you had never committed, Git has no copy because Git never saw them.

    Your only hope is outside Git — your editor's local history (VS Code and the JetBrains IDEs both keep one), an open buffer you have not closed yet, or filesystem snapshots like Time Machine. This is the genuinely permanent category. Everything else in this lesson is a rescue; this one is a lesson.

    When the reflog is not enough: git fsck

    git fsck walks every object in the repository and reports the ones nothing points at. It is the shelf-by-shelf search you run when the catalogue has failed you:

    bash
    git fsck --lost-found        # write danglers to .git/lost-found
    git fsck --unreachable       # just list them

    --lost-found creates a .git/lost-found directory with commit/ and other/ subdirectories, containing files named after each dangling object. Inspect any of them with git show <hash>, and when you find the right one, give it a name with git branch rescued <hash>.

    Reach for the reflog first; reach for fsck when the reflog has expired or the entry was never written at all.

    Never clean up while something is missing

    Unreferenced objects survive until garbage collection removes them. These two commands delete the safety net immediately: git gc --prune=now destroys every unreachable object, and git reflog expire --expire=now --all erases the entire journal. Run either while a commit is missing and it is gone for good. "Cleaning up" is exactly the wrong instinct in a crisis.

    A pushed commit is a backed-up commit

    Everything so far is local recovery. There is a second net, and it is often the faster one: if the commits ever reached a remote, that remote still has them.

    bash
    git fetch origin                    # refresh remote refs
    git switch -c rescue origin/api     # rebuild from the remote
    git log --oneline origin/api        # or just look

    Even after a force-push, a colleague who has not fetched since still holds the old commits in their clone and can push them to a rescue branch. A commit that exists in two places is far harder to lose than one guarded by a 30-day journal on your laptop — which is the real argument for pushing work-in-progress branches.

    Habits that make recovery boring

    1. Commit early and often, badly

      A commit called "wip" is fully recoverable; an uncommitted file is not. You can rewrite the message later with interactive rebase — frequency, not polish, is what protects you.

    2. Branch before anything hairy

      A long rebase, a reset --hard, a filter of history. The backup branch costs two files and a hash.

    3. Force-push with --force-with-lease

      It refuses to overwrite a remote that moved since you last fetched, which turns "I destroyed a colleague's commits" into an error message.

    Three habits, and the reflog is the safety net for when all three failed

    The middle one, concretely:

    bash
    git switch -c backup/pre-rebase   # snapshot the current tip
    git switch -                      # back to where you were

    That turns a recovery hunt into a one-line git reset --hard backup/pre-rebase.

    Branch before you gamble

    Make git switch -c backup/<what-you-are-about-to-do> a reflex before risky operations. Branches are two files and a hash; creating one is instant and deleting it later is trivial. It is the cheapest insurance in the whole tool.

    A cheat sheet to keep

    bash
    git reflog                          # journal of HEAD's positions
    git reflog show <branch>            # one branch's journal
    git reflog --date=iso               # with real timestamps
    git show HEAD@{1}                   # inspect a past position
    git show 'HEAD@{2.hours.ago}'       # position by time, not index
    
    git switch -c rescue <hash>         # safe: look before you leap
    git reset --hard <hash>             # move the branch back
    git reset --hard ORIG_HEAD          # undo a rebase/merge/reset
    git branch <name> <hash>            # resurrect a deleted branch
    
    git fsck --lost-found               # dump danglers to .git/lost-found
    git fsck --unreachable | grep commit # list unreferenced commits
    git stash apply <hash>              # apply a dropped stash
    git stash store -m "msg" <hash>     # or put it back on the list
    
    git fetch origin                    # recover from the remote
    git push --force-with-lease         # rewrite safely, never blindly
    
    git gc --prune=now                  # DANGER: deletes the safety net
    git reflog expire --expire=now --all # DANGER: erases the journal

    Where to go next

    You now have a rescue manual: find the hash, name it with a branch, look before you leap, and never run garbage collection while something is missing. The commits you thought you burned were, almost always, just uncatalogued.

    Next comes automating Git with hooks — teaching your repository to run checks before a commit lands, and shaping git config so the safe behaviour is the default one. Several of the habits here (backup branches, --force-with-lease, a generous gc.reflogExpire) are things you can bake in once and stop thinking about.

    The best practice for this lesson is deliberate destruction. Make a scratch repository, commit a few times, then reset, amend, and delete branches on purpose and recover each one from the reflog. Doing it once while calm is what makes it automatic when you are not.