Git Internals: Recover from Git HARD Reset!

Restore a destroyed point of time in the timeline

In this short article, we will have a look at some practical implications of what we have seen in the previous articles. We will be looking at ways to do damage recovery and control.

This also addresses one of the most common questions that I get, "Can I recover data from a hard reset?"

Disaster Recovery

Let us consider the following as your commit history:

a (first-commit) <- b <- c <- d <- e <- f (latest-commit)

Now you realise that the commits, d, e, f, were incorrect, and you no longer want them. So, you have two options now:

  1. Revert these commits individually. The commit history will look something like this:

     (first-commit) <- b <- c <- d <- e <- f <- ~f <- ~e <- ~d (latest-commit),
    
     Where, ~f is the revert of f
            ~e is the revert of e
            ~d is the revert of d.
    

    You must have noticed here that you have to revert the changes from the latest to the oldest. So, you should always first revert the latest commit, and then move to the older ones. The command to revert the git commit is:

     git revert <commit-id>
    

    The advantage of this is that the history is preserved, and if at some later point in time, you realise that you needed the commit, you can simply refer to the commit ID and get back the changes that were made in those commits.

  2. Delete the commits from the timeline itself, which deletes them from the history itself, as if they never existed. This is the one we will be looking at in detail in the next section.

Git RESET

As mentioned above, we have the option of deleting commits itself. There are two types of RESET that git supports:

  1. Soft Reset:

    • git reset --soft <commit>

    • A soft reset does not modify the working directory or staging area. It only moves the branch pointer to the specified commit, effectively "undoing" the commits that came after that commit.

    • The changes from the undone commits are retained in the staging area. You can then make further modifications and create a new commit that incorporates both the retained changes and your new modifications.

Example:

    ➜  time-travel-with-git git:(main) git log --oneline
    892665b (HEAD -> main) main-branch commit to merge
    9393d41 Add merge.txt
    b4f560a (soft-delete) Second commit to time travel!
    cc3a96b First stop in time-travel

    ➜  time-travel-with-git git:(main) git reset --soft b4f560a
    //ON SOFT RESET, THE CHANGES MADE AFTER THE b4f560a COMMIT
    //ARE MOVED IN THE STAGING AREA.
    ➜  time-travel-with-git git:(main) ✗ git status
    On branch main
    Changes to be committed:
      (use "git restore --staged <file>..." to unstage)
            new file:   merge.txt

    //THE TOP TWO COMMITS ARE COMPLETELY REMOVED FROM THE HISTORY.
    ➜  time-travel-with-git git:(main) git log --oneline
    b4f560a (HEAD -> main) Second commit to time travel!
    cc3a96b First stop in time-travel
  1. Hard Reset:

    • git reset --hard <commit>

    • A hard reset, on the other hand, not only moves the branch pointer but also modifies the working directory and staging area to match the specified commit. It discards changes in both the working directory and the staging area, effectively resetting the branch to the specified commit.

Example:

    ➜  time-travel-with-git git:(hard-reset) git log --oneline
    892665b (HEAD -> hard-reset, main) main-branch commit to merge
    9393d41 Add merge.txt
    b4f560a (soft-delete) Second commit to time travel!
    cc3a96b First stop in time-travel

    ➜  time-travel-with-git git:(hard-reset) git reset --hard b4f560a
    HEAD is now at b4f560a Second commit to time travel!

    //HARD RESET COMPLETELY DELETES THE COMMITS FROM THE HISTORY. 
    //THE CHANGES ARE LOST FOREVER
    ➜  time-travel-with-git git:(hard-reset) git status
    On branch hard-reset
    nothing to commit, working tree clean

    //THE COMMITS ARE LOST FOREVER
    ➜  time-travel-with-git git:(hard-reset) git log --oneline
    b4f560a (HEAD -> hard-reset, soft-delete) Second commit to time travel!
    cc3a96b First stop in time-travel

It is evident from the above explanation that hard reset is an irrecoverable damage. But is it really for someone who is a time-traveller? Aren't we a Git time-travellers?

Reflog

"reflog" (reference log) is a mechanism that records the history of changes to Git references in the local repository. References in Git include branches (refs/heads/), remote branches (refs/remotes/), and tags (refs/tags/). The reflog is particularly useful for recovering lost commits, branches, or other changes. The reflog entries have a limited lifetime, and older entries are eventually pruned by Git's garbage collection mechanism. The default expiration time is 90 days, but it can be configured.

Let us see what does reflog show us:

➜  time-travel-with-git git:(hard-reset) 
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{0}: reset: moving to b4f560a
892665b (main) HEAD@{1}: checkout: moving from main to hard-reset
892665b (main) HEAD@{2}: checkout: moving from soft-delete to main
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{3}: reset: moving to HEAD
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{4}: reset: moving to b4f560a
892665b (main) HEAD@{5}: checkout: moving from main to soft-delete
892665b (main) HEAD@{6}: checkout: moving from soft-delete to main
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{7}: reset: moving to HEAD
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{8}: reset: moving to b4f560a
892665b (main) HEAD@{9}: checkout: moving from main to soft-delete
892665b (main) HEAD@{10}: commit: main-branch commit to merge
9393d41 HEAD@{11}: checkout: moving from merge-branch to main
adb0db6 (merge-branch) HEAD@{12}: commit: Merge-branch commit to merge
9393d41 HEAD@{13}: checkout: moving from main to merge-branch
9393d41 HEAD@{14}: commit: Add merge.txt
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{15}: checkout: moving from timeline1 to main
0c8e8d9 (timeline1) HEAD@{16}: checkout: moving from main to timeline1
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{17}: checkout: moving from timeline1 to main
0c8e8d9 (timeline1) HEAD@{18}: checkout: moving from 01b3b247a53a68b399494b392003c81d0ea63087 to timeline1
01b3b24 HEAD@{19}: checkout: moving from timeline1 to 01b3b247a53a68b399494b392003c81d0ea63087
0c8e8d9 (timeline1) HEAD@{20}: checkout: moving from main to timeline1
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{21}: checkout: moving from timeline1 to main
0c8e8d9 (timeline1) HEAD@{22}: commit: Second commit on timeline1
01b3b24 HEAD@{23}: commit: First commit on timeline1
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{24}: checkout: moving from main to timeline1
b4f560a (HEAD -> hard-reset, soft-delete) HEAD@{25}: commit: Second commit to time travel!
cc3a96b HEAD@{26}: commit (initial): First stop in time-travel

Woah! There's a lot in here. But what we are particularly interested in is the latest commit that we had, which we deleted. It is 892665b. Here comes the interesting recovery step:

➜  time-travel-with-git git:(hard-reset) git checkout -b recover-from-hard-reset 892665b
Switched to a new branch 'recover-from-hard-reset'
➜  time-travel-with-git git:(recover-from-hard-reset) git status
On branch recover-from-hard-reset
nothing to commit, working tree clean
➜  time-travel-with-git git:(recover-from-hard-reset) git log --oneline
892665b (HEAD -> recovered-from-hard-reset, main) main-branch commit to merge
9393d41 Add merge.txt
b4f560a (soft-delete, hard-reset) Second commit to time travel!
cc3a96b First stop in time-travel

Congratulations, we have magically recovered everything!

Conclusion

However, do remember that git reflog is only available locally, i.e. only on your machine, and has a limited lifetime. So it is always better to be cautious about hard resets. But, just in case, if you ever mess up, remember, that you are an expert git time-traveller. Recover, travel and build!

Did you find this article valuable?

Support Aman Mulani by becoming a sponsor. Any amount is appreciated!