In Part I, we looked behind Git’s familiar commands and built a mental model around commits, branches and HEAD. We learned that commits are snapshots, branches are nothing more than movable sticky notes, and HEAD simply tells us where we currently are. By the end, our feature branch had moved a few commits ahead of main, leaving us with one important question: how do we bring those two lines of development back together? In this second part, we’ll pick up exactly where we left off and see how Git delivers that feature using merges, merge commits and rebasing. Time to move some sticky notes!
As mentioned earlier, there are two possible outcomes of a merge:
Let’s start with the simplest and most efficient scenario: the fast-forward merge.
A fast-forward merge happens when main hasn’t changed at all since you branched off to create feature-a. In other words, its sticky note is still pointing at the same commit where your feature branch started. It hasn’t diverged and moved on. To use the ‘match score’ analogy, mentioned in my previous blog, main is still scoreless. And just to be clear, fast-forward is a consequence of the commit graph, not a user decision. You cannot tell Git: “I want to merge in a fast-forward kind of manner”.
Since main is directly behind feature-a, Git doesn’t need to combine histories or create new commits. The solution is much simpler. Git just moves the main sticky note forward until it points at the same commit as feature-a. Once the branch has been merged then we could delete the feature branch. Git doesn’t automatically delete the feature-a sticky note when merging. But, the proper boy scout we are, we issue a git branch -d feature-a and all of a sudden our repository looks like this:
No new commit is created, no conflicts, no merging logic needed. Just a sticky note being moved from one commit to another. This is the simplest and most efficient type of merge. Git is essentially saying: “Main hasn’t done anything since you left, so we’ll just fast-forward it to where the feature ended up.”
A fast-forward merge is wonderfully simple, but it only works if nothing has happened on main since you created your feature branch. But what if the world did move on? What if someone committed and pushed his work to main while you were busy working on feature-a?
In that case, the two branches no longer share a simple linear path. Both sticky notes, main and feature-a, have moved forward from the point where they split. Their histories have diverged.
We can clearly see in the commit graph that if we walk all the way back from feature-a to the beginning, we can’t/don’t see the main sticky note. Just as in real life, things move on!
Both branches now contain work that the other branch doesn’t know about. Because of this, Git cannot fast-forward, there is no single straight line that includes all commits from both branches.
So what does Git do?
When you are on main and run git merge feature-a, Git creates a new commit that ties both histories together.
This new commit is called a merge commit (M) and it has two parents:
After merging, the commit graph looks like this:
M is the merge commit. It represents the combined state of both branches. The main sticky note moves to point at M. feature-a stays where it was; it does not move.
Why do we need this extra commit, you might think?
Because Git must create a point in history that:
This merge commit becomes the new tip of main, preserving both sets of changes.
The key takeaway for your mental model: a fast-forward merge only moves a sticky note. A diverging merge creates a new commit that glues the two lines of history together, and the branch’s sticky note moves to point to that new commit.
Now that we’ve seen how merging brings two branches back together, either by simply moving a sticky note forward (fast-forward) or by creating a merge commit when histories diverge, you might wonder:
Is merging the only way to integrate changes from one branch into another?
Git actually offers another powerful tool for this: rebasing.
Where merging connects branches by creating a new commit that ties both histories together, rebasing takes a different approach. Instead of adding a merge commit, Git can “lift” your branch’s commits and place them on top of another branch, creating a cleaner, linear history.
Let’s take a closer look at how that works.
When two branches have diverged, merging them creates a new commit that ties the two lines of history back together. That’s one way to integrate your work. But sometimes you may prefer a cleaner, linear history, a history without merge commits. This is where rebasing comes in.
Instead of connecting two lines of development with a merge commit, rebasing replays your changes on top of another branch, as if you had created your feature starting from that point all along.
Both branches have commits the other does not. As we have seen, if you merge feature-a into main, Git would need a merge commit. But what if you want to integrate your feature work (D and E) into main and have a linear history? That's exactly what rebasing does.
Incorporating feature-a into main can be done by:
The rebase checks which commits of the main branch are not in feature-a. In this case it is only commit F. Git will take that commit and replays/applies the changes of F onto commit E. This results in a copy of commit F with a brand new ID (commit F’, which replaces original commit F).
Technically this is perfectly fine, Git will not complain… But let’s pause for a minute and take a look at the above commit graph! Although rebasing main onto your feature branch is technically valid, you should NOT rebase a branch where your colleagues are also working on (like develop or main) on top of your feature branch. You are in essence now re-writing the history of the shared branch main (F, a commit that your colleagues might depend on, became F’). If you look in the diagram below it now seems as if main always contained feature-a. This breaks collaboration with conflicts and chaos as a result.
What you want to do, instead of picking up the changes in main and putting them on top of your feature branch, is to pick up your commits in the feature branch and put them on top of main.
As mentioned earlier, the preferred way of working, when delivering your feature, is:
Let’s rewind to the moment you finished your development and want to deliver your feature. The repo looks like this:
First you want to incorporate changes of main into your feature by:
Now it looks like you branched off of F (instead of C). Which is exactly what you want.
Now you merge the feature into main by:
And, as we know by now, merging this situation is a fast-forward merge. Which means that Git only moves the main sticky note from commit F to commit E’. This results in a nice linear Git history.
This workflow only rebases the feature branch, which is safe because no one else depends on the history of your feature branch.
With merging you can deliver a feature into main.
Rebasing cleans up your Git history.
It lifts your feature’s commits and replays them on top of the latest commit in main, giving you a clean, linear history.
The safest workflow:
Never rewrite main’s history.
Only rebase (feature) branches, not branches that you and your colleagues are working on (shared branches). You could rebase shared branches but that requires caution. You could end up overwriting work of your colleagues and/or creating merge conflict chaos.
Git may seem mysterious at first, but once you understand what’s really happening under the hood, it becomes surprisingly elegant. Commits are just snapshots tied together; branches are nothing more than movable sticky notes; and HEAD is your simple “you are here” marker. With these pieces, the whole system suddenly makes sense.
Branching and merging stop being scary once you realize how lightweight Git’s pointers are. Creating a branch? Just a new sticky note. Switching branches? Move HEAD to a different sticky note. Delivering a feature? Bring two lines of history back together, either with a fast-forward or a merge commit. And if you want to keep things clean, rebasing lets you replay your work on top of the latest changes before merging it in.
Once these concepts click, Git becomes less about memorizing commands and more about understanding how the commit graph evolves. And with that mental model in place, you can work confidently, explore boldly, and use Git as it was meant to be used: a fast, flexible, powerful tool that stays out of your way and keeps your project history tidy and understandable.
Now you are ready to Git things done!
Before we call it a day, here’s a fun little brain teaser. Cherry-picking is a Git operation where you copy a single commit from one branch and apply it onto another branch, without merging the branches themselves.
This is how you would execute a cherry-pick of a commit in main into feature branch:
If you think in terms of sticky notes and commits:
How would you visualize cherry-picking? What happens to the sticky notes? Which commit gets recreated? What happens to the commit IDs? Where does HEAD move? If you can answer those questions, then you truly understand Git’s model.