Branches, Fast-Forward and Three-Way Merges, and Branching Strategies: Configuration, Design Choices, and Tradeoffs
Design branch and merge policy around merge.ff, pull.ff, branch-specific merge options, branch lifetime, server-side protection boundaries, and merge/squash/rebase history tradeoffs.
Learning objectives
-
Explain merge.ff, pull.ff, and branch.
.mergeOptions with scope/precedence implications. - Design branch naming and lifecycle rules that are explicit and reproducible.
- Separate local Git mechanics from hosted branch-protection and review controls.
- Compare merge commits, squash integration, and rebased linear history as history-design choices.
- Choose branch lifetime and integration policy from deployment cadence and support constraints.
1. Branch mechanics are local; branching strategy is governance
Git core gives you refs, commits, merge bases, fast-forward rules, merge commits, squash mode, and rebase machinery. A team decides which of those shapes is acceptable for each integration path. Hosting products can then enforce server-side controls such as protected branches, required review, and merge queues; those are not local branch objects.
2. merge.ff controls ordinary merge behavior
git config --show-origin --show-scope --get merge.ff
git config --local merge.ff only
Current Git supports three practical modes: the normal
fast-forward-when-possible behavior, false (equivalent
to --no-ff for ordinary merges), and
only (equivalent to --ff-only). A global
setting can surprise repositories whose policy expects a different
graph shape, so shared workflow rules should be explicit in
documentation/automation rather than depending on an invisible
developer default.
3. pull.ff belongs to pull/integration policy
git pull fetches and then integrates.
pull.ff controls fast-forward behavior during that
integration and can override merge.ff for pull. A team
that wants pull never to create an accidental merge commit can
choose pull.ff=only; a team using rebase on pull needs
to define that separately.
git config --show-origin --show-scope --get pull.ff
git config --show-origin --show-scope --get pull.rebase
Chapter 07 teaches fetch/pull/remotes deeply. Here the key lesson is that a “surprise merge from pull” is often a configuration/policy issue, not mysterious Git behavior.
4. branch.<name>.mergeOptions can hide defaults
in one branch
git config --local branch.trunk.mergeOptions --ff-only
git config --show-origin --show-scope --get branch.trunk.mergeOptions
This can make merges into a particular branch inherit options. It is powerful but easy to forget. Prefer obvious CI/runbook commands for critical integration constraints when hidden local configuration would make another clone behave differently.
5. Branch naming and lifecycle are discoverability policies
Names should tell humans what a ref represents without pretending
the name supplies authorization. A small team might use
feature/<topic>,
fix/<incident>, and
release/<line>. Lifecycle rules matter more than
punctuation: who creates it, expected lifetime, integration target,
who may delete it, and whether CI must pass before integration.
6. Protected integration is server-side, not a special Git branch type
A local trunk branch is structurally the same kind of
ref as any other local branch. A hosting service may reject pushes,
require reviews, require CI, or serialize merges. Those controls
live at the remote service boundary and will be revisited in
platform-specific courses. Local labs can still model the underlying
ancestry and merge shape without an account.
7. Merge commit, squash, and rebased linear history answer different history-design questions
| Integration shape | What history preserves | Main tradeoff |
|---|---|---|
| True merge commit | Feature commit ancestry plus explicit integration event | Graph is non-linear; reviewers must understand parents |
| Squash merge | Final combined content as one new commit | Original feature commits are not ancestors of the target branch |
| Rebased linear history | Individual logical commits replayed on a new base | Commit IDs change; unsafe as a casual rewrite of shared history |
git merge --squash topic updates the working tree/index
with the merge result but does not create a merge commit or record
the topic as a merge parent; a subsequent normal commit creates one
new single-parent commit. Rebase is covered deeply in Chapter 09 and
should be limited to unpublished/local work unless a coordinated
policy says otherwise.
8. Long-lived branches increase integration cost nonlinearly
The longer two branches evolve independently, the more code, configuration, tests, infrastructure assumptions, and deployment behavior can diverge. Even when Git reports no textual conflict, the combined result can be semantically wrong. Long-lived branches are justified when support/release constraints outweigh that integration cost—not because a diagram looks professional.
9. Decision table for common team constraints
| Constraint | Likely policy | Why |
|---|---|---|
| Deploy many times per day, one supported line | Trunk + very short branches, fast integration | Minimize divergence and feedback delay |
| Review required before trunk | Short-lived feature branches + controlled server integration | Review gate without long isolation |
| Maintain v1 and v2 simultaneously | Release/support branches plus targeted fixes | Separate supported baselines are real operational constraints |
| Monthly release train with stabilization window | Possibly a temporary release branch | Allows stabilization while trunk moves, but adds backport cost |
| No requirement for multiple long-lived lanes | Avoid Git Flow complexity | Every long-lived ref adds synchronization work |
10. Worked scenario — a six-person DevOps product team
The team deploys daily, supports only the currently deployed major version, requires review plus CI, and can hide incomplete features behind flags. A reasonable policy is: trunk is always releasable; feature/fix branches should normally live hours to a few days; integration is controlled on the server; hotfixes branch only long enough for review/validation; release branches are created only if a future support requirement appears.
That policy is maintainable because it follows the delivery model.
Adopting permanent develop, release, and
hotfix lanes without a matching support problem would
increase operational cost without adding capability.
11. Knowledge check
Question 1. What does
merge.ff=only enforce?
Question 2. Why can a global
merge.ff=false surprise a team?
Question 3. Does a squash merge preserve the feature commits as parents of the target history?
Question 4. When is a long-lived release branch justified?
12. Summary
Fast-forward and pull settings are policy levers, not universal best practices. Hidden global defaults can make clones behave differently. Merge, squash, and rebase shapes preserve different information, and branch lifetime should be chosen from deployment/support constraints rather than fashion.
Authoritative references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.