Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXI · Fetch vs PullFetchVsPull

git pull --ff-only — refusing a pull that would require a merge

Intermediate⏱ ~16 mingit

What you'll learn

  • Explain what git pull --ff-only refuses to do, and why the refusal is the safety property
  • Distinguish a fast-forward pull from a divergent pull in terms of the resulting local history
  • Predict the failure message when --ff-only is used on a branch that has diverged
  • Configure pull.ff = only at the repo or branch level so every pull defaults to ff-only
  • Identify the production scenarios — shared branches, CI clones, GitOps controllers — where ff-only is the right default

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.

git pull --ff-only is the strictest of the three pull modes (default, --rebase, and --ff-only). It succeeds only when the local branch tip is already an ancestor of the fetched upstream tip — that is, only when the pull can be a pure fast-forward with no merge commit and no rebase. Any other state causes the pull to refuse and exit non-zero. The refusal is the safety property: it turns a silent divergence into a loud failure that the engineer (or the CI pipeline) cannot ignore.

What ff-only allows and what it refuses

git pull --ff-only runs git fetch and then attempts a fast-forward merge of the fetched upstream into the current branch. The fast-forward succeeds when the local branch has not moved since the last fetch (or has only moved to commits the upstream already has). It refuses when:

  • The local branch has commits the upstream does not have (divergence — a true merge or rebase would be required).
  • The local branch tip is not an ancestor of the fetched upstream tip (which is the same condition expressed more formally).
# Fast-forward case: ff-only succeeds
git pull --ff-only origin main
# From github.com:acme/infra
#    4d2c8e0..9e1f2a3  main           -> origin/main
# Updating 4d2c8e0..9e1f2a3
# Fast-forward
#  modules/iam/main.tf | 12 ++++++------
#  1 file changed, 6 insertions(+), 6 deletions(-)

The output is the same as a successful fast-forward pull without --ff-only. The interesting case is the failure:

# Divergent case: ff-only refuses
git pull --ff-only origin main
# From github.com:acme/infra
#    4d2c8e0..9e1f2a3  main           -> origin/main
# fatal: Not possible to fast-forward, aborting.

The exit code is non-zero. The local branch has not moved; the fetched refs have been updated, but the merge did not happen. The engineer is forced to make an explicit decision: rebase the local branch onto the fetched upstream, merge the fetched upstream into the local branch, or investigate why the divergence happened before doing anything.

flowchart LR
    A["git pull --ff-only origin main"] --> B["git fetch origin"]
    B --> C["refs/remotes/origin/main advances"]
    C --> D{"local tip ancestor\nof origin/main?"}
    D -->|yes| E["fast-forward merge\nlocal tip advances"]
    D -->|no| F["fatal: Not possible to fast-forward, aborting"]
    E --> G["working tree updated\nno merge commit"]
    F --> H["exit non-zero\nlocal branch unchanged"]

The fatal: Not possible to fast-forward, aborting line is the safety net firing. It is not a bug; it is the design.

Why ff-only is the right default for shared branches

On a shared infrastructure branch (the team’s main, a release stabilisation branch, a long-running production branch), a merge commit produced by git pull is noise: it documents an integration that the team did not author and did not review. A pull that cannot be a pure fast-forward is a pull that the engineer should not run at all — the divergence needs to be resolved by a deliberate merge or rebase, with a meaningful commit message, and ideally via a reviewed pull request.

git pull --ff-only enforces this by refusing anything that is not a clean fast-forward. The engineer is forced to stop and think before producing a merge commit. The cost is occasional friction when the local branch has fallen behind; the benefit is that the shared branch history is always a clean fast-forward lineage of the upstream.

# CI: refuse any pull that would require a merge
git fetch origin
git pull --ff-only origin main
# exit 0 if local is a fast-forward of origin/main
# exit non-zero otherwise — fail the CI job

# Or, equivalently, without --ff-only:
git pull origin main
# then assert the tip is origin/main
git rev-parse main
git rev-parse origin/main
# if they differ, a merge commit was produced — fail the job

The CI version that asserts after the fact works but is fragile: a merge commit can be produced and then the job can move on. The git pull --ff-only version is cleaner because the failure surfaces inside the pull itself, with a clear error message, before any merge commit lands.

Configuring ff-only as the default

The behaviour can be made the default at the repo level (pull.ff = only) or the global level (pull.ff = only). The configuration accepts three values:

  • true (default) — allow fast-forward merges; fall back to a merge commit if fast-forward is not possible.
  • false — always produce a merge commit, even when a fast-forward is possible.
  • only — refuse anything that is not a fast-forward.
# Set ff-only at the repo level (this clone only)
git config pull.ff only

# Set ff-only at the global level (every clone on this machine)
git config --global pull.ff only

# Verify
git config --get pull.ff
# only

# Override for one pull
git pull --no-ff origin main   # force a merge commit
git pull --rebase origin main  # force a rebase

A repo-level setting is preferable to a global one because it encodes the policy in the repository’s .git/config and travels with the clone. A new engineer cloning the repository gets the policy for free; a global setting requires every engineer to configure their own machine the same way.

When ff-only is the wrong default

There are scenarios where ff-only is too strict:

  • Local feature branches with WIP commits. A feature branch that has unpushed local commits cannot fast-forward from origin/main by definition. ff-only on every pull would refuse every sync with the upstream. Merge or rebase is the right default here.
  • Repositories with a true long-running branch. A team that maintains main and production as parallel branches and pulls between them regularly needs merge-mode pulls, not ff-only. The long-running branch is structurally divergent from the upstream.
  • Workflows that integrate many sources. A monorepo with many feature branches landing into a single integration branch daily needs merge-mode pulls to integrate them. The integration branch is not a fast-forward of any single source.

For these scenarios, --rebase or the default merge mode is appropriate. ff-only is right when the local branch is meant to be a pure mirror of a single upstream.

Production discipline

  1. Use --ff-only on CI clones and GitOps controllers. A CI job that pulls the main branch should refuse anything that is not a pure fast-forward; the failure surfaces before any deploy step runs.
  2. Set pull.ff = only at the repo level, not globally. A repo-level setting encodes the policy in the repository’s .git/config and travels with the clone. A global setting requires every engineer to remember to set it.
  3. Treat a refused ff-only as an investigation, not a retry. The failure is information: the local branch has diverged from the upstream, or the local branch tip is wrong, or the remote is not what the engineer thinks it is. Retrying with --no-ff or --rebase to make the pull “go through” hides the information.
  4. Pair ff-only with a deterministic checkout. A CI job that pulls and then checks out a specific SHA is more deterministic than a CI job that pulls and runs whatever tip the upstream currently advertises. Pin the SHA when reproducibility matters.

Cross-course references

  • Terraform for Production Sysadmins - Part XVI (ModuleVers) uses git pull --ff-only in CI to ensure the local Terraform module cache always tracks the upstream as a pure fast-forward; a merge commit in the local cache is a signal that something is wrong upstream.
  • GitOps with Argo CD - Part V (DriftDetection) describes how Argo CD’s repo-server uses a ff-only-equivalent policy internally: it refuses to advance the local clone to a commit that is not a fast-forward of the fetched upstream, surfacing the divergence as a sync error.
  • Ansible for Production Sysadmins - Part XXXIX (MirrorRefresh) recommends git pull --ff-only in mirror scripts so a mirror never diverges from the upstream by accident; the mirror’s job is to reflect, not to integrate.

Quiz

Knowledge check · 4 questions

  1. Q1. What happens when `git pull --ff-only origin main` is run on a local branch that has diverged from origin/main?

  2. Q2. Setting `pull.ff = only` in `.git/config` means every `git pull` in that clone will refuse to do anything other than a pure fast-forward.

  3. Q3. Explain why `git pull --ff-only` is the right default for a CI clone and the wrong default for a local feature branch with WIP commits.

  4. Q4. A CI job that runs `git pull --ff-only origin main` starts failing with `Not possible to fast-forward` after a teammate force-pushes the shared branch. Diagnose and recommend a fix.

    A CI pipeline for an infrastructure repo has been green for weeks. The pipeline runs `git fetch origin && git pull --ff-only origin main` and then deploys. This morning the pipeline fails with `fatal: Not possible to fast-forward, aborting`. The on-call engineer checks the team's main branch and sees the latest commit SHA is different from what the CI was previously tracking. A teammate admits they force-pushed earlier today.

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