We often find ourselves needing to undo or revert a commit while using Git, whether it's to roll back to a particular point in time or to revert a particularly troublesome commit. In this tutorial, we'll go through the most common commands to undo and revert commits in Git. We'll also demonstrate how there are subtle differences in the way these commands function.
2. Reviewing Old Commits with git checkout
To start with, we're able to review the state of a project at a particular commit by using the git checkout command. We can review the history of a Git repository by using the git log command. Each commit has a unique SHA-1 identifying hash, which we're able to use with git checkout in order to revisit any commit in the timeline.
In this example, we'll revisit a commit that has an identifying hash of e0390cd8d75dc0f1115ca9f350ac1a27fddba67d:
git checkout e0390cd8d75dc0f1115ca9f350ac1a27fddba67d
Our working directory will now match the exact state of our specified commit. Thus, we're able to view the project in its historical state and edit files without worrying about losing the current project state. Nothing we do here saves to the repository. This is known as a detached HEAD state.
We can use git checkout on locally modified files to restore them to their working copy versions.
3. Reverting a Commit with git revert
We revert a commit in Git by using the git revert command. It's important to remember that this command is not a traditional undo operation. Instead, it inverts changes introduced by the commit and generates a new commit with the inverse content.
This means that git revert should only be used if we want to apply the inverse of a particular commit. It does not revert to the previous state of a project by removing all subsequent commits — it undoes a single commit.
git revert does not move ref pointers to the commit that we're reverting, which is in contrast to other ‘undo' commands such as git checkout and git reset. Instead, these commands move the HEAD ref pointer to the specified commit.
Let's go through an example of reverting a commit:
mkdir git_revert_example cd git_revert_example/ git init . touch test_file echo "Test content" >> test_file git add test_file git commit -m "Adding content to test file" echo "More test content" >> test_file git add test_file git commit -m "Adding more test content" git log git revert e0390cd8d75dc0f1115ca9f350ac1a27fddba67d cat test_file
In this example, we've created a test_file, added some content, and committed it. Then, we've added and committed more content to the file before running a git log to identify the commit hash of the commit we want to revert.
In this instance, we're reverting the most recent commit. Finally, we've run git revert and verified that the changes in the commit were reverted by outputting the file's contents.
4. Reverting to Previous Project State with git reset
Reverting to a previous state in a project with Git is achieved by using the git reset command. This tool undoes more complex changes. It has three primary forms of invocation that relate to Git's internal state management system: –hard, –soft, and –mixed. Understanding which invocation to use is the most complicated part of performing a git revert.
git reset is similar in behavior to git checkout. However, git reset will move the HEAD ref pointer, whereas git checkout operates on the HEAD ref pointer and doesn't move it.
To understand the different invocations, we'll look at Git's internal state management system — also known as Git's three trees.
The first tree is the working directory. This tree is in sync with the local file system and represents immediate changes made to content in files and directories.
Next, we have the staging index tree. This tree tracks changes in the working directory — in other words, changes that have been selected with git add to be stored in the next commit.
The final tree is the commit history. The git commit command adds changes to a permanent snapshot that is stored in the commit history.
The most dangerous and frequently used option, with this invocation, is commit history ref pointers to update to the specified commit. After this, the staging index and working index reset to match that of the specified commit. Any previously pending changes to the staging index and working directory reset to match the state of the commit tree. We'll lose any pending or uncommitted work in the staging index and working index.
Following on from the example above, let's commit some more content to the file and also commit a brand new file to the repository:
echo "Text to be committed" >> test_file git add test_file touch new_test_file git add new_test_file
git commit -m "More text added to test_file, added new_test_file"
Let's say we then decide to revert to the first commit in the repository. We'll achieve that by running the command:
git reset --hard 9d6bedfd771f73373348f8337cf60915372d7954
Git will tell us that the HEAD is now at the commit hash specified. Looking at the contents of test_file shows us that our latest text additions are not present, and our new_test_file no longer exists. This data loss is irreversible, so it is critical that we understand how –hard works with Git's three trees.
When invocation happens with –soft, the ref pointers are updated, and the reset stops there. Thus, the staging index and working directory remain in the same state.
In our previous example, the changes we committed to the staging index would not have been deleted if we used the –soft argument. We're still able to commit our changes in the staging index.
The default operating mode, if no argument is passed, –mixed offers a middle ground between the –soft and –hard invocations. The staging index resets to the state of the specified commit and ref pointers update. Any undone changes from the staging index move to the working directory.
Using –mixed in our example above means that our local changes to files are not deleted. Unlike –soft, however, the changes are undone from the staging index and await further action.
A simple way to compare the two methods is that git revert is safe, and git reset is dangerous. As we've seen in our example, there is a possibility of losing work with git reset. With git revert, we can safely undo a public commit, whereas git reset is tailored towards undoing local changes in the working directory and staging index.
git reset will move the HEAD ref pointer, whereas git revert will simply revert a commit and apply the undo via a new commit to the HEAD. It's also important to note that we should never use git reset when any subsequent snapshots have been pushed to a shared repository. We must assume that other developers are reliant upon published commits.