
Git is a fundamental tool that every developer must have in their arsenal. In a modern and agile world of software development, where codebases are managed by huge teams, having a tool that helps to streamline the process of adding features, changes, or fixes is, without a doubt, a necessity.
Like many tools, git is, with no exception, easy to learn and hard to master. In just one day, an inexperienced developer can learn how to connect to a remote repository, create a branch, push commits, create pull requests, and merge the code. This blog post aims to help developers become more mindful about what and when is being committed to keeping the history well organized, informative and clean.
Keeping the commits history clean helps understand what changes were made to the codebase to backtrack easier if something is not working properly. Not having descriptive commit messages or having too many changes in a single commit are common issues of larger codebases.
The easy parts
Every developer knows these commands by heart:
git add . # add all unstaged files
git checkout -b branch-name # create new branch and switch
git commit -m "commit message" # commit staged files
git push origin # push to remote repository
git pull # fetch new code from remote repository to the local one
git merge branch-name # merge new code in to existing one
git reset --hard # reset all the changes to the last commit
These commands are the foundation of working with Git. However, some of these commands can be slightly modified to create better commits.
git add
When working on a new feature, it is often hard to stop in the middle of the process just to create a commit. After some time, the feature is fully implemented. X number of files have been altered, and Y files were added. The time to commit code has come. Instead of creating one big commit, it is often better to have multiple smaller ones, e.g., creating a UI component, creating a test case, updating the types, etc. But the work is already done, so how can one split it now?
git add . -p
Simply by adding -p
(patch) flag to git add
command will open an interactive window that will go change by change (it
will not
go through newly created files). Developer will be able to decide whether or not to add given change, skip it or break it down even more. Now all changes regarding the tests, creating new ui components or updating the types can be separated into a different commits. Same file can be committed multiple times containing different changes that are part of a given commit, thus bringing it one step closer to create a clean commit history.
git commit
Now that the bigger changes can be grouped into smaller chunks adding a descriptive commit message is next in order.
git commit -m "feat: implement amazing feature"
Adding one -m
flag will create a header for the commit message that will be shown in the history. However, sometimes one liner is not enough to fully describe the changes because more context is required. Adding another -m
flag will create a commit's description:
git commit -m"feat: implement amazing feature" -m"This is a description of what this feature entails"
When running the git log
to display the commit history, it will be displayed as so:
Author: Michał Gąsiorek <xyz@buziaczek.pl>
Date: Sun Feb 25 11:44:38 2024 +0100
feat: implement amazing feature
This is a description of what this feature entails
To quit the git log command, click 'q' on the keyboard.
Descriptions can be improved by adding $
before the description to introduce better text formatting.
git commit -m"feat: init project" -m$'- configure eslint,\n- configure prettier,\n- add .nvmrc file,\n- install tailwind'
Adding \n creates a new line
This will be visible as:
Author: Michał Gąsiorek <xyz@buziaczek.pl>
Date: Sun Feb 25 11:49:42 2024 +0100
feat: init project
- configure eslint,
- configure prettier,
- add .nvmrc file,
- install tailwind
Not every commit requires a description. It is always up to a developer to consider if changes that are being committed require more context or not.
At Bitnoise, we follow the guidelines laid out by the conventional commits to ensure that our commit messages are well structured. This ensures that commits in the history have a similar structure, making them easier to read.
There can be cases where the commit was made, but there was a typo in the code, or some small improvement was still missing. Instead of creating a new "fix typo" or "remove console.log" commit --amend
flag can be used.
git add ...
git commit --amend # will keep the last commit's message
# or
git commit -m"some new message" --amend # will rewrite the last commit message
It will add all new changes to the last existing commit, so it's super useful for small updates on the last commit (how to update commits that are higher in the history will also be covered later in the blog post).
Amending will change the commit hash, so if the commit has been pushed to the remote branch, it will be required to force push; thus, amending on shared branches must be avoided!
Bringing it all together
Moving changes from one branch to another is a crucial part of working with git. It serves three purposes:
-
shared branch can be merged to a local branch to have it up-to-date with the latest changes made by the rest of the team, Local branches can be merged into a shared branch to make the new feature or bug fix accessible to other developers in the team (usually via pull request).
-
local branch can be merged to another local branch to have them up-to-date
These cases may seem the same at first - making one branch up-to-date with the changes of the other. However, there is an important difference between the shared branch that is being used by the whole team and the local branch that's being used by one developer. The difference is that on a shared branch, the commit hashes should never change (pushing with force should never be required). On local branches, commit hashes can change (until the changes are not merged to the shared branch), and it will not impact the team's work.
In those scenarios, the developer has access to two different strategies: merging and rebasing .
Based Rebase
git rebase branch-name
When is it safe to rebase?
-
when updating the private branch with the shared / main branch to preserve linear history.
-
When making changes to the commits made on the private branch that are not a part of the shared branch, e.g., editing commits, rewording commit messages, squashing commits, or deleting them.
When is it not safe to rebase?
-
when working on a shared branch like development or main, it's always better to use merge over rebase to ensure that commit hashes will not change and no forcing push will be required. If merge commits are not needed,
git merge branch-name --ff-only
can be utilized to merge changes without adding the merge commit.
Interactive rebasing
Where rebase surely shines is the interactive version, which can be used to alter the commit history in many ways.
git rebase HEAD~n -i
_ n - number of commits in the history to take into the rebasing from latest to oldest f.g if two latest commits should be rebased, then n = 2_
Interactive rebasing allows you to edit committed files, reword commit messages and even squash or delete commits.
When running the command, it will show a window that can look like this:
# runned git rebase HEAD~3 -i
pick [hash] implement eslint
pick [hash] implement prettier
pick [hash] add necessary eslint rule
# Rebase [commit_hash]..[commit_hash] onto [commit_hash] (1 command)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, unless -C is used, in which case
# keep only this commit's message; -c is same as -C but
# opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# create a merge commit using the original merge commit
# message (or the online, if no original merge commit was
# specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
# to this position in the new commits. The <ref> is
# updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
For example, above following actions can be utilized to organize the commits better:
# edit or e
edit [hash] implement eslint # stop here so the necessary rule can be added in this commit
# pick or p
pick [hash] implement prettier # keep this commit as is
# drop or d
drop [hash] fix eslint rules configuration # delete this commit
When editing a commit, after editing was finalized, git rebase --continue
should be executed to move to the next commit
Rebase will go through each listed commit, execute the desired action, and after completion, replace a commit, thus changing commit hash , and move to the next one.
If the commits were already pushed to the remote branch push with force will be required.
When a need arises to edit a commit message or whole commit that is not the latest one (the latest one can be updated by amending with git commit—-amend
), interactive rebasing truly becomes handy.
More is not always better
On the other hand, having 30 commits for a single feature or bug fix might be an overkill and reduce the history's readability rather than improve it. Sometimes, it makes more sense to join or squash some commits into one bigger commit with more substance.
For example:
chore: fix typo
chore: remove console.log
chore: remove console.log
chore: fix typo
chore: remove todo comments
The commits above are very granular, and it does not make sense to have them separated like that. One can leverage interactive rebasing to squash these commits easily.
# runned git rebase HEAD~5
r [hash] fix typo # reword the last commit to edit it's commit message
s [hash] remove console.log # squash with previous commit and keep the commit message
f [hash] remove console.log # squash with previous commit and omit the commit message
s [hash] fix typo # squash with previous commit and omit the commit message
s [hash] remove todo comments # squash with previous commit and keep the commit message
The end result may look something like this:
Author: Michal Gasiorek <xyz@buziaczek.pl>
Date: Mon Mar 11 09:38:22 2024 +0100
chore: cleanup # reworded (previously fix typo)
- remove console.logs # squashed and fixed up to remove duplicated remove console.log commits
- fix typo # squashed fix typo commit
- remove todo comments # squashed remove todo comments commit
Now, instead of having multiple remove console.logs, typo fix commits can be organized into one big, easy-to-understand commit.
Conclusion
Many developers stop learning git after understanding the basics, which can negatively influence the team's work. Clean history is a part of code documentation and improves not only tracking what was done and when but also reviewing the code in pull requests. Tracking where something could've gone wrong when bug fixing is also less painful if commits are well organized.
This blog post shows that small changes to well-known commands can drastically improve the quality of the commits being created. Adding interactive rebasing into the mix will also make any developer an even better team player.
Useful tips and tricks
Git game
If you're a beginner and those topics still appear too advanced, you may want to check out this game to familiarize yourself with the git basics.
Editing name of the branch
Sometimes, a typo can sneak into a branch when creating it. So here is a flow that will allow me to edit branches to fix the typo.
git push origin –d "wrong-branch-name" # delete from remote if already pushed
git checkout "wrong-branch-name" # checkout local branch
git branch --unset-upstream # remove the upstream from local branch
git branch -m "updated-branch-name" # modify the branch name
git push origin # push new branch to remote
Removing all local branches
When working on a big project, sometimes a lot of old branches can be deleted. Instead of going branch by branch, this command can be used to delete all local branches:
git branch | grep -v "branch-to-keep-locally" | xargs git branch -D