Undoing in Git

„(..) great responsibility follows inseparably from great power.”
― French National Convention

Git is probably the most popular distributed version control system. When used properly, it allows a team to be extremely effective. When misused, it can create a total mess out of the code base. Every now and then you end up in a situation, when you have to undo your actions — it can include invalid file edits or deletions or even improperly executed Git commands. Git provides many ways of undoing things, including bringing back removed commits. This posts is intended to be a summary for the most common scenarios of undoing with Git.

Table of contents

  1. Restoring deleted files
    1. Deletion is not committed and not staged
    2. Deletion is not committed but has been added to the staging area
    3. Deletion was committed
  2. Undoing commits
    1. Reverting commits
    2. Deleting n last commits
    3. Deleting commits selectively
  3. Restoring “lost” commits
  4. Undoing merges
    1. Merge not finished
    2. Merge finished
  5. Undoing rebases
    1. Rebase not finished
    2. Rebase finished
  6. Reverting arbitrary patches
  7. Removing untracked files

Restoring deleted files

Deletion is not committed and not staged yet

We made some changes to the working tree. This includes a file deletion. git status shows the following:

$ git status

On branch master
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   src/main/java/com/tratif/blog/Address.java
	deleted:    src/main/java/com/tratif/blog/Person.java

no changes added to commit (use "git add" and/or "git commit -a")

The deleted file turns up to be needed, so you want to restore it. The git status command shows the solution — we have to use git checkout as follows:

$ git checkout -- src/main/java/com/tratif/blog/Person.java
$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   src/main/java/com/tratif/blog/Address.java

no changes added to commit (use "git add" and/or "git commit -a")

Deletion is not committed but has been added to the staging area

If the deletion was added to the staging area then using just git checkout is not enough, though. In such case, we have to unstage it first with git reset and only then restore with git checkout. We specify file path after -- as follows:

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   src/main/java/com/tratif/blog/Address.java
	deleted:    src/main/java/com/tratif/blog/Person.java

$ git reset -- src/main/java/com/tratif/blog/Person.java
$ git checkout -- src/main/java/com/tratif/blog/Person.java
$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   src/main/java/com/tratif/blog/Address.java

File deletion was committed

Let’s assume that we want to restore a file removed a few commits ago. The commit tree looks as in the following picture:

We can perform git checkout with hash of the commit when the file existed and -- to specify the file:

$ git checkout 74eaf -- src/main/java/com/tratif/blog/Person.java
$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	new file:   src/main/java/com/tratif/blog/Person.java

Undoing commits

This section presents ways of undoing changes committed to a Git repository.

Reverting commits

We can use git revert COMMIT_HASH to revert changes made in a commit specified by the hash. This creates a new commit with reverse changes. It is shown in the picture below:

While it might be very useful to keep track of such reversions, sometimes we might want to remove a commit “silently”. It is described in the next section.

Hint: You can specify more than one commit for git revert to undo multiple commits.

Deleting n last commits

We can use git reset COMMIT_HASH to remove all commits after specified hash from the working tree. It is illustrated in the following picture:

Hint: Instead of using commit hash, you can use HEAD~n to remove n last commits.

What happens with the changes from the removed commits? By default they just remain in the working directory (will be reported as untracked files by git status). You can also decide to delete them — just add --hard option to the command. There are multiple other switches: for example --soft will put all remaining changes to the staging area.

If this is just a local branch, then that’s it. But if it is tracking a remote branch, it must be pushed with --force option, as this approach rewrites the history. This must always be done with special care as it might disorganize the work of other team members.

Deleting commits selectively

Let’s assume that we want to get rid of the commit 74eaf completely as shown in the following picture:

We can use interactive rebase for this purpose. The commit preceding our target (55c24) becomes the rebase point. We start the process with git rebase -i 55c24. Git then presents a text file summarizing all commits after that point:

$ git rebase -i 55c24


pick 74eaf added Person
pick 3fbd8 added Address
pick ab442 added some more files

# Rebase 55c24..ab442 onto 55c24
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out


To get rid of a commit, just remove the related line (in this case it is pick 74eaf added Person) and save the file. Git will pick remaining commits and apply them one by one.

Hint: Instead of using commit hash, you can use HEAD~n to start rebasing from n commits ago.

It is super-important to remember that this approach changes the branch history. Since changes are applied on a new base (which does not contain the deleted commit anymore), they will have different hashes:

If this is just a local branch, then that’s it. But if it is tracking a remote branch, it must be pushed with --force option, as this approach rewrites the history. This must always be done with special care as it might disorganize the work of other team members.

Amending the last commit

If you want to include additional changes in the last commit, you can use --amend switch for git commit. This will replace the last commit with a new one, which will include all the changes from the old commit as well as all the changes from the staging area. It is described in the picture below:

If this is just a local branch, then that’s it. But if it is tracking a remote branch, it must be pushed with --force option, as this approach rewrites the history. This must always be done with special care as it might disorganize the work of other team members.

Restoring “lost” commits

Some of the approaches presented in previous sections modified the branch history. It is useful to remember, though, that Git internal data structures are immutable. It means, that when you e.g. amend a commit, it is not really changed, but rather a new commit is created and put on the tip of the branch. What happens with the old commit then? If it is present in another branch, you can find it there. But what if it is not? There is still a good chance to restore it. Unless garbage collection was performed on the repository, you can still refer to the commit using its hash. The example below shows how you can cherry-pick a “deleted” commit:

$ git log --graph

* commit 44b36827a13559db8359e02f31972e434cdd1170
| Author: Tomasz Kaczmarzyk <tk>
| Date:   Wed Jan 3 13:14:04 2018 +0100
| 
|     Added Address.java
| 
* commit c9f88c5c6ecb421552f86c42128c5af07958ebdf
| Author: Tomasz Kaczmarzyk <tk>
| Date:   Sat Dec 30 20:43:51 2017 +0100
| 
|     Added Person.java
| 
* commit b5a31df82387bf37aab6c39931d0251a9f1772ce
  Author: Tomasz Kaczmarzyk <tk>
  Date:   Sat Dec 30 20:39:39 2017 +0100
  
      initial commit
 
$ git reset --hard HEAD~1

HEAD is now at c9f88c5 added Added Person.java

$ git cherry-pick 44b36827a13559db8359e02f31972e434cdd1170

[master 2013ac2] Added Address.java
 1 file changed, 223 insertions(+), 0 deletions(-)

What if we don’t remember the commit hash, though? We can use git reflog to see previous values of the HEAD marker. Let’s consider the following example for git commit --amend:

$ git reflog

be1e6b5 HEAD@{3}: commit (amend): Added Person.java
44b3682 HEAD@{4}: checkout: moving from master to test
2013ac2 HEAD@{6}: cherry-pick: Added Person.java
be1e6b5 HEAD@{7}: commit: Initial commit

This log starts with the most recent events. As we can see, commit 2013ac2 has been amended. This resulted in a new commit — be1e6b5, which is the current HEAD and the current tip of test branch.

Undoing merges

Merge not finished

If we are in the middle of a merge and encountered conflicts, we can use git merge --abort to cancel the entire operation. This will leave the commit tree and the working directory in the state from before the merge.

Merge finished

Let’s assume we have a merge which we want to undo. The situation is presented in the picture below:

Generally, we can use git reset as described in Deleting n last commits section above. In this case, git reset --hard c19d3 would work. We can use ORIG_HEAD marker as a shortcut. ORIG_HEAD points to the HEAD value before the last dangerous operation. Merge is considered dangerous, so it will point exactly to the commit which we want to switch back to. Therefore git reset --hard ORIG_HEAD is a universal way of undoing a just-finished merge.

Undoing rebases

Rebase not finished

If we are in the middle of a rebase and encountered conflicts, we can use git rebase --abort to cancel the entire operation. This will leave the commit tree and the working directory in the state from before the rebase.

Rebase finished

If no other dangerous operation has been done after the rebase, then ORIG_HEAD still points to the previous tip of the branch. You can use git reset --hard ORIG_HEAD in such case (as usual, be aware that --hard might be harmful).

If you can’t rely on ORIG_HEAD (because another dangerous operation was done after the rebase), then you can use git reflog to find the previous HEAD value. For example, consider the following reflog:

$ git reflog

47cd9e2 HEAD@{1}: merge feature1: Merge made by the 'recursive' strategy.
17575ff HEAD@{2}: rebase -i (finish): returning to refs/heads/master3
17575ff HEAD@{3}: rebase -i (pick): created bef3
9f1f93e HEAD@{4}: rebase -i (start): checkout HEAD~3
6f5ba87 HEAD@{5}: checkout: moving from master to test

$ git reset --hard 6f5ba87

As we can see, HEAD was initialiy set to 6f5ba87, then interactive rebase was performed. After that, branch feature1 was merged. The last merge modified ORIG_HEAD, so we need to use commit hash (in this case it is 6f5ba87) to undo both the merge and the rebase.

Reverting arbitrary patches

You can use git apply to apply a patch to files in the working directory. There is a useful -R switch, which will apply reverse changes, i.e. if the patch adds a line to a file, then git apply -R will remove that line etc. This creates a many ways of effective undoing things. Let’s consider the following commands first:

  • git diff — shows all unstaged changes in the working directory as a patch
  • git diff COMMIT_1..COMMIT_2 — shows a patch which includes changes from all commits between COMMIT_1 (exclusive) and COMMIT_2 (inclusive)
  • git stash show -p  — shows stashed changes as a patch

For example, you can combine git diff with git apply -R to revert changes from your working directory:

$ echo "additional line" >> file1
$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   file1

$ git diff

diff --git a/file1 b/file1
index 96eea2c..ad6b9cf 100644
--- a/file1
+++ b/file1
@@ -3,3 +3,4 @@
 3
 
 first line
+additional line


$ git diff | git apply -R
$ git status

On branch master
nothing to commit, working directory clean

Removing untracked files

You can remove untracked files from the working directory with git clean command. Depending on your Git configuration it might require additional flags to delete files (-f), directories (-d) or both (-fd):

$ git status

On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

	src/main/java/com/tratif/blog/Project.java
	src/main/java/com/tratif/blog/dto/

nothing added to commit but untracked files present (use "git add" to track)

$ git clean -fd

Removing src/main/java/com/tratif/blog/Project.java
Removing src/main/java/com/tratif/blog/dto/

$ git status

On branch master
nothing to commit, working directory clean

Summary

This post presented multiple commands which might be useful for reverting changes in a Git repository. The list is not exhaustive, but hopefully makes a useful cheat-sheet for the most common scenarios. I encourage to explore Git manual for more options. Happy coding!

More articles about Git

If you liked this post, then you might also like my The Power Of Git Interactive Rebase post, which shows (on pictures) how interactive rebase works and what you can achieve with it. I highly recommend it!

Related Post

1 response to "Undoing in Git"

Leave a Reply

Your email address will not be published. Required fields are marked *