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.
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.
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 withgit commit, readgit logandgit 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
originis, the difference betweengit fetch,git pull, andgit 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.
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
| Operation | What it does to the graph | New commits? |
|---|---|---|
| Fast-forward merge | Slides target pointer forward to source commit. | No |
| Merge commit | Creates a commit with two parents joining both lines. | Yes β the merge commit |
| Rebase | Copies your commits and replays them onto a new base, one at a time. | Yes β rewritten copies (new hashes) |
| Squash | Collapses several commits into one new commit on the target. | Yes β one new commit |
| Cherry-pick | Copies 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
| Model | What happens | History shape | Command / 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 |
| Squash | Collapses 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" |
| Rebase | Replays 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.
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.
What a PR configures
| Setting | What it enforces |
|---|---|
| Required reviewers / approvals | At least N people (or a CODEOWNER) must approve before merge. |
| Required status checks | CI must pass; optionally the branch must be up to date with the base. |
| Merge strategy buttons | Which of merge / squash / rebase the green button offers β and which is default. |
| Auto-delete branch | Remove the source branch after merge to keep the branch list clean. |
| Required linear history | Forbid merge commits entirely, forcing rebase/squash. |
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 bisectlands on broken commits. - Keep the base fresh. Rebase or merge
maininto 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
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:
| Step | During a merge | During a rebase |
|---|---|---|
| After editing | git add <file> | git add <file> |
| Continue | git merge --continue (or git commit) | git rebase --continue |
| Give up | git merge --abort | git rebase --abort |
| Skip a bad commit | β | git rebase --skip |
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
mainis always deployable. Anything inmainis, or can immediately be, in production.- Branch off
mainfor 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 frommain. - 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)
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
mainor 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 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
mainadvanced and a merge commitMexists. - Confirm the feature branch is gone but its commits survive in
main. - Run
git log --graphand 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
| Branch | Lifetime | Branched from | Merged into |
|---|---|---|---|
main | Forever | β | β (production; tagged here) |
develop | Forever | main initially | main (via release) |
feature/* | Short | develop | develop |
release/* | Short | develop | main and develop |
hotfix/* | Short | main | main 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.
Pros
- Explicit release management. Clear "we are shipping 2.0" branch; tags mark exactly what shipped.
- Production vs next-release separation.
mainmirrors production;developholds 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
developcan drift frommain, making releases surprisingly large.
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.
# 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
developcontains both the release and the hotfix (back-merges worked). - Verify
mainhas exactly the production commits and tagsv1.0.0,v1.0.1. - Run
git log --graph --oneline --alland 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(ortrunk) 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)
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.
# 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
mainhas 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 reflogand 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(ormaster) 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 toproduction, and is cherry-picked back tomainso 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 ...
Pros
- Traceability.
productionliterally is what is deployed β no guessing what shipped. - Simpler than Git Flow. No
developorrelease/*ceremony; just environment branches. - Suits staged deployment pipelines where you want explicit gates between environments.
- Release-branch variant exists too: for installed software,
stablebranches per release get cherry-picked bugfixes β Git Flow's release idea without its full weight.
Cons
- Cherry-pick drift. Backporting hotfixes to
mainby hand creates duplicate commits and can drift over time. productioncan become a bottleneck if promotions are manual and infrequent β it lagsmainarbitrarily.- 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).
# 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
productiongot the change only after promotion, not at feature-merge time. - Confirm the hotfix commit appears on both
productionandmain(cherry-pick succeeded; note the two have different hashes β same diff, new commit). - Verify downstream-only flow: nothing ever merged from
productionintomainexcept 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
| Workflow | Release cadence | Team size | Deploys / envs | Supported versions | Typical merge |
|---|---|---|---|---|---|
| GitHub Flow | Continuous | Smallβlarge | Single live env | One | Squash or merge |
| Git Flow | Scheduled / versioned | Smallβmedium | Shipped artifacts | Several | Merge commit |
| Trunk-based | Continuous (many/day) | Mediumβvery large | Single live env + flags | One (+ release branches) | Squash / rebase |
| GitLab Flow | Continuous, staged | Smallβmedium | Multi-env pipeline | One (or release branches) | Merge commit |
Recommendation by situation
| If your situation is⦠| Lean toward | Because |
|---|---|---|
| Web app / SaaS, deploy on every merge, one prod environment | GitHub Flow | Simplest model that fits; nothing between merge and ship. |
| Same as above but large team, many merges/day, strong CI | Trunk-based | Short branches + flags minimize integration pain at scale. |
| Shipped/installed software with semver releases you patch later | Git Flow | Release and hotfix branches are exactly the structure you need. |
| Multi-stage deploy pipeline (staging β prod) you want to track explicitly | GitLab Flow | Environment branches make "what's deployed" a branch you can read. |
| Solo dev or tiny team, low ceremony | GitHub Flow | Overhead of the others buys you nothing at that size. |
| Open-source project with external contributors | GitHub Flow + forking | Forks + PRs are the OSS integration model; covered in Further study. |
A decision walk
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.
| Rule | What it guarantees |
|---|---|
| Require pull request before merge | No one commits straight to main. |
| Require approvals (N) | At least N reviewers sign off. |
| Require status checks to pass | CI must be green; optionally branch must be up to date. |
| Require linear history | Forbids merge commits β forces rebase/squash. |
| Dismiss stale approvals | New pushes invalidate old approvals. |
| Restrict force pushes & deletions | History 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 part | When it bumps | Example |
|---|---|---|
| MAJOR | Incompatible / breaking change | 1.x.x β 2.0.0 |
| MINOR | New backward-compatible feature | 1.2.x β 1.3.0 |
| PATCH | Backward-compatible bug fix | 1.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.
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.
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.
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"
# --- 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:
maincontains the search change. - The feature branch was deleted on the remote after merge, just like a real merged PR.
# --- 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
maincarries the release and tagv1.0.0;develophas the back-merge.- Bob can see both branches and the tag after fetching with
--tags.
# --- 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
mainhistory 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 mainon alice, then promotemainintoproductionand push it.
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
| Symptom | Likely cause | Action |
|---|---|---|
| Push rejected as "non-fast-forward" | Remote has commits you lack; your branch diverged | git fetch, inspect, then git pull --rebase (own branch) or merge. Never blind force-push a shared branch. |
| Merge or rebase stuck with conflict markers | Same lines changed on both sides | Edit 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 target | Use git branch -D deliberately β expected after squash. Confirm via reflog first if unsure. |
main is red / broken | CI gate missing or bypassed; broken merge landed | Revert 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' clones | Shared history was rewritten | Stop; coordinate; teammates git fetch then git reset --hard origin/<branch>. Enable force-push protection to prevent recurrence. |
Release missed a fix that's on develop | Git Flow back-merge to develop was skipped, or fix landed after the release branch forked | Cherry-pick the fix onto the release/hotfix branch; ensure future releases merge releaseβdevelop. |
production far behind main | GitLab Flow promotions stalled (manual bottleneck) | Automate promotion; treat a lagging production as a process smell, not a feature. |
| Tag missing on the remote | Tags aren't pushed by default | git push origin <tag> or git push --tags. |
| Commit "disappeared" after rebase/reset | Hash rewritten; old commit orphaned but reachable | git reflog β find the old hash β git reset --hard <hash> or branch off it. |
| Unsure what a merge will do | Risk of surprise | Preview with git log <branch>..main / git diff ...; do risky merges on a throwaway branch first. |
Five-second pre-flight before any integration
- Where am I?
git status, current branch, clean tree? - What will move? Which branch advances, and is it shared?
- Reversible? Is this local-only (reflog saves me) or pushed (revert instead)?
- History rewrite? Am I rebasing commits anyone else has? If yes, stop.
- How do I verify? What command or CI result confirms it worked?
16 Β· Cheat sheet
GitHub Flow
main always deployableGit Flow
Trunk-based
main = single green lineGitLab Flow
Merge strategies at a glance
Fast-forward --ff-only
Merge commit --no-ff
Squash --squash
Rebase
Everyday commands
Branch & sync
git switch -c feat maingit pull --rebasegit push -u origin featgit branch -d featIntegrate
git merge --no-ff featgit merge --squash featgit rebase maingit cherry-pick <hash>Recover
git refloggit reset --hard ORIG_HEADgit revert -m 1 <merge>git merge --abortRelease
git tag -a v1.0.0 -m "..."git push origin v1.0.017 Β· 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, ortrunk). - 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
mainortrunk. - 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.
main is always deployable β kept green by CI and review. Without trustworthy CI keeping main releasable, both models break.release/* and hotfix/* branches each merge into?main (where they're tagged) and back into develop, so the release contents and fixes aren't lost on the integration branch.git branch -d refuse after a squash merge, and what do you use instead?-d (safe delete) sees them as "not fully merged." Use git branch -D deliberately β the commits remain recoverable via reflog.main?production, merges back to production, then is cherry-picked back to main (nothing merges upstream from production except the cherry-pick).main behind a default-off switch, decoupling merge from release so branches can stay short without blocking partially-built features.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.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.feat! / breaking change; MINOR β feat (backward-compatible feature); PATCH β fix (backward-compatible fix). chore/docs trigger no release.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.Sources
External links require network; the guide itself works fully offline. Content reflects the widely documented public models as of 2026-07-23.
- Vincent Driessen β A successful Git branching model (Git Flow, 2010)
- gitflow β the model and the CLI extension (nvie)
- GitHub Docs β GitHub Flow
- GitHub Docs β Configuring pull request merges (merge strategies)
- trunkbaseddevelopment.com β Trunk-Based Development reference
- GitLab β What is GitLab Flow?
- GitLab Docs β GitLab Flow (environment & release branches)
- Pro Git β Basic Branching and Merging
- Pro Git β Rebasing
- Conventional Commits specification
- Semantic Versioning 2.0.0
- GitHub Docs β About CODEOWNERS