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
- Restoring deleted files
- Undoing commits
- Restoring “lost” commits
- Undoing merges
- Undoing rebases
- Reverting arbitrary patches
- 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.
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:
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.
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 patchgit diff COMMIT_1..COMMIT_2
— shows a patch which includes changes from all commits betweenCOMMIT_1
(exclusive) andCOMMIT_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!
1 response to "Undoing in Git"