For intermediate Git users joining real teams

Git Workflows,
branching without the chaos.

A workflow is not a tool β€” it is the contract a team makes about where work happens, how it integrates, and how it ships. Learn the four models you will actually meet, the merge mechanics underneath them, and how to choose, run, and survive any of them.

GitHub Flow Git Flow Trunk-based GitLab Flow single-file / offline real-repo labs interactive quiz

1 Β· Orientation: a workflow is a contract, not a tool

Every team that shares a repository already has a git workflow β€” written down or not. The question is never "do we have one?" but "is ours deliberate, and does it fit how we ship?"

Git itself ships with almost no opinion about how you organize work. It gives you commits, branches, merges, tags, and a remote protocol. A workflow is the set of rules a team layers on top: which branch is sacred, where new work starts, how it gets reviewed, how it reaches production, and what the resulting history should look like. The four workflows in this guide are simply four common, named answers to those questions.

What a workflow decides

  • Which branch is always shippable.
  • Where feature work lives and how long it stays separate.
  • How work integrates into the shared line (merge vs rebase vs squash).
  • How releases and hotfixes are cut and tracked.
  • What "the history" is supposed to read like.

What it does not decide

  • Which hosting platform you use (GitHub, GitLab, Bitbucket all support all four).
  • Whether you use a GUI or the CLI.
  • Your programming language or build system.
  • Whether you deploy continuously β€” though cadence heavily influences the choice.

The four decision axes

Almost every "which workflow?" debate collapses onto four axes. Memorize these; the decision matrix in Β§11 is built directly on them.

1 Β· Release cadence

Do you deploy the moment code lands, or do you cut scheduled, versioned releases? Continuous deployment pushes you toward trunk-based or GitHub Flow. Scheduled or installed releases push you toward Git Flow or GitLab Flow release branches.

2 Β· Team size & concurrency

How many people push at once? A solo or small team tolerates almost anything. Large concurrent teams need a model that minimizes integration pain β€” short-lived branches and strong automation.

3 Β· Deployment topology

One live environment you can redeploy instantly? A staged pipeline (dev β†’ staging β†’ prod)? Software installed on customer machines that you must patch in place? Each maps to a different branch structure.

4 Β· CI/CD maturity + supported versions

Fast, trustworthy automated tests are the prerequisite for short-lived-branch workflows. Having to maintain several old, supported versions (and backport fixes to them) pulls you toward release branches.

Workflow vs merge strategy β€” don't confuse them A workflow says where work goes (GitHub Flow, Git Flow, …). A merge strategy says how a branch lands β€” fast-forward, merge commit, squash, or rebase (Β§4). They are orthogonal: you can run GitHub Flow with squash merges or with merge commits. Teams usually pair a workflow with a default strategy, but the choice is separate.

How to use this guide

Read Β§2–§6 once; they are the engine every workflow is built from. Then study each workflow in Β§7–§10 β€” each is self-contained with a topology diagram, the real commands, pros and cons, and a throwaway-repo lab. Use Β§11 when you must pick, Β§12 when you must operationalize the choice, and Β§14 to practice all four end to end. Treat the capstone and per-workflow labs as the real test β€” reading about merging is not the same skill as merging.

2 Β· Prerequisite floor: what you already need

This guide assumes you can already move around a repository on your own. If any item below is shaky, fix it first β€” workflows will not make sense until the primitives do.

Hard prerequisites

  • Commits and history: stage with git add, commit with git commit, read git log and git diff.
  • Branches: create (git switch -c), switch (git switch), list (git branch), delete (git branch -d).
  • Merging: merge one branch into another and read the result; know that a merge can create a merge commit.
  • Remotes: what origin is, the difference between git fetch, git pull, and git push, and how to set an upstream.

Soft prerequisites (helpful)

  • Having opened a pull request or merge request at least once.
  • Having seen a merge conflict and at least attempted to resolve it.
  • A vague sense of what a "tag" or "release" is.

Just-in-time prerequisites

Interactive rebase, conflict markers, and the ours/theirs vocabulary are introduced in Β§6, exactly when you need them β€” not before.

Self-assessment

Answer each before opening it. If you miss more than two, revisit the prerequisites before continuing.

1. What does git pull actually do, in two steps?

git pull = git fetch (update your remote-tracking refs from origin) then integrate the fetched commits into your current branch (by merge, by default; or rebase if configured). Fetching alone never touches your working branch.

2. After git switch -c feature main, where does HEAD point?

HEAD points to the new branch feature, which itself points at the same commit main pointed at when you created it. Both labels now sit on that commit until one moves.

3. What is the difference between a fast-forward merge and a merge commit?

A fast-forward just slides the target branch pointer forward when the source is strictly ahead β€” no new commit is made. A merge commit is a new commit with two parents, created when the two branches have diverged (or when you force one with --no-ff).

4. You merged a feature branch and want to throw the merge away while it is still local. What do you reach for?

git reset --hard HEAD~1 (undo the merge commit) or git reset --hard ORIG_HEAD (the recorded position before the merge). The reflog (git reflog) is your safety net. Once the merge is pushed and others have pulled, prefer a git revert -m 1 instead.

5. Why is git push --force on a shared branch dangerous?

It rewrites the remote branch to match your local history, discarding any commits teammates pushed in the meantime. Their clones still hold the old commits; their next pull or push will conflict or silently re-push what you deleted. Use --force-with-lease if you must, and never on a protected integration branch.

6. What is origin/main, and how is it different from main?

main is your local branch. origin/main is a remote-tracking branch β€” a local cache of where main was on the remote last time you fetched. It moves only when you fetch; it is not live.

3 Β· The DAG model: commits, pointers, and why branches are cheap

Every workflow is easier to reason about once you stop picturing branches as "lines" and start picturing them as movable labels on a graph of immutable commits.

Commits are immutable; branches are labels

A commit is a snapshot plus metadata (author, message, timestamp) plus a pointer to its parent (or parents). A commit's hash is the SHA-1 of all of that content. Change anything β€” even the message β€” and you get a different commit with a different hash. The old commit still exists in your object store until garbage collection; that is what makes recovery possible.

A branch is none of that. A branch is a tiny movable pointer β€” a file containing one hash. main is just "the name of the commit we currently call main." Moving a branch is free, which is why creating and deleting branches is trivial and why short-lived branches are viable.

First principle: nothing is ever "on a branch" Commits are not owned by branches. A commit is reachable from zero or more branches. "Delete the feature branch" rarely deletes the commits β€” it just removes one label. As long as some ref (a branch, a tag, the reflog) can still reach them, the commits survive.

The graph is a DAG

Commits form a directed acyclic graph (DAG). Each commit points back to its parent; merge commits point to two parents. HEAD is a special pointer that says "where you are now" β€” usually it points to a branch, which points to a commit. (Detached HEAD means HEAD points directly at a commit instead of a branch.)

# Two commits on main, then a feature branch diverges:
#
#   C0 ── C1 ────────── C4   (main)
#             \
#              C2 ── C3       (feature)
#
# HEAD -> feature -> C3 ;  main -> C4
git log --graph --oneline --all --decorate

What merging and rebasing really do

OperationWhat it does to the graphNew commits?
Fast-forward mergeSlides target pointer forward to source commit.No
Merge commitCreates a commit with two parents joining both lines.Yes β€” the merge commit
RebaseCopies your commits and replays them onto a new base, one at a time.Yes β€” rewritten copies (new hashes)
SquashCollapses several commits into one new commit on the target.Yes β€” one new commit
Cherry-pickCopies a single commit's changes onto your current branch.Yes β€” a new commit with the same diff

The whole rest of this guide is choosing which of those operations happen, when, and on which branches. That is literally what a workflow is.

4 Β· Merge models: the engine underneath every workflow

Before the named workflows, you must internalize the four ways a branch can land. Most heated "merge vs rebase" arguments are people talking past each other because they never separated these.

The four models

ModelWhat happensHistory shapeCommand / button
Fast-forward (FF)Target is an ancestor of source, so the pointer just advances. No merge commit.Perfectly linear.git merge --ff-only
Merge commit (no-FF)Always creates a two-parent commit, even when a FF was possible. Preserves "this came from a branch."Branch bubbles visible.git merge --no-ff
SquashCollapses the branch's commits into a single new commit on the target.Linear; one commit per PR.git merge --squash then commit; or "Squash and merge"
RebaseReplays the branch's commits on top of the target, then fast-forwards.Linear; keeps individual commits.git rebase; or "Rebase and merge"

Same situation, four outcomes

Start with main at C1 and a feature branch with two commits C2, C3. Here is the resulting main history under each model:

# Starting point
#   C0 ── C1            main
#           \
#            C2 ── C3   feature

# 1) Fast-forward  --ff-only
#   C0 ── C1 ── C2 ── C3        main
#   (no merge commit; feature label absorbed)

# 2) Merge commit  --no-ff
#   C0 ── C1 ─────── M          main
#           \       /
#            C2 ── C3
#   (M is the merge commit with two parents)

# 3) Squash
#   C0 ── C1 ── S               main
#   (S holds the combined diff of C2+C3; C2,C3 dropped from main)

# 4) Rebase + ff
#   C0 ── C1 ── C2' ── C3'      main
#   (C2',C3' are replayed copies with new hashes; linear)

Linear history (rebase / squash / ff)

Reads top-to-bottom with no bubbles. Easier git log, cleaner git bisect, no "merge branch X" noise. Cost: rebase and squash rewrite commit hashes, and squash throws away per-commit granularity (and can flatten a set of commits where only the final one built).

Preserved topology (merge commit)

Keeps the true story β€” which commits were grouped, where they branched. Useful for archaeology. Cost: a "noisy" graph, more commits to scroll, and merge commits that don't themselves carry changes.

History shape is a policy β€” pick one and set the default Mixed strategies produce the worst of all worlds: some PRs squashed, some merged, history you cannot reason about. Pick a single merge strategy per repository, configure it as the default button in your host, and document it. The workflow (Β§7–§10) usually implies a natural choice; the matrix in Β§11 lists the conventional pairing.

Decoupling deploy from release: feature flags

One idea appears in modern workflow debates constantly: you do not have to release a feature when you merge it. With a feature flag, half-finished code can merge to the main line behind a default-off switch and be turned on later, independently of any deploy. This single technique is what makes trunk-based development viable for large teams β€” and it loosens the grip of long-lived feature branches everywhere.

5 Β· Pull requests: the integration gate every modern workflow shares

Whatever the workflow, on a hosted platform the moment of integration is almost always a pull request (GitHub/Bitbucket) or merge request (GitLab). Understanding what it is and is not prevents a lot of confusion.

A pull request (PR) is not a git concept β€” git has no PR command. It is a feature of the hosting platform: a proposed merge of one branch into another, bundled with a discussion thread, a diff view, continuous-integration status, and review approvals. Under the hood, finishing a PR just performs one of the four merges from Β§4 on the server.

1 Β· BranchOff the base
2 Β· PushTo origin
3 Β· OpenCreate the PR
4 Β· ReviewApprove + CI green
5 Β· MergeStrategy chosen here

What a PR configures

SettingWhat it enforces
Required reviewers / approvalsAt least N people (or a CODEOWNER) must approve before merge.
Required status checksCI must pass; optionally the branch must be up to date with the base.
Merge strategy buttonsWhich of merge / squash / rebase the green button offers β€” and which is default.
Auto-delete branchRemove the source branch after merge to keep the branch list clean.
Required linear historyForbid merge commits entirely, forcing rebase/squash.
"Merge the PR" = "do a merge on the server" The platform UI is just a friendly front-end over a server-side git merge. That is why the merge-strategy choice (Β§4) appears as buttons on the PR. When you click Squash and merge, GitHub runs the equivalent of a squash merge and advances the base branch. The same primitives, wrapped in policy.

Review etiquette that transcends the workflow

  • Small PRs. A 50-line PR gets a careful review; a 500-line PR gets a rubber stamp. Workflow choice does not change this β€” but trunk-based's short branches make it the default.
  • Every commit on a feature branch should ideally build if you use rebase or merge-commit (not squash) integration β€” otherwise git bisect lands on broken commits.
  • Keep the base fresh. Rebase or merge main into your branch before opening to shrink the final integration diff and catch conflicts early.

6 Β· Rebase and conflict survival (just-in-time refresher)

Every workflow produces conflicts eventually. This section gives you the minimum mechanics to survive them and the one rule that prevents most rebase disasters.

What rebase does, precisely

git rebase <base> takes the commits that are on your current branch but not on <base>, sets your branch aside, moves its tip to <base>, then replays each saved commit one at a time. Each replayed commit becomes a new commit with a new hash. The history reads as if you had started from the new base all along.

# Before
#   C0 ── C1 ── C2        main
#           \
#            A ── B       feature
#
# git switch feature
# git rebase main
#
# After: A' and B' are replays (new hashes), tip now on C2
#   C0 ── C1 ── C2        main
#                \
#                 A' ── B'  feature
The Golden Rule of rebase Never rebase commits that other people already have β€” i.e. shared, pushed branches. Rebase rewrites hashes; anyone who built on the old commits is now stranded. Rebase your own local feature branch onto fresh main freely; rebase main itself, never.

Conflicts and how to read them

A conflict happens when two branches change the same lines and git cannot combine them automatically. Git pauses and writes conflict markers into the file:

def greeting():
<<<<<<< HEAD
    return "hello"        # your current branch (HEAD)
=======
    return "howdy"        # the commit being merged/replayed
>>>>>>> feature

Edit the file to the correct final content (removing all markers), then continue. The flow differs only slightly between merge and rebase:

StepDuring a mergeDuring a rebase
After editinggit add <file>git add <file>
Continuegit merge --continue (or git commit)git rebase --continue
Give upgit merge --abortgit rebase --abort
Skip a bad commitβ€”git rebase --skip
"ours" and "theirs" flip during rebase During a merge, HEAD (ours) is the branch you are on. During a rebase, git is replaying your commits onto the base one at a time β€” so at each step the base is "ours" and your own commit is "theirs." Never resolve by the label alone; read the actual diff and decide on content.

Recovery net

If a merge or rebase goes sideways, git reflog records where every ref has been. You can almost always recover by checking out or resetting to a reflog entry. ORIG_HEAD conveniently records your position before the last merge/reset.

7 Β· GitHub Flow β€” the simplest model that works

One immortal branch (main), short-lived feature branches, a pull request, and a merge. That is the entire model. It is the default at most modern web companies for a reason.

The rules

  • main is always deployable. Anything in main is, or can immediately be, in production.
  • Branch off main for anything: feature/x, fix/y, chore/z.
  • Open a pull request early; get review; keep the branch short-lived (hours to a few days).
  • When approved and CI is green, merge into main. Deploy from main.
  • Delete the branch. No develop, no release branches.

Topology

main    C0 ── C1 ── M1 ── M2 ── M3   (always deployable)
                 \   /   \   /
feature           F1      F2              (short-lived, deleted after merge)
Branchoff main
Commitsmall & often
PRopen & review
Mergeinto main
Deployfrom main

The real loop

git switch main
git pull --ff-only            # start from a fresh main
git switch -c feature/dark-mode

# ... make changes, commit ...
git commit -am "Add dark mode"

git push -u origin feature/dark-mode
# open the PR in your host; get review; CI green

# after merge (server side), clean up:
git switch main
git pull --ff-only
git branch -d feature/dark-mode

Pros

  • Simple. One integration branch. New people understand it in minutes.
  • Few moving parts. No release/hotfix branch ceremony to learn.
  • Deploys continuously. Nothing between "merged" and "shipped" but a deploy button.
  • Short branches β†’ fewer conflicts. Integration happens constantly.

Cons

  • main must be always-releasable. This is load-bearing: it demands strong CI and the discipline to keep broken code out. Without it, the model breaks.
  • No built-in release management. No formal "release 2.3" concept; versioning and changelogs are an add-on.
  • Awkward for multiple supported versions. If you must patch v1 and v2 simultaneously, there is no structure for it.
  • Rolled-back deploys need care. A bad merge means redeploying the previous main or reverting β€” there is no separate production branch to promote from.

Use it when you deploy continuously (web apps, SaaS), have trustworthy CI, and support essentially one live version. Avoid it when you ship versioned, installed artifacts or must maintain several old releases.

Lab 1 β€” GitHub Flow, end to end
8 minutes Β· local only Β· no remote needed
# Lab 0 builds the playground. Run it once, then:
git switch -c feature/dark-mode
printf 'theme=dark\n' >> app.conf
git add app.conf
git commit -m "Add dark mode option"

# simulate the PR merge (server-side, no-ff) locally:
git switch main
git merge --no-ff feature/dark-mode -m "Merge dark mode"
git tag -a v0.2.0 -m "Dark mode release"   # optional version marker
git branch -d feature/dark-mode
git log --graph --oneline --all
  • Confirm main advanced and a merge commit M exists.
  • Confirm the feature branch is gone but its commits survive in main.
  • Run git log --graph and match it to the topology diagram above.

8 Β· Git Flow β€” structure for versioned releases

Vincent Driessen's 2010 "successful Git branching model." Five branch types, explicit release and hotfix tracks, tags on production. It is the heavyweight champion β€” powerful for shipped software, overkill for a website.

The five branch types

BranchLifetimeBranched fromMerged into
mainForeverβ€”β€” (production; tagged here)
developForevermain initiallymain (via release)
feature/*Shortdevelopdevelop
release/*Shortdevelopmain and develop
hotfix/*Shortmainmain and develop

Topology

main      C0 ──────────────── R ────────── H ──   (tagged v1.0, v1.0.1)
             \                / \          / \
develop      D1 ── D2 ── D3 ──   ── D4 ──     ── D5
                  \      /                \
feature            F1 β”€β”€β”˜                  (next features)

Features accumulate on develop. When a release is due, a release/* branch freezes, gets last polish, then merges to main (tagged) and back to develop. A production bug spawns hotfix/* off main, fixes it, merges to main (tagged) and back to develop so the fix isn't lost.

Feature→ develop
Releaseoff develop
Tagon main
Back-merge→ develop
Hotfixmain→develop

Pros

  • Explicit release management. Clear "we are shipping 2.0" branch; tags mark exactly what shipped.
  • Production vs next-release separation. main mirrors production; develop holds the future.
  • First-class hotfix path. Patch production without waiting for the next release train.
  • Suits installed/versioned software where artifacts are shipped and later patched in place.

Cons

  • Complex. Five branch types, two back-merge points per release/hotfix β€” easy to get wrong.
  • Heavy merge overhead. Lots of no-ff merges; the graph gets busy.
  • Overkill for continuously-deployed web apps. Driessen himself later noted the model is overkill for teams that deploy many times a day; GitHub Flow or trunk-based fits better there.
  • Long-lived develop can drift from main, making releases surprisingly large.
Don't confuse the model with the tool "git-flow" is also a CLI extension (git flow init, git flow feature start) that automates the model. You do not need the tool to follow Git Flow β€” every step is ordinary git. Many teams follow the model with plain commands.
Lab 2 β€” Git Flow release and hotfix
15 minutes Β· local only
# from Lab 0 repo, on main
git switch -c develop

# feature β†’ develop
git switch -c feature/login develop
printf 'login=true\n' >> app.conf
git commit -am "Implement login"
git switch develop
git merge --no-ff feature/login -m "Merge feature/login"
git branch -d feature/login

# release β†’ main (tag) AND develop
git switch -c release/1.0 develop
printf 'version=1.0.0\n' > version.txt
git commit -am "Bump version 1.0.0"
git switch main
git merge --no-ff release/1.0 -m "Release 1.0.0"
git tag -a v1.0.0 -m "Release 1.0.0"
git switch develop
git merge --no-ff release/1.0 -m "Back-merge release/1.0 into develop"
git branch -d release/1.0

# hotfix off main β†’ main (tag) AND develop
git switch -c hotfix/1.0.1 main
printf 'patch=security\n' >> app.conf
git commit -am "Critical security fix"
git switch main
git merge --no-ff hotfix/1.0.1 -m "Hotfix 1.0.1"
git tag -a v1.0.1 -m "Hotfix 1.0.1"
git switch develop
git merge --no-ff hotfix/1.0.1 -m "Back-merge hotfix into develop"
git branch -d hotfix/1.0.1
git tag --list
  • Verify develop contains both the release and the hotfix (back-merges worked).
  • Verify main has exactly the production commits and tags v1.0.0, v1.0.1.
  • Run git log --graph --oneline --all and identify the two-parent merge commits.

9 Β· Trunk-based development β€” integration as the heartbeat

Everyone integrates to one branch (main/trunk) frequently, with branches that live hours to a couple of days at most. Scale comes from small changes, fast review, strong CI, and feature flags β€” not from elaborate branch topology.

The rules

  • main (or trunk) is the single integration line and is always green.
  • Feature branches are short-lived β€” ideally less than a day, rarely more than three. Small, frequently merged changes.
  • Every merge is gated by CI; broken builds are the top priority to fix or revert.
  • Feature flags let incomplete-but-safe code merge and hide behind a switch, decoupling merge from release.
  • History is kept linear β€” squash or rebase merges are conventional.

Topology

main   C0 ── C1 ── C2 ── C3 ── C4 ── C5 ── C6   (one green line)
            \    /\    /\    /
short         F0   F1    F2   (each <1 day, squash-merged, deleted)
Small branchoff main
CI gatemust pass
Fast reviewhours not days
Squash mergeto main
Flag-gaterelease later

Pros

  • Minimal integration pain. Branches live so briefly that conflicts are tiny and rare.
  • Fast feedback. Changes reach shared, tested code in hours.
  • Scales to huge teams β€” used at Google, Meta, and others β€” provided CI and tooling are excellent.
  • One clean line of history; bisects and reverts are trivial.

Cons

  • Demands discipline and automation. Weak CI kills it: one bad merge breaks everyone within hours.
  • Feature flags become mandatory infrastructure, with their own complexity and debt if unmanaged.
  • Unnatural for multiple supported versions. Backporting fixes across old lines is not its strength β€” you'd add release branches on top.
  • Culture shift. Teams used to week-long feature branches must learn to slice work small.
Trunk-based vs GitHub Flow β€” what's the difference? They look alike (main + short feature branches + PR). The distinction is emphasis and degree: trunk-based makes frequency the doctrine β€” branches measured in hours, integration many times a day, feature flags to ship-incomplete-code safely. GitHub Flow is the simpler cousin that works fine with slightly longer branches and no flags. In practice the boundary is blurry; "we do trunk-based" usually means "we push the GitHub-Flow ideas to the limit."
Lab 3 β€” Trunk-based squash-merge cycle
8 minutes Β· local only
# from Lab 0 repo, on main (the trunk)
git switch -c add-flag
printf 'feature_x=on   # flag-gated, off in prod config\n' >> app.conf
git commit -am "Implement X behind flag"

# squash-merge keeps main linear; one commit per change
git switch main
git merge --squash add-flag
git commit -m "Add feature X behind flag"
git branch -D add-flag          # -D, not -d: see note

git log --oneline
  • Confirm main has one new commit (the squash), not the original branch commit.
  • Note we used git branch -D: after a squash merge the original branch commits are not on main, so -d (safe delete) refuses β€” that is expected and is itself a trunk-based teaching moment.
  • Run git reflog and confirm the squashed-away commit is still recoverable if needed.

10 Β· GitLab Flow β€” environment branches between you and production

GitLab's model tries to keep GitHub Flow's simplicity while restoring a sane path from "merged" to "deployed." The signature idea: environment branches that track what is actually running in each environment.

The rules (environment-branch variant)

  • main (or master) is the source of truth where feature branches merge β€” the "latest" code.
  • Downstream environment branches β€” e.g. pre-prod, production β€” represent what is deployed in each place.
  • Code flows strictly downstream: main β†’ pre-prod β†’ production. You promote by merging upstream into downstream, never by cherry-picking forward.
  • A production hotfix branches off production, merges back to production, and is cherry-picked back to main so the fix isn't lost.

Topology

main        C0 ── C1 ── C2 ── C3 ── C4 ── C5 ── (latest; feature merges land here)
              \    \    \    \
pre-prod       P1 ── P2 ── P3 ── P4 ───────────── (what's in staging)
                              \
production                     Q1 ── Q2 ───────── (what's actually live)

# hotfix off production flows back to main via cherry-pick
production  ── H ──
main           ... cherry-pick H ...
Feature→ main
Promotemain→pre-prod
Promotepre-prod→prod
Hotfixoff production
Backportcherry-pick→main

Pros

  • Traceability. production literally is what is deployed β€” no guessing what shipped.
  • Simpler than Git Flow. No develop or release/* ceremony; just environment branches.
  • Suits staged deployment pipelines where you want explicit gates between environments.
  • Release-branch variant exists too: for installed software, stable branches per release get cherry-picked bugfixes β€” Git Flow's release idea without its full weight.

Cons

  • Cherry-pick drift. Backporting hotfixes to main by hand creates duplicate commits and can drift over time.
  • production can become a bottleneck if promotions are manual and infrequent β€” it lags main arbitrarily.
  • More branches than GitHub Flow, so a bit more to keep straight; merges flow one direction only, which must be enforced.
  • Environment-branch count grows with each deployment target (staging, UAT, prod, regional prods).
Two flavors, one idea GitLab Flow's environment branches map a deployment pipeline (dev β†’ staging β†’ prod). Its release-branch flavor maps supported versions (v1, v2). Same downstream-only principle; different thing being tracked. Choose based on whether your pain is "what's deployed where" or "which versions do we support."
Lab 4 β€” GitLab Flow promotion + hotfix
12 minutes Β· local only
# from Lab 0 repo, on main
git switch -c pre-prod main
git switch -c production pre-prod     # production starts behind main

# feature merges to main
git switch -c feature/billing main
printf 'billing=v2\n' >> app.conf
git commit -am "Rewrite billing"
git switch main
git merge --no-ff feature/billing -m "Merge billing"
git branch -d feature/billing

# promote downstream: main -> pre-prod -> production
git switch pre-prod
git merge --no-ff main -m "Promote to pre-prod"
git switch production
git merge --no-ff pre-prod -m "Promote to production"

# hotfix on production, then cherry-pick back to main
git switch -c hotfix/prod-fix production
printf 'fix=1\n' >> app.conf
git commit -am "Production hotfix"
git switch production
git merge --no-ff hotfix/prod-fix -m "Apply prod hotfix"
git switch main
git cherry-pick hotfix/prod-fix
git branch -D hotfix/prod-fix     # -D: original hotfix isn't on main (cherry-pick = new hash)
git log --graph --oneline --all
  • Confirm production got the change only after promotion, not at feature-merge time.
  • Confirm the hotfix commit appears on both production and main (cherry-pick succeeded; note the two have different hashes β€” same diff, new commit).
  • Verify downstream-only flow: nothing ever merged from production into main except via cherry-pick.

11 Β· Decision matrix: choosing the right workflow

There is no universally best workflow β€” only the one that fits your release cadence, team size, deployment topology, and CI maturity. This matrix collapses the four axes from Β§1 onto the four models.

At a glance

WorkflowRelease cadenceTeam sizeDeploys / envsSupported versionsTypical merge
GitHub FlowContinuousSmall–largeSingle live envOneSquash or merge
Git FlowScheduled / versionedSmall–mediumShipped artifactsSeveralMerge commit
Trunk-basedContinuous (many/day)Medium–very largeSingle live env + flagsOne (+ release branches)Squash / rebase
GitLab FlowContinuous, stagedSmall–mediumMulti-env pipelineOne (or release branches)Merge commit

Recommendation by situation

If your situation is…Lean towardBecause
Web app / SaaS, deploy on every merge, one prod environmentGitHub FlowSimplest model that fits; nothing between merge and ship.
Same as above but large team, many merges/day, strong CITrunk-basedShort branches + flags minimize integration pain at scale.
Shipped/installed software with semver releases you patch laterGit FlowRelease and hotfix branches are exactly the structure you need.
Multi-stage deploy pipeline (staging β†’ prod) you want to track explicitlyGitLab FlowEnvironment branches make "what's deployed" a branch you can read.
Solo dev or tiny team, low ceremonyGitHub FlowOverhead of the others buys you nothing at that size.
Open-source project with external contributorsGitHub Flow + forkingForks + PRs are the OSS integration model; covered in Further study.

A decision walk

How do you ship? β†’ continuous or versioned
If continuous: one live env? β†’ yes β†’ GitHub Flow (or trunk-based if large/many-merges)
If continuous but staged envs matter? β†’ GitLab Flow
If versioned/shipped/patched? β†’ Git Flow
The wrong question: "which is best?" The right question is "which fits our cadence, size, and environments β€” and can our CI actually support it?" A team that picks trunk-based without trustworthy CI will suffer; a team that picks Git Flow for a continuously-deployed website will drown in ceremony. Match the tool to the reality, then invest in the prerequisite (usually CI) that makes it work.

These models also compose. It is common to run trunk-based on main and add release branches for the few supported versions you must patch β€” a hybrid of trunk-based and Git Flow's release idea. Pragmatism beats purity.

12 Β· The team-process layer: making a workflow stick

Choosing a workflow is half the job. The other half is the policy and automation that make it real: branch protection, required checks, code ownership, and versioning conventions. Without these, the workflow is a suggestion.

Branch protection / rulesets

All major hosts let you make a branch (usually main) protected: direct pushes are blocked, merges require a PR, and certain conditions must be met. GitHub calls these rulesets (formerly branch-protection rules); GitLab calls them protected branches.

RuleWhat it guarantees
Require pull request before mergeNo one commits straight to main.
Require approvals (N)At least N reviewers sign off.
Require status checks to passCI must be green; optionally branch must be up to date.
Require linear historyForbids merge commits β€” forces rebase/squash.
Dismiss stale approvalsNew pushes invalidate old approvals.
Restrict force pushes & deletionsHistory rewriting on the protected branch is blocked.

CODEOWNERS β€” automatic review routing

A CODEOWNERS file maps paths to the people or teams responsible for them. When a PR touches those paths, the host automatically requests their review β€” and with "require review from code owners" enabled, their approval becomes mandatory. It scales review across a large repo without a gatekeeper.

# .github/CODEOWNERS (GitHub)  /  CODEOWNERS (GitLab, repo root)
# Each line: path pattern  owner(s)
/src/payments/    @payments-team
/src/auth/        @security-team @alice
/docs/            @docs-team
*.md              @docs-team
/                 @lead-developers   # default fallback

Conventional commits and semantic versioning

Pairing a workflow with a commit-message convention makes releases and changelogs automatable. Conventional Commits prefix messages with a type; SemVer derives the version bump from those types.

feat: add dark mode               # β†’ MINOR bump (1.2.0)
fix: correct tax calculation      # β†’ PATCH bump (1.2.1)
feat!: redesign API               # β†’ MAJOR bump (2.0.0)  (! = breaking)
chore: bump dependencies          # β†’ no release
docs: update README               # β†’ no release
SemVer partWhen it bumpsExample
MAJORIncompatible / breaking change1.x.x β†’ 2.0.0
MINORNew backward-compatible feature1.2.x β†’ 1.3.0
PATCHBackward-compatible bug fix1.2.3 β†’ 1.2.4

Tags and releases

  • Annotated tags (git tag -a v1.2.0 -m "Release 1.2.0") carry a message, tagger, and date β€” recommended for releases.
  • Lightweight tags (git tag v1.2.0) are just a pointer; fine for ephemeral marks.
  • Tags are not pushed automatically β€” git push origin v1.2.0 (or --tags) ships them.
Process layer is workflow-independent Branch protection, CODEOWNERS, conventional commits, and SemVer apply to all four workflows. They are the scaffolding that turns a branching model into a reliable team practice. Adopt them regardless of which workflow you chose in Β§11.

13 Β· Anti-patterns and failure modes

Most workflow pain is not from picking the wrong model β€” it is from violating the model's assumptions. These are the recurring ways teams get hurt.

Long-lived feature branches

A branch open for weeks drifts far from main; the merge becomes a conflict marathon ("merge hell") and a risky big-bang integration. Fix: slice work small; merge or rebase onto main daily. If work can't be small, use a feature flag and merge the incomplete-but-safe code.

Force-pushing shared branches

Rewriting a branch others have built on strands their commits and corrupts their clones. Fix: enforce the Golden Rule (Β§6) β€” rebase only your own local branches; protect integration branches against force-push.

Git Flow for a continuously-deployed website

The release/hotfix ceremony exists for shipped artifacts. On a web app it adds branches, back-merges, and delay for no benefit. Fix: drop to GitHub Flow or trunk-based; reserve Git Flow for genuinely versioned releases.

Merge button without a CI gate

Anyone can land broken code on main, which then isn't deployable β€” breaking the one assumption GitHub Flow and trunk-based depend on. Fix: require passing status checks before merge (Β§12); treat a red main as an emergency.

Committing straight to main

Bypasses review and CI. Sometimes fine for docs solo; fatal on a team. Fix: protect main so direct pushes are rejected; even small changes go through a PR.

Squashing away bisect granularity

If a feature branch's intermediate commits didn't build, squashing them into one hides which step broke. A future git bisect may point only at the combined commit. Fix: keep every commit on a feature branch building; or accept that squash histories bisect at PR granularity.

Cherry-pick drift across release branches

Manually cherry-picking the same fix into many branches creates duplicate, diverging commits that fall out of sync. Fix: automate backports; prefer one source of truth and scripted propagation; consider whether so many supported versions are really needed.

Giant pull requests

Huge diffs get skimmed, not reviewed; they conflict more and are harder to revert. Fix: review culture that asks for smaller PRs; stack related changes; let trunk-based's short-branch discipline set the norm.

The shared theme Almost every anti-pattern is a violation of a workflow's load-bearing assumption: that main is green, that branches are short, that integration is frequent, that history of shared branches is stable. When a workflow hurts, ask first which assumption you are breaking β€” usually the fix is honoring the model, not changing it.

14 Β· Capstone lab: run all four on one repo

A single cumulative lab. You build a real shared repository with a bare remote and two "teammates," run GitHub Flow end to end, cut a Git Flow release, and finish with a trunk-based cycle. No GitHub account needed β€” the bare repo is the remote, and "merging a PR" is a local fetch-and-merge.

Capstone Β· Phase 0 β€” Shared remote + two clones
setup Β· creates origin.git, alice, bob
cap_dir="$(mktemp -d)/wf-capstone"
mkdir -p "$cap_dir" && cd "$cap_dir"

# bare repo = the shared "server"
git init --bare origin.git

# alice bootstraps the project
git clone origin.git alice
cd alice
git switch -c main
printf '# Capstone App\n' > README.md
printf 'theme=light\nversion=0.1.0\n' > app.conf
git add README.md app.conf
git config user.name "Alice" && git config user.email "alice@example.invalid"
git commit -m "Initial commit"
git push -u origin main
cd ..

# bob clones the now-populated remote
git clone origin.git bob
cd bob
git config user.name "Bob" && git config user.email "bob@example.invalid"
Capstone Β· Phase 1 β€” GitHub Flow (Bob ships a feature)
branch β†’ push β†’ Alice reviews & merges
# --- in bob/ ---
git switch -c feature/search
printf 'search=on\n' >> app.conf
git commit -am "Add search"
git push -u origin feature/search

# --- in alice/ ---  (Alice fetches the PR branch and merges it)
cd ../alice
git fetch origin
git switch main
git merge --no-ff origin/feature/search -m "Merge search (approved)"
git push origin main
git push origin --delete feature/search   # clean up the merged branch

# --- back in bob/ ---  (Bob syncs the integrated main)
cd ../bob
git switch main
git pull --ff-only
cat app.conf        # should now contain search=on
  • Both clones now agree: main contains the search change.
  • The feature branch was deleted on the remote after merge, just like a real merged PR.
Capstone Β· Phase 2 β€” Git Flow release (Alice cuts v1.0.0)
develop β†’ release β†’ main (tag) β†’ develop
# --- in alice/ ---
git switch -c develop
git switch -c release/1.0 develop
printf 'version=1.0.0\n' > version.txt
git add version.txt
git commit -m "Bump version 1.0.0"

git switch main
git merge --no-ff release/1.0 -m "Release 1.0.0"
git tag -a v1.0.0 -m "Release 1.0.0"
git switch develop
git merge --no-ff release/1.0 -m "Back-merge release/1.0 into develop"
git branch -d release/1.0

git push origin main develop
git push origin v1.0.0

# --- in bob/ ---  (Bob receives the new branches + tag)
cd ../bob
git fetch origin --tags
git branch --track develop origin/develop 2>/dev/null || true
git switch main && git pull --ff-only
git tag --list          # v1.0.0 present
  • main carries the release and tag v1.0.0; develop has the back-merge.
  • Bob can see both branches and the tag after fetching with --tags.
Capstone Β· Phase 3 β€” Trunk-based squash cycle (Bob, fast)
short branch β†’ squash merge β†’ linear main
# --- in bob/ ---  switch to trunk-style: develop is retired, main is the trunk
git switch main
git pull --ff-only
git switch -c add-flag
printf 'feature_x=on   # flag-gated\n' >> app.conf
git commit -am "Implement X behind flag"

git switch main
git merge --squash add-flag
git commit -m "Add feature X behind flag"
git branch -D add-flag
git push origin main

# --- in alice/ ---  (Alice pulls the linear history)
cd ../alice
git switch main
git pull --ff-only
git log --oneline --graph --all
cd ../bob
  • main history is linear β€” one squash commit for the feature, no merge bubble.
  • You have now exercised GitHub Flow, Git Flow, and trunk-based on the same shared repo with two contributors.
  • Stretch: add a GitLab Flow environment branch β€” git switch -c production main on alice, then promote main into production and push it.
What the capstone proves You can move between workflows on a real shared repository, integrate another person's work, cut and tag a release, back-merge it, and keep history linear β€” all with plain git and a bare remote standing in for GitHub. The hosting platform only wraps these same operations in a PR UI and policy. If you completed every checklist item, you can run any team's workflow.

15 Β· Troubleshooting playbook

SymptomLikely causeAction
Push rejected as "non-fast-forward"Remote has commits you lack; your branch divergedgit fetch, inspect, then git pull --rebase (own branch) or merge. Never blind force-push a shared branch.
Merge or rebase stuck with conflict markersSame lines changed on both sidesEdit markers out, git add, then --continue; or --abort to retreat. See Β§6.
Branch delete refused ("not fully merged")Squash-merged: original commits aren't on targetUse git branch -D deliberately β€” expected after squash. Confirm via reflog first if unsure.
main is red / brokenCI gate missing or bypassed; broken merge landedRevert the offending merge (git revert -m 1) to restore green fast; fix forward on a branch. Add required checks (Β§12).
Force-pushed branch broke teammates' clonesShared history was rewrittenStop; coordinate; teammates git fetch then git reset --hard origin/<branch>. Enable force-push protection to prevent recurrence.
Release missed a fix that's on developGit Flow back-merge to develop was skipped, or fix landed after the release branch forkedCherry-pick the fix onto the release/hotfix branch; ensure future releases merge release→develop.
production far behind mainGitLab Flow promotions stalled (manual bottleneck)Automate promotion; treat a lagging production as a process smell, not a feature.
Tag missing on the remoteTags aren't pushed by defaultgit push origin <tag> or git push --tags.
Commit "disappeared" after rebase/resetHash rewritten; old commit orphaned but reachablegit reflog β†’ find the old hash β†’ git reset --hard <hash> or branch off it.
Unsure what a merge will doRisk of surprisePreview with git log <branch>..main / git diff ...; do risky merges on a throwaway branch first.

Five-second pre-flight before any integration

  1. Where am I? git status, current branch, clean tree?
  2. What will move? Which branch advances, and is it shared?
  3. Reversible? Is this local-only (reflog saves me) or pushed (revert instead)?
  4. History rewrite? Am I rebasing commits anyone else has? If yes, stop.
  5. How do I verify? What command or CI result confirms it worked?

16 Β· Cheat sheet

GitHub Flow

main always deployable
branch β†’ PR β†’ merge β†’ deploy
no release/hotfix branches
best: continuous deploy, 1 env

Git Flow

main + develop + feature
+ release + hotfix
release β†’ main(tag)+develop
best: versioned/shipped software

Trunk-based

main = single green line
branches < 1–3 days
feature flags + strong CI
best: large team, many merges

GitLab Flow

main + env branches
flow downstream only
hotfix β†’ cherry-pick β†’ main
best: staged deploy pipeline

Merge strategies at a glance

Fast-forward --ff-only

linear Β· no merge commit
only when target is ancestor

Merge commit --no-ff

preserves branch topology
two-parent commit

Squash --squash

one commit per PR
loses per-commit granularity

Rebase

linear, keeps commits
rewrites hashes β€” own branches only

Everyday commands

Branch & sync

git switch -c feat main
git pull --rebase
git push -u origin feat
git branch -d feat

Integrate

git merge --no-ff feat
git merge --squash feat
git rebase main
git cherry-pick <hash>

Recover

git reflog
git reset --hard ORIG_HEAD
git revert -m 1 <merge>
git merge --abort

Release

git tag -a v1.0.0 -m "..."
git push origin v1.0.0
conventional commits β†’ SemVer
protect main; require CI
One rule worth memorizingMatch the workflow to your release cadence, team size, deployment topology, and CI maturity β€” then invest in the CI and policy that make it stick. A workflow is a contract; honor its assumptions or it will hurt you.

17 Β· Glossary

Branch
A movable pointer (ref) to a commit. Cheap to create and delete; never confuses with the commits it points to.
Commit
An immutable snapshot + metadata + parent pointer(s), identified by a SHA hash. Changing anything yields a new commit.
DAG
Directed acyclic graph β€” the shape of git history. Commits point to parents; the graph has no cycles.
Fast-forward (FF)
A merge that only advances the target pointer because the source is strictly ahead. Creates no merge commit.
Feature flag
A runtime switch that lets merged-in code stay dormant, decoupling merge from release. Enables trunk-based at scale.
Golden Rule (rebase)
Never rebase commits others already have. Rebase only your own local branches.
Integration branch
The shared line everyone merges into (main, develop, or trunk).
Merge commit
A commit with two parents, created when diverged lines join (or forced with --no-ff).
Pull request (PR)
A hosting-platform feature: a proposed branch merge with review and CI. Not a git command. Called "merge request" on GitLab.
Rebase
Replaying commits onto a new base, producing new hashes. Yields linear history; rewrites shared history at your peril.
Reflog
A local log of where every ref has been. The recovery net for "lost" commits after reset/rebase.
Remote-tracking branch
A local cache (e.g. origin/main) of a remote branch's position at last fetch. Not live.
SemVer
Semantic versioning: MAJOR.MINOR.PATCH, bumped by breaking/feature/fix respectively.
Squash
Collapsing several commits into one new commit on the target. Clean history; loses granularity.
Tag
An immutable-ish ref marking a specific commit, usually a release. Annotated tags carry metadata.
Trunk
The single integration branch in trunk-based development; often literally named main or trunk.
Workflow
The agreed rules for where work happens, how it integrates, and how it ships β€” a contract, not a tool.

18 Β· Retrieval quiz and sources

Answer aloud before revealing. Mark honestly; your score persists in this browser only.

Score: 0 learned Β· 0 review Β· 0 answered
Q1What is the single load-bearing assumption shared by GitHub Flow and trunk-based development?
That main is always deployable β€” kept green by CI and review. Without trustworthy CI keeping main releasable, both models break.
Q2Name the four merge models and the one-word history shape each produces.
Fast-forward (linear), merge commit/no-ff (branch bubbles/topology), squash (linear, one commit per PR), rebase (linear, keeps commits).
Q3In Git Flow, where do release/* and hotfix/* branches each merge into?
Both merge into main (where they're tagged) and back into develop, so the release contents and fixes aren't lost on the integration branch.
Q4State the Golden Rule of rebase and why it matters.
Never rebase commits that other people already have (shared/pushed branches). Rebase rewrites hashes, stranding anyone who built on the old commits and corrupting their clones.
Q5Why does git branch -d refuse after a squash merge, and what do you use instead?
Squash creates a brand-new commit on the target; the branch's original commits are not on the target, so -d (safe delete) sees them as "not fully merged." Use git branch -D deliberately β€” the commits remain recoverable via reflog.
Q6How do "ours" and "theirs" differ between a merge and a rebase?
In a merge, ours = your current branch (HEAD). In a rebase, the base you're replaying onto is "ours" at each step and your own replayed commit is "theirs" β€” so never resolve by label; read the diff.
Q7What is GitLab Flow's environment-branch rule, and how does a production hotfix return to main?
Code flows strictly downstream (main β†’ pre-prod β†’ production) via merge. A hotfix branches off production, merges back to production, then is cherry-picked back to main (nothing merges upstream from production except the cherry-pick).
Q8What single technique makes trunk-based development viable for large teams, and why?
Feature flags. They let incomplete-but-safe code merge to main behind a default-off switch, decoupling merge from release so branches can stay short without blocking partially-built features.
Q9Why is squashing a feature branch whose intermediate commits didn't build a problem?
A future git bisect can only point at the combined squash commit, hiding which step broke β€” you lose the granularity to locate a regression. Keep every commit on a feature branch building, or accept PR-granularity bisects.
Q10A push is rejected as "non-fast-forward." What's the safe next step β€” and what must you not do?
git fetch, inspect the divergence, then git pull --rebase (or merge) on your own branch. Do not blind --force-push a shared branch β€” it discards teammates' commits and corrupts their clones. Use --force-with-lease only if you understand what it protects.
Q11Map each SemVer bump (MAJOR/MINOR/PATCH) to a conventional-commit type.
MAJOR ← feat! / breaking change; MINOR ← feat (backward-compatible feature); PATCH ← fix (backward-compatible fix). chore/docs trigger no release.
Q12A teammate force-pushed the shared feature branch and your clone is now confused. How do you recover and prevent it?
Recover: git fetch, then git reset --hard origin/<branch> to align with the rewritten remote (after confirming your local work is saved/committed elsewhere). Prevent: enable force-push protection / rulesets on shared branches; enforce the Golden Rule of rebase.
Q13Four decision axes for choosing a workflow β€” name them.
Release cadence (continuous vs versioned), team size & concurrency, deployment topology (one env vs staged pipeline vs shipped artifacts), and CI/CD maturity + number of supported versions.
Q14True or false: "Git Flow is the right default for a web app that deploys many times a day." Why?
False. Git Flow's release/hotfix ceremony exists for shipped/versioned artifacts; on a continuously-deployed web app it adds branches, back-merges, and delay for no benefit. Use GitHub Flow or trunk-based instead. (Driessen himself flagged this.)

Sources

External links require network; the guide itself works fully offline. Content reflects the widely documented public models as of 2026-07-23.

Keep practicingRe-run the capstone (Β§14) until every phase feels routine, then try it on a real team repository. The workflows differ in topology, but the primitives β€” branch, rebase, merge, squash, tag, protect β€” are the same everywhere.