Git Workflows

A field guide to branching models, release flows, and the conventions that keep a shared git history sane — trunk-based development, GitHub Flow, and GitFlow side by side, plus the everyday mechanics: monorepos, branch protection, commit conventions, and merge strategies.

Most sections below end with a quick knowledge check — try answering before revealing:

0/0 checks

1. Workflow Comparison

Trunk-Based GitHub Flow GitFlow
Main branches main only main main + develop
Feature branches short-lived (< 1 day) any length feature/*
Release process continuous deploy deploy on merge release/* branch
Hotfix fix on trunk fix on main hotfix/* branch
Complexity low low high
Best for CI/CD, small teams SaaS / web apps versioned releases
Merge to main direct / tiny PR PR only via develop only
History linear, clean linear complex merge graph

A quick way to feel the difference: how does code actually get from a laptop into main?

Everyone commits straight to main, daily. Feature branches exist but live less than a day, and incomplete work ships behind a feature flag instead of waiting on a long-lived branch. Merges to main are direct or via a tiny PR. Best for CI/CD shops and small teams that deploy continuously.
One branch per change, any length, always through a PR into main. Merging to main is what triggers the deploy. Simple and linear — the default choice for SaaS and web apps that deploy on every merge.
Two long-lived branches, main and develop, plus feature/*, release/*, and hotfix/* around them. Nothing reaches main except via develop (or a hotfix). More moving parts and a more complex merge graph, but built for projects that ship versioned releases rather than deploying continuously.

Under Trunk-Based Development, is it normal for a feature branch to stay open for several days while it's reviewed?


2. GitFlow

Five branch types:

Branch Purpose Branches from Merges into
main production code, tags
develop integration branch main
feature/* new features develop develop
release/* release prep, bugfixes develop main + develop
hotfix/* urgent prod fixes main main + develop
# Feature
git checkout -b feature/login develop
# ... work ...
git checkout develop && git merge --no-ff feature/login

# Release
git checkout -b release/1.2 develop
# ... bump version, fix bugs ...
git checkout main && git merge --no-ff release/1.2
git tag -a v1.2
git checkout develop && git merge --no-ff release/1.2

# Hotfix
git checkout -b hotfix/fix-crash main
git checkout main && git merge --no-ff hotfix/fix-crash
git tag -a v1.1.1
git checkout develop && git merge --no-ff hotfix/fix-crash
gitGraph
   commit id: "init" tag: "v1.0"
   branch develop
   commit id: "dev-start"
   branch feature/login
   commit id: "login-wip"
   commit id: "login-done"
   checkout develop
   merge feature/login id: "merge-login"
   branch release/1.1
   commit id: "bump-version"
   checkout main
   merge release/1.1 id: "release" tag: "v1.1"
   checkout develop
   merge release/1.1 id: "sync-dev"
   branch hotfix/crash
   checkout main
   commit id: "hotfix"
   merge hotfix/crash tag: "v1.1.1"

The release branch's lifecycle, step by step:

1. Branch. Cut release/1.2 from develop once everything planned for the release has landed there.
2. Stabilize. Only version bumps and bugfixes land on the release branch now — no new features.
3. Ship. Merge the release branch into main and tag it (v1.2). That tag is what actually goes to production.
4. Sync back. Merge the same release branch into develop too, so the version bump and any release-branch bugfixes aren't lost from ongoing development.

When a release/* branch finishes in GitFlow, which branch (or branches) does it merge into?


3. Trunk-Based Development

All developers commit to main (trunk) daily. Feature branches live < 1–2 days.

Key practices:

  • Feature flags: merge incomplete code behind a flag, enable in production when ready
  • Branch by abstraction: refactor in place without long-lived branches
  • CI gates: tests must pass before merge; no broken trunk ever
# Short-lived feature branch
git checkout -b feat/add-cache
# ... small focused change ...
git push origin feat/add-cache
# PR → review → merge same day

Feature flags (Go example):

var featureFlags = map[string]bool{
    "new-checkout": os.Getenv("FF_NEW_CHECKOUT") == "true",
}

if featureFlags["new-checkout"] {
    return newCheckoutFlow(ctx, cart)
}
return legacyCheckoutFlow(ctx, cart)

Is it safe to merge unfinished code for new-checkout straight to main before the feature is done?


4. GitHub Flow

Simplest workflow for teams with continuous deployment:

  1. main is always deployable
  2. Create a descriptive branch from main
  3. Push commits, open a PR early (Draft PR for WIP)
  4. Review, CI checks, iterate
  5. Merge to main → auto-deploy
1. Always deployable. main is expected to be production-ready at every commit.
2. Branch. Create a descriptive branch off main for the change.
3. Open a PR early. Push commits and open the pull request right away — a Draft PR if the work's still in progress — instead of waiting until it's finished.
4. Review & iterate. CI checks run, reviewers comment, you push more commits until it's approved.
5. Merge & deploy. Merging to main is what triggers the deploy.
git checkout -b feature/user-auth
# ... commits ...
git push -u origin feature/user-auth
gh pr create --title "Add user auth" --body "Closes #42"
# After approval:
gh pr merge --squash

In GitHub Flow, should you wait until a feature is fully finished before opening its pull request?


5. Monorepo Patterns

Sparse Checkout

Check out only the subdirectory you need:

git clone --filter=blob:none --sparse https://github.com/org/monorepo
git sparse-checkout set services/api services/shared

git worktree

Multiple working trees from one repo (no re-clone):

git worktree add ../repo-feature feature/my-feature
git worktree list
git worktree remove ../repo-feature

Path-Based CI Triggers

Only run CI for changed paths (GitHub Actions):

on:
  push:
    paths:
      - 'services/api/**'
      - 'services/shared/**'

6. Branch Protection & CODEOWNERS

Branch protection rules (GitHub):

Settings → Branches → Add rule for "main":
☑ Require pull request before merging
☑ Require approvals: 1
☑ Require status checks to pass (CI)
☑ Require branches to be up to date
☑ Do not allow bypassing the above settings

CODEOWNERS (.github/CODEOWNERS):

# Global owner
*                     @org/platform-team

# Service-specific
services/payments/    @org/payments-team
services/auth/        @org/security-team @org/auth-team

# Infrastructure
*.tf                  @org/infra-team

7. Conventional Commits & Semantic Versioning

Conventional Commits format:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]
Type Meaning SemVer bump
feat new feature MINOR
fix bug fix PATCH
feat! or BREAKING CHANGE breaking API change MAJOR
chore maintenance none
docs documentation none
refactor code change, no feature/fix none
perf performance improvement PATCH
ci CI config changes none
git commit -m "feat(auth): add OAuth2 login"
git commit -m "fix(db): handle nil connection gracefully"
git commit -m "feat!: remove deprecated /v1 API endpoints"

Semantic Versioning: MAJOR.MINOR.PATCH

  • 1.0.01.0.1 (fix)
  • 1.0.11.1.0 (feat)
  • 1.1.02.0.0 (breaking)

Tools: semantic-release, release-please, conventional-changelog.

Does a plain feat: commit always bump only the MINOR version?


8. Merge Strategies

Strategy Command Pros Cons
Merge commit git merge --no-ff preserves full history, shows branch noisy graph, merge commits
Squash merge git merge --squash clean linear history, one commit per PR loses individual commits
Rebase merge git rebase main + fast-forward linear history, preserves commits rewrites SHAs, can confuse
# Merge commit
git checkout main && git merge --no-ff feature/x

# Squash
git checkout main && git merge --squash feature/x
git commit -m "feat: add x (#42)"

# Rebase merge
git checkout feature/x && git rebase main
git checkout main && git merge --ff-only feature/x

Rule of thumb:

  • OSS/library: rebase (linear history, blame works well)
  • Team SaaS: squash (clean log, each PR = one commit)
  • GitFlow: merge commit (preserve branch structure)
git merge --no-ff. Preserves full history and shows the branch shape, but leaves a noisier graph full of merge commits. What GitFlow relies on to keep branch structure visible.
git merge --squash. Collapses a whole PR into one commit on main — clean, linear history, but the individual commits from the branch are lost.
git rebase main + fast-forward. Linear history that still preserves the individual commits, but it rewrites their SHAs in the process — which can confuse anyone with a copy of the old ones.

Rebase merge keeps your individual commits instead of squashing them into one. Does that mean it also keeps their original SHAs?


Workflow Comparison

flowchart LR
    subgraph Trunk-Based
        TM[main] --> TF1["feat/a<br/>1 day max"]
        TM --> TF2["feat/b<br/>1 day max"]
        TF1 -->|PR merge| TM
        TF2 -->|PR merge| TM
    end

    subgraph GitFlow
        GM[main] --- GD[develop]
        GD --> GF["feature/*"]
        GD --> GR["release/*"]
        GM --> GH["hotfix/*"]
        GF -->|merge| GD
        GR -->|merge| GM
        GR -->|merge| GD
        GH -->|merge| GM
        GH -->|merge| GD
    end