03 · Git Workflow Strategies (GitFlow, Trunk-Based)¶
Git doesn't enforce a branching model — GitFlow, trunk-based development, and GitHub Flow are just conventions about which branches exist, how long they live, and how they merge. This module builds a small GitFlow release by hand, then contrasts it with trunk-based development on the same repo.
GitFlow: long-lived develop, short-lived release/*¶
git init -q -b main
echo v1 > app.txt
git add -A && git commit -m "Initial release"
git branch develop
git switch develop
echo feature1 >> app.txt && git commit -aqm "Add feature 1"
git switch main
git switch -c release/1.1 develop
echo "release notes" > CHANGELOG.md
git add -A && git commit -m "Prepare 1.1"
git switch main
git merge --no-ff release/1.1 -m "Merge release 1.1"
git tag -a v1.1.0 -m "v1.1.0"
git log --oneline --graph --all
* b6dfdf4 Merge release 1.1
|\
| * 8739f20 Prepare 1.1
| * 0ff1e35 Add feature 1
|/
* 79a9272 Initial release
GitFlow's structure: main only ever receives merge commits from
release/* or hotfix/* branches, so main's history is always
"one commit per shipped release." Feature work happens on branches cut
from develop, and --no-ff is used deliberately — it forces a merge
commit even when a fast-forward is possible, so the release boundary is
visible in git log instead of features blending invisibly into main.
Trunk-based: short-lived branches, squash into main¶
git switch -c feat/small-fix main
echo fix >> app.txt && git commit -am "Small fix behind flag"
git switch main
git merge --squash feat/small-fix
git commit -m "Small fix behind flag (squashed)"
git log --oneline -3
Trunk-based development has one long-lived branch (main) and branches
that live hours to a couple of days, merged behind feature flags rather
than left unmerged for weeks. git merge --squash collapses the topic
branch's commits into a single new commit on main without recording a
merge relationship at all — git log --graph on trunk-based history shows
a straight line, not the diamond shapes GitFlow produces, because there's
no merge commit object linking the two parent histories.
Comparing the two side by side¶
| GitFlow | Trunk-based | |
|---|---|---|
| Long-lived branches | main, develop |
main only |
| Feature branch lifetime | days–weeks | hours–days |
| Merge strategy | --no-ff merge commits |
squash or rebase |
| Release cadence | batched via release/* |
continuous, flag-gated |
| Best fit | scheduled/versioned releases (libraries, mobile apps) | continuous deployment (web services) |
GitHub Flow is a middle ground used by most GitHub-hosted projects:
one main, short-lived feature branches, merged via pull request as soon
as they're reviewed and CI passes — no develop, no release/*, and
deploys happen straight off main.
Enforcing the model with branch protection¶
A workflow choice is only real if the repo enforces it. On GitHub:
gh api repos/OWNER/REPO/branches/main/protection -X PUT --input - <<'EOF'
{
"required_status_checks": {"strict": true, "contexts": ["ci"]},
"enforce_admins": true,
"required_pull_request_reviews": {"required_approving_review_count": 1},
"restrictions": null
}
EOF
This blocks direct pushes to main and force-pushes, requires the ci
check to pass on the branch's current tip (strict: true re-runs the
check if main moves while the PR is open), and requires at least one
approving review — regardless of the branching model chosen, this is what
actually stops someone from bypassing it.
How It Actually Works¶
- A merge commit (
--no-ff) is a normal commit object with two or more entries in itsparentfield instead of one.git log --graphrenders the diamond shape purely by walking those parent pointers; there is no separate "merge" bit stored anywhere else. A fast-forward merge (what happens without--no-ffwhen the target hasn't diverged) doesn't create a commit at all — it just moves the branch ref forward to the feature branch's tip, which is why GitFlow insists on--no-ff: without it, a release with no concurrent hotfix would leave zero trace inmain's history that a merge ever happened. git merge --squashcomputes the merge (a three-way diff against the common ancestor, same algorithm as a normal merge) and stages the result, but deliberately does not recordMERGE_HEADor write a commit — it leaves that to a plaingit commit, which then has exactly one parent (whatevermainwas pointing at). This is why squashed branches never show up as "merged" ingit branch --merged: from Git's graph-reachability perspective, the squash commit shares no ancestry with the original topic branch's commits at all, even though its content came from them.required_status_checks.strict: trueworks by making GitHub re-diff the PR's head commit against the currentmain, not themainthe branch was created from — internally this is the same merge-base computationgit merge-baseperforms, so ifmainhas moved forward since the check last ran, the PR is required to re-run CI against a fresh three-way merge to catch integration conflicts the two branches' individual histories wouldn't reveal.enforce_admins: truematters because GitHub's protection rules are otherwise bypassable by repo admins by default — protection is enforced by the API layer that accepts pushes, not by anything in Git itself, so agit pushstill succeeds locally against a mirror or fork; it's GitHub's server-side ref-update hook that rejects it against the protected branch.
Exercise¶
In a scratch repo, build the GitFlow sequence above through the v1.1.0
tag, then add a hotfix/1.1.1 branch cut directly from the v1.1.0 tag
(not from develop), merge it into both main and develop with
--no-ff, and tag the result v1.1.1. Use git log --graph --all to
confirm the hotfix commit appears in both branches' history.