Git Fast-Forward VS Non-Fast-Forward

Introduction

When we run git push, sometimes we will encounter rejections because the remote origin branch cannot be fast-forward updated. It’s because we might be doing something sloppy and a git mechanism was triggered for preventing losing commits.

1
2
3
4
5
6
7
8
$ git push
To github.com:leimao/onnx.git
! [rejected] grid_sample_nd -> grid_sample_nd (non-fast-forward)
error: failed to push some refs to 'github.com:leimao/onnx.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

In this blog post, I would like to discuss git fast-forward versus non-fast-forward, understanding the rationale behind the differences, and using git push --force correctly and carefully.

Fast-Forward

When an update changes a branch (or more in general, a ref) that used to point at commit A to point at another commit B, it is called a fast-forward update if and only if B is a descendant of A.

In a fast-forward update from A to B, the set of commits that the original commit A built on top of is a subset of the commits the new commit B builds on top of. Hence, it does not lose any history.

Example 1

This is the most common example we will encounter during our daily git usages. We created a local branch, created a new commit, and want to update the origin branch from the local branch.

The commit B is a descendant of the commit A so the origin branch can be fast-forward updated from the local branch via git push.

Before fast-forward updating the origin branch, the diagram looks like this.

1
2
3
     B local branch
/
---A origin branch

After fast-forward updating the origin branch, the consequent diagram looks like this.

1
---A---B origin branch and local branch

Example 2

This example is extended from the example 1 that we created multiple commits in the local branch, and we want to update the origin branch from the local branch.

The commit C is a descendant of the commit A so this branch can be fast-forward updated via git push.

Before fast-forward updating the origin branch, the diagram looks like this.

1
2
3
     B---C local branch
/
---A origin branch

After fast-forward updating the origin branch, the consequent diagram looks like this.

1
---A---B---C origin branch and local branch

This normally happens when the user created a branch and is the only user who works on the branch. There are multiple descendent commits being updated to the branch together.

Example 3

This example is extended from the example 1 that while we created commits in the local branch, the original branch got updated by other contributors, and we want to update the origin branch from the local branch.

1
2
3
     B local branch
/
---X---A origin branch

We will have to pull the commits from the origin branch to the local branch, resolve merge conflicts if there is any, resulting in a new commit C.

The commit C is now a descendant of A so this branch can be fast-forward updated via git push.

Before fast-forward updating the origin branch, the diagram looks like this.

1
2
3
     B---C local branch
/ /
---X---A origin branch

After fast-forward updating the origin branch, the consequent diagram looks like this.

1
---X---A---C origin branch and local branch

Non-Fast-Forward

In contrast, a non-fast-forward update will lose history. For example, suppose you and somebody else started at the same commit X, and you built a history leading to commit B while the other person built a history leading to commit A.

Git Rebase Example

It’s very common that the origin branch will fall behind the origin main branch in a collaborative repository. Therefore, when the user tries to merge the origin branch to the origin main branch, there can be merge conflicts.

1
2
3
     B origin branch
/
---X---A origin main

A common habit is that the user will rebase the origin branch to the origin main branch from time to time and resolve conflicts if there is any during rebasing so that when the origin branch is about to be merged to the origin main branch, the diagram will look like this and the original main branch can be fast-forward updated.

1
2
3
         C origin branch
/
---X---A origin main

However, in practice, after rebasing the local branch to the origin main, when the user tries to update the origin branch from the local branch, the origin branch cannot be fast-forward updated.

Before rebasing the local branch to the origin main branch, the diagram looks like this.

1
2
3
     B origin branch and local branch
/
---X---A origin main

After rebasing the local branch to the origin main branch and resolving the rebasing conflicts if there is any,

1
2
3
4
5
     B origin branch
/
---X---A origin main
\
C local branch (after rebasing local branch to origin main)

To update the origin branch, we will have to force the non-fast-forward update using git push --force. The consequent diagram will look like this.

1
2
3
         C origin branch and local branch
/
---X---A origin main

Notice that if there is another commit D that is merged to the origin branch before git push --force, such as

1
2
3
4
5
     B---D origin branch
/
---X---A origin main
\
C local branch (after rebasing to origin main)

The commit D will be erased by the non-fast-forward update and the commit D will be lost. The consequent diagram will still look like this.

1
2
3
         C origin branch and local branch
/
---X---A origin main

Git Commit Amend Example

Git operations that changes the git commit history, such as git commit --amend, will sometimes disable fast-forward update.

Before fast-forward updating the origin branch, the diagram looks like this.

1
2
3
     A local branch
/
---X origin branch

After fast-forward updating the origin branch, the diagram looks like this.

1
---X---A origin branch and local branch

Then we continue working on the local branch and somehow we used git commit --amend that creates a new commit B changes the past git history. The diagram will now look like this.

1
2
3
     B local branch
/
---X---A origin branch

In this situation, even if we know that the commit B is a continuation of the the commit A, fast-forward updating the origin branch from the local branch becomes impossible, because the commit B from the local branch is not a descendent of the commit A from the origin branch. We will have to use git push --force and the consequent diagram will look like this.

1
---X---B origin branch and local branch

Conclusions

Therefore, git push --force is “dangerous” and will cause losing commits. But it’s not an enemy. In many situations it’s inevitable to use. We should know what we are doing and communicate with collaborators well when we are working on a non-main branch. The origin main branch, however, should never be non-fast-forward updated as we usually cannot afford losing commits from the origin main branch.

References

Author

Lei Mao

Posted on

07-03-2023

Updated on

07-03-2023

Licensed under


Comments