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 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 the subtle differences in the way these commands function.
2. Reviewing Old Commits With git checkout
To start, we can 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 can 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 can 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. We call this 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 can revert a commit in Git by using the git revert command. It's important to remember that this command isn't a traditional undo operation. Instead, it inverts changes introduced by the commit, and generates a new commit with the inverse content.
This means that we should only use git revert if we want to apply the inverse of a particular commit. It doesn't revert to the previous state of a project by removing all subsequent commits, it simply undoes a single commit.
git revert doesn't 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 created a test_file, added some content, and committed it. Then we 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 run git revert, and verify 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's stored in the commit history.
The most dangerous and frequently used option with this invocation is commit history, as ref pointers 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.
Adding on to 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 this 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 aren't present, and our new_test_file no longer exists. This data loss is irreversible, so it's 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 wouldn't have been deleted if we used the –soft argument. We're still able to commit our changes in the staging index.
If no argument is passed, the default operating mode is –mixed, which 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 the files aren't deleted. Unlike –soft, however, the changes are undone from the staging index and await further action.
A simple way to summarize the two methods is that git revert is safe, and git reset is dangerous. As we saw in our example, there's a possibility of losing our work with git reset. With git revert, we can safely undo a public commit, whereas git reset is tailored toward 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.