Zum Hauptinhalt springen

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.

Focus on the Why!

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:

typedescription
featA new feature (increments the minor version)
fixA bug fix (increments the patch version)
docsDocumentation only changes (e.g. in the README.md)
refactorCode changes that neither fix a bug nor add a feature
styleChanges that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
perfCode changes that improve performance
testAdding missing tests or correcting existing tests
buildChanges in the build process or auxiliary tools and libraries
ciChanges to our CI configuration files and scripts
choreChanges which do not fit in any other category (e.g. dependency updates)
revertReverts 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:

BenefitRebasingMerging
Cleaner HistoryIt 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 VisualizationEliminates unnecessary merge commits, simplifying the branch structure.Results in a more complex branch structure with multiple merge commits.
Easier Cherry-PickingFacilitates 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:

ChallengesDescription
History ModificationRebasing rewrites commit history, potentially causing confusion, especially if the branch has been shared.
Force-Pushing RequirementRebasing often requires force-pushing changes to the remote repository, which can be disruptive for collaborators who have pulled the branch.
Complex Conflict ResolutionResolving conflicts during a rebase can be more complex than with merging, since it requires per-commit conflict resolution.
Risk of Data LossIf not done carefully, rebasing can lead to data loss, as squashing or amending commits may discard changes unintentionally.
Not Ideal for Shared BranchesRebasing is generally not recommended for branches that have been shared with others, as it can create confusion for collaborators.
Learning CurveInteractive rebase and history rewriting concepts may be challenging for users unfamiliar with advanced Git features.
Difficulty with Merge CommitsRebasing 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:

  1. NO: You can simply reset your local branch to the new version of the remote branch by using:

    git reset --hard origin/<branch-name>
  2. 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.

    tip

    Note 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.

warning

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
tip

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.

read more in the git docs

info

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.

tip

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