Introduction: Why Most Teams Get Git Wrong
Let's start with an uncomfortable truth: most development teams use Git badly. They've graduated beyond committing directly to master (hopefully), but they're stuck in a perpetual state of merge conflicts, unclear branch naming, and pull requests that sit for weeks. The problem isn't Git itself—it's that teams adopt workflows without understanding why those workflows exist or whether they fit their specific context. You've probably seen it: a startup with three developers using the same branching strategy as a 200-person enterprise team, complete with release branches, hotfix procedures, and change approval boards. Or the opposite—a large team doing cowboy commits to a single long-lived branch because "we're agile and don't need process."
Git is powerful, but that power comes with complexity. The difference between a team that ships confidently and one that's terrified of deployments often comes down to Git workflow. A good workflow makes collaboration invisible—you pull, branch, commit, push, and merge without thinking. A bad workflow makes every code change feel like defusing a bomb. This guide cuts through the dogma around Git workflows to show you what actually works in production. We'll cover branching strategies from simple to complex, when to use each, how to structure pull requests that don't waste everyone's time, and the conflict resolution techniques that separate senior developers from everyone else. No theoretical BS—just battle-tested patterns from teams that ship code multiple times per day without breaking production.
Branching Strategies: Choosing Your Workflow
The branching strategy debate has generated more pointless arguments than tabs versus spaces. Let's cut through it: there are three workflows that actually matter, and your choice depends on two factors—team size and deployment frequency. Everything else is cargo cult engineering. Trunk-Based Development is the simplest: one main branch, short-lived feature branches (hours to days, not weeks), and continuous integration. You branch off main, make your change, merge back to main, and deploy from main. That's it. No develop branch, no release branches, no hotfix branches. Just main and temporary feature branches.
This works beautifully for small teams (2-10 developers) deploying multiple times per day. Companies like Google and Facebook use variants of this for teams with thousands of engineers. The key is feature flags—you merge incomplete features to main behind flags, so main is always deployable even if features aren't finished. The discipline required is real: you can't let branches live for weeks, you can't break main, and you need robust CI/CD. But when it works, it's magical. No merge hell, no complicated release procedures, just continuous delivery.
GitHub Flow adds one layer of formality: you still have one main branch, but you gate merges with pull requests and code review. Branch off main, work on your feature, open a PR, get reviewed, merge to main, deploy. This is trunk-based development with guardrails. It's perfect for teams of 5-30 people who want code review but don't need complex release management. The pull request becomes your quality gate and documentation of what changed and why.
Here's a real GitHub Flow cycle:
# Start new feature
git checkout main
git pull origin main
git checkout -b feature/user-authentication
# Make changes, commit frequently
git add .
git commit -m "Add login form component"
git commit -m "Implement JWT token validation"
git commit -m "Add user session persistence"
# Push and open PR
git push origin feature/user-authentication
# Open PR on GitHub, request review
# After approval, merge via GitHub UI (squash or merge commit)
# Delete feature branch
git checkout main
git pull origin main
git branch -d feature/user-authentication
Git Flow is the heavyweight champion—and usually overkill. It has main (production), develop (integration), feature branches, release branches, and hotfix branches. It was designed for software with scheduled releases and version numbers, like desktop applications or libraries. Most web applications don't need this complexity. You're deploying continuously, not shipping boxed software every quarter. But if you're working on a product with strict release schedules, version numbers that mean something, and need to maintain multiple versions in production simultaneously, Git Flow provides the structure you need.
The brutal reality: most teams using Git Flow don't need it. They adopted it because it seemed "professional" or because some blog post said it was a best practice. Then they spend hours managing release branches and cherry-picking hotfixes for problems that wouldn't exist with a simpler workflow. Start with trunk-based or GitHub Flow. Only adopt Git Flow if you have a specific need for release branches—and be honest about whether you actually have that need.
Pull Request Architecture: Doing Code Review Right
Pull requests are where most teams waste time. You've seen it: PRs with 50 files changed and 2,000 lines of diff that sit for a week because nobody wants to review a novel. Or tiny PRs with single-line changes that still take days to merge because the process is bureaucratic. The problem is treating all PRs the same when they're fundamentally different types of work. Let's fix this with practical patterns that respect everyone's time.
Size matters more than you think. The ideal PR is 200-400 lines of changes. This is small enough to review in 20-30 minutes but large enough to represent a complete, deployable change. Research shows that review effectiveness drops dramatically after 400 lines—reviewers start skimming instead of reading. If your PR is larger than 1,000 lines, you're not getting real review, you're getting rubber stamps. Break it up. "But my feature requires 3,000 lines!" Then you're not breaking it up correctly. Use feature flags to merge incomplete work. Split refactoring from feature changes. Commit the infrastructure in one PR, the feature in another. This isn't busywork—it's respecting your reviewers' cognitive capacity.
Here's how to structure PRs for different change types:
# Feature PR - single, complete feature
feature/add-export-functionality
- Adds export button to UI
- Implements CSV generation
- Adds download handler
- Includes tests and documentation
Est. size: 300 lines across 8 files
# Refactoring PR - pure refactor, no behavior change
refactor/extract-api-client
- Extracts API calls to service class
- Updates all callsites
- No functional changes (proven by tests)
Est. size: 500 lines across 15 files
# Infrastructure PR - changes that enable future features
infra/add-feature-flag-system
- Adds feature flag library
- Implements LaunchDarkly integration
- Adds feature flag component
- No features using it yet (comes in next PR)
Est. size: 250 lines across 5 files
# Bug fix PR - minimal, surgical change
fix/user-profile-null-check
- Adds null check for missing profile data
- Includes test that reproduces bug
- Links to issue #1234
Est. size: 15 lines across 2 files
PR descriptions are documentation. Your PR title should complete the sentence "This PR will..." Your description should answer three questions: What changed? Why did it change? How should reviewers verify it works? The best PR descriptions include screenshots for UI changes, before/after examples for behavior changes, and explicit testing instructions. Don't make reviewers play detective.
Here's a template that works:
## What Changed
- Implemented user profile editing functionality
- Added form validation for email and phone fields
- Updated user settings page layout
## Why
Users reported inability to update contact information (Issue #456)
Current workaround requires contacting support
This blocks self-service account management
## How to Test
1. Log in as test user (credentials in 1Password)
2. Navigate to /settings/profile
3. Update email and phone
4. Submit form
5. Verify validation errors for invalid formats
6. Verify success toast and DB update for valid data
## Screenshots
[Before/After images]
## Notes for Reviewers
- Phone validation uses libphonenumber-js (new dependency)
- Email validation client + server side
- Form state managed with React Hook Form
- Existing users grandfathered (no forced updates)
Review speed matters more than review depth. Controversial take: a PR reviewed in 2 hours is more valuable than a perfect review in 2 days. Fast feedback loops keep developers in context. Slow reviews context-switch developers to other work, increasing cognitive load when they eventually address comments. Set team expectations: PRs get first-pass review within 4 hours during business hours. Not final approval necessarily, but initial feedback. This prevents PRs from going stale and keeps work moving.
Merge Strategies: When to Squash, Merge, or Rebase
The merge versus rebase debate is religious warfare in some teams, but the answer is context-dependent and less dramatic than people make it. Let's demystify the three strategies: merge commits, squash and merge, and rebase and merge. Each has specific use cases where it shines and situations where it creates problems.
Merge commits preserve the entire history of your feature branch in the main branch. When you merge, Git creates a merge commit that has two parents—the tip of main and the tip of your feature branch. This means every commit you made during development stays in history. The advantage is complete traceability—you can see exactly how the feature evolved. The disadvantage is noisy history—if you made 20 commits while developing a feature ("WIP", "fix typo", "actually fix typo", "revert last commit"), all 20 commits appear in main's history. This makes git log harder to read and git bisect less useful for tracking down bugs.
Use merge commits when: you're working on complex features where the development history provides value, you're maintaining multiple versions simultaneously (like open-source libraries supporting v2 and v3), or you're integrating long-lived branches where you want to preserve the branch context. Don't use merge commits for everyday feature development—the noise outweighs the benefit.
# Merge commit strategy
git checkout main
git merge feature/user-auth
# Creates merge commit with message like "Merge feature/user-auth into main"
# Result: all feature branch commits appear in main history
git log --oneline main
a1b2c3d Merge feature/user-auth into main
d4e5f6g Add login form
e7f8g9h Implement JWT validation
h0i1j2k Add session persistence
k3l4m5n Previous main commit
Squash and merge takes all commits from your feature branch and combines them into a single commit on main. This is the cleanest option for most teams. Your 15 commits of "fix tests", "address review comments", and "fix lint" become one commit: "Add user authentication feature". Main's history stays linear and readable. Each commit on main represents a complete, reviewed change. The downside is you lose granular history—if someone wants to see how the feature evolved, that information is gone (though it's still in the PR if you need it).
Use squash and merge for: 99% of feature development in web applications, any PR where the individual commits don't tell a coherent story, situations where you want main to have semantic, deployable commits. This is the default strategy for most successful teams because it optimizes for the most common use case—understanding what changed in production, not how someone developed a feature.
# Squash and merge strategy (typically done via GitHub UI)
# Or manually:
git checkout main
git merge --squash feature/user-auth
git commit -m "Add user authentication feature
- Implemented login form component
- Added JWT token validation
- Included user session persistence
- Added comprehensive tests
Closes #123"
# Result: single commit on main representing entire feature
git log --oneline main
a1b2c3d Add user authentication feature
k3l4m5n Previous main commit
Rebase and merge rewrites your feature branch commits on top of main, then fast-forwards main. This creates a linear history without merge commits, and unlike squash, it preserves individual commits. It's the middle ground—cleaner than merge commits, more detailed than squash. The catch is you're rewriting history, which can cause problems if others have based work on your branch.
Use rebase and merge when: you've made semantically meaningful commits during development that tell a story, you're working solo on a branch, or you want linear history but also granular commits. The key requirement is discipline—your commits need to be actually meaningful, not "WIP" and "fix stuff". In practice, few developers have this discipline during active development, which is why squash is more popular.
# Rebase and merge strategy
git checkout feature/user-auth
git rebase main # Replay your commits on top of latest main
git checkout main
git merge --ff-only feature/user-auth # Fast-forward merge
# Result: your commits appear as if they were made directly on main
git log --oneline main
a1b2c3d Add session persistence
b2c3d4e Implement JWT validation
c3d4e5f Add login form
k3l4m5n Previous main commit
Here's my controversial recommendation: default to squash and merge. Reserve merge commits for release branches or major integrations. Only use rebase if your team has the discipline to make meaningful commits during development (most don't). The benefits of clean, semantic history on main outweigh the preservation of development artifacts. Future you, debugging a production issue, will thank current you for keeping main's history readable.
Conflict Resolution: Making Merges Suck Less
Merge conflicts are inevitable in any team larger than one person. The question isn't how to avoid them—it's how to minimize their impact and resolve them confidently when they happen. Most developers handle conflicts poorly because they don't understand what Git is actually showing them. Let's fix that with practical techniques that work in high-stakes situations.
Prevention is 80% of the solution. Most conflicts are self-inflicted through poor branching hygiene. Keep branches short-lived—the longer your branch exists, the more main diverges, the more conflicts you'll face. Pull main regularly. If you're working on a feature for more than 2-3 days, pull main into your branch daily. Yes, this might create conflicts, but better to resolve small conflicts incrementally than one giant conflict at merge time. Communicate with your team. If someone's refactoring the authentication system and you're adding a new auth feature, coordinate so you're not creating guaranteed conflicts.
Here's a workflow that minimizes conflict pain:
# Daily rebase workflow (keeps your branch up to date)
git checkout feature/user-profile
git fetch origin
git rebase origin/main
# If conflicts occur during rebase:
# 1. Git pauses and shows conflicted files
# 2. Open each file, resolve conflicts
# 3. Stage resolved files
git add resolved-file.ts
# 4. Continue rebase
git rebase --continue
# Or if you mess up:
git rebase --abort # Start over
# After rebase completes:
git push --force-with-lease origin feature/user-profile
# Use --force-with-lease (not --force) for safety
Understanding conflict markers is critical. When Git shows you a conflict, it's not arbitrary noise—it's showing you three pieces of information. The section between <<<<<<< HEAD and ======= is your current branch's version. The section between ======= and >>>>>>> branch-name is the incoming branch's version. Git is asking: which version do you want, or do you want to combine them?
// Example conflict in a TypeScript file
<<<<<<< HEAD
export function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
=======
export function calculateTotal(items: CartItem[], taxRate: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
>>>>>>> feature/add-tax-calculation
// Bad resolution (just picking one):
export function calculateTotal(items: CartItem[], taxRate: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
// This breaks code expecting the old signature
// Good resolution (combining both changes):
export function calculateTotal(items: CartItem[], taxRate: number = 0): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
// Default parameter maintains backwards compatibility while adding new feature
Use the right tools. VS Code's built-in merge conflict resolver is decent but basic. For complex conflicts, use git mergetool with a three-way merge tool like kdiff3, meld, or p4merge. These tools show three versions side-by-side: base (common ancestor), yours (current branch), and theirs (incoming branch). This context makes resolution significantly easier because you can see what both sides changed from the original.
# Configure merge tool (one-time setup)
git config --global merge.tool kdiff3
git config --global mergetool.kdiff3.path "/usr/local/bin/kdiff3"
# When conflicts occur:
git mergetool
# Opens GUI showing three-way diff
# Resolve conflicts visually
# Save and close
# Clean up backup files
git clean -f
The hardest conflicts are architectural—where your branch and main made incompatible assumptions. Example: you built a feature assuming synchronous API calls, but main refactored to async/await. The conflict markers might be minimal, but the conceptual conflict is huge. These require human judgment. Don't just pick a side—understand what both changes tried to accomplish, then write new code that achieves both goals. Sometimes this means throwing away both versions and writing a third solution.
Test after resolving conflicts. This seems obvious but gets skipped constantly. After resolving conflicts and completing a merge or rebase, run your test suite. Conflicts can introduce subtle bugs that aren't syntactically invalid but logically broken. A successful merge doesn't mean correct code—it means Git doesn't see text conflicts. You're responsible for semantic correctness.
Advanced Patterns: Feature Flags, Commit Conventions, and Automation
Once your team has mastered basic workflows, these advanced patterns separate good teams from great teams. They're force multipliers that reduce friction, improve communication, and prevent entire classes of problems. Let's cover the three patterns that have the highest ROI: feature flags for deployment decoupling, conventional commits for semantic versioning, and Git hooks for automation.
Feature flags change everything about branching. The traditional problem: you're building a feature that takes two weeks, but you can't merge to main until it's complete because incomplete features break production. So your branch lives for two weeks, diverging from main, accumulating conflicts, and blocking other work that depends on your changes. Feature flags solve this: wrap your incomplete feature in a conditional, merge to main behind a disabled flag, and enable it when ready. Your branch lives for hours, not weeks. You get continuous integration benefits. Other developers can build on your work. And when something goes wrong, you toggle the flag off instead of rolling back deployments.
// Simple feature flag implementation
// config/features.ts
export const features = {
newCheckoutFlow: process.env.FEATURE_NEW_CHECKOUT === 'true',
aiRecommendations: process.env.FEATURE_AI_RECOMMENDATIONS === 'true',
// Production: false, Staging: true, Local: true
experimentalSearch: process.env.NODE_ENV !== 'production',
};
// Using feature flags in components
// components/Checkout.tsx
import { features } from '@/config/features';
export function Checkout() {
if (features.newCheckoutFlow) {
return <NewCheckoutExperience />;
}
return <LegacyCheckoutFlow />;
}
// Using feature flags for gradual rollouts
// lib/feature-flags.ts
import { LaunchDarkly } from 'launchdarkly-node-server-sdk';
export async function shouldShowNewFeature(userId: string): Promise<boolean> {
const ldClient = await getLDClient();
return ldClient.variation('new-dashboard', { key: userId }, false);
}
// Gradual rollout: 0% → 5% → 25% → 50% → 100%
// If issues arise at any stage, rollback to previous percentage instantly
This changes your workflow fundamentally. Instead of long-lived feature branches, you have short-lived branches merging incomplete work to main. Instead of coordinating releases, you enable features independently. Instead of rollbacks, you toggle flags. The discipline required is different—you need to clean up flags after rollout, you need to test both code paths, and you need infrastructure to manage flags. But the payoff is enormous: deploy daily while working on monthly features.
Conventional commits add semantic meaning. If your git log looks like "fix stuff", "more changes", "final version", you're missing out on automation opportunities. Conventional commits are a specification for commit messages that machines can parse: type(scope): description. Types include feat (new feature), fix (bug fix), docs (documentation), refactor (code change that doesn't affect behavior), test (adding tests), chore (maintenance). This enables automatic changelog generation, semantic versioning, and better git log filtering.
# Conventional commit examples
git commit -m "feat(auth): add OAuth2 login support"
git commit -m "fix(checkout): prevent duplicate order submission"
git commit -m "docs(api): update rate limiting documentation"
git commit -m "refactor(database): extract query builder to separate module"
git commit -m "test(payment): add integration tests for Stripe webhook"
git commit -m "chore(deps): update React to v18.2.0"
# Breaking changes use BREAKING CHANGE footer
git commit -m "feat(api): change response format
BREAKING CHANGE: API now returns data in snake_case instead of camelCase.
Clients need to update their parsers."
# Use commitlint to enforce this
# .commitlintrc.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'revert'
]],
'subject-case': [2, 'always', 'sentence-case'],
}
};
# Automatically generate changelog from commits
npx standard-version
# Creates CHANGELOG.md with sections:
# Features, Bug Fixes, Breaking Changes
# Bumps version in package.json according to semver
The real value isn't the format itself—it's the forcing function. When you have to categorize your commit, you think about what you're actually changing. This leads to smaller, more focused commits. And the automation is genuinely useful. Seeing a changelog that says "5 features, 12 bug fixes, 1 breaking change" is more valuable than reading 18 commit messages manually.
Git hooks automate quality checks. Pre-commit hooks run before commits, pre-push hooks run before pushes. Use them to enforce standards that would otherwise require manual code review. The key is making them fast—if hooks take 30 seconds to run, developers will bypass them. Keep hooks under 5 seconds by running only on changed files.
# .husky/pre-commit (using Husky for cross-platform hooks)
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Run linter on staged files only (fast)
npx lint-staged
# .lintstagedrc.js
module.exports = {
'*.{ts,tsx}': [
'eslint --fix',
'prettier --write',
],
'*.{json,md}': ['prettier --write'],
};
# .husky/pre-push
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Run tests before pushing (catches broken code before CI)
npm test -- --bail --findRelatedTests
# Prevent pushing to main directly
current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
if [ "$current_branch" = "main" ]; then
echo "❌ Cannot push directly to main. Create a PR instead."
exit 1
fi
# .husky/commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Enforce conventional commit format
npx --no-install commitlint --edit $1
Here's the controversial part: don't go overboard with hooks. I've seen teams add so many checks that committing takes a minute and developers start using --no-verify to bypass hooks entirely, defeating the purpose. The best hooks are invisible—they catch obvious mistakes (syntax errors, failing tests) without slowing down the happy path. Save expensive checks (full test suite, end-to-end tests, security scans) for CI/CD, not local hooks.
Real-World Scenarios: Handling Common Team Situations
Theory is great, but let's talk about the messy situations that actually happen in production. These scenarios come from real teams, and the solutions work because they've been battle-tested. No perfect-world assumptions—just practical approaches to common problems.
Scenario 1: The stale PR disaster. Developer opens a PR, reviewer is busy, PR sits for a week, main has moved on significantly, and now there are 47 conflicts. Resolution: First, prevent this with team norms—PRs get initial review within 4 hours. But when it happens, don't try to resolve all conflicts manually. Instead, create a fresh branch from current main, cherry-pick your commits, and resolve conflicts incrementally:
# Instead of resolving conflicts in old branch:
git checkout main
git pull origin main
git checkout -b feature/user-auth-v2
# Cherry-pick commits from old branch, resolving conflicts as you go
git cherry-pick abc123 # First commit
# Resolve conflicts if any, then continue
git cherry-pick def456 # Second commit
# Repeat for each commit
# Close old PR, open new PR from fresh branch
# Old PR becomes documentation of what was tried
This is faster than resolving a massive conflict bomb and gives you a clean slate. The old PR's discussion remains as context.
Scenario 2: Deployed a bug, need hotfix immediately. Main branch has new commits that aren't ready for production. You can't just fix the bug on main and deploy—you'd deploy untested code. The Git Flow hotfix branch pattern exists for this reason:
# Create hotfix branch from production tag/commit
git checkout -b hotfix/critical-payment-bug v1.2.3
# Fix the bug
git add .
git commit -m "fix(payment): prevent duplicate charges on retry"
# Deploy hotfix branch directly to production
# After verification, merge hotfix to both main and any release branches
git checkout main
git merge hotfix/critical-payment-bug
git push origin main
# Tag the hotfix
git tag v1.2.4
git push origin v1.2.4
# Delete hotfix branch
git branch -d hotfix/critical-payment-bug
If you're using trunk-based development without release branches, you either revert the unready commits, fix the bug, then re-apply the reverted commits, or you fix the bug on main and cherry-pick to a deployment branch. Both are messier than having release branches for production code, which is why some degree of Git Flow makes sense for critical production services.
Scenario 3: Two developers working on dependent features. Developer A is building authentication, Developer B needs authentication for their profile feature. Developer B can't wait for A's PR to merge. Solution: B branches from A's feature branch:
# Developer B creates branch from A's branch
git fetch origin
git checkout -b feature/user-profile origin/feature/user-auth
# B works on profile feature, committing normally
git add .
git commit -m "Add user profile display"
# When A's branch merges to main:
# B rebases onto main, which now includes A's changes
git fetch origin
git rebase origin/main
# B's commits now sit on top of main (which includes A's merged work)
# Open PR for B's branch
This works, but communication is critical. If A force-pushes to their branch while B is based on it, B has problems. Better approach: have A merge to main early (even if incomplete) using feature flags, then B branches from main. This is why feature flags and short-lived branches are superior to long dependency chains.
Scenario 4: Refactoring breaks everyone's branches. Someone merges a major refactor—renamed files, moved directories, changed APIs. Every open PR now has conflicts. Solution: This is why large refactors need coordination. Announce the refactor in advance, ask team to merge or close their PRs, merge the refactor, then everyone re-branches from main. If coordination failed and conflicts exist everywhere:
# Script to help developers update their branches after major refactor
# rebase-after-refactor.sh
echo "Fetching latest main..."
git fetch origin main
echo "Current branch: $(git branch --show-current)"
echo "Commits ahead of main: $(git rev-list --count origin/main..HEAD)"
echo "Starting rebase..."
git rebase origin/main
echo "If conflicts occur:"
echo "1. Resolve conflicts in your editor"
echo "2. Run: git add <resolved-files>"
echo "3. Run: git rebase --continue"
echo "4. Repeat until rebase completes"
echo ""
echo "If too many conflicts, consider recreating branch:"
echo "git checkout main"
echo "git pull origin main"
echo "git checkout -b feature/my-feature-v2"
echo "git cherry-pick <commits-from-old-branch>"
The lesson: communicate before large changes, keep branches short-lived, and use feature flags to merge incrementally rather than in one massive PR.
Conclusion: Workflows Are Tools, Not Religion
Here's the final truth about Git workflows: there is no perfect workflow that works for every team at every stage. The three-person startup using Git Flow with release branches is cargo culting. The 50-person company doing trunk-based development without code review is asking for chaos. Your workflow should match your team's size, deployment frequency, risk tolerance, and engineering maturity. And it should evolve as these factors change.
Start simple. If you're a small team (under 10 people), begin with GitHub Flow: one main branch, feature branches, pull requests, merge to main, deploy from main. Add feature flags so you can merge incomplete work. Enforce conventional commits if you want automated changelogs. Set up pre-commit hooks for linting and formatting. This covers 90% of teams. When you hit problems with this workflow—maybe you need to support multiple production versions, or coordinate releases across services, or your test suite takes too long to run on every commit—then add complexity. Graduate to Git Flow's release branches if you need them. Implement more sophisticated CI/CD pipelines. But don't start with complexity hoping it prevents future problems. Start simple and add process when simple breaks.
The worst teams have elaborate workflows they don't understand and don't follow. Better to have a simple workflow that everyone executes consistently than a perfect workflow that exists only in a documentation file nobody reads. Document your workflow in your repository's README or CONTRIBUTING.md. Include diagrams, examples, and the reasoning behind decisions. When new developers join, pair with them on their first few PRs to teach the workflow hands-on.
Remember that Git is a tool, not a religion. Rebase versus merge isn't a moral choice—it's a tradeoff with different costs and benefits. The sign of a mature team isn't following best practices dogmatically—it's understanding why practices exist, evaluating whether they fit your context, and adapting them when circumstances change. Focus on outcomes: Can you deploy confidently? Do code reviews happen quickly? Are merge conflicts rare? Is it easy to find and revert problematic changes? If yes, your workflow is working regardless of whether it matches some blog post's "best practices."
Now stop reading about Git workflows and go improve yours. Start with one change—maybe shortening your branch lifespans, or adding feature flags, or setting up conventional commits. Measure the impact over a sprint. Iterate. The best workflow for your team is the one you'll actually use, not the one that sounds impressive on paper. Ship code, learn from problems, evolve your process. That's how great teams work.
Appendix: Essential Git Commands and Aliases for Team Workflows
Let's be practical about the Git commands you'll actually use daily in a team environment. The Git documentation is comprehensive but overwhelming. This section gives you the 20% of commands that solve 80% of real-world collaboration problems, plus aliases that make your life easier.
Branch management commands you need:
# Create and switch to new branch from current HEAD
git checkout -b feature/new-feature
# Modern Git syntax (Git 2.23+, more intuitive)
git switch -c feature/new-feature
# Create branch from specific commit or tag
git checkout -b hotfix/bug-fix v1.2.3
# List all branches with last commit info
git branch -vv
# List remote branches
git branch -r
# Delete local branch (safe - prevents deletion if unmerged)
git branch -d feature/completed-feature
# Force delete local branch (dangerous - use when branch is merged remotely but Git doesn't know)
git branch -D feature/abandoned-feature
# Delete remote branch
git push origin --delete feature/old-feature
# Rename current branch
git branch -m new-branch-name
# Set upstream tracking (do this after first push of new branch)
git push -u origin feature/new-feature
# After this, just `git push` and `git pull` work without specifying remote/branch
# Prune deleted remote branches from your local tracking
git fetch --prune
# Or configure auto-prune
git config --global fetch.prune true
Keeping your branch updated:
# Basic update from main (creates merge commit)
git checkout feature/my-feature
git merge main
# Rebase onto main (linear history)
git checkout feature/my-feature
git rebase main
# If conflicts, resolve them, then:
git add resolved-file.ts
git rebase --continue
# Or abort if things go wrong:
git rebase --abort
# Interactive rebase to clean up commits before merging
git rebase -i main
# Opens editor showing commits:
# pick abc123 Add login form
# pick def456 Fix typo
# pick ghi789 Add tests
# Change 'pick' to 'squash' or 'fixup' to combine commits
# Change order to reorder commits
# Delete lines to remove commits
# Save and close to execute
# Pull with rebase (keeps linear history)
git pull --rebase origin main
# Make this default behavior:
git config --global pull.rebase true
# Fetch all remotes without merging
git fetch --all
Handling common mistakes:
# Undo last commit (keep changes in working directory)
git reset --soft HEAD~1
# Undo last commit (discard changes - dangerous!)
git reset --hard HEAD~1
# Amend last commit (add forgotten files or fix message)
git add forgotten-file.ts
git commit --amend --no-edit
# Or change message:
git commit --amend -m "Better commit message"
# Revert a commit that's already pushed (creates new commit)
git revert abc123
# Stash changes temporarily
git stash
git stash save "WIP: refactoring auth module"
# List stashes
git stash list
# Apply most recent stash
git stash apply
# Apply and remove most recent stash
git stash pop
# Apply specific stash
git stash apply stash@{2}
# Discard changes to specific file
git checkout -- path/to/file.ts
# Modern syntax:
git restore path/to/file.ts
# Discard all uncommitted changes (dangerous!)
git reset --hard HEAD
# Cherry-pick specific commit from another branch
git cherry-pick abc123
# Cherry-pick range of commits
git cherry-pick abc123..def456
Viewing history and changes:
# View commit history (various formats)
git log --oneline
git log --graph --oneline --all
git log --oneline --author="John Doe"
git log --oneline --since="2 weeks ago"
git log --oneline -- path/to/file.ts # History of specific file
# Show changes in working directory
git diff
# Show staged changes
git diff --cached
# Show changes between branches
git diff main..feature/my-feature
# Show changes in specific commit
git show abc123
# Find when a bug was introduced using binary search
git bisect start
git bisect bad # Current commit has bug
git bisect good abc123 # This old commit was good
# Git checks out middle commit, you test it
git bisect bad # or git bisect good
# Repeat until Git finds the commit that introduced bug
# Find who changed each line of a file
git blame path/to/file.ts
# More useful with commit messages:
git blame -L 10,20 path/to/file.ts # Blame lines 10-20
Useful aliases to add to your .gitconfig:
# Add these to ~/.gitconfig under [alias] section
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.st status
# More powerful aliases:
# Pretty log with graph
git config --global alias.lg "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"
# Show branches sorted by last commit date
git config --global alias.recent "branch --sort=-committerdate --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) - %(contents:subject) %(color:green)(%(committerdate:relative)) %(color:blue)<%(authorname)>%(color:reset)'"
# Undo last commit but keep changes
git config --global alias.undo "reset --soft HEAD~1"
# Show what you're about to push
git config --global alias.outgoing "log @{u}.."
# Show what you're about to pull
git config --global alias.incoming "log ..@{u}"
# Delete all merged branches
git config --global alias.cleanup "!git branch --merged | grep -v '\\*\\|main\\|master\\|develop' | xargs -n 1 git branch -d"
# Quick commit with message
git config --global alias.cm "commit -m"
# Amend without editing message
git config --global alias.amend "commit --amend --no-edit"
# Force push safely (won't overwrite if remote has changes you don't have)
git config --global alias.pushf "push --force-with-lease"
# Show files in conflict
git config --global alias.conflicts "diff --name-only --diff-filter=U"
Team-specific workflows as scripts:
# Create executable script: ~/bin/git-new-feature
#!/bin/bash
# Usage: git new-feature "feature-name" "Optional description"
if [ -z "$1" ]; then
echo "Usage: git new-feature <feature-name> [description]"
exit 1
fi
BRANCH_NAME="feature/$1"
DESCRIPTION="${2:-$1}"
git checkout main
git pull origin main
git checkout -b "$BRANCH_NAME"
echo "✓ Created branch: $BRANCH_NAME"
echo "Description: $DESCRIPTION"
# Optional: create initial commit
git commit --allow-empty -m "feat: initialize $DESCRIPTION"
git push -u origin "$BRANCH_NAME"
echo "✓ Pushed to remote and set upstream"
# Create executable script: ~/bin/git-cleanup-merged
#!/bin/bash
# Clean up local branches that have been merged to main
echo "Fetching latest from origin..."
git fetch origin
echo "Switching to main..."
git checkout main
git pull origin main
echo "Deleting merged local branches..."
git branch --merged | grep -v '\*\|main\|master\|develop' | while read branch; do
echo " Deleting: $branch"
git branch -d "$branch"
done
echo "Pruning remote tracking branches..."
git fetch --prune
echo "✓ Cleanup complete"
# Create executable script: ~/bin/git-sync
#!/bin/bash
# Sync current feature branch with main
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" = "main" ]; then
echo "Already on main, just pulling..."
git pull origin main
exit 0
fi
echo "Current branch: $CURRENT_BRANCH"
echo "Fetching latest main..."
git fetch origin main
echo "Rebasing onto main..."
git rebase origin/main
if [ $? -eq 0 ]; then
echo "✓ Rebase successful"
echo "Force push? (y/n)"
read -r response
if [ "$response" = "y" ]; then
git push --force-with-lease origin "$CURRENT_BRANCH"
echo "✓ Force pushed to remote"
fi
else
echo "✗ Rebase had conflicts - resolve them and run 'git rebase --continue'"
exit 1
fi
Make these scripts executable and add them to your PATH:
chmod +x ~/bin/git-*
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
source ~/.bashrc
VS Code Git settings for better workflows:
// .vscode/settings.json (project-level settings)
{
"git.autofetch": true,
"git.confirmSync": false,
"git.enableSmartCommit": true,
"git.pruneOnFetch": true,
"git.rebaseWhenSync": true,
"git.fetchOnPull": true,
"git.branchPrefix": "feature/",
"git.defaultCloneDirectory": "~/projects",
"git.timeline.date": "committed",
"git.timeline.showUncommitted": true
}
The real power of these commands and aliases comes from muscle memory. You don't think about git checkout main && git pull && git checkout -b feature/new-thing—you just type git new-feature new-thing and it happens. This reduces cognitive load for the routine stuff so you can focus on the interesting problems. Customize these aliases for your team's specific workflow, document them in your repo, and get everyone using them. Consistency matters more than perfection.