Working with Git
Git is at the heart of every code repository, as it is probably the most used version control system in todays code repositories. Mastering git is an important skill for our craft.
Commit Messages
There is more to a commit message, than it sometimes meets the eye. Writing good commit messages is an art of its own. Gitlab has a well crafted checklist of useful guidelines on how to write good commit messages. They also refer to the #1 Google hit for how to write good commit messages.
Keep in mind, that commit messages are extremely helpful in case someone is looking for why a change happened, not so much what actually happened. The what is already part of the files you changed. Therefore, include a body which describes why you did these changes for a future reader (which will be also you!).
Conventional Commits
Applying a convention on how to structure a commit message across your team can be extremely helpful. A lot of us use Conventional Commits as their convention within their teams.
Defined types
The specification is rather minimal and more intended for tool builders rather
than end-users
(#515).
It only defines the types feat
and fix
and intentionally does not provide
the definitions for other types
(#283).
The angular guidelines list additional types. The following table gives an overview of the angular types and additional ones, which we use in our daily work:
type | description |
---|---|
feat | A new feature (increments the minor version) |
fix | A bug fix (increments the patch version) |
docs | Documentation only changes (e.g. in the README.md) |
refactor | Code changes that neither fix a bug nor add a feature |
style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) |
perf | Code changes that improve performance |
test | Adding missing tests or correcting existing tests |
build | Changes in the build process or auxiliary tools and libraries |
ci | Changes to our CI configuration files and scripts |
chore | Changes which do not fit in any other category (e.g. dependency updates) |
revert | Reverts the previous commit |
Specific repositories
Adopting Conventional Commits in library or application repositories is straight forward. However, it can be confusing to use it in a specific repositories, such as documentation.
You might be tempted to categorize commit types specific to the nature of these projects, leading to questions like, "What counts as a new feature in the context of documentation?"
Rather than attempting a direct correlation of commit types to such specialized
use-cases, we suggest using only the types that are truly relevant. In the case
of repos dedicated to documentation, the majority of your commits will likely
fit best under docs
, and you may not need to use feat
at all.
Commit Scope
Structuring commits during the development of a feature can be completely different to the commit structure we apply if we prepare a branch to be reviewed within a merge request. People strictly following TDD might even do a commit after each of the TDD phases red, green, refactor. This leads to a fine grained commit history, which allows for sensitive resets in case a change lead us in a wrong direction.
However, this fine-grained commit structure is usually not suitable for an efficient review process. Ideally there is only a single commit to be reviewed (to keep a merge request small).
In case of larger merge requests, commits should be structured in a way that it
supports the review process. This requires an incremental structure, where each
commit has a meaningful scope. It can be helpful to always group changes
together into a single commit, if they would also be reverted together, in case
a feature needs to be reverted for some reason after it was merged into the
main
branch.
Therefore, commits usually need to be restructured to prepare the branch for the
review process. git rebase
is a valuable tool for this process.
Rebasing vs Merging
Rebasing and merging are two different strategies used in Git to integrate changes from one branch into another. Both have their advantages and use cases, and the choice between them often depends on the specific workflow and preferences of the development team.
Benefits
Here are some benefits of rebasing compared to merge commits:
Benefit | Rebasing | Merging |
---|---|---|
Cleaner History | It creates a linear history by moving the branch to the tip of the target branch. | Creates additional commit nodes, leading to a potentially more cluttered history. |
Simplified Branch Visualization | Eliminates unnecessary merge commits, simplifying the branch structure. | Results in a more complex branch structure with multiple merge commits. |
Easier Cherry-Picking | Facilitates cherry-picking by maintaining a linear commit history. | Cherry-picking from a branch with merge commits may be more complex. |
Challenges
The table above highlights the clear benefits of using rebasing instead of merging in a teams Git workflow. However, a rebasing driven workflow has some costs and challenges:
Challenges | Description |
---|---|
History Modification | Rebasing rewrites commit history, potentially causing confusion, especially if the branch has been shared. |
Force-Pushing Requirement | Rebasing often requires force-pushing changes to the remote repository, which can be disruptive for collaborators who have pulled the branch. |
Complex Conflict Resolution | Resolving conflicts during a rebase can be more complex than with merging, since it requires per-commit conflict resolution. |
Risk of Data Loss | If not done carefully, rebasing can lead to data loss, as squashing or amending commits may discard changes unintentionally. |
Not Ideal for Shared Branches | Rebasing is generally not recommended for branches that have been shared with others, as it can create confusion for collaborators. |
Learning Curve | Interactive rebase and history rewriting concepts may be challenging for users unfamiliar with advanced Git features. |
Difficulty with Merge Commits | Rebasing does not handle merge commits in a straightforward manner, making it challenging when rebasing a branch with merge commits. |
Some of the challenges can be addressed with some workarounds:
Force-Pushing Requirement
A force push introduces challenges on shared branches, since another team member might run into unexpected conflicts, if he/she pulls from a branch which was recently forced pushed. There are two options here, depending if you have uncommitted or committed changes on your local branch:
-
NO: You can simply reset your local branch to the new version of the remote branch by using:
git reset --hard origin/<branch-name>
-
YES: Commit your uncommitted changes first, since the golden rule of Git is: You will be able to recover everything, which was part of a commit! Then pull the remote branch with interactive rebase enabled:
git pull --rebase=1
This will allow you to choose which of your local changes should be kept (your recently committed changes) and which one to drop.
tippNote that you will see each commit which differs between your local history and the history of the remote branch. Commits which have the same commit message on your local and the remote branch might still be part of this list, since they have been changed by the rebase operation and the force push operation on the remote branch. You need to drop them in your interactive rebase operation to avoid unnecessary conflicts.
Complex Conflict Resolution
The per-commit conflict resolution of the rebasing process is often criticized by people favoring a merge-based workflow. However, it can be mitigated by reducing the number of commits which need to be rebased. Consider the following example:
Rebasing the branch feat/new-feature
onto the main
branch could require
resolving conflicts three times (for commit C, D and E). This process can be
simplified by first reducing the number of commits on the feature branch:
git rebase --i <commit-hash-of-commit-C>
We also use rebase to reduce the number of commits with a interactive rebase command. But since we rebase the feature branch onto the commit C, we do not introduce conflicts here.
The result might look like this (after using fixup to integrate all commits into C):
Now we can rebase our feature branch onto the main branch and only expect a single iteration of resolving conflicts. The result will look like this:
Now the feature branch can be integrated fast-forward into the main
branch.
Reducing the number of commits on the feature branch might not be possible, if
all the commits should become part of the main
branch history!
Not Ideal for Shared Branches
Force-pushing a branch is often required after a rebase operation. This often
introduces conflicts if the branch was already pulled by others
(Force-Pushing Requirement). Ideally, we avoid
using force push as long a possible (at least until the code-review is done). We
still might want to amend commits with new changes (e.g. as part of
incorporating review feedback). This can be achieved with fixup commits.
Consider you have the following two commits A
and B
on your branch and you
want to amend your current changes into commit A
:
git commit -a --fixup <commit-hash-of-commit-A>
Git will create a new commit with your changes with the following commit message: `fixup! "commit message of commit A". You can push this commit to the remote branch and your colleagues can pull the branch without the fear of creating conflicts.
After the code-review and is done and the feature branch is ready to be
integrated into your main
branch, you can rebase it locally (GitLab does not
support autosquash) with
the autosquash flag:
git rebase --autosquash --interactive
Fixup commits are ideal for code-review workflows: Simply commit the changes requested by the reviewer as a fixup commit for the commit in which the affected lines of code have been introduced.
Git will automatically fixup all commits based on their commit message. The
resulting branch now needs to be force pushed, which should not be an issue,
since it will be integrated immediately to the main
branch.
Use git push
with the --force-with-lease
option to prevent accidental
overrides of commits on the remote.
git push --force-with-lease
This option allows you to say that you expect the history you are updating is what you rebased and want to replace. If the remote ref still points at the commit you specified, you can be sure that no other people did anything to the ref. It is like taking a "lease" on the ref without explicitly locking it, and the remote ref is updated only if the "lease" is still valid.
IDEs such as IDEA will perform a --force-with-lease push to avoid overriding others work.
Decide with the Team
The decision between the rebasing and merging affects the way a team works and integrate changes into their codebase. Therefore, you must agree on one of the two approaches within your team. Everybody should be especially aware of the challenges and how to deal with them.
The codementors recommend adopting a rebase centric workflow, since we appreciate a clean and easy to understand history!
Automation with git Hooks
Automation can help git commits achieve consistent quality. One possibility is to use git hooks to prepare the commit message.
pre-commit Hook
We maintain a pre-commit hook to add an issue number to each conventional commit.
Using a custom shell script
This example is inspired by the
blog article of Wiktor Malinowski.
The ticket number is extracted from the branch name and placed in the commit
message. To install copy the script and save it to
<repo-name>/.git/hooks/prepare-commit-msg
.
#!/bin/bash
FILE=$1
MESSAGE=$(cat $FILE)
TICKET=$(git rev-parse --abbrev-ref HEAD | grep -Eo '^(\w+/)?(\w+[-_])?[0-9]+' | grep -Eo '(\w+[-])?[0-9]+' | tr "[:lower:]" "[:upper:]")
if [[ $TICKET == "[]" || "$MESSAGE" == "$TICKET"* ]];then
exit 0;
fi
echo "[$TICKET]: $MESSAGE" > $FILE
git hooks can now also be defined globally