Git Workflows
A field guide to branching models, release flows, and the conventions that keep a shared git history sane — trunk-based development, GitHub Flow, and GitFlow side by side, plus the everyday mechanics: monorepos, branch protection, commit conventions, and merge strategies.
Most sections below end with a quick knowledge check — try answering before revealing:
1. Workflow Comparison
| Trunk-Based | GitHub Flow | GitFlow | |
|---|---|---|---|
| Main branches | main only |
main |
main + develop |
| Feature branches | short-lived (< 1 day) | any length | feature/* |
| Release process | continuous deploy | deploy on merge | release/* branch |
| Hotfix | fix on trunk | fix on main | hotfix/* branch |
| Complexity | low | low | high |
| Best for | CI/CD, small teams | SaaS / web apps | versioned releases |
| Merge to main | direct / tiny PR | PR only | via develop only |
| History | linear, clean | linear | complex merge graph |
A quick way to feel the difference: how does code actually get from a laptop into main?
main, daily. Feature branches exist but live less than a day, and incomplete work ships behind a feature flag instead of waiting on a long-lived branch. Merges to main are direct or via a tiny PR. Best for CI/CD shops and small teams that deploy continuously.
main. Merging to main is what triggers the deploy. Simple and linear — the default choice for SaaS and web apps that deploy on every merge.
main and develop, plus feature/*, release/*, and hotfix/* around them. Nothing reaches main except via develop (or a hotfix). More moving parts and a more complex merge graph, but built for projects that ship versioned releases rather than deploying continuously.
Under Trunk-Based Development, is it normal for a feature branch to stay open for several days while it's reviewed?
feature/*) are built for; trunk-based instead merges fast and hides unfinished work behind a feature flag.2. GitFlow
Five branch types:
| Branch | Purpose | Branches from | Merges into |
|---|---|---|---|
main |
production code, tags | — | — |
develop |
integration branch | main |
— |
feature/* |
new features | develop |
develop |
release/* |
release prep, bugfixes | develop |
main + develop |
hotfix/* |
urgent prod fixes | main |
main + develop |
# Feature
git checkout -b feature/login develop
# ... work ...
git checkout develop && git merge --no-ff feature/login
# Release
git checkout -b release/1.2 develop
# ... bump version, fix bugs ...
git checkout main && git merge --no-ff release/1.2
git tag -a v1.2
git checkout develop && git merge --no-ff release/1.2
# Hotfix
git checkout -b hotfix/fix-crash main
git checkout main && git merge --no-ff hotfix/fix-crash
git tag -a v1.1.1
git checkout develop && git merge --no-ff hotfix/fix-crash
gitGraph
commit id: "init" tag: "v1.0"
branch develop
commit id: "dev-start"
branch feature/login
commit id: "login-wip"
commit id: "login-done"
checkout develop
merge feature/login id: "merge-login"
branch release/1.1
commit id: "bump-version"
checkout main
merge release/1.1 id: "release" tag: "v1.1"
checkout develop
merge release/1.1 id: "sync-dev"
branch hotfix/crash
checkout main
commit id: "hotfix"
merge hotfix/crash tag: "v1.1.1"
The release branch's lifecycle, step by step:
release/1.2 from develop once everything planned for the release has landed there.
main and tag it (v1.2). That tag is what actually goes to production.
develop too, so the version bump and any release-branch bugfixes aren't lost from ongoing development.
When a release/* branch finishes in GitFlow, which branch (or branches) does it merge into?
main (tagged as the release) and develop. Merging into main alone would ship the version bump and any release-branch bugfixes to production but leave them missing from ongoing development.3. Trunk-Based Development
All developers commit to main (trunk) daily. Feature branches live < 1–2 days.
Key practices:
- Feature flags: merge incomplete code behind a flag, enable in production when ready
- Branch by abstraction: refactor in place without long-lived branches
- CI gates: tests must pass before merge; no broken trunk ever
# Short-lived feature branch
git checkout -b feat/add-cache
# ... small focused change ...
git push origin feat/add-cache
# PR → review → merge same day
Feature flags (Go example):
var featureFlags = map[string]bool{
"new-checkout": os.Getenv("FF_NEW_CHECKOUT") == "true",
}
if featureFlags["new-checkout"] {
return newCheckoutFlow(ctx, cart)
}
return legacyCheckoutFlow(ctx, cart)
Is it safe to merge unfinished code for new-checkout straight to main before the feature is done?
FF_NEW_CHECKOUT is flipped on, no separate long-lived branch required.4. GitHub Flow
Simplest workflow for teams with continuous deployment:
mainis always deployable- Create a descriptive branch from
main - Push commits, open a PR early (Draft PR for WIP)
- Review, CI checks, iterate
- Merge to
main→ auto-deploy
main is expected to be production-ready at every commit.
main for the change.
main is what triggers the deploy.
git checkout -b feature/user-auth
# ... commits ...
git push -u origin feature/user-auth
gh pr create --title "Add user auth" --body "Closes #42"
# After approval:
gh pr merge --squash
In GitHub Flow, should you wait until a feature is fully finished before opening its pull request?
5. Monorepo Patterns
Sparse Checkout
Check out only the subdirectory you need:
git clone --filter=blob:none --sparse https://github.com/org/monorepo
git sparse-checkout set services/api services/shared
git worktree
Multiple working trees from one repo (no re-clone):
git worktree add ../repo-feature feature/my-feature
git worktree list
git worktree remove ../repo-feature
Path-Based CI Triggers
Only run CI for changed paths (GitHub Actions):
on:
push:
paths:
- 'services/api/**'
- 'services/shared/**'
6. Branch Protection & CODEOWNERS
Branch protection rules (GitHub):
Settings → Branches → Add rule for "main":
☑ Require pull request before merging
☑ Require approvals: 1
☑ Require status checks to pass (CI)
☑ Require branches to be up to date
☑ Do not allow bypassing the above settings
CODEOWNERS (.github/CODEOWNERS):
# Global owner
* @org/platform-team
# Service-specific
services/payments/ @org/payments-team
services/auth/ @org/security-team @org/auth-team
# Infrastructure
*.tf @org/infra-team
7. Conventional Commits & Semantic Versioning
Conventional Commits format:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
| Type | Meaning | SemVer bump |
|---|---|---|
feat |
new feature | MINOR |
fix |
bug fix | PATCH |
feat! or BREAKING CHANGE |
breaking API change | MAJOR |
chore |
maintenance | none |
docs |
documentation | none |
refactor |
code change, no feature/fix | none |
perf |
performance improvement | PATCH |
ci |
CI config changes | none |
git commit -m "feat(auth): add OAuth2 login"
git commit -m "fix(db): handle nil connection gracefully"
git commit -m "feat!: remove deprecated /v1 API endpoints"
Semantic Versioning: MAJOR.MINOR.PATCH
1.0.0→1.0.1(fix)1.0.1→1.1.0(feat)1.1.0→2.0.0(breaking)
Tools: semantic-release, release-please, conventional-changelog.
Does a plain feat: commit always bump only the MINOR version?
feat: alone bumps MINOR, but feat! (or a BREAKING CHANGE footer) bumps MAJOR instead — the breaking-change marker overrides the type's default SemVer bump.8. Merge Strategies
| Strategy | Command | Pros | Cons |
|---|---|---|---|
| Merge commit | git merge --no-ff |
preserves full history, shows branch | noisy graph, merge commits |
| Squash merge | git merge --squash |
clean linear history, one commit per PR | loses individual commits |
| Rebase merge | git rebase main + fast-forward |
linear history, preserves commits | rewrites SHAs, can confuse |
# Merge commit
git checkout main && git merge --no-ff feature/x
# Squash
git checkout main && git merge --squash feature/x
git commit -m "feat: add x (#42)"
# Rebase merge
git checkout feature/x && git rebase main
git checkout main && git merge --ff-only feature/x
Rule of thumb:
- OSS/library: rebase (linear history, blame works well)
- Team SaaS: squash (clean log, each PR = one commit)
- GitFlow: merge commit (preserve branch structure)
git merge --no-ff. Preserves full history and shows the branch shape, but leaves a noisier graph full of merge commits. What GitFlow relies on to keep branch structure visible.
git merge --squash. Collapses a whole PR into one commit on main — clean, linear history, but the individual commits from the branch are lost.
git rebase main + fast-forward. Linear history that still preserves the individual commits, but it rewrites their SHAs in the process — which can confuse anyone with a copy of the old ones.
Rebase merge keeps your individual commits instead of squashing them into one. Does that mean it also keeps their original SHAs?
main, which gives every one of them a new SHA even though the commit boundaries themselves are preserved. That's the "rewrites SHAs, can confuse" trade-off: the content and commit count survive, the hashes don't.Workflow Comparison
flowchart LR
subgraph Trunk-Based
TM[main] --> TF1["feat/a<br/>1 day max"]
TM --> TF2["feat/b<br/>1 day max"]
TF1 -->|PR merge| TM
TF2 -->|PR merge| TM
end
subgraph GitFlow
GM[main] --- GD[develop]
GD --> GF["feature/*"]
GD --> GR["release/*"]
GM --> GH["hotfix/*"]
GF -->|merge| GD
GR -->|merge| GM
GR -->|merge| GD
GH -->|merge| GM
GH -->|merge| GD
end