Git Workflow Best Practices for Teams
Solo, you can commit straight to main and nobody gets hurt. Add two more developers and things change fast: someone force-pushes over a teammate's work, a feature branch lives for three weeks and then conflicts with half the codebase, and nobody can tell from git log why a line changed. None of that is a Git problem. It's a missing agreement about how the team uses Git.
This post covers the pieces of that agreement: picking a branching strategy, writing commit messages people can actually use, keeping pull requests reviewable, handling merge conflicts, and automating the boring checks.
A written workflow does more than cut down merge conflicts. Reviews get faster because PRs are smaller, git log becomes useful when you're hunting a regression, and new people don't have to guess how things work. Most of it is cheap to set up and pays off within a few weeks.
Understanding Branching Strategies
Start with the branching strategy, because everything else (reviews, CI, releases) hangs off it. There's no single right answer. It depends on how big the team is, how often you ship, and whether you have to support more than one released version at a time. These are the three models most teams end up choosing between.
Three common branching strategies and where each one fits
GitFlow: The Classic Approach
Vincent Driessen published GitFlow in 2010, and it's still the most structured of the three. You keep two long-lived branches (main and develop) plus feature, release, and hotfix branches. That overhead makes sense when you ship versioned releases on a schedule. For a web app that deploys several times a day, it mostly gets in the way.
When to Use
Versioned releases, multiple environments, larger teams with QA cycles
When to Avoid
Continuous deployment, small teams, rapid iteration
# Long-lived branches
main # Production-ready code, tagged releases
develop # Integration branch for features
# Short-lived branches
feature/* # New features (from develop)
release/* # Release preparation (from develop)
hotfix/* # Production fixes (from main)
# Example workflow
git checkout develop
git checkout -b feature/user-authentication
# ... work on feature ...
git checkout develop
git merge --no-ff feature/user-authentication
git branch -d feature/user-authentication
GitHub Flow: Simple and Effective
GitHub Flow drops develop and release branches entirely. There's one rule: anything in main is deployable. If your team deploys continuously, this is usually where I'd start.
Create a Branch
Branch off from main with a descriptive name: git checkout -b feature/add-search
Add Commits
Make changes and commit with clear messages. Push regularly to back up your work.
Open a Pull Request
Start discussion about your changes. Request reviews from teammates.
Review and Discuss
Respond to feedback, make changes, and iterate until approved.
Merge and Deploy
Once approved and CI passes, merge to main and deploy immediately.
Trunk-Based Development
With Trunk-Based Development (TBD), everyone integrates into a single branch (trunk/main), and any feature branches last hours, not days. Unfinished work hides behind feature flags instead of sitting on a branch. It has the least process of the three, but it only works if your test suite is good enough that you trust a green build.
- Branches live for hours, not days
- Feature flags control incomplete features
- CI/CD runs on every commit to trunk
- No long-lived feature branches
- Requires strong testing culture
| Aspect | GitFlow | GitHub Flow | Trunk-Based |
|---|---|---|---|
| Complexity | High | Low | Very Low |
| Release Cadence | Scheduled | Continuous | Continuous |
| Team Size | Large (10+) | Any | Senior teams |
| Branch Lifespan | Days to weeks | Days | Hours |
| Merge Conflicts | Common | Occasional | Rare |
| CI/CD Required | Helpful | Recommended | Required |
Commit Message Conventions
Six months from now, someone will run git blame on a weird line and land on your commit. If the message says "fix stuff", they're stuck reading the diff and guessing. A decent message tells them what changed and why, which is usually the part the code can't tell you.
Conventional Commits format for clear, machine-readable commit messages
Conventional Commits Standard
Conventional Commits gives you a format that people can skim and tools can parse. Tools like semantic-release and release-please read the prefixes to bump versions and generate changelogs, so the convention pays for itself once you automate releases.
feat: # New feature for the user
fix: # Bug fix for the user
docs: # Documentation only changes
style: # Formatting, missing semi-colons, etc (no code change)
refactor: # Code change that neither fixes a bug nor adds a feature
perf: # Performance improvement
test: # Adding missing tests or correcting existing tests
build: # Changes to build system or external dependencies
ci: # Changes to CI configuration files and scripts
chore: # Other changes that don't modify src or test files
revert: # Reverts a previous commit
For breaking changes, add ! after the type/scope or include BREAKING CHANGE: in the footer:
feat(api)!: change authentication header format
BREAKING CHANGE: Authorization header now requires Bearer prefix
Seven Rules for Commit Messages
Do This
- Use imperative mood ("Add feature" not "Added feature")
- Limit subject line to 50 characters
- Capitalize the subject line
- Separate subject from body with blank line
- Wrap body at 72 characters
- Explain what and why, not how
- Reference issues and PRs
Avoid This
- "Fixed bug" (what bug?)
- "WIP" or "Work in progress"
- "asdfasdf" or keyboard smash
- Giant commits with multiple changes
- "Updated file" (which file? why?)
- Meaningless commit chains
- Mixing unrelated changes
Pull Request Best Practices
The PR is where review actually happens, so its job is to make the reviewer's life easy. If they have to figure out what you were trying to do before they can judge whether you did it, you'll get shallow feedback or none at all.
The lifecycle of a well-managed pull request
Writing Effective PR Descriptions
A useful PR description answers three questions: what changed, why, and how to test it. Drop a template like the one below into .github/pull_request_template.md and GitHub pre-fills it on every new PR.
## What does this PR do?
Adds OAuth2 authentication with Google as a provider. Users can now
sign in with their Google accounts in addition to email/password.
## Why is this change needed?
- Reduces friction in the signup process (35% of users prefer social login)
- Addresses user feedback request #234
- Aligns with our Q1 goal of improving onboarding conversion
## How to test
1. Go to /login
2. Click "Sign in with Google"
3. Authorize the application
4. Verify you're redirected to the dashboard
## Screenshots

## Checklist
- [x] Tests added/updated
- [x] Documentation updated
- [x] No console errors
- [x] Responsive design verified
- [ ] Accessibility checked
## Related Issues
Closes #234
Relates to #189
PR Size Guidelines
Aim for PRs under about 400 changed lines. That's the commonly cited point where reviewers start skimming instead of reading. In practice, a 50-line PR gets real comments and a 2,000-line PR gets "LGTM".
| PR Size | Lines Changed | Typical Review Time | Review Quality |
|---|---|---|---|
| 🟢 Small | 1-100 lines | 15-30 minutes | High (thorough review) |
| 🟡 Medium | 100-400 lines | 30-60 minutes | Good |
| 🟠 Large | 400-1000 lines | 1-2 hours | Decreasing |
| 🔴 Too Large | 1000+ lines | Often skipped | Low (rubber-stamp risk) |
Code Review Guidelines
Review catches bugs, but that's only part of it. It's also how the team learns the codebase and how you keep two people from solving the same problem two different ways. Both sides of the review have work to do.
As a Reviewer
- Review promptly: aim to respond within 24 hours. A PR waiting three days goes stale and starts conflicting.
- Be constructive. Suggest a fix instead of only pointing at the problem.
- Explain your reasoning, e.g. "This might cause X because Y".
- Separate must-fix from nice-to-have so the author knows what blocks the merge.
- Say when something is good. Positive feedback matters too.
- Ask questions: "What happens if...?" or "Have you considered...?"
- Focus on logic, not style. Let linters handle formatting.
Prefixes make the intent of a comment obvious:
nit:minor suggestion, not blockingquestion:asking for clarificationsuggestion:optional improvementissue:must be fixed before merge
Handling Merge Conflicts
If more than one person touches the same files, you'll get conflicts. You can't avoid them completely, but you can make them rarer and smaller, and you can stop being scared of resolving them.
Conflicts occur when two branches modify the same lines
Conflict Prevention Strategies
Sync Often
Pull from main daily. The longer you're out of sync, the worse conflicts get.
Small PRs
Smaller changes mean fewer opportunities for overlap with teammates.
Communicate
Let the team know when you're working on shared files or modules.
Modular Code
Well-structured code with clear boundaries reduces accidental overlap.
Resolving a Conflict Step by Step
# Step 1: Update your main branch
git checkout main
git pull origin main
# Step 2: Go to your feature branch
git checkout feature/my-feature
# Step 3: Merge main into your branch (or rebase)
git merge main
# OR: git rebase main
# Step 4: Git shows conflicts. Open the files and look for:
# <<<<<<< HEAD
# Your changes
# =======
# Their changes
# >>>>>>> main
# Step 5: Edit the file to keep the correct code
# Remove the conflict markers
# Step 6: Mark as resolved
git add <resolved-files>
# Step 7: Complete the merge/rebase
git commit -m "Resolve merge conflicts with main"
# OR: git rebase --continue
# Step 8: Push your branch
git push origin feature/my-feature
If you took the rebase route and the branch was already pushed, step 8 fails with ! [rejected] ... (non-fast-forward) and "Updates were rejected because the tip of your current branch is behind its remote counterpart". Don't run git pull here; that merges the old commits back in and duplicates them. Use git push --force-with-lease, which refuses to overwrite the remote if someone else pushed to the branch since you last fetched. Plain --force doesn't check, which is how teammates lose work. If the same conflict keeps coming back on a long rebase, git config --global rerere.enabled true makes Git remember how you resolved it.
- VS Code: built-in merge editor with Accept Current/Incoming/Both buttons
- GitKraken: visual merge tool for messier conflicts
- Meld: free, cross-platform diff/merge tool
git mergetoolopens whichever merge tool you've configured
Protected Branches & Policies
Branch protection stops someone from pushing straight to main at 6 p.m. on a Friday, by accident or otherwise. Once there's more than one person on the repo, turn it on. On GitHub it lives under Settings → Branches (or Rulesets on newer repos).
# For main/production branch:
✓ Require pull request reviews (minimum 1-2 reviewers)
✓ Dismiss stale reviews when new commits are pushed
✓ Require status checks to pass (CI/tests)
✓ Require branches to be up to date before merging
✓ Include administrators in restrictions
✓ Restrict who can push (no direct commits)
✓ Require signed commits (optional but recommended)
# For develop branch (if using GitFlow):
✓ Require pull request reviews (minimum 1 reviewer)
✓ Require status checks to pass
✓ Allow administrators to bypass (for emergencies)
Automation & CI/CD Integration
Conventions that rely on people remembering them drift within a month. Put the checks in CI and in Git hooks so the tooling enforces them and reviewers can spend their time on the logic.
GitHub Actions Workflow Example
# .github/workflows/pr-checks.yml
name: PR Checks
on:
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ESLint
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Tests
run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- name: Build
run: npm run build
commit-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check Commit Messages
uses: wagoid/commitlint-github-action@v5
Git Hooks with Husky
# Install Husky
npm install husky --save-dev
npx husky install
# Pre-commit hook (runs before each commit)
npx husky add .husky/pre-commit "npm run lint-staged"
# Commit-msg hook (validates commit message)
npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1"
# package.json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}
These commands are for Husky v8. Husky v9 removed husky add and replaced husky install with npx husky init; in v9 you write the command straight into .husky/pre-commit. Check which version your package.json pulls in before you copy commands from an older tutorial.
Git Commands I Use Every Day
# Start new work
git checkout main && git pull
git checkout -b feature/new-feature
# Save work in progress (before switching branches)
git stash
git stash pop
# Interactive rebase to clean up commits before PR
git rebase -i HEAD~3
# Squash all commits into one before merging
git rebase -i main # Then mark commits as 'squash'
# Undo last commit but keep changes
git reset --soft HEAD~1
# See what changed in a branch
git log main..feature/my-feature --oneline
# Find who changed a line (blame)
git blame path/to/file.js
# Search commit messages
git log --grep="fix login"
# Cherry-pick a specific commit
git cherry-pick abc1234
Which Workflow I'd Pick (and When I Wouldn't)
For most web teams that deploy from main, I'd go with GitHub Flow plus branch protection and a CI check. It's simple enough that people actually follow it.
- Don't adopt GitFlow just because it looks thorough. If you deploy continuously, the
developandreleasebranches add merges and nobody gets anything for them. It earns its keep when you ship versioned software (mobile apps, libraries, on-prem installs) and have to patch old releases. - Don't jump to trunk-based development without the safety net. No reliable tests and no feature flags means you're just pushing half-done work to production faster.
- Don't enforce Conventional Commits on a two-person side project unless you're also generating changelogs from them. The commitlint failures will annoy you more than they help.
- Do squash-merge if your branch history is noisy, but know that it rewrites commits. Anyone who branched off your feature branch will hit conflicts afterwards.
If you're setting up a new repo, this is the order I'd do things in. Don't try all of it on day one; add each piece when its absence starts to hurt.
Setup Checklist for a New Team Repo
- ✓ Choose a branching strategy
- ✓ Define naming conventions
- ✓ Set up branch protection
- ✓ Document the workflow
- ✓ Adopt Conventional Commits
- ✓ Set up commit linting
- ✓ Configure pre-commit hooks
- ✓ Review commit message guide
- ✓ Create PR template
- ✓ Define size guidelines
- ✓ Establish review SLAs
- ✓ Set up CI checks
- ✓ Configure CI/CD pipeline
- ✓ Set up auto-labeling
- ✓ Enable auto-merge rules
- ✓ Add status badges
Whatever you pick, write it down in the repo's CONTRIBUTING.md. A simple workflow everyone follows beats a clever one that lives in one person's head.