Git, CI/CD & GitOpsIX · MergingMerging
Merge commits and --no-ff — forcing topology for audit
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
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/configon 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 '<name>' message is the default; it can be
overridden with git merge --no-ff -m "<custom message>" <branch>. 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:
- Pick a posture and configure it at the repo level. A
team-wide
--no-ffpolicy ismerge.ff=falsein.git/config; a team-wide linear policy ismerge.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. - Use
--first-parentfor the trunk view, not for forensics. A release manager scanning the last quarter ofmainbenefits 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. - Write the ticket reference into the merge message. A
merge commit with
Merge branch 'feature/iam-rotation' into main: TICKET-1234is 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-ffis 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-ffmakes visible.
Quiz
Knowledge check · 4 questions
Q1. What does `--no-ff` change in the merge algorithm when fast-forward would have been possible?
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.
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.
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.