Advanced Git Workflows

Understanding force push options is essential for maintaining clean repository histories while collaborating with your team.

Git provides powerful tools for version control and collaboration. Proper configuration ensures your workflow is efficient and secure.

Git2025-03-28

Git Workflow Strategies for Modern Teams

How a team uses Git determines how fast they ship, how often they break things, and how painful releases become. The wrong workflow creates merge conflicts, blocks deployments, and forces developers to spend time on process instead of code. This guide compares the major Git workflow strategies and when each one makes sense.

Trunk-Based Development

In trunk-based development, all developers commit directly to a single branch (usually main). Long-lived feature branches don't exist. Changes land on main within a day, often through short-lived branches that last hours rather than weeks.

main: A---B---C---D---E---F---G
              \   /       \   /
feature-1:    X-Y         Z-W   (hours, not days)

This workflow requires:

  • Small, incremental changes. Each commit should be deployable. Large features are broken into a series of small, safe changes.
  • Feature flags. Incomplete features are merged behind flags and enabled when ready.
  • Strong CI. The main branch must always be deployable. Automated tests, linting, and integration checks run on every commit.
  • Automated deployments. When main is always deployable, you can deploy on every merge.
# Typical trunk-based workflow
git checkout -b add-user-search
# Make small, focused changes
git add -A && git commit -m "Add search index to user model"
git push -u origin add-user-search
# Create PR, get quick review, merge within hours

Trunk-based development works best for teams with strong engineering discipline, comprehensive tests, and continuous deployment. It eliminates merge hell and integration pain at the cost of requiring more careful change management.

GitFlow

GitFlow uses long-lived branches for different purposes: main for production releases, develop for integration, feature/* for new work, release/* for stabilization, and hotfix/* for production fixes.

main:      A-----------M1-----------M2---H
           |           |            |    |
release:   |     R1----R2     R3---R4    |
           |     |                       |
develop:   B--C--D--E--F--G--H--I--J--K--L
              |     |     |        |
feature:      X--Y  Z     P--Q    S--T

GitFlow provides clear boundaries between development, testing, and production. Each release goes through a stabilization period where only bug fixes are accepted.

This workflow suits teams that:

  • Ship on a schedule (biweekly, monthly releases).
  • Need a formal QA phase before releases.
  • Support multiple production versions simultaneously.
  • Have compliance requirements that mandate release processes.

The downside is complexity. Developers must understand which branch to target, cherry-picking hotfixes across branches is error-prone, and long-lived feature branches accumulate merge conflicts.

GitHub Flow

GitHub Flow simplifies GitFlow to just two concepts: main is always deployable, and all work happens on feature branches that merge via pull requests.

main:     A---B---C---D---E---F
              \   /   \       /
feature-1:    X-Y     |      |
                      |      /
feature-2:            Z---W-V
# GitHub Flow workflow
git checkout -b feature/add-notifications
# Develop and commit
git push -u origin feature/add-notifications
# Open pull request for review
# After approval and CI passes, merge to main
# Deploy main to production

GitHub Flow is the default for most web application teams. It's simple enough that everyone understands it, structured enough to enforce code review, and flexible enough for both small and medium-sized teams.

Feature Flags

Feature flags decouple deployment from release. Code is deployed to production but only activated for specific users or environments:

// Simple feature flag check
if (featureFlags.isEnabled('new-search', { userId: user.id })) {
  return <NewSearchExperience />;
}
return <LegacySearch />;

Feature flags enable:

  • Trunk-based development. Merge incomplete features safely.
  • Gradual rollouts. Enable for 1% of users, then 10%, then 100%.
  • Instant rollback. Disable a flag instead of reverting a deployment.
  • A/B testing. Show different experiences to different user segments.

Clean up flags after features are fully rolled out. Stale flags accumulate into technical debt that makes code harder to reason about.

Rebasing vs Merging

This debate affects every team's Git history. Both strategies integrate changes from one branch into another, but they produce different histories.

Merge commits preserve the full branching history:

git checkout main
git merge feature/add-search
# Creates a merge commit with two parents

Rebasing replays your commits on top of the target branch:

git checkout feature/add-search
git rebase main
# Moves your commits to the tip of main
git checkout main
git merge feature/add-search  # Fast-forward merge

Practical guidance:

  • Rebase your feature branch onto main before creating a PR. This ensures your changes apply cleanly on the latest code.
  • Never rebase shared branches. If other people have based work on your branch, rebasing rewrites commit hashes and creates confusion.
  • Use merge commits for PRs. They preserve the context that a set of changes was reviewed and merged together.
# Safe rebase workflow for feature branches
git checkout feature/my-feature
git fetch origin
git rebase origin/main
# Resolve any conflicts
git push --force-with-lease  # Safe force push (only if no one else pushed)

The --force-with-lease flag refuses to push if the remote branch has commits you haven't seen, preventing you from accidentally overwriting a teammate's work.

Squash Commits

Squashing combines multiple commits into one before merging. GitHub's "Squash and merge" button does this automatically.

Squash when:

  • Feature branch commits are messy ("WIP", "fix typo", "actually fix it this time").
  • You want a clean, bisectable main branch where each commit represents one logical change.
  • Your team values a linear history in main.

Don't squash when:

  • The branch contains multiple logical changes that are better understood separately.
  • You need to preserve authorship across pair-programming commits.
  • Your team uses git bisect and needs granular commits.

A balanced approach: squash by default on merge, but allow merge commits for large features with meaningful commit history.

Branch Protection Rules

Branch protection rules enforce workflow policies at the repository level:

# GitHub branch protection for main
branches:
  main:
    protection:
      required_pull_request_reviews:
        required_approving_review_count: 1
        dismiss_stale_reviews: true
        require_code_owner_reviews: true
      required_status_checks:
        strict: true  # Branch must be up-to-date before merging
        contexts:
          - "ci/tests"
          - "ci/lint"
          - "ci/security-scan"
      enforce_admins: true  # Even admins follow the rules
      required_linear_history: true  # No merge commits
      allow_force_pushes: false
      allow_deletions: false

Essential protections for production branches:

  1. Require pull request reviews. At least one approval before merging.
  2. Require status checks. CI must pass before merging.
  3. Require branches to be up-to-date. Prevents merging stale code that might break with recent changes.
  4. Dismiss stale reviews. New commits invalidate previous approvals.
  5. Restrict force pushes. Prevent history rewriting on shared branches.

Release Strategies

Continuous deployment releases every merge to main. Works with trunk-based development and strong CI:

# On merge to main:
# 1. CI runs tests
# 2. Build artifacts
# 3. Deploy to staging
# 4. Run smoke tests
# 5. Deploy to production
# 6. Monitor error rates

Release trains bundle changes and deploy on a schedule. Tag a release from main at a regular cadence:

git tag -a v2.3.0 -m "Release 2.3.0: search improvements, bug fixes"
git push origin v2.3.0
# CI triggers deployment pipeline for tagged commits

Release branches create a branch from main for stabilization. Bug fixes go to the release branch and are merged back to main:

git checkout -b release/2.3 main
# Only bug fixes from here
git cherry-pick abc123  # Fix from main
git tag v2.3.0
git checkout main
git merge release/2.3

The right Git workflow depends on your team size, release cadence, and tolerance for process overhead. Start simple with GitHub Flow, add feature flags when you need to decouple deployment from release, and only adopt GitFlow if you genuinely need its formality. The best workflow is the simplest one that prevents your team from breaking production.

© 2025 DevPractical. Practical guides for modern software engineering.