DEV Community

Cover image for Learn Git fast forward by reproducing GitHub's merges in practice
Kacper Rychel
Kacper Rychel

Posted on • Updated on

Learn Git fast forward by reproducing GitHub's merges in practice

We are engineers for many reasons. The common one is we want to know, how it works and how it is made. In this article, I will feed your curiosity hunger for both how it works, showing you how GitHub closes pulls request under the hood, and how it is made, playing with Git commands to reproduce the same result. On top of that, I made a practical brief in fast-forward (--ff, --no-ff, --ff-only) merge strategies in practice. Two birds with one stone 🐦🐦

Note 💬
If you are not interested in details of reproducing the GitHub's pull requests results, you can go straight to the TLDR section, but I do not recommend missing out on the fun 😎

Let's start the adventure.

Table of content

--ff, or --no-ff, or ff-only, that is that question

GitHub merges branches with the --no-ff or --ff-only strategy depending on the closing options.

--ff (fast forward) and --ff-only (fast forward only) simply move a pointer forward. They vary when fast forward is not possible. Then, --ff switches to --no-ff, and --ff-only rejects the operation.

--no-ff (no fast forward) just creates a merge commit.

--ff is a default strategy for git pull and git merge commands.

fast forward in practice

Now, let's see how it works in practice with the initial state:

ff---tree-before-merge-ff

$ git pull origin feature/4
From https://github.com/zdybasny/git-merges-strategies
 * branch            feature/4  -> FETCH_HEAD
Updating 6cf021a..fa923ee
Fast-forward
 README.md | 4 ++++
 1 file changed, 4 insertions(+)

$ git push
Enter fullscreen mode Exit fullscreen mode

See? Fast-forward was used by default, even if no flag was provided.

The result:

ff---tree-after-merge-ff

Here is a sample scenario, which fast forward is not possible for:

ff---tree-before-failing-merge-ff-only

$ git pull origin feature/5 --ff-only

From https://github.com/zdybasny/git-merges-strategies
 * branch            feature/5  -> FETCH_HEAD
fatal: Not possible to fast-forward, aborting.
Enter fullscreen mode Exit fullscreen mode

The operation was aborted for the --ff-only strategy.

$ git pull origin feature/5 --ff

From https://github.com/zdybasny/git-merges-strategies
 * branch            feature/5  -> FETCH_HEAD
Merge made by the 'ort' strategy.
 new-file.md | 3 +++
 1 file changed, 3 insertions(+)
 create mode 100644 new-file.md
Enter fullscreen mode Exit fullscreen mode

For the --ff strategy, the operation switched to the --no-ff one and created a merge commit:

ff---tree-after-merge-ff-switched-to-no-ff

Bonus puzzle

Being familiar with the fast forward strategy, you can try yourself with a simple puzzle.

What will the command below do for feature/5 branch in current state:

ff-bonus---current-tree

  • git pull origin main --no-ff?

⁝

⁝

⁝

ff-bonus---merge-no-ff

Imagine more such merges between more branches 😱

  • git pull origin main --ff?

⁝

⁝

⁝

ff-bonus---merge-ff

Much cleaner 🤩

Unfortunately, GitHub supports only --no-ff merges, what you will see in the next part of the article.

Closing Pull Request in practice

Create a merge commit

Before starting to play with reproducing the outcome of *Create a merge commit`, let's see what GitHub's documentation says about it:

Merge your commits

When you click the default Merge pull request option on a pull request on GitHub.com, all commits from the feature branch are added to the base branch in a merge commit. The pull request is merged using the --no-ff option.

The feature branch here means the source branch, and the base branch means the target branch.

And this is the result from Github to reproduce:

merge---state-to-reproduce

Knowing the theory and what is to do, we can use following Git commands:

$ git checkout main
$ git pull origin feature/3 --no-ff
$ git push

merge---after-merge

There are two things to notice:

  1. Even the green branch has been removed, its history still exists. The same will happen if we remove the feature/3 branch. This is exactly the result of the --no-ff strategy of git pull and git merge.
  2. Default messages generated by GitHub and by Git differ.
    1. The message generated by Git doesn't include a PR's number. We can still write our own merge commit message and attach a PR reference to it. We must add the -m flag to do so.
    2. I described default messages from GitHub in the first post of this series.

As you see, the results are pretty the same, so you know what happens behind the scenes of GitHub when it merges your PR.

Squash and merge

GitHub's documentation says about Squash and merge:

Squash and merge your commits

When you select the Squash and merge option on a pull request on GitHub.com, the pull request's commits are squashed into a single commit. Instead of seeing all of a contributor's individual commits from a topic branch, the commits are combined into one commit and merged into the default branch. Pull requests with squashed commits are merged using the fast-forward option.

The documentation says that the fast-forward strategy is used for squashing. From Git point of view, --squash is the flag of git merge. The statement seems to be informative only, because:

$ git merge origin feature/6 --squash --no-ff
fatal: You cannot combine --squash with --no-ff.

Here is the state to reproduce:

squash---state-to-reproduce

Git CLI commands:

$ git checkout main
$ git merge origin feature/7 --squash

--ff flag could be skipped in the command above because it is the default strategy.

squash---after-squash

$ git commit

squash---vscode-commit-editor

We could also use -m flag to write a message in a terminal inline, but writing it in an editor is more convenient and a there is a default message to edit already.

$ git push

squash---after-squash-n-push

The results look the same but the default commit messages.

Rebase and merge

Rebase is a powerful tool to rewriting a Git history, and its description in the documentation is pretty complex and focused on vary cases. You can just read it on your risk 😉 here: https://git-scm.com/docs/git-rebase. Just kidding, it is worth to read it.

GitHub rebases in only one way, so its documentation is much clearer:

Rebase and merge your commits

When you select the Rebase and merge option on a pull request on GitHub.com, all commits from the topic branch (or head branch) are added onto the base branch individually without a merge commit. In that way, the rebase and merge behavior resembles a fast-forward merge by maintaining a linear project history. However, rebasing achieves this by re-writing the commit history on the base branch with new commits.

The rebase and merge behavior on GitHub deviates slightly from Git rebase. Rebase and merge on GitHub will always update the committer information and create new commit SHAs, whereas Git rebase outside of GitHub does not change the committer information when the rebase happens on top of an ancestor commit

The state to reproduce:

rebase---state-to-reproduce

This time, it is not enough to run one or two Git commands to achieve the same result.

Long story short, we need to:

  1. rebase the feature/9 branch onto the main
  2. move the main pointer to feature/9
  3. reset feature/9 to origin/feature/9

So, let's do it step by step.

First, we rebase the feature/9 branch onto the main branch with the flag --force-rebase to achieve a similar state on your local:

$ checkout feature/9
$ git rebase --force-rebase --onto main main feature/9

rebase---after-rebase-on-local

Here is the difference, which GitHub says in its documentation about. The feature/9 is rebased onto main, but GitHub doesn't touch the source branch. So, you don't as well. You need to just keep the origin/feature/9 as it is. To do so, we need to move the main pointer to feature/9 and reset feature/9 to origin/feature/9. To move the pointer, we will use the fast-forward strategy we have already learned:

$ git checkout main
$ git merge feature/9 --ff-only
$ git push

rebase---after-rebase-n-merge

And now the reset of feature/9:

$ git checkout -
$ git reset HEAD~2 --hard
$ git pull

git checkout - is a shortcut for git checkout @{-1}. It is useful when you need to switch to the previous branch.

The reset command above moved the branch pointer back for 2 commits (HEAD~2) and dropped their changes (--hard).

git pull is for updating the local feature/9 branch to the state of the remote origin/feature/9 branch.

rebase---after-rebase-n-reset

Voilà! 🎉 The result is the same as we did it by using the GitHub's UI.

TLDR

Create a merge commit:

$ git checkout main
$ git pull origin feature/source-branch --no-ff
$ git push

Squash and merge:

$ git checkout main
$ git merge origin feature/source-branch --squash --ff
$ git commit
$ git push --force

Rebase and merge:

$ git checkout feature/source-branch
$ git rebase --force-rebase --onto main main feature/source-branch
$ git checkout main
$ git merge feature/source-branch --ff
$ git push
$ git checkout -
$ git reset HEAD~2 --hard
$ git pull

Ending

I hope you enjoyed our play with Git and GitHub. Asking yourself questions like "How does it work?" and "How can I do it by myself?" is a wonderful way to learn something new. Very often, something we have never thought about before, like me before when I started to draft this article.

This article is the last part of the series, within which I wanted to share with you what options of closing pull requests on GitHub are, when to use them, and now how to do it by yourself. I hope you have found it useful and interesting.

Top comments (1)

Collapse
 
iamhectorsosa profile image
Hector Sosa

Git is definitely one of those technologies that you never know how much you need until you're working out there in the real world. People often procrastinate learning Git well. This was a great read!

Also, it would've been chef's kiss if some of these screenshots would be framed. I've built a simple OSS tool to help with this! Check it out and let me know what you think github.com/ekqt/screenshot I'd appreciate a star if you find it helpful

Cheers