Article
Git Workflow That Actually Works
A practical branching strategy for small teams that balances speed with safety.
The Problem: Too Many Workflows, No Clear Winner
You’ve probably heard of:
- Gitflow: Complex, lots of branches, enterprise-ready
- GitHub Flow: Simple, one main branch, always deployable
- Trunk-Based Development: Everyone commits to main, relies on feature flags
- Git Forking Workflow: One repo per person, lots of rebasing
They all work. They all fail. The success depends on your team, codebase, and deployment process.
This article cuts through the confusion. We’ll build a workflow that’s:
- Simple enough for a 2-person startup
- Scalable to a 20-person team
- Safe enough to prevent disasters
- Fast enough to not waste time in git mechanics
Trunk-Based Development: The Hidden Winner
Most successful companies use trunk-based development (TBD). Here’s why:
- One main branch: Everyone converges here
- Short-lived branches: Feature branches exist 1-3 days max
- Frequent integration: Less merge conflict pain
- Feature flags: Deploy without releasing
Git is just a tool. TBD makes git boring (in a good way).
How It Works
main
├─ Feature A (Day 1)
├─ Feature B (Day 1)
├─ Bug fix (2 hours)
├─ Refactor (4 hours)
└─ Feature C (Day 2)
All merged back to main within a day or two.
No develop branch. No release-1.0 branch. Just main and short-lived feature branches.
The Recommended Workflow for Small Teams
Here’s the sweet spot for teams 1-20 people:
1. Branching Strategy
Main branch: The source of truth. Always deployable. Tagged for releases.
Feature branches: Created from main, merged back to main. Naming:
feature/user-authentication
bugfix/payment-timeout
chore/update-dependencies
refactor/simplify-cache-layer
Pattern: type/short-description
Types:
feature/— New functionalitybugfix/— Bug fixchore/— Maintenance (deps, configs)refactor/— Code cleanup (no logic change)docs/— Documentation onlyperf/— Performance improvement
Protect main: Require pull request reviews before merging. No force-pushing to main.
# GitHub/GitLab settings:
- Require 1+ approvals
- Require status checks to pass (tests, linting)
- Dismiss stale reviews when new commits pushed
- Allow auto-merge (speeds up workflow)
2. Starting a Feature
# Get latest main
git checkout main
git pull origin main
# Create feature branch
git checkout -b feature/user-auth
# Make changes and commit
git add .
git commit -m "feat: add JWT authentication"
3. Commit Messages: A Simple Standard
Good commit messages save hours of debugging. Use conventional commits:
<type>(<scope>): <subject>
<body>
<footer>
Type: feat, fix, refactor, perf, test, chore, docs
Scope: Optional, but helpful. What part of the codebase?
Subject: 50 characters max. Imperative mood (“add”, not “added”).
Examples
feat(auth): add JWT token refresh endpoint
The client can now call /api/auth/refresh to get a new token
without re-authenticating. This prevents session timeouts during
long-running operations.
Fixes #123
fix(api): prevent race condition in user creation
Added mutex to ensure only one user per email. Previously,
concurrent requests could create duplicates.
refactor(cache): simplify Redis integration
Removed caching layer abstraction that wasn't used outside
this module. Simplified from 200 to 80 lines.
chore(deps): upgrade Node.js from 18 to 20
Includes updated type definitions and security patches.
All tests pass.
4. Pushing and Creating a PR
# Push your branch
git push origin feature/user-auth
# GitHub/GitLab will prompt you to create a PR
# Or do it from the CLI
gh pr create --title "feat: add JWT authentication" \
--body "Implements JWT tokens for stateless auth"
5. Code Review and Feedback
Your PR is now open. Teammates review it:
Reviewer 1: "Why are we storing tokens in local storage?
It's vulnerable to XSS."
You: "Good point. I'll move to httpOnly cookies instead."
(Make changes, push new commit)
Reviewer 2: "LGTM"
You: "Thanks!"
Respond to all feedback. Don’t take it personally. All code is reviewed.
6. Keeping Your Branch Up to Date
If main gets new commits while you’re reviewing:
# Option 1: Rebase (cleaner history)
git fetch origin
git rebase origin/main
# Might have conflicts. Fix them, then:
git add .
git rebase --continue
# Force-push your branch (safe because it's yours)
git push origin feature/user-auth --force-with-lease
# Option 2: Merge (simpler, but messier history)
git fetch origin
git merge origin/main
git push origin feature/user-auth
Recommendation: Rebase. Cleaner history. But either works.
7. Merging
Once approved and tests pass:
# Option 1: Squash (single commit for this feature)
git merge --squash feature/user-auth
git commit -m "feat(auth): add JWT authentication"
# Option 2: Rebase and fast-forward (linear history)
git rebase feature/user-auth
# Git automatically fast-forwards
# Option 3: Merge commit (preserves branch history)
git merge --no-ff feature/user-auth
# Creates a merge commit, visible in history
Most teams use squash for small PRs (cleaner) and merge commit for larger features (preserves structure).
In GitHub: Use the dropdown on PR → “Squash and merge” or “Create a merge commit”
8. Cleanup
After merging:
# Delete local branch
git branch -d feature/user-auth
# Delete remote branch
git push origin --delete feature/user-auth
GitHub can auto-delete after merge. Enable in repo settings.
Real-World Example: Day in the Life
Morning: Start Feature
git checkout main
git pull origin main
# Alice starts the auth feature
git checkout -b feature/user-auth
# ... writes code ...
git commit -m "feat(auth): add JWT token generation"
git push origin feature/user-auth
Afternoon: Bob Starts Bug Fix
git checkout main
git pull origin main
# Bob sees an urgent bug
git checkout -b bugfix/payment-timeout
# ... fixes bug ...
git commit -m "fix(payment): increase timeout from 5s to 30s"
git push origin bugfix/payment-timeout
# Bob's fix is merged and deployed within 2 hours
Meanwhile: Alice Continues
Alice’s feature takes 1.5 days. While she works, Bob’s fix and other features are merged to main. Alice:
# Get latest main (includes Bob's fix)
git fetch origin
git rebase origin/main
# Resolve any conflicts (unlikely if Bob touched payment code and Alice touched auth)
# Push updated branch
git push origin feature/user-auth --force-with-lease
Next Day: Alice’s PR Merged
Alice’s PR is approved. Admin merges:
# GitHub: Click "Squash and merge"
# Or manually:
git checkout main
git pull origin main
git merge --squash feature/user-auth
git commit -m "feat(auth): add JWT authentication"
git push origin main
Branch Naming in Detail
Good names are searchable and self-explanatory:
✓ feature/user-registration
✓ bugfix/null-pointer-exception
✓ refactor/simplify-payment-logic
✓ perf/optimize-db-queries
✓ docs/api-authentication
✗ feature/foo
✗ bugfix/issue
✗ feature/alice-changes
✗ feature/new-stuff
✗ WIP-auth (ambiguous type)
Tools can enforce naming:
# .git/hooks/prepare-commit-msg
#!/bin/bash
branch=$(git rev-parse --abbrev-ref HEAD)
if ! [[ $branch =~ ^(feature|bugfix|refactor|chore|docs|perf)/ ]]; then
echo "Branch must start with feature/, bugfix/, etc."
exit 1
fi
Merge Conflicts: The Inevitable
Merge conflicts happen. Don’t panic. Here’s how to handle them:
Simple Conflict Example
# Alice: feature/auth
if (user.token) {
validateToken();
}
# Bob: feature/logging
if (user.token) {
console.log("Token found");
validateToken();
}
# Merged: CONFLICT
Git marks the conflict:
<<<<<<< HEAD (your current branch)
if (user.token) {
validateToken();
}
=======
if (user.token) {
console.log("Token found");
validateToken();
}
>>>>>>> feature/logging
Fix it:
if (user.token) {
console.log("Token found");
validateToken();
}
Then:
git add .
git rebase --continue # if rebasing
# or
git commit # if merging
Preventing Conflicts
The best strategy: Keep branches short-lived (1-2 days max).
Shorter branches = fewer new commits on main = fewer conflicts.
Also:
- Communicate with teammates about what you’re changing
- Pair program on overlapping changes
- Refactor to reduce shared code
Visual Tools for Conflict Resolution
Command-line is fine, but visual tools help:
# Use VSCode as merge tool
git config merge.tool vscode
git config mergetool.vscode.cmd 'code --wait $MERGED'
git mergetool
# Or use Meld (GUI)
git config merge.tool meld
git mergetool
Rebase vs Merge: The Eternal Debate
Rebase (Linear History)
git rebase origin/main
History looks like:
main ─── Alice's commit 1 ─── Alice's commit 2 ─── (main)
↑
No merge commit
Advantages:
- Clean, linear history
- Easy to read
git log - Easier to bisect for bugs
- Each commit is independent
Disadvantages:
- Rewrites history (confusing if you don’t understand it)
- Can accidentally “lose” commits if you mess up
--force-with-leaserequired to push (scary to new developers)
Merge (Preserves History)
git merge origin/main
History looks like:
main ─┬─ Alice's commit 1 ─┬─ (main)
│ │
└─ Bob's commit ─────┘
(Merge commit)
Advantages:
- Preserves what actually happened
- Safe (no history rewriting)
- Clear when features were merged
Disadvantages:
- “Messy” history with merge commits
- Harder to read with many parallel branches
- More commits (noise in git log)
Recommendation
For teams: Use rebase. Cleaner history, easier to read.
For public/open-source repos: Use merge commits. Preserves history, less confusing.
For this workflow: Rebase locally, merge commit when pushing to main.
# On your feature branch
git rebase origin/main
# Then when merging to main, use:
git merge --no-ff feature/branch # Creates a visible merge commit
# Or use GitHub's UI: "Create a merge commit"
Tags and Releases
When you’re ready to ship, tag a release:
# Tag the current commit
git tag -a v1.0.0 -m "Release version 1.0.0"
# Push tags to remote
git push origin v1.0.0
# Or all tags
git push origin --tags
In CI/CD, automate this:
# When a commit is tagged, build and deploy
if git describe --exact-match --tags; then
# This is a release commit
npm run build
npm run deploy
fi
View tags:
git tag # List all tags
git show v1.0.0 # Show tag details
git log --oneline v1.0.0..v1.1.0 # Commits between versions
Advanced: Feature Flags
For true continuous deployment, use feature flags:
// Instead of branching, use flags
if (features.newAuthFlow) {
renderNewLoginUI();
} else {
renderOldLoginUI();
}
// Deploy code to production
// Feature is behind a flag (disabled)
// Turn on flag for 5% of users
// Monitor metrics
// Turn on flag for 50% of users
// Turn on flag for 100% of users
Benefits:
- Deploy daily without waiting for reviews
- A/B test features
- Rollback instantly (flip flag, no redeploy)
Downside: Code becomes complex with many flags.
Recommendation: Use flags for risky changes. Use branches for regular changes.
Common Mistakes and How to Avoid Them
Mistake 1: Committing to Main by Accident
git commit -m "oops"
git push origin main
# DISASTER! You pushed directly to main!
Solution: Use branch protection rules in GitHub/GitLab.
Also: Local prevention (Pre-commit hook):
# .git/hooks/pre-commit
#!/bin/bash
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ $branch == "main" ]]; then
echo "Cannot commit directly to main!"
exit 1
fi
Mistake 2: Large, Long-Lived Branches
bugfix/website-redesign
├─ 50 commits
├─ 2 weeks old
├─ 20 files changed
├─ Conflicts with 5 other PRs
Solution: Break into smaller PRs. One feature per branch, max 1-2 days of work.
Mistake 3: Unclear Commit Messages
git log
b3d4e2f Update stuff
f1a9c8e Fix things
9e2b7a3 Changes
Solution: Use conventional commits (feat, fix, etc.). Write descriptive messages.
Mistake 4: Force-Pushing to Shared Branches
git push origin main --force
# DISASTER! Everyone's local main is now stale
Solution: Only force-push your own feature branch (with --force-with-lease).
git push origin feature/auth --force-with-lease # OK
git push origin main --force # NEVER
Mistake 5: Merging Without Tests
# Tests haven't run yet
git merge feature/payment
git push origin main
# Payment endpoint is broken, users can't checkout
Solution: Require CI checks to pass before merging.
# GitHub: Branch Protection Rules
- Require status checks to pass
- Unit tests (required)
- Integration tests (required)
- Linting (required)
Mistake 6: Not Reviewing Code
"Looks good to me" (without reading)
Solution: Actually review. Ask questions. Suggest improvements. It’s your codebase.
Workflow for Different Scenarios
Scenario 1: Hotfix (Critical Bug in Production)
# Branch from main (which is production)
git checkout main
git pull origin main
git checkout -b bugfix/critical-payment-issue
# Fix the bug
# Test locally
git push origin bugfix/critical-payment-issue
# Create PR, ask for urgent review
# Merge and deploy immediately (no waiting)
Scenario 2: Large Feature (2+ weeks)
For very large features, consider a feature branch:
git checkout -b feature/new-payment-system
# Everyone on the team commits to this branch
# Regularly merge main into this branch to stay updated
git merge main
When ready:
git rebase origin/main
git push origin feature/new-payment-system
# Create PR, review, merge to main
Scenario 3: Parallel Features (No Conflicts Expected)
Everyone works on separate feature branches. Merge as ready:
Alice: feature/auth (1 day)
Bob: feature/notifications (1 day)
Charlie: feature/analytics (2 days)
All merge to main independently, no conflicts
Scenario 4: Shared Feature Work (Multiple People)
If two people work on the same feature:
Option A: One feature branch, both commit to it
git checkout -b feature/redesign
# Alice and Bob both commit to this branch
# No separate PRs needed
# Merge as one when done
Option B: Separate branches, sync frequently
Alice: feature/redesign-header
Bob: feature/redesign-footer
# Alice's PR merged
git pull origin main (Bob's local)
git rebase origin/main
# Resolve conflicts, continue
Summary: The Workflow in 30 Seconds
- One main branch: Always deployable, always protected
- Feature branches: Short-lived (1-2 days), from main
- Naming:
type/description(feature/auth, bugfix/timeout) - Commit messages: Conventional (feat, fix, refactor, etc.)
- Keep updated: Rebase on main frequently
- Code review: Always. No exceptions.
- Merge cleanly: Squash or rebase, keep history linear
- Tag releases: v1.0.0, v1.0.1, etc.
This scales from 2 people to 50. It’s boring (good). It keeps code safe and teams moving fast.
Git Commands Cheat Sheet
# Create and switch to feature branch
git checkout -b feature/my-feature
# Push to remote
git push origin feature/my-feature
# Keep your branch updated
git fetch origin
git rebase origin/main
# View your branches
git branch -a
# See what changed
git diff origin/main
# Squash commits before pushing
git rebase -i origin/main
# Merge to main (after PR review)
git checkout main
git pull origin main
git merge --squash feature/my-feature
git commit -m "feat(scope): description"
git push origin main
# Delete branch (local)
git branch -d feature/my-feature
# Delete branch (remote)
git push origin --delete feature/my-feature
# See commit history
git log --oneline --graph --all
# Undo last commit (not pushed)
git reset --soft HEAD~1
# Undo last commit (pushed) - create a new commit that undoes it
git revert HEAD
# Find which commit broke something
git bisect start
git bisect bad HEAD
git bisect good v1.0.0
# Test current commit
git bisect good # or git bisect bad
Final Thoughts
Git workflows aren’t magic. They’re just agreed-upon rules for how you work together. The best workflow is the one your team actually follows.
Start simple. Use this workflow. Adjust as you grow. Don’t over-engineer early.
The goal isn’t to have perfect history. The goal is to ship code safely and quickly. Everything else is secondary.