Back in time with git

🗓️ • 🔄 • ⏳ 5 min

One useful feature of VCS (git or otherwise) is the ability to restore the state of a project to a previous point in time.

Here are some common mistakes and how to fix them.

Local changes

Changes you might want to undo before being pushed to a remote.

Commit

Commits can be undone using the git reset command.
There are multiple ways to undo a commit, depending on what you want to do with the changes in it.

We also need to tell git which commit we are resetting to. This is done by passing the hash of the commit prior to the one to be undone:

sh
git reset --soft <hash_of_good_commit>

You can get the last 10 hashes with this command:

sh
git log -10 --abbrev-commit --pretty=oneline

If only the last commit needs to be reset, HEAD~1 can be used instead of the hash to tell git to go to the commit before the current one:

sh
git reset HEAD~1

Of course this allows for any number of commits to be undone, not just the last one.

Change

Let’s suppose, to keep things simple, that all changes to a file need to be undone.
If these changes have not yet been added to the staging area, git restore <file> will remove those changes.

If instead they are already in the staging area, git restore --staged <file> will unstage them, so that they can be either modified and restaged, or removed altogether using the previous command.

Of course if multiple files need to be handled a . can be used instead of a list of file names.
Consider that this will apply to all files.

Similarly, if all uncommitted changes need to be fully discarded, running git reset --hard with no commit hash will reset the state of the project to whatever is in the current commit, removing all other changes.

Merge

Undoing an in-progress merge is as simple as running git merge --abort.

If however the merge has already been committed, the previously mentioned git reset --hard HEAD~1 will also work here. Of course, using the hash instead of HEAD~1 would work as expected.
Merges are ultimately just fancy commits.

The Nuclear option

Sometimes, the local work tree gets mangled by a combination of odd git abstractions and user error.

It might be easier to fully reset the local env to whatever is currently on the remote repo.
To do this, run these commands:

sh
git fetch origin
git reset --hard origin
git clean -xdf

Here, the state of the remote repo is fetched, the state of the local repo is reset to the remote one, and all untracked files are cleaned recursively, leaving the working area with no changes.

Indeed, at this point one might consider rm -rf ./the_whole_project/ && git clone the_thing_again.
This works, but also removes all branches and ignored files. Plus, big repos might take a while to fully clone. The commands described here should be more time-efficient.

Pushed changes

If the changes have already been pushed, using reset like before will require a git push --force, which will overwrite the remote repo with your current one (or more specifically, overwrite the conflicting changes).

This might not be an issue in a personal project but when working with other people it’s a bit no-no.
In fact, force pushes might be disabled altogether.

This makes sense, since changing the state of the remote while another person’s work depends on that (now overwritten) state can render their work useless or take a while to merge back together.

Revert

Apart from resetting a branch to a given commit, we can also revert a specific commit (or set of commits).

This way, instead of removing commits, we add new ones with the changes required to reset the state of the project to how it was before the commit to be reverted.

So given git log like this:

621d866 (HEAD -> master, origin/master) oh fuck
07ef6b4 another goot commit
3dbbc2b good commit

Resetting the last commit would require a force push, but git revert HEAD will simply add a new commit that can be safely pushed:

5c07fa2 (HEAD -> master) Revert "oh fuck"
621d866 (origin/master) oh fuck
07ef6b4 another goot commit
3dbbc2b good commit

Beware however, that if a revert is done on a commit previous to the last one, and the reverted changes are needed for the changes in newer commits to work, those commits might break (as in, the build might break, or the tests might fail).
In those cases you might need to revert multiple commits or introduce further ones to fix the issues.

This has no good solution, so consider reverting a commit as soon as possible and committing small changes at a time.
A commit that changes 200 files is bound to cause issues when reverted, while one that only modifies a function likely will not.

Merges

Perhaps surprisingly, using the previous revert command on a merge commit will fail:

error: commit <HASH> is a merge but no -m option was given.
fatal: revert failed

That -m flag takes a number that corresponds to the Main parent.

This makes sense, a merge by definition has two parent commits: the one you are on when running git merge (1) and the one you are merging into it (2).

So if merging feature-branch into master, the former would be 2 and the latter would be 1.

The command:

sh
git revert -m 1 <merge-commit-hash>

Would create a revert commit restoring the state of master.

Since more than two commits can be merged, the -m flag takes an indefinite number.
In most cases, the expected behavior will be achieved passing 1 to it.


Other posts you might like