We used Git Flow for 6 months. We had 14 orphaned branches, a merge conflict that took 2 days to resolve, and a release process that made everyone dread Fridays. Then we simplified everything.

The problem with most Git workflow advice is that it comes from large companies. Google's trunk-based development works because Google has 50,000 engineers, a custom build system, and an army of infrastructure engineers maintaining their CI/CD pipeline. Spotify's "squad model" works because Spotify has hundreds of developers across dozens of teams. You have 4-8 developers. You do not need their workflow. You need yours.

After trying every popular Git workflow across three years and 30+ client projects, we landed on something dead simple. It has worked for 2-person teams and 9-person teams. It works for agencies, startups, and internal tools teams. Here is the complete playbook.

Git Workflow Good Git workflows are invisible — you follow them without thinking about them

The Three Main Workflows: An Honest Comparison

Before I tell you what we use, you should understand the landscape. There are three dominant Git workflows, and each makes different tradeoffs.

1. Git Flow (Gitflow)

Git Flow was introduced by Vincent Driessen in 2010 and became the de facto standard for years. Here is how it works:

Branches:

  • main — production code, tagged releases only
  • develop — integration branch, where features merge
  • feature/* — individual feature branches (branch from develop)
  • release/* — release preparation branches (branch from develop)
  • hotfix/* — emergency production fixes (branch from main)

The flow:

main ─────────────────●────────────────●──── (releases)
                     ↑                ↑
release/1.0 ────────●     release/2.0──●
                    ↑                 ↑
develop ──●──●──●──●──●──●──●──●──●──●──── (integration)
          ↑  ↑     ↑        ↑  ↑
feat/A ───●  |     |   feat/D──●
             |     |
feat/B ──────●     |
                   |
feat/C ────────────●

When it works: Large teams with scheduled releases, enterprise software with long release cycles, products that maintain multiple versions simultaneously (like libraries that support v2.x and v3.x).

When it fails: Small teams, continuous deployment, web applications. The ceremony is enormous. You branch from develop, merge back to develop, create a release branch, fix release bugs on the release branch, merge the release branch to both main and develop, tag main, delete the release branch. For a 5-person team shipping a web app, this is bureaucracy that slows you down without adding value.

Our experience: We used Git Flow for a client project with 6 developers. Within three months, we had:

  • 14 feature branches that were never merged (developers left the project or features were canceled)
  • A develop branch that was 47 commits ahead of main because nobody wanted to do the release ceremony
  • A merge conflict between release/2.1 and develop that took 2 full days to untangle

We abandoned Git Flow for small teams after that project.

2. GitHub Flow

GitHub Flow is radically simpler. It was GitHub's response to Git Flow's complexity.

Branches:

  • main — always deployable, production code
  • Feature branches — short-lived, branch from main, merge back via PR

The flow:

main ──●──●──●──●──●──●──●──●──●── (always deployable)
       ↑     ↑        ↑     ↑
feat/A─●     |   feat/C─────●
             |
feat/B───────●

When it works: Web applications with continuous deployment, small to medium teams, any project where "the latest commit on main is production."

When it fails: Projects that need staging environments (you can add staging with deploy previews, but it is not built into the workflow). Projects with complex release schedules.

3. Trunk-Based Development

The simplest workflow: everyone commits to main. Short-lived feature branches (1-2 days max) are optional.

The flow:

main ──●──●──●──●──●──●──●──●──●── (everyone commits here)
       ↑     ↑
(optional short-lived branches, merged same day)

When it works: Small teams with strong CI/CD, experienced developers who write small incremental changes, projects using feature flags.

When it fails: Teams without good CI/CD (broken commits go straight to production), junior-heavy teams (no code review gate), projects without feature flag infrastructure.

Our experience: We tried trunk-based on a 3-person internal project. It works beautifully if you have:

  1. Comprehensive automated tests that run in under 2 minutes
  2. Feature flags for in-progress work
  3. Developers who commit small, atomic changes

Most teams do not have all three. Without them, trunk-based development means broken production deploys.

Our Recommendation: Simplified GitHub Flow

For teams under 10 developers, we use what I call Simplified GitHub Flow. It takes GitHub Flow and adds a few conventions that prevent the common problems.

The Rules

Rule 1: main is always deployable. Every commit on main should be ready for production. If it is not, you broke the process. CI/CD auto-deploys main to production (or staging, depending on your setup).

Rule 2: All work happens on feature branches. No direct commits to main. Ever. Not even "just fixing a typo." The feature branch is where code gets reviewed, tested, and validated.

Rule 3: Feature branches are short-lived. A feature branch should live for 1-3 days. If it lives longer than 5 days, something is wrong — the feature is too big and needs to be broken down.

Rule 4: Pull requests are required for everything. Even solo developers should create PRs. Why? Because a PR is documentation. It records what changed, why it changed, and provides a place for discussion. Six months from now, when someone asks "why does this code work this way?", the PR has the answer.

Rule 5: Squash merge to keep history clean. When a feature branch merges to main, squash all commits into a single commit with a clear message. Nobody needs to see your "WIP", "fix typo", "actually fix the typo" commit history.

Rule 6: Delete branches after merge. A merged branch is a dead branch. Delete it immediately. GitHub and GitLab have options to auto-delete branches after PR merge — enable this.

Rule 7: No develop branch. The develop branch in Git Flow is a solution to a problem you do not have. If main is always deployable and feature branches are short-lived, there is nothing for a develop branch to do.

Why This Works for Small Teams

Minimal ceremony. Create branch, write code, open PR, get review, squash merge. That is the entire process. No release branches, no hotfix branches, no tagging ceremonies.

Clean history. Every commit on main represents a complete, reviewed feature or fix. You can git log --oneline and understand the project's evolution at a glance.

Fast feedback. Short-lived branches mean merge conflicts are small and easy to resolve. You merge daily, not weekly.

Built-in documentation. Every change has a PR with a description, discussion, and review. This is your project's living history.

Branch Naming Convention

Consistent branch names make it easy to understand what a branch does at a glance. Here is our convention:

feat/user-authentication
feat/dark-mode-toggle
feat/csv-export

fix/login-redirect-loop
fix/payment-rounding-error
fix/safari-layout-bug

chore/update-dependencies
chore/ci-pipeline-optimization
chore/remove-unused-code

docs/api-documentation
docs/deployment-guide

refactor/payment-module
refactor/database-queries

test/e2e-checkout-flow
test/unit-user-service

perf/image-lazy-loading
perf/database-query-optimization

The pattern: type/short-kebab-case-description

Why kebab-case? It is URL-safe (important for CI/CD URLs and deploy previews), readable, and the most common convention in the JavaScript ecosystem.

Naming rules:

  • Keep it under 50 characters
  • Use present tense ("add-dark-mode" not "added-dark-mode")
  • Be descriptive enough that someone can understand the branch without opening the PR
  • Never use your name in the branch name ("anurag/fix-bug" is useless information)

What We Do NOT Do

We do not use ticket numbers in branch names (feat/JIRA-1234-user-auth). Here is why:

  1. Ticket numbers are meaningless without looking up the ticket
  2. They make branch names longer and harder to type
  3. The PR description should link to the ticket — that is where the connection belongs
  4. If you switch project management tools, your branch naming convention breaks

Instead, we link tickets in the PR description and commit messages.

Commit Message Format

We follow Conventional Commits with a few additional rules:

type: short description in imperative mood

Optional longer explanation. Wrap at 72 characters per line.
Explain the "why", not the "what"the diff shows the "what".

Closes #123

Types we use:

  • feat: — A new feature visible to users
  • fix: — A bug fix
  • chore: — Maintenance work (dependency updates, config changes)
  • docs: — Documentation changes only
  • refactor: — Code restructuring without behavior change
  • test: — Adding or modifying tests only
  • perf: — Performance improvement
  • ci: — CI/CD pipeline changes

Examples of good commit messages:

feat: add email notification for failed payments

When a payment fails, the user now receives an email with details
about the failure and a link to retry. This reduces support tickets
by giving users self-service recovery.

Closes #234
fix: prevent duplicate form submissions on slow connections

Added client-side debouncing (300ms) and server-side idempotency
check using request ID. Previously, users on slow connections could
submit the contact form 3-4 times by clicking rapidly.

Closes #189
refactor: extract payment logic into dedicated service

Moved payment processing, validation, and webhook handling from
the monolithic OrderController into PaymentService. No behavior
change — all existing tests pass.

Examples of bad commit messages:

fix: fixed the bug          (what bug?)
feat: updates               (what updates?)
chore: stuff                (what stuff?)
refactor: refactoring       (thank you, Captain Obvious)
fix: WIP                    (do not commit WIP to main)

Why Conventional Commits

Beyond readability, Conventional Commits enable automation:

  1. Automated changelogs. Tools like semantic-release or changesets parse commit types to generate changelogs automatically. feat: becomes a "Features" section, fix: becomes "Bug Fixes."

  2. Semantic versioning. feat: triggers a minor version bump. fix: triggers a patch bump. feat!: (with breaking change) triggers a major bump. Your version numbers reflect actual changes, not arbitrary decisions.

  3. Filtering history. Want to see only bug fixes? git log --oneline --grep="^fix:". Only features? git log --oneline --grep="^feat:". Conventional Commits make your git history queryable.

PR Review Process: How We Actually Do It

Code review is where most teams either waste time or skip entirely. Here is our process, refined over 30+ projects.

Review Requirements

Change Type Required Reviewers Max Turnaround
Feature (non-critical) 1 developer 4 business hours
Feature (critical path: auth, payments) 2 developers 4 business hours
Bug fix (production) 1 developer 1 hour
Infrastructure / CI / deployment 2 developers 4 business hours
Documentation only 1 developer (any) 8 business hours
Dependency updates 1 developer + automated checks 4 business hours

Why 4 hours? Long-standing PRs are a productivity killer. If a developer opens a PR at 10 AM and does not get a review until the next day, they context-switch to something else. When the review comes back with changes, they need to context-switch back. Two context switches cost roughly 45 minutes of productivity each. Fast reviews keep developers in flow.

We have a simple team rule: when you see a PR notification, review it before starting new work. Reviews take 15-30 minutes. The cost of a slow review is much higher.

What to Actually Look For in a Code Review

Most code review checklists are generic. Here is what we specifically look for:

1. Does the code do what the PR description says? Read the PR description first, then read the code. If the code does something the description does not mention, that is either missing documentation or unexpected behavior.

2. Error handling. What happens when the API call fails? What happens when the user provides invalid input? What happens when the database is down? Missing error handling is the number one source of production bugs in our experience.

3. Edge cases. Empty arrays, null values, very long strings, concurrent requests, timezone differences. If the code handles a list, what happens when the list is empty? If it handles user input, what happens with Unicode characters or HTML injection?

4. Security. SQL injection, XSS, CSRF, auth bypass, exposed secrets. Every PR that touches user input or authentication gets extra scrutiny.

5. Performance. N+1 queries, unnecessary re-renders, missing indexes, large bundle imports. Not every PR needs a performance review, but data-heavy changes do.

6. Tests. Does the PR include tests for the new behavior? Do the tests actually test something meaningful, or are they just testing that the mocks return what they were told to return?

7. Naming. Are variables, functions, and files named clearly? Can someone understand the code without reading comments? Bad naming is a tax on every future developer who reads this code.

How to Give Good Feedback

Bad review feedback: "This is wrong." Good review feedback: "This query runs inside a loop, which will cause N+1 database calls. Consider using a JOIN or batch query instead. Here is how I would restructure it: [code snippet]."

Rules for review feedback:

  • Be specific. Point to the exact line. Explain what the problem is.
  • Suggest a solution. Do not just identify the problem — show how to fix it.
  • Distinguish between blocking and non-blocking comments. Use prefixes:
    • [blocking] — Must be fixed before merge. Security issues, bugs, broken functionality.
    • [suggestion] — Would be nice to change, but not required. Style preferences, minor improvements.
    • [question] — You do not understand something. Not a change request, just seeking clarification.
    • [nit] — Extremely minor. Typo, formatting, naming preference. Fix if easy, skip if not.

Example PR review comment:

[blocking] This SQL query interpolates user input directly:

const query = `SELECT * FROM users WHERE name = '${name}'`;

This is vulnerable to SQL injection. Use parameterized queries:

const query = `SELECT * FROM users WHERE name = $1`;
const result = await db.query(query, [name]);
[suggestion] Consider extracting this validation logic into a
separate function. It is used in three places and having a single
source of truth would prevent them from drifting apart.
[nit] Typo on line 42: "recieve" should be "receive"

CI/CD Integration: Automated Checks on Every PR

Our CI pipeline runs automatically on every pull request. Here is what it checks:

# .github/workflows/pr-checks.yml
name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - run: npm ci

      - name: Type Check
        run: npm run typecheck

      - name: Lint
        run: npm run lint

      - name: Unit Tests
        run: npm run test -- --run --coverage

      - name: Build
        run: npm run build

  e2e:
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - run: npm ci

      - name: Install Playwright
        run: npx playwright install --with-deps chromium

      - name: E2E Tests
        run: npm run test:e2e

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

All checks must pass before merge. We configure branch protection rules on main:

  • Require status checks to pass (typecheck, lint, test, build)
  • Require at least 1 approving review
  • Dismiss stale reviews when new commits are pushed
  • Do not allow bypassing (even for admins)

Deploy previews on every PR. Both Vercel and Netlify create a unique preview URL for every PR. The reviewer can click the link and see the changes running in a real environment. This catches visual bugs, responsive layout issues, and integration problems that code review alone misses.

Auto-deploy on merge to main. When a PR is merged, the CI pipeline runs again on main. If all checks pass, it auto-deploys to production. No manual deployment step. No "release manager" clicking a button. If it passes CI, it ships.

Common Mistakes and How to Fix Them

After mentoring dozens of developers and auditing team Git workflows, these are the mistakes I see repeatedly.

Mistake 1: Committing Directly to Main

"It is just a one-line change." "It is just a README update." "I will do it properly next time."

No. Every direct commit to main bypasses code review, skips CI checks, and creates a habit. One "just this once" becomes two, becomes ten, becomes "we do not really use PRs anymore."

Fix: Enable branch protection on main. Configure it so that even repository admins cannot push directly. If you need to make a quick fix, create a branch, push it, open a PR, and merge it. The extra 2 minutes is worth the consistency.

Mistake 2: Long-Lived Feature Branches

A feature branch that lives for 2 weeks is a merge conflict waiting to happen. The longer a branch lives, the further it drifts from main, and the harder it is to merge.

Fix: Break large features into smaller PRs. A feature like "user authentication" is not one PR. It is:

  1. PR 1: Database schema for users table
  2. PR 2: Registration endpoint and form
  3. PR 3: Login endpoint and session management
  4. PR 4: Protected route middleware
  5. PR 5: Password reset flow

Each PR is small (under 400 lines), reviewable in 20 minutes, and mergeable independently. Use feature flags to hide incomplete features from users.

The rule: If your branch is more than 3 days old, it is too old. Merge what you have, even if the feature is not complete. Ship incrementally.

Mistake 3: Giant Pull Requests

A PR with 1,200 lines of changes is not a PR — it is a hostage situation. Nobody can meaningfully review 1,200 lines. They will skim it, approve it, and move on. Which defeats the entire purpose of code review.

Fix: Aim for PRs under 400 lines changed. Under 200 is ideal. If a PR is over 400 lines, ask yourself: "Can I split this into two PRs that each make sense independently?"

Data to back this up: Studies from Google and Microsoft show that code review quality drops sharply after 400 lines. Defect detection rate is ~60% for PRs under 200 lines and drops to ~15% for PRs over 1,000 lines. Small PRs catch more bugs.

Mistake 4: Merge Commits Instead of Squash Merge

Merge commits preserve the entire branch history on main. This means every "WIP", "fix lint", "forgot to save" commit shows up in the main branch history. The result is a cluttered, unreadable git log.

Fix: Configure your repository to use squash merging by default. In GitHub: Settings, Pull Requests, "Allow squash merging" (enable), "Allow merge commits" (disable or leave enabled for special cases).

When you squash merge, all commits in the feature branch collapse into a single commit on main with the PR title as the commit message. Clean, readable, one commit per feature.

The exception: For very large PRs that genuinely contain multiple logical changes (a migration, for example), merge commits can be appropriate. But this is rare — and if the PR contains multiple logical changes, it should probably be multiple PRs.

Mistake 5: Not Pulling Before Pushing

You work on a feature for a day. You try to push. Git rejects it because main has moved ahead. You pull, get merge conflicts, and spend 30 minutes resolving them.

Fix: Rebase your branch on main daily. Before starting work each morning:

git checkout main
git pull
git checkout your-feature-branch
git rebase main

If you rebase daily, conflicts are tiny — one or two files, a few lines each. If you wait a week, conflicts are enormous.

Even better: Set up git to auto-rebase when pulling:

git config --global pull.rebase true

Mistake 6: Storing Secrets in Commits

Once a secret is in a git commit, it is there forever — even if you delete it in a subsequent commit. The git history preserves it. Anyone with repo access can find it. Automated scanners actively look for this.

Fix:

  1. Use .env files for secrets. Never commit .env files.
  2. Add .env to .gitignore before your first commit.
  3. Use environment variables in CI/CD (GitHub Secrets, Vercel environment variables).
  4. Install a pre-commit hook that scans for secrets (we use trufflehog or gitleaks).
  5. If you accidentally commit a secret: rotate it immediately (assume it is compromised), then use git filter-branch or BFG Repo-Cleaner to remove it from history.

Git Aliases That Save Us Time

We configure these aliases on every developer's machine. They save a few keystrokes per command, which adds up to meaningful time savings over a day of development.

# Short versions of common commands
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.st status
git config --global alias.cm "commit -m"

# View recent history (compact)
git config --global alias.last 'log -1 HEAD --pretty=format:"%h %s"'
git config --global alias.lg 'log --oneline --graph --decorate -20'
git config --global alias.recent 'log --oneline -10'

# Branch management
git config --global alias.new 'checkout -b'
git config --global alias.done '!git checkout main && git pull && git branch -d @{-1}'
git config --global alias.branches 'branch -a --sort=-committerdate'
git config --global alias.cleanup '!git branch --merged main | grep -v main | xargs -r git branch -d'

# Quick operations
git config --global alias.unstage 'reset HEAD --'
git config --global alias.amend 'commit --amend --no-edit'
git config --global alias.undo 'reset --soft HEAD~1'
git config --global alias.stash-all 'stash save --include-untracked'

# Show what changed
git config --global alias.today 'log --oneline --since="midnight" --author="$(git config user.name)"'
git config --global alias.week 'log --oneline --since="1 week ago" --author="$(git config user.name)"'

The most useful ones:

  • git new feat/my-feature — Creates and checks out a new branch in one command.
  • git done — After merging a PR, run this to switch back to main, pull latest, and delete the old feature branch.
  • git cleanup — Deletes all local branches that have been merged to main. Run this weekly to keep your branch list clean.
  • git today — Shows all your commits from today. Great for standup notes.
  • git undo — Undoes the last commit but keeps the changes staged. Use this when you realize your commit message was wrong or you forgot to add a file.

Our .gitignore Template

Every project starts with this .gitignore. We add framework-specific entries on top.

# Dependencies
node_modules/
.pnp.*
.yarn/cache
.yarn/unplugged

# Build output
dist/
build/
.output/
.next/
.astro/
.vercel/
.netlify/

# Environment variables (NEVER commit these)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# IDE and editor
.vscode/settings.json
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db

# Testing
coverage/
playwright-report/
test-results/
*.lcov

# Logs
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*

# Misc
*.tsbuildinfo
.turbo/
.cache/

Important note: We do NOT gitignore .vscode/extensions.json (recommended extensions) or .vscode/launch.json (debug configurations). Those are team-shared settings that help everyone stay consistent. We DO gitignore .vscode/settings.json because it often contains personal preferences (font size, theme) that should not be shared.

Putting It All Together: A Day in the Life

Here is what our Git workflow looks like in practice for a developer on the team:

Morning:

git checkout main
git pull
git new feat/add-search-filter

During development:

# Work on the feature, commit in small chunks
git add src/components/SearchFilter.tsx src/hooks/useSearch.ts
git cm "feat: add search filter component with debounced input"

git add src/components/SearchFilter.test.ts
git cm "test: add unit tests for search filter"

# Rebase on main if others have merged PRs
git fetch origin
git rebase origin/main

Ready for review:

git push -u origin feat/add-search-filter
# Open PR on GitHub (or use `gh pr create`)

After PR approval:

# Squash merge via GitHub UI
# Then locally:
git done  # switches to main, pulls, deletes old branch

That is it. No ceremony, no confusion, no orphaned branches. The process is lightweight enough that nobody skips it and structured enough that nothing falls through the cracks.

When to Break the Rules

Every workflow has exceptions. Here are the situations where we deviate:

Hotfixes: If production is down, we create a fix/ branch, get a verbal approval from one teammate (no waiting for formal review), and merge immediately. The formal PR review happens retroactively. Getting production back up takes priority over process.

Solo projects: When a developer is working alone on a project (internal tools, proofs of concept), we relax the PR review requirement. They still use branches and PRs (for documentation), but self-merging is allowed. The PR serves as a record, not a gate.

Hackathons and prototypes: For time-boxed experiments, trunk-based development is fine. Commit directly to main, ship fast, iterate. The code will be rewritten properly if the prototype proves the concept.

The principle: Rules exist to prevent predictable problems. When the situation changes (production outage, solo work, time pressure), adapt the rules. But always return to the standard workflow when the exception passes.


Need Help Setting Up Your Team's Git Workflow?

At CODERCOPS, we help development teams ship faster with clean, simple processes. From Git workflows to CI/CD pipelines to code review practices, we have set up engineering workflows for 30+ teams.

Talk to us about optimizing your development process, or browse our blog for more engineering best practices and practical guides.

Comments