How to not f- up your local files with Git part 3
Rewriting history and how to handle mistakes
This article assumes a good understanding of the basic actions behind git and its relative workflow, if you have no idea what this all means the two previous articles cover just that.
Scope of this article
Following the first two articles I received a number of questions regarding details about Git history, some less common Git commands, and the reasoning behind certain aspects of Git.
In this article I will try to address those arguments, as well as providing practical advice about using them as part of your development workflow.
Introduction — The logic behind Git
I wanted to give a little more detail on Git and its utility. First of all, like most you may have approached Git as a tool to save your projects so you do not lose your work. This is a good reason to start using Git but there is more to it than that as you might have already noticed, Git’s strength is the ability to document your projects history, using it only to save is like having a plane and just using the wheels to go around.
Mastering Git history will help you understand a lot about how a project is structured and works. It is an absolutely important skill to have as it reduces the time you will spend trying to get around someone else’s code base, thus making you a better and faster contributor, employee and freelancer.
Chapter I — Atomic commits
How can we use Git to it’s full potential then? I talked about doing scoped commit but I did not fully explain the reasoning behind it.
Consider you are cooking a cake, you go on my trial and error, experimenting various different ingredients. You realise that keeping notes of what are you trying to do is actually a good idea.
If you write everything you try on every page as a random amalgam of ideas and scrabbles you will probably not be able to replicate your cake because your notes will be really hard to read.
However, if you meticulously write down every passage you do and every ingredient you try, you will end up with a list like this:
- Take large pan
- Add 50 grams of butter
- Cook at medium heat until butter melts
- Add some milk
- Pour a glass of wine
- Drink glass of wine
This looks really easy to follow and you could replicate this tomorrow on a new cake and even your friend on the other side of the world would be able to follow it.
These are your commits, their purpose is to document your project so you can go back, check what, how and why something was done in a specific way instead of another.
If you are coding small projects in span of a week you might not see the value of using this approach to document your projects, but once they will start to grow and once you will have to go back working on them after months you will thank your past self for the documentation you left behind.
This doesn’t mean you have to commit every minute, you can still code for a couple of hours and commit after you are finished. Using Visual Studio Code there’s a simple way to do atomic commits:
As you can see from the images above, in this Redux reducer I modified two different things:
- Update the reducer JSDoc
- Add new multiply action
These two changes are not related to one another so each deserves a separate commit.
Lucky for us we can again use Visual Studio Code to simply commit a few selected lines instead of the whole file.
Highlight the lines you want to commit, click the 3 dots button on the top right side of the screen and select “Stage Selected Ranges” to stage the selected lines for your next commit, this way we can save this piece of code in our
Update the reducer JSDoc
commit using VSCode itself (more on that here Commit using VSCode).
After this commit has been made we can follow the same process to commit the new reducer action (or just stage the whole file since it’s the only change left).
Similarly we can remove lines from a staged file by using the “Unstage Selected Ranges”.
The better you make use of atomic commits, the easier it will be to deal with the issues that may arise during the project, this is extremely important in understanding and using the commands specified in the next chapter.
Chapter II — Changing Git history
We’ve discussed how important history is related to Git, all the commits and relative descriptions paired with a good use of Branches and Pull requests (more on pull requests here) can make even the biggest projects easy to follow and work on.
Git history should be immutable and although there are methods to alter it, it is discouraged to do so.
Let’s see the most popular use cases of altering history.
Part I — Git pull: Merge vs Rebase
Pulling is frequently used on projects where different people are working simultaneously. For example you start you branch as a child of the branch dev and work on it for a week before submitting a pull request from your branch back into dev. In the meantime someone else might have already pushed changes do the dev branch. To ensure that your code is compatible with their changes you should make a pull to “import” the updated dev branch in your local branch.
There are two way to accomplish this — merge and rebase. Both do the same, grab the new stuff from REMOTE (the branch stored on Github) and slap it into your local branch, but in a sightly different way.
- Merge is the default action used when you pull changes from REMOTE to local, it creates an automatic commit called something like “Merge xx from yy”.
# This is the default way to merge
# fetch grabs the changes, merge applies them
$ git fetch
$ git merge
# Can be written as:
$ git pull
- Rebase is the alternative, it does not create a commit so it might be a bit harder to spot a pull, I suggest not using this method.
Even though using merge “pollutes” your history with additional commits, it is clearly stated when actions are performed so it renders your commit history more descriptive.
# This is the default way to rebase
# fetch grabs the changes, rebase applies them
$ git fetch
$ git rebase
# Can be written as:
$ git pull --rebase
Rebase has more hack-y uses as we will see later.
Merge vs Rebase is quite a holy war for some devs and I suggest you to always use merge and don’t touch history, it will save you a lot of headaches in the long run.
To not have too many merge commits my advice is to do it if it’s strictly necessary, usually you can just use it once when you have finished working on your branch and you are ready for a pull request.
Part II — Removing commits: Revert vs Rebase vs Reset
It happens sometimes to commit a piece of code by mistake, I lost count how many times it happened to me. Let’s learn how to solve it without Google or Stack Overflow.
When removing a commit there are always major chances of destroying the world, one of the most effective things you can do to reduce the chances of that happening is to have atomic commits.
Since all commits are independent from one another (for the most part) it should be fairly painless to remove one without causing half of the project’s code to fail miserably. A good rule of thumb is, if you have a commit to remove, do it instantly, the closer the commit is to the tip of your branch, the better.
To use these commands you will have to know the hash of the commits you intend to modify, this is easily achieved using
Navigate up and down the commit history using the arrow keys, exit using the Q key.
As for Pulling, there are multiple ways of removing a commit.
- Revert is the preferred way, it does not modify the previous history, it just adds a new commit on top that removes the changes in the specified commit.
This is the safest way to remove changes if you are removing commits that are already pushed to REMOTE and are available to everyone as it simply adds a commit on top of your changes, it’s not harmful and will not modify the already set history.
Consider this code where commitHash is the hash of the commit you want to revert.
$ git revert commitHash
- Rebase can be used to remove a commit too, the big difference is that it removes the whole commit from history, so this is permanent and you will lose the commit forever.
Removing a commit using rebase is incredibly dangerous as it alters the history of your project, possibly paving the road to an endless wave of conflicts and broken code that you will have a hard time fixing since the deleted code is gone forever.
It is discouraged to use rebase this way, and pretty much a definite ‘NO’ if you have pushed already to REMOTE.
The only reason I would use rebase to remove a commit is if you have pushed sensitive info on a publicly visible repository.
In our example branch:
$ git rebase -i HEAD~2
HEAD~2 tells git to bring us the 2 last commits, HEAD~4 would bring us the last 4, it is an alternative to using the commit hash.
- Reset is similar to rebase as it deletes the history permanently so you will lose your commits, but not the code. All the changes contained in the deleted commits will remain available as unstaged changes, ready to be staged and commited again.
Considering this, it is strongly discouraged to use reset for code that has already been pushed to REMOTE. The biggest difference between rebase or revert and reset is that the latter works not by grabbing a selected commit but removing everything after the specified commit.
There are different uses for reset, the most useful one is probably as a way to delete some badly written commit messages to have a second chance at writing them again, using it does not delete the code, just unstages it.
Consider this code where commitHash is the hash of the commit you want to go back to.
$ git reset commitHash
Different ways to use reset are to unstage or delete unstaged code, functions already covered by VSCode by default, more on how to use those here.
Conclusion or TLDR
Your project history is the biggest tool at your disposal when using Git, you can interact with it in a number of ways, get comfortable checking it with git log, to have something meaningful to look at write good atomic commits, easy to read and easy to modify in case such need arise.
Revert wherever you want as it reserves history, rebase only in case of sensitive info leak, reset to write better commits.