Skip to main content
RunBook Academy

Git, CI/CD & GitOpsIX · MergingMerging

Merge commits and --no-ff — forcing topology for audit

Intermediate⏱ ~22 mingit

What you'll learn

  • Explain why `--no-ff` produces a merge commit even when fast-forward would be possible
  • Read a merge commit and identify which parent is which branch tip
  • Recognise the trade-off between linear history (cheap, opaque) and merge commits (expensive, auditable)
  • Configure `merge.ff` and `merge.branchdesc` to enforce team policy on when merge commits are required
  • Choose between `--no-ff` and `--ff-only` for a given branch policy

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

A merge commit is what Git writes when a merge cannot be fast-forwarded. The --no-ff flag forces the same outcome — two parents, a constructed tree, a record in the log — even when the topology would have allowed a fast-forward. The flag is a policy choice: it says “I want the merge to be visible in the graph, regardless of whether the algorithm thinks it is necessary”. Whether that choice is right for a given team depends on what the team audits: the branch lifecycle (use --no-ff) or the commit content (do not use --no-ff).

What --no-ff does to the graph

The flag changes only one thing: it tells the merge algorithm to create a merge commit even when fast-forward is possible. Everything else — the three-way merge of trees, the conflict resolution, the staging in the index — happens as if a true merge were required. The result is a new commit with two parents where a single-parent commit would have done.

git checkout main
git merge --no-ff feature/iam-rotation
# Merge made by the 'recursive' strategy.
#  terraform/main.tf | 2 +-
#  1 file changed, 1 insertion(+), 1 deletion(-)

The output is identical to a true merge: the same header (Merge made by the 'recursive' strategy.), the same file-change report. The only thing that changed relative to a plain git merge is that Git wrote a merge commit rather than moving the pointer. The hash printed is the new merge commit; the previous main tip and the feature/iam-rotation tip are its two parents.

flowchart LR
    subgraph AFTER_FF["git merge (default, fast-forward)"]
        A1["6f4e5a6"] --> B1["8a3f9d2 main"] --> C1["9f3c1d7 main = feature"]
    end
    subgraph AFTER_NOFF["git merge --no-ff"]
        A2["6f4e5a6"] --> B2["8a3f9d2 main"]
        B2 --> D2["MERGE main"]
        C2["9f3c1d7 feature"]
        C2 --> D2
    end

The two diagrams show the same repository state before the merge, and the same final contents on main after the merge — the only difference is the topology. In AFTER_FF, main points at a single commit that contains the work. In AFTER_NOFF, main points at a merge commit whose first parent is the old main and whose second parent is the feature tip; the feature commits are reachable from main via the merge commit but are not on main’s first-parent chain.

Why a team would choose --no-ff

The argument for --no-ff is that the merge commit encodes information that a linear history erases:

git log --first-parent main
# MERGE Merge branch 'feature/iam-rotation'
# 9f3c1d7 feat(iam): add role assumption policy
# 8a3f9d2 chore: bump provider versions
# 6f4e5a6 feat(network): initial vpc layout

With --first-parent, the log reads as: “this is what landed on main, in order”. Each merge commit is a unit of work; the commits underneath it are the contents of that unit. A reader who wants the details can drop --first-parent and see the feature branch’s commits in their original order.

Without --no-ff, the same main reads as:

git log --first-parent main
# 9f3c1d7 feat(iam): add role assumption policy
# 8a3f9d2 chore: bump provider versions
# 6f4e5a6 feat(network): initial vpc layout

The reader cannot tell from the log which commits were a unit of work, which were one-off fixes, and which were direct edits to main. The information is in the reflog (now) and in the pull-request records (if the team keeps them), but not in the graph.

For an audit-driven team — a team whose security or compliance posture requires being able to answer “what was the scope of this merge?” — the merge commit is the unit of audit. The trade-off is one extra commit per feature, which is a small cost in object-store size and git log speed for a few dozen commits per quarter.

For a content-driven team — a team that audits by reading the diff of each commit individually — the merge commit is overhead. A linear history is easier to read, every commit has a single diff against its parent, and git bisect walks a straight line. The --no-ff flag would inflate the history without adding information the team uses.

The merge.ff configuration

The merge.ff config variable controls the default fast-forward behaviour. It accepts four values that map directly to the command-line flags:

# Default: fast-forward if possible, otherwise true merge
git config merge.ff true

# Refuse anything other than fast-forward (equivalent to --ff-only)
git config merge.ff only

# Always create a merge commit, even when fast-forward is possible
git config merge.ff false

The setting can be applied at three scopes:

  • System-wide (--system): affects every repository for every user on the machine. Almost never the right scope.
  • User-wide (--global): affects every repository the user works on. Useful for an engineer’s personal preference but not for team policy.
  • Repository-wide (--local, the default): affects only this repository. This is the scope for team policy, and it is the one that should be committed to the repository via .git/config (or, more cleanly, by distributing the setting through a configuration management tool that writes to .git/config on clone).

A team that wants --no-ff to be the default for all merges in a repository runs git config --local merge.ff false. After that, a bare git merge <branch> always produces a merge commit; an engineer who specifically wants a fast-forward must pass --ff explicitly. The reverse — merge.ff = only — makes a fast-forward the only acceptable outcome; an engineer who wants a merge commit must pass --no-ff.

git config --local merge.ff false
git config --local --list | grep merge
# merge.ff=false

Reading a merge commit

A merge commit is structurally identical to any other commit object — it has a tree, parents, an author, and a message — but the parent list has two entries instead of one. The first parent is the branch that was checked out when the merge ran; the second parent is the branch that was passed on the command line.

git cat-file -p $MERGE_COMMIT_OID
# tree <merged-tree-oid>
# parent <main-oid>             <-- first parent: was checked out
# parent <feature-oid>          <-- second parent: was merged in
# author Ops <ops@example.com> 1730000000 +0000
# committer Ops <ops@example.com> 1730000000 +0000
#
# Merge branch 'feature/iam-rotation'

The Merge branch '&lt;name&gt;' message is the default; it can be overridden with git merge --no-ff -m "<custom message>" &lt;branch&gt;. Teams that audit by merge commit often write the ticket reference into the message: -m "Merge feature/iam-rotation into main: TICKET-1234". The message is part of the audit trail.

The --first-parent flag on git log reads only the first parent of each merge commit, which gives a flattened view of the trunk without the feature-branch detail. This is the operational view: a release manager scanning main’s history sees the merge commits as “this is what landed”, and drops --first-parent only when investigating a specific merge.

Production discipline

Three rules for --no-ff in a production-grade workflow:

  1. Pick a posture and configure it at the repo level. A team-wide --no-ff policy is merge.ff=false in .git/config; a team-wide linear policy is merge.ff=true (the default). The decision should be in the team’s contributing guide and enforced by the merge command runners, not left to each engineer’s habit.
  2. Use --first-parent for the trunk view, not for forensics. A release manager scanning the last quarter of main benefits from --first-parent. An investigator looking at a specific incident needs the full graph. The two views are complementary; neither is a substitute for the other.
  3. Write the ticket reference into the merge message. A merge commit with Merge branch 'feature/iam-rotation' into main: TICKET-1234 is auditable by ticket number. A merge commit with the default message is auditable only by branch name, which may have been deleted and may not match the ticket system.

Cross-course references

  • Linux for Production Sysadmins - Parts XII (RepoSecurity) covers signed-tag enforcement; the audit-trail argument for merge commits is the same as for signed tags.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers playbook review workflows; --no-ff is the Ansible-repository analogue of “PR review required”.
  • Terraform for Production Sysadmins - Parts IX-XII (State) cover Terraform state; a merge commit that lands a state format change is the kind of event that --no-ff makes visible.

Quiz

Knowledge check · 4 questions

  1. Q1. What does `--no-ff` change in the merge algorithm when fast-forward would have been possible?

  2. Q2. Setting `merge.ff = false` in a repository's local configuration makes every `git merge` produce a merge commit, including merges that would otherwise fast-forward.

  3. Q3. Name the `git log` flag that walks only the first parent of each merge commit, and explain what view of the history it produces.

  4. Q4. Recommend a merge policy for an infrastructure repository whose compliance team audits by branch.

    Your infrastructure repository has a compliance team that audits by branch: every change to production must be traceable to a pull request, every pull request must map to a feature branch, and every feature branch must produce a merge commit on main so the audit tool can grep for 'Merge branch' lines. The current repository uses the default merge behaviour, so fast-forward is the common outcome and the audit tool reports 'no merge commits found in the last quarter'.

Passing score: 75%. Answers are checked in this browser.