Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXII · Merge vs RebaseFoundations

When to merge — shared branches, integrations, and release topology

Advanced⏱ ~20 mingit

What you'll learn

  • Identify the four scenarios where merge is the right verb for an infrastructure repository
  • Apply `git merge` and `git merge --no-ff` to preserve the branch event in the DAG
  • Recognise why integration branches, long-lived shared branches, and release branches must always use merge
  • Read the merge commit to recover the branch lifecycle and the PR reference for audit
  • Configure `merge.ff = false` to enforce a no-fast-forward policy on protected branches

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.

Merge is the right verb when the branch has downstream consumers — anyone or anything that holds the OIDs of the branch’s commits. The four scenarios in an infrastructure repository are: (1) any branch that has been pushed and consumed (fetched by a teammate, built by CI, pinned by an artifact registry), (2) integration branches that aggregate work from multiple feature branches, (3) long-lived shared branches that multiple engineers work against simultaneously, and (4) release branches that need the integration event visible in the graph for audit. Each scenario is bounded by the same rule: OIDs of the integrated branch must not be rewritten, because someone downstream holds them.

Anything pushed and consumed

The simplest merge scenario: the feature branch has been pushed, fetched by teammates, built by CI, and pinned by an artifact registry. The OIDs of its commits are held by downstream consumers. Rebasing the branch would break every one of those consumers.

git checkout main
git merge feature/iam-rotation
# Merge made by the 'recursive' strategy.
# 4d2c8e0 (main) Merge branch 'feature/iam-rotation'
flowchart LR
    subgraph MERGED["after git merge (default, fast-forward possible)"]
        C["merge base"] --> F1["F1 (feature)"]
        F1 --> F2["F2"]
        F2 --> F3["F3 (also new main tip)"]
    end
    subgraph MERGED_NOFF["after git merge --no-ff (forced merge commit)"]
        C2["merge base"] --> M["main tip (M1)"]
        C2 --> F1b["F1"]
        F1b --> F2b["F2"]
        F2b --> F3b["F3"]
        M --> MG["merge commit"]
        F3b --> MG
    end

When fast-forward is possible (the second diagram’s left side), git merge simply moves main’s pointer forward to F3. The history reads as a straight line; the branch event is invisible. When --no-ff is used (right side), Git creates a merge commit MG even though fast-forward was possible. The history records the branch event as a node in the DAG.

The boundary that makes merge mandatory: any consumer — teammate, CI runner, artifact registry, GitOps controller, signed tag — holds the OIDs. A rebase would invalidate every held reference. The merge adds a new commit (the merge commit) without changing the OIDs of the feature’s commits, so every downstream reference remains valid.

Integration branches

The second scenario: a branch whose purpose is to aggregate work from multiple feature branches. Examples include develop in a Git Flow workflow, integration in a trunk-based workflow, and any branch on which multiple engineers’ work is consolidated before being promoted to a trunk.

git checkout develop
git merge --no-ff feature/vpc-peering
git merge --no-ff feature/route-table-update
git merge --no-ff feature/iam-policy
# Each merge creates a merge commit that records which feature came in when
flowchart LR
    subgraph INTEGRATION["integration branch with multiple merges"]
        D1["develop base"] --> F1["F1 (vpc-peering)"]
        D1 --> F2["F2 (route-table)"]
        D1 --> F3["F3 (iam-policy)"]
        D1 --> D2["develop tip"]
        F1 --> M1["merge vpc-peering"]
        F2 --> M2["merge route-table"]
        F3 --> M3["merge iam-policy"]
        M1 --> D3["develop tip"]
        M2 --> D3
        M3 --> D3
    end

The merge commits M1, M2, M3 each record one integration event: which feature branch came in, when, and via which PR (in the commit message). The integration branch’s history is a sequence of merge commits interleaved with the development work — the topological record of what was integrated, in what order. The auditor can reconstruct the integration timeline by reading git log --first-parent develop: each merge commit is one integration event.

The boundary that makes merge mandatory: integration branches are shared by definition. Every feature branch merged into them has downstream consumers; rebasing the integration branch would rewrite OIDs of every merged feature’s commits. The merge verb is the only safe choice.

Long-lived shared branches

The third scenario: a branch that exists for weeks or months and is worked on by multiple engineers simultaneously. Examples include release/x.y branches, long-running feature branches for major refactors, and branches maintained by multiple teams.

git checkout release/2026-q3
git merge feature/bugfix-1234
# Merge made by the 'recursive' strategy.
flowchart LR
    subgraph LONG_LIVED["long-lived shared branch"]
        R1["release base"] --> RF1["RF1 (shared)"]
        R1 --> RF2["RF2 (engineer A)"]
        R1 --> RF3["RF3 (engineer B)"]
        RF1 --> RA["RA (shared tip)"]
        RF2 --> RB["RB (A's tip)"]
        RF3 --> RC["RC (B's tip)"]
        RA --> M1["merge A's work"]
        RB --> M1
        RC --> M2["merge B's work"]
        M1 --> RD["release tip"]
        M2 --> RD
    end

Long-lived branches accumulate merges from many sources. Each merge commit records one integration event; the DAG topology encodes who contributed when. Rebasing such a branch is structurally infeasible: the branch has dozens or hundreds of downstream consumers, and the rewrite would invalidate all of them. The merge verb is the only viable choice.

The boundary that makes merge mandatory: the branch has many consumers, has existed long enough that those consumers have built state on top of its OIDs (signed tags, release notes, downstream forks), and the rewrite would invalidate all of that state. The merge is mandatory.

Release branches

The fourth scenario: a release branch is cut from a trunk (main or develop), receives only bug fixes during the release stabilisation window, and is then merged back into the trunk with the release event visible.

# Cut the release branch
git checkout -b release/2026-q3 main

# ... bug fixes during stabilisation ...

# Merge back to main with --no-ff to record the release event
git checkout main
git merge --no-ff release/2026-q3
# Merge made by the 'recursive' strategy.
# a1b2c3d (main) Merge branch 'release/2026-q3'
flowchart LR
    subgraph RELEASE["release branch cut, stabilised, merged back"]
        M["main tip at cut"] --> R1["R1 (release commit)"]
        R1 --> R2["R2 (bugfix)"]
        R2 --> R3["R3 (bugfix)"]
        R3 --> R4["R4 (release tag candidate)"]
        M --> RM["release merge commit"]
        R4 --> RM
    end

The merge commit RM records the release event: the release branch came back into main here, with these commits. The audit trail — what was in the release, what was fixed during stabilisation, when the release was cut and merged back — is fully reconstructible from the DAG. The auditor runs git log --first-parent main and sees the release merge commit; from there, the second-parent edge leads to the release branch’s commits.

The boundary that makes merge mandatory: the release branch has been tagged (a signed release tag points into it), the release notes reference its commits, and downstream consumers may have pinned to its OIDs. Rebasing the release branch would invalidate all of that state. Merge is the only safe verb.

Production discipline

  1. Default to git merge --no-ff for shared branches. The merge commit is cheap; the audit trail it creates is expensive to reconstruct after the fact.
  2. Configure merge.ff = false on protected branches. The setting forces every merge into the protected branch to produce a merge commit, regardless of whether fast-forward would be possible. The configuration is per-repository and prevents the fast-forward default from silently erasing branch events.
  3. Never rebase a release branch. Release branches have signed tags, release notes, and downstream pins. The OIDs of their commits are immutable from the moment the release is cut.
  4. Treat long-lived shared branches as append-only. Every contribution comes in as a merge commit; the branch’s DAG topology encodes the integration history. Squash-merge into a trunk periodically to keep the trunk linear.
  5. Document the merge policy in CONTRIBUTING. A team that has not made the policy explicit will accumulate inconsistent histories — some branches merged with --no-ff, some fast-forwarded, some rebased — producing a graph that is neither linear nor consistently topologically informative.

Cross-course references

  • GitOps with Argo CD - Part VI (MergeStrategies) maps the release branch pattern onto GitOps: a release branch is cut from main, receives stabilisation fixes, and is merged back with the release event visible in the graph; the GitOps controller reads the merge commit to detect the new release.
  • CI/CD Pipeline Patterns - Part V (MergeQueues) treats integration branches as merge queues: each pull request is rebased onto a temporary branch in the queue (not onto the integration branch itself), and the integration branch receives the result as a merge commit.
  • Terraform for Production Sysadmins - Part XI (PRWorkflows) requires --no-ff for every merge into the Terraform main: the merge commit records which plan was applied and via which PR, giving the audit trail a node in the DAG that links the production state to the change author.

Quiz

Knowledge check · 4 questions

  1. Q1. An engineer is merging a feature branch that has been pushed for two days, reviewed by two teammates, and built by CI. The trunk currently points at a commit that is an ancestor of the feature tip (fast-forward is possible). Which merge command should the engineer use, and why?

  2. Q2. Configuring `merge.ff = false` on a protected branch forces every merge into that branch to produce a merge commit, regardless of whether fast-forward is possible.

  3. Q3. List the four scenarios where merge is the right verb for an infrastructure branch and identify the common boundary that makes merge mandatory in all four.

  4. Q4. Choose the right merge command for three integration events on a shared branch and justify each choice using the audit-trail requirement.

    An infrastructure team maintains a `develop` branch that aggregates work from multiple feature branches. Three events have happened today: (1) `feature/vpc-peering` was merged in by engineer A; (2) `feature/route-table-update` was merged in by engineer B; (3) a hotfix `hotfix/critical-iam-bug` was merged in by engineer C. The team needs the audit trail to show who merged what when, and the team's policy is `merge.ff = false` on `develop`.

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