When I started using Git, I took it mainly as a tool to avoid having to redo the work already done. In other words, as a replacement for the periodic backups of the files needed to minimize the risks associated with having one or more people dealing with them. And during all these years, I've verified that Git does fulfill this need, but also that it can be other amazing things!
In the first place, it can be a documentation tool. All the changes and decisions that have been made during the life of the project are stored there. Thus, it is the best tool that we have when trying to understand when and why some line of code was changed. And the best of all is that it is always updated!
Secondly, it can be a team communication tool. Every commit is a message from a member of the team saying "Hey! Today, I changed this and that, and this is the reason why". We can think of the repository as a very long conversation between the members of a team, that allows them to improve others work while learning from them.
These Git features are enhanced when using a source code hosting platform, such as GitHub, GitLab or BitBucket, and all their additional possibilities: issue tracking, pull or merge requests, automated tests, code reviews, etc. However, their actual profit will depend mainly on the commit quality. In these articles I explain how we can work with our Git repository history while learning to use some basic Git commands. Here we go!
Eventually, after spending some hours or days of working on a feature or a fix, we all reach to a point where it seems that our code is ready to be committed. Until that moment, we were making changes on the master branch, worrying only to get things working and not too much about Git.
At this point, the simplest option is to commit all our changes to the Git repository in a big and monolithic commit. Thus, we spend a few seconds to create a new branch and add, commit and push all the changes to the server:
# create a new branch git checkout -B feat/the-feature # stage all the changes git add . # commit the staged changes to the local repository git commit -m"feat: the feature" # push the commit to the server git push
Now we can create a Pull Request (a.k.a Merge Request on GitLab), run some automated tests on it, and request a review from our team. But it is then when we pay the price for not putting so much effort into the commit part of the process: unless the change is very small, it will be very difficult for them to review, understand and find suggestions to improve the quality of the code.
To try another approach we need to undo the commit to return to the previous state, where all the changes were pending to be commited. You can do that with the following command:
git reset master
git reset command allows us to undo all the changes done after the specified commit. To specify the commit that we are going to use, we can use its hash, or a branch or tag name, if there are any pointing to that commit. In this case, we want to return to the last commit that we had before adding our commit, and that is the one where the
master branch points to.
By default, all changes from the undone commits will be kept as pending changes that could be commited again. We could use the
--hard modifier to completely discard those changes, but that is not what we want at this moment.
⚠️ Note that this is the first time we are rewriting the Git history. Our local Git repository has a different log than the server Git repository. To be more specific, it has one commit less than the server. That is why is very important to avoid running commands that synchronize both repositories like
git pushuntil we finish creating our alternative version of the history.
So here we are again, at the beginning of the process. But now we will follow a different approach: we will try to make each commit have a clear goal, including only the changes that help to reach it, and a description message that explain the reason for the change.
For this purposes, its very useful to try to describe in a few phrases which changes we've done to the code in chronological order. Each phrase should be a separate commit. I include some examples here, using Imperative mood and Conventional commits format:
- refactor: prepare some class to handle a different scenario
- feat: add some fields to a model
- feat: update views to include the new fields
- test: update tests with the new fields
- feat: allow to sort using the new fields
- fix: solve bug found when trying to sort by a different data type field
- test: add tests for the new sort option
- doc: update model documentation
- doc: add a changelog entry
Once we have a clear chronology of changes, we can start committing our changes. Instead of adding all the changes at once, we will use the interactive version of the
git add command:
git add -i
Git's interactive add interface allows us to carefully browse and choose the changes that we want to add to the stage to be committed. It splits changed files in hunks that we can add, ignore, discard, edit or split in smaller hunks.
Git CLI's interactive add interface
At any moment, we can check the current status of the local repository to see the changes that were added to the stage:
# show the local repository status git status # show the changed lines that were added to the stage git diff —-cached
When we have all the changes related to the first change in our list added to the stage, we can commit them with the related description message:
git commit -m"refactor: prepare some class to handle a different scenario"
And that's all. All we have to do is repeat the add and the commit steps until we have all our pending changes committed.
Finally, we can push all the changes that we made to the server, but we must remember that our local repository has a different history than the server. That's why we need to add the
-f) additional argument to the command:
git push -f
When visiting our open Pull Request, we and our teammates will be able to see the list of commits, and browse them chronologically one by one. Browsing commits in this way, having only its description and included changes, will allow them to fully understand its purpose and focus their efforts on learning its good practices and suggest better solutions for its weak points.
But there also other advantages of using this approach. As when writing our ideas before saying them, the commit preparation process will help us to have a better understanding of what exactly we are changing , allowing us to early detect bugs sooner and avoid uploading sensitive data, debugging statements and useless code.
On the other hand, maybe it seems that the effort and time that we have to invest with this approach is too much compared with the simple approach. But I think that the real comparison should be against the total effort and time that each of your teammates has to invest when reviewing your code. Also, you should consider the effort fixing problems saved by double-checking your code before uploading it to the repository.
Nonetheless, there is one thing that we could do to speed up this process, and it is related to the interactive add process. A few years ago, I discovered that this could be done faster and easier with a GUI tool for Git. I'm currently using GitKraken, but I think that many other GUIs for Git could be useful for you.
GitKraken's interactive add interface
I feel that the way we use Git is very different from one team to another, even from one person to another in the same team. We often modify our Git flow when we change project, tool or job. We can learn from practice, from our work colleagues, from a presentation that we attend or from any other source. In the end, it comes down to continually improving the way we use these wonderful tools that we have. Hopefully, this article helps you to do it, at least a bit.
And if you feel curious about working with Git history, let me tell you that we have barely started to scratch the surface. We still have to face that powerful command feared by everyone: the Git rebase.
So, stay tuned and I wish you nice and clear commits!
Cover photo by Andre Hunter