Ana and Bruno have two finished, tested features, each on its own branch. And yet main — the real project, the one that ships — is still exactly where it was a week ago, at c5d9b1e. Isolating the work was half the problem; this lesson solves the other half.

Merging is the operation of bringing one branch's work into another. It is the whole point of branches: if you could not put them back together, isolating the work would achieve nothing.

Git distinguishes two very different scenarios when merging, and confusing them is the source of most of the questions people have. In one there is nothing genuinely to merge and it is enough to move a pointer. In the other there are two divergent lines of work and Git has to create a new commit with two parents. We are going to watch both happen, understand when each one occurs, and learn to force or forbid either behaviour when it suits us.

In this lesson every merge will succeed first time. When two people touch the same lines, conflicts appear, and those have a lesson of their own (03-05).

Contents

  1. How you merge: always from the target branch
  2. Scenario 1: fast-forward, the pointer moves along
  3. Undoing a local merge
  4. --no-ff: forcing the merge commit
  5. Scenario 2: the three-way merge
  6. The common ancestor and git merge-base
  7. Anatomy of a merge commit
  8. Reading a history that contains merges
  9. --ff-only: forbidding merge commits

  1. How you merge: always from the target branch

First of all, the rule that avoids 90% of beginners' mistakes:

You stand on the branch that wants to receive the work and merge the one that provides it.

git switch <target-branch>
git merge <source-branch>

git merge only moves the branch you are on. The branch you name as an argument is not touched at all: it points at exactly the same commit before and after.

Applied to Ana's case: she wants main to receive the task counter.

git switch main
git merge feature/task-counter

The other way round — standing on feature/task-counter and running git merge main — is a different and equally valid operation, but it means something else: bringing the latest from main into my working branch, which is what you do to stay up to date. Getting the order wrong by accident is a classic. Before merging, check where you are:

git branch --show-current
main

  1. Scenario 1: fast-forward, the pointer moves along

The starting situation:

git log --oneline --graph --all
* b2e6d3f (feature/pending-filter) Restore field focus after adding a task
* 7c1f4a9 Apply style to completed tasks
* 3d5b8e1 Add a pending tasks filter
| * 9d1e4b7 (feature/task-counter) Mark tasks as done on click
| * 6f2b9d4 Add the pending task counter
|/
* c5d9b1e (HEAD -> main) Document installation in the README
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure

Look at the relationship between main and feature/task-counter. main sits at c5d9b1e, and c5d9b1e is a direct ancestor of 9d1e4b7. In the vocabulary of lesson 03-01: main is behind, it has not diverged. It has no commits of its own that Ana's branch lacks.

In this situation, "merging" is too grand a word. There is nothing to combine: everything on main is already contained in the other branch. It is enough to move main's pointer forward to 9d1e4b7.

git switch main
git merge feature/task-counter
Updating c5d9b1e..9d1e4b7
Fast-forward
 app.js     | 24 ++++++++++++++++++++++--
 styles.css |  6 ++++++
 2 files changed, 28 insertions(+), 2 deletions(-)

Let us take the output apart line by line:

  • Updating c5d9b1e..9d1e4b7: where main moved from and to.
  • Fast-forward: the name of the scenario. No commit was created.
  • The file summary: the accumulated changes of Ana's two commits, in the same git diff --stat format you already know.

Before and after, on disk:

BEFORE: .git/refs/heads/main → c5d9b1e…
AFTER:  .git/refs/heads/main → 9d1e4b7…

That is everything that happened: 41 bytes rewritten. No new commit, no new objects, nothing to compute.

graph RL
    subgraph after["AFTER"]
        D2["c5d9b1e"] --> C2["4e7f2a9"]
        E2["6f2b9d4"] --> D2
        F2["9d1e4b7<br/><b>main</b> · task-counter"] --> E2
    end
    subgraph before["BEFORE"]
        D1["c5d9b1e<br/><b>main</b>"] --> C1["4e7f2a9"]
        E1["6f2b9d4"] --> D1
        F1["9d1e4b7<br/>task-counter"] --> E1
    end

The resulting history:

git log --oneline --graph
* 9d1e4b7 (HEAD -> main, feature/task-counter) Mark tasks as done on click
* 6f2b9d4 Add the pending task counter
* c5d9b1e Document installation in the README
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure

A perfectly straight line. Both branches now point at the same commit.

And here is the detail worth weighing up: looking at this history, there is no way of telling that a branch ever existed. 6f2b9d4 and 9d1e4b7 look like two ordinary commits made directly on main. The information that they formed a unit of work — a feature — has been lost.

Sometimes that is exactly what you want (a clean, linear history). Sometimes it is not. For the second case there is --no-ff, but first we have to undo what we just did.

  1. Undoing a local merge

Ana is left wondering and wants to try the other approach. Because the merge is local and has not yet left her laptop, undoing it is trivial: just put main's pointer back where it was.

git reset --hard c5d9b1e
HEAD is now at c5d9b1e Document installation in the README
git log --oneline --graph --all
* b2e6d3f (feature/pending-filter) Restore field focus after adding a task
* 7c1f4a9 Apply style to completed tasks
* 3d5b8e1 Add a pending tasks filter
| * 9d1e4b7 (feature/task-counter) Mark tasks as done on click
| * 6f2b9d4 Add the pending task counter
|/
* c5d9b1e (HEAD -> main) Document installation in the README
...

Everything as it was. Ana's two commits are not lost, because feature/task-counter still points at them: they are perfectly reachable.

Three warnings about what we have just done:

  • git reset --hard is a sharp tool. It moves the branch pointer and overwrites the working tree, discarding any uncommitted change. We will study it in full detail in lesson 09-02.
  • It is safe here because the work is protected by another branch and because there was nothing uncommitted.
  • It only applies to local merges. If the merge had already been shared with the rest of the team, moving the pointer backwards would cause problems for everyone else; there the right tool is git revert (lesson 05-06). Since this whole module happens in a single local repository, we do not have that problem.

A useful shortcut: Git automatically saves the previous position in the reference ORIG_HEAD just before operations such as merge or reset. So this would have been equivalent, with no need to remember the hash:

git reset --hard ORIG_HEAD

  1. --no-ff: forcing the merge commit

Ana tries again, this time explicitly asking for a merge commit even though none is needed:

git merge --no-ff feature/task-counter

Git opens the editor with a proposed message:

Merge branch 'feature/task-counter'

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.

Ana expands the first line so that it says something useful, and saves:

Merge the pending task counter

Brings in the counter in the header and marking tasks as
done on click. Reviewed with Bruno.
Merge made by the 'ort' strategy.
 app.js     | 24 ++++++++++++++++++++++--
 styles.css |  6 ++++++
 2 files changed, 28 insertions(+), 2 deletions(-)

Note the difference: it now says Merge made by the 'ort' strategy instead of Fast-forward. A commit has been created.

git log --oneline --graph
*   8d4e6b2 (HEAD -> main) Merge the pending task counter
|\
| * 9d1e4b7 (feature/task-counter) Mark tasks as done on click
| * 6f2b9d4 Add the pending task counter
|/
* c5d9b1e Document installation in the README
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure
gitGraph
   commit id: "1a4c8d6"
   commit id: "8b6d3c2"
   commit id: "4e7f2a9"
   commit id: "c5d9b1e"
   branch task-counter
   checkout task-counter
   commit id: "6f2b9d4"
   commit id: "9d1e4b7"
   checkout main
   merge task-counter id: "8d4e6b2"

The commit 8d4e6b2 contributes no code change at all: the resulting content is identical to 9d1e4b7. What it does contribute is information about the structure of the work: it records that those two commits formed a unit and when they were integrated into the project.

When --no-ff is worth it

In favour of --no-ff In favour of fast-forward
The history documents which features were integrated and when A linear history, easier to read commit by commit
A whole feature can be reverted with a single git revert -m 1 No "empty" commits that change no code
git log --first-parent gives a clean summary of integrations git bisect walks through less noise
It is the practice assumed by workflows such as Git Flow (module 7) It suits teams that prefer a single-line history

Many teams pin it in their configuration so they never have to remember:

git config --global merge.ff false

With that, every merge will create a merge commit. A more nuanced and very widespread variant is to set it per branch, or simply to type --no-ff when integrating a feature and allow fast-forward for everything else.

It is a team decision, not a technical question: what matters is that everybody does the same thing. We will come back to it in module 8.

  1. Scenario 2: the three-way merge

Now Ana integrates Bruno's work. And the situation has changed completely:

git log --oneline --graph --all
*   8d4e6b2 (HEAD -> main) Merge the pending task counter
|\
| * 9d1e4b7 (feature/task-counter) Mark tasks as done on click
| * 6f2b9d4 Add the pending task counter
|/
| * b2e6d3f (feature/pending-filter) Restore field focus after adding a task
| * 7c1f4a9 Apply style to completed tasks
| * 3d5b8e1 Add a pending tasks filter
|/
* c5d9b1e Document installation in the README
...

main is no longer an ancestor of feature/pending-filter: it has three commits of its own (Ana's two plus the merge) that Bruno's branch lacks. And Bruno's branch has three others that main lacks. They have diverged.

Moving a pointer will not do here. Git has to genuinely combine two different versions of the files.

git merge feature/pending-filter

Git opens the editor with the proposed message — Ana accepts it as it stands this time — and replies:

Merge made by the 'ort' strategy.
 app.js     | 15 +++++++++++++++
 styles.css |  4 ++++
 index.html |  3 +++
 3 files changed, 22 insertions(+)
git log --oneline --graph
*   f7a3e92 (HEAD -> main) Merge branch 'feature/pending-filter'
|\
| * b2e6d3f (feature/pending-filter) Restore field focus after adding a task
| * 7c1f4a9 Apply style to completed tasks
| * 3d5b8e1 Add a pending tasks filter
* |   8d4e6b2 Merge the pending task counter
|\ \
| * | 9d1e4b7 (feature/task-counter) Mark tasks as done on click
| * | 6f2b9d4 Add the pending task counter
|/ /
* / c5d9b1e Document installation in the README
|/
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure
gitGraph
   commit id: "1a4c8d6"
   commit id: "8b6d3c2"
   commit id: "4e7f2a9"
   commit id: "c5d9b1e"
   branch task-counter
   checkout task-counter
   commit id: "6f2b9d4"
   commit id: "9d1e4b7"
   checkout main
   merge task-counter id: "8d4e6b2"
   branch pending-filter
   checkout pending-filter
   commit id: "3d5b8e1"
   commit id: "7c1f4a9"
   commit id: "b2e6d3f"
   checkout main
   merge pending-filter id: "f7a3e92"

(In the diagram, pending-filter is drawn branching off main; remember that it really starts from c5d9b1e, before the first merge.)

The project finally has both features. app.js contains Ana's counter and Bruno's filter, and neither of them had to copy anything by hand.

Why it is called "three-way"

Because Git uses three versions of each file to decide the result:

Version Where it comes from Its role
base The common ancestor (c5d9b1e) The neutral point of reference
ours The branch you are on (main, at 8d4e6b2) Your side
theirs The branch you are merging (b2e6d3f) The other side

And the rule, line by line, is the one we previewed in lesson 03-01:

Changed in ours Changed in theirs Result
No No The base's line is kept
Yes No The ours version is taken
No Yes The theirs version is taken
Yes Yes, identically That version is taken (there is no disagreement)
Yes Yes, differently Conflict: a human decides (lesson 03-05)

There were no conflicts in this case because Ana touched the header and the counter logic, while Bruno added the filter further down and a few new rules in styles.css. Different areas of the same files.

Note the important consequence: two people touching the same file does not cause a conflict. A conflict appears when they touch the same lines (or lines very close together) in different ways. It is a very widespread and quite unnecessary source of fear.

  1. The common ancestor and git merge-base

Git does not ask you what the common ancestor is: it works it out. And you can work it out too:

git merge-base main feature/pending-filter
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9
git log --oneline -1 c5d9b1e
c5d9b1e Document installation in the README

Sure enough: the last commit both branches have in common, the point where they parted ways.

Some useful variants:

# Is A an ancestor of B? Answers with the exit code, printing nothing
git merge-base --is-ancestor main feature/pending-filter
echo $?
1

A 1 means "no". If it returned 0, the merge would be a fast-forward. This is the exact check git merge makes to decide the scenario, and it is very handy in scripts.

# See the merge base readably, together with the branch tips
git log --oneline --graph --boundary main...feature/pending-filter

Note the three dots. You saw a..b in lesson 02-06 (what is in b and not in a); a...b is the symmetric difference: what each one has and the other does not. It is the natural way to answer "how do these two branches differ?".

A widely used trick for seeing what a branch contributes relative to the point it parted from, without everything main has moved on by in the meantime:

git diff main...feature/pending-filter

With three dots in git diff, Git compares the common ancestor against the tip of the second branch. In other words, it shows only what Bruno has done, ignoring whatever has changed on main since then. It is what code review platforms show you as the changes in a proposal.

  1. Anatomy of a merge commit

A merge commit is a perfectly ordinary commit object with one single peculiarity: it has more than one parent line. Remember the data model from lesson 01-04, where we saw that the parent field can repeat.

git cat-file -p f7a3e92
tree 3f7b2e9c1a5d8b4f2e6a9c3d7b1f5e8a4c2d9b6f
parent 8d4e6b2f1a3c5e7b9d2f4a6c8e0b1d3f5a7c9e2b
parent b2e6d3f8a1c5e9d2b4f7a3c6e8d1b5f9a2c4e7d3
author Ana Ferrer <ana.ferrer@example.com> 1754049600 +0200
committer Ana Ferrer <ana.ferrer@example.com> 1754049600 +0200

Merge branch 'feature/pending-filter'

Two parent lines. And the order matters enormously:

Position Name What it is Referred to as
First parent first parent The commit main was on before merging f7a3e92^1 or f7a3e92^
Second parent second parent The tip of the merged branch f7a3e92^2
git log --oneline -1 f7a3e92^1
8d4e6b2 Merge the pending task counter
git log --oneline -1 f7a3e92^2
b2e6d3f Restore field focus after adding a task

That ordering is what makes the target branch always ^1 and the incoming branch ^2. It has concrete practical consequences:

  • git log --first-parent follows only the first parent and produces a clean summary of the main project.
  • git revert -m 1 <merge> undoes a merge while keeping the main line, using -m 1 to say which parent is the "good" one (lesson 05-06).
  • It is the reason HEAD^ and HEAD~1 are not synonyms on a merge commit, something already flagged in lesson 02-06.

git show on a merge

git show f7a3e92
commit f7a3e92b5d1c8f4a6e3b7d9f2a5c1e8b4d6f3a9c (HEAD -> main)
Merge: 8d4e6b2 b2e6d3f
Author: Ana Ferrer <ana.ferrer@example.com>
Date:   Fri Aug 1 12:20:00 2026 +0200

    Merge branch 'feature/pending-filter'

Two things come as a surprise:

  1. There is a Merge: line with the abbreviated hashes of the two parents. It is the visual marker that you are looking at a merge.
  2. There is no diff. By default, git show and git log -p show no changes for merge commits.

This is not a bug. A merge commit has two parents, so "the diff" is ambiguous: relative to which one? And in a clean merge like this one, relative to either parent the result would simply be everything the other branch contributed, which is already in its own commits. Showing it would duplicate information.

If you need it anyway:

# Diff against the first parent (what the merge brought into main)
git show --first-parent f7a3e92

# Combined diff: only the lines that differ from BOTH parents,
# that is, whatever was decided by hand when resolving conflicts
git show --cc f7a3e92

# One diff per parent, separately
git show -m f7a3e92

--cc is the genuinely valuable option and is worth remembering: in a clean merge it shows next to nothing, but in a merge with resolved conflicts it shows exactly what the person decided, which is precisely what you want to audit. We will use it in lesson 03-05.

  1. Reading a history that contains merges

main's history is no longer a straight line, and it is worth knowing how to look at it from two different altitudes.

The full view, with all the detail:

git log --oneline --graph

The high-level view, following only the first parent:

git log --oneline --graph --first-parent
*   f7a3e92 (HEAD -> main) Merge branch 'feature/pending-filter'
*   8d4e6b2 Merge the pending task counter
* c5d9b1e Document installation in the README
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure

Six entries instead of eleven. This is the history from the project's point of view: what was integrated into main and in what order, without the internal detail of each feature. For a product manager, or for writing release notes, it is infinitely more useful than the full view.

And here you can see why good merge-commit messages are worth writing: Merge the pending task counter tells you something; Merge branch 'feature/pending-filter' leaves you guessing.

Other related filters:

git log --merges       # merge commits only
git log --no-merges    # commits of real work only

--no-merges is especially useful combined with --author, to see what a person has actually written without counting their integrations.

  1. --ff-only: forbidding merge commits

The opposite of --no-ff. With --ff-only, Git merges only if it can do so by moving the pointer forward; if a merge commit would be needed, it refuses and touches nothing:

git switch main
git merge --ff-only feature/pending-filter
fatal: Not possible to fast-forward, aborting.

What is the point of refusing to merge? Guaranteeing a linear history. Some teams do not want to see a single merge commit on main and prefer whoever integrates to bring their branch up to date first (with rebase, which we will see in lesson 05-01) and then merge as a fast-forward.

It is also an excellent safety net against accidental merges. And in fact you already have it switched on without knowing: in lesson 01-06 you configured

git config --global pull.ff only

which applies exactly this policy to git pull (which, as we will see in module 4, is a fetch followed by a merge). The equivalent setting for git merge would be:

git config --global merge.ff only

The three policies, compared

Option When it can move forward When the branches have diverged
Default Fast-forward, no new commit Creates a merge commit
--no-ff Creates a merge commit anyway Creates a merge commit
--ff-only Fast-forward, no new commit Fails and does nothing

A couple more options worth knowing:

# Edit the message even when Git would not have asked
git merge --edit feature/pending-filter

# Accept the proposed message without opening the editor
git merge --no-edit feature/pending-filter

# Merge but do NOT commit: leaves the result staged for review
git merge --no-commit feature/pending-filter

--no-commit is particularly interesting and we will pick it up again in the next lesson, when we talk about strategies: it lets you inspect and adjust the result of the merge before sealing it.

Common Mistakes and Tips

Mistake 1: merging in the wrong direction. Standing on feature/task-counter and running git merge main when you wanted the opposite. The feature branch receives main, main knows nothing about it, and then you cannot work out why the project looks unchanged. Always check with git branch --show-current before merging.

Mistake 2: believing that merging moves both branches. git merge moves only the branch you are on. After integrating feature/pending-filter into main, that branch still sits at b2e6d3f. It still exists and still points at the same commit; deleting it once it is no longer needed is the subject of lesson 03-06.

Mistake 3: fearing that merging two branches that touch the same file will conflict. It will not, if they touch different areas. Git works line by line, not file by file.

Mistake 4: not understanding why git show on a merge displays no changes. It is the default behaviour and it makes sense: the diff would be ambiguous with two parents. Use --cc to see what was decided by hand, or --first-parent to see what the merge brought in.

Mistake 5: leaving the default merge message on important integrations. Merge branch 'feature/x' says nothing the graph does not already say. In a --first-parent view, that message is all the information available about the integration: use it to explain what is being integrated and why.

Tip 1: check what you are about to bring in before merging. Two commands that cost a second:

git log --oneline main..feature/pending-filter   # which commits arrive
git diff main...feature/pending-filter --stat    # which files change

Tip 2: merge with a clean working tree. Git refuses to merge if there are uncommitted changes that might be lost, but even when it lets you through, mixing your half-finished changes with the result of a merge is a recipe for not knowing what is yours and what the merge brought.

Tip 3: merge often in the "main into your branch" direction. Bringing main into your feature branch every few days keeps divergence small and turns big conflicts into trivial ones. It is the best possible prevention against lesson 03-05.

Tip 4: use --first-parent to present the work. When someone asks you "what went into the project this month?", git log --oneline --first-parent --since="1 month ago" gives the exact answer.

Exercises

Exercise 1: the two scenarios, in a test repository

Create a new repository and deliberately trigger both scenarios:

  1. A merge that is a fast-forward.
  2. A three-way merge, with no conflicts.

Before running each git merge, predict with git merge-base --is-ancestor which of the two will happen.

Exercise 2: the anatomy of the merge commit

On the three-way merge from the previous exercise, work out:

  1. The full hashes of its two parents, using only plumbing commands.
  2. Which of the two was the target branch.
  3. Why git show <merge> shows no diff, and how to see it anyway.
  4. How many commits git log --oneline shows and how many git log --oneline --first-parent shows.

Exercise 3: choosing the policy

For each situation, say which merge option you would use (default --ff, --no-ff or --ff-only) and justify it in one sentence:

  1. Integrating a 12-commit feature developed over two weeks into main.
  2. Integrating a branch with a single commit that fixes a typo in the README.
  3. Bringing your feature branch up to date with the latest on main, knowing you have not touched main.
  4. A continuous integration script that must fail if anyone tries to introduce an unexpected merge into main.

Solutions

Solution 1:

mkdir /tmp/practice-merge && cd /tmp/practice-merge
git init -b main
echo "line 1" > file.txt
git add . && git commit -m "First commit"

Fast-forward scenario:

git switch -c branch-ff
echo "line 2" >> file.txt
git commit -am "Add line 2"
git switch main

Prediction:

git merge-base --is-ancestor main branch-ff; echo $?
0

0 means that main is an ancestor of branch-ff: it will be a fast-forward.

git merge branch-ff
Updating a1b2c3d..e4f5a6b
Fast-forward
 file.txt | 1 +
 1 file changed, 1 insertion(+)

Three-way scenario:

git switch -c branch-3w
echo "contribution from the branch" > other.txt
git add . && git commit -m "Add other.txt on the branch"

git switch main
echo "contribution from main" > third.txt
git add . && git commit -m "Add third.txt on main"

Prediction:

git merge-base --is-ancestor main branch-3w; echo $?
1

1 means "no": main now has a commit of its own. They have diverged, so a merge commit will be needed.

git merge --no-edit branch-3w
Merge made by the 'ort' strategy.
 other.txt | 1 +
 1 file changed, 1 insertion(+)

No conflict: each branch touched a different file.

Solution 2:

# 1. Parents, with plumbing
git cat-file -p HEAD | grep "^parent"
parent 7f3c9a2e5b1d8f4a6c2e9b3d7f1a5c8e4b2d6f9a
parent 2b1a3c5e7d9f0b2a4c6e8d0f2a4b6c8e0d2f4a6b

Or more directly:

git rev-parse HEAD^1 HEAD^2
# 2. The target branch is the FIRST parent
git log --oneline -1 HEAD^1
7f3c9a2 Add third.txt on main

It was main, which is where we were standing when we merged. The second parent is the tip of branch-3w.

# 3. No diff by default…
git show HEAD
commit 5e8b1d4f2a6c9e3b7d1f5a8c4e2b6d9f3a7c1e5b (HEAD -> main)
Merge: 7f3c9a2 2b1a3c5
...

Because with two parents the diff is ambiguous. To see it:

git show --first-parent HEAD    # what the merge brought into main
git show --cc HEAD              # only what was resolved by hand (here, nothing)
# 4. The count
git log --oneline | wc -l
4
git log --oneline --first-parent | wc -l
3

The second view omits the merged branch's commit and shows only the main line: the first commit, third.txt and the merge.

Solution 3:

  1. --no-ff. Twelve commits are a unit of work with an identity of their own. The merge commit documents when the feature went in and makes it possible to revert the whole thing with git revert -m 1. It will also appear as a single entry under --first-parent.

  2. Default (fast-forward). A typo is not a unit of work that deserves a node in the graph. A merge commit here would only add noise.

  3. --ff-only. If you have not touched main, your branch should simply be behind and the fast-forward will work. If it fails, there was unexpected divergence, and the failure is valuable information: it warns you before you create a merge you were not expecting.

  4. --ff-only. This is exactly the use case: turning the linear-history policy into an automatic check that fails instead of creating the merge commit.

Conclusion

You can now bring together the work that branches were keeping apart:

  • You merge from the target branch: git switch main and then git merge <branch>. git merge moves only the branch you are on.
  • Fast-forward: if the target branch is an ancestor of the one you are merging, there is nothing to combine and Git simply moves the pointer forward. No commit is created and the history stays linear, at the cost of losing any trace that a branch existed.
  • Three-way merge: if the branches have diverged, Git combines the three versions of each file (base, ours, theirs) and creates a merge commit with two parents. Lines only one side changed are taken from that side; lines both changed differently are a conflict.
  • The common ancestor is worked out by Git and you can query it with git merge-base. --is-ancestor predicts the scenario; the a...b syntax compares two branches from the point they parted.
  • The merge commit is an ordinary commit with two parent lines. The order matters: ^1 is the target branch and ^2 the incoming one. Hence --first-parent, git revert -m 1 and the fact that HEAD^ and HEAD~1 differ on merges. git show displays no diff by default; use --cc.
  • --no-ff forces the merge commit even when none is needed, to document the integration; --ff-only forbids it, to guarantee a linear history. Both can be pinned with merge.ff.

main now contains the task counter and the pending filter. The project is whole for the first time since Bruno joined.

What comes next

One word has appeared over and over in this lesson's output and we have let it pass: Merge made by the **'ort'** strategy. What is ort? Are there others? Can you choose?

Yes, and in some cases choosing well saves a great deal of work. In the next lesson, Merge Strategies, we will look at the strategies Git knows how to apply (ort, resolve, octopus, ours, subtree), the strategy options behind -X — including the classic trap of confusing the ours strategy with the -X ours option — the squash merge, which flattens a whole branch into a single commit without recording the merge, and a decision table for choosing how to integrate depending on the history you want to end up with.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved