Skip to content

Feature Branch Workflow: A Practical Guide

Lesson 2 of 8Beginner → Intermediate10 min readModern Git Workflows · BranchingVerified: Git 2.43.0 on Ubuntu 24.04

The feature branch workflow is one rule: no work happens directly on the main branch. Every change — a feature, a bug fix, a dependency bump — gets its own branch, is reviewed there, and is integrated back when it is ready.

That is the base pattern almost every named branching model is a variation on. GitHub Flow is this plus pull requests and deploy-on-merge. Git Flow is this plus a develop branch and formal releases. Trunk-based development is this with an emphasis on keeping branches very short. Learn the base and the variations become adjustments rather than separate systems.

One branch per unit of work, integrated and deleted

A trunk lane labelled main with commits A, B and a merge commit M. A branch lane labelled feature slash parser leaves main after commit A with commits X and Y, then merges into main at M. After integration the feature branch label is removed.

ABMXYmainfeature/parserThe branch exists only between creation and integration. Afterwards it is deleted.

main stays deployable. Work in progress lives on branches. Integration is a deliberate, reviewable event rather than something that happens continuously by accident.

main stays releasable. If nobody commits half-finished work to main, main is always a candidate for deployment. That single property is what makes continuous delivery possible.

Changes become reviewable units. A branch collects the commits belonging to one piece of work, so a reviewer sees a coherent change rather than a stream of unrelated edits.

CI has something to validate. Automated checks run against the branch before integration, so failures are caught while they are still one person’s problem.

Work is isolated. Two people can develop simultaneously without one’s half-finished refactor breaking the other’s tests.

Abandoning is cheap. An approach that does not work out is a branch you delete. Nothing on main needs undoing.

The most common mistake in this workflow is scope, not mechanics. A branch should contain one thing a reviewer can evaluate as a unit.

Good branch scopes:

  • Add pagination to the search endpoint
  • Fix the null dereference when the parser receives empty input
  • Upgrade the HTTP client and adjust the three call sites it breaks

Poor branch scopes:

  • “Sprint 14 work”
  • Add pagination, fix an unrelated logging bug, and rename twenty variables
  • Rewrite the authentication system

The last one is a real feature but a poor branch, because it cannot be reviewed meaningfully in one sitting and cannot be integrated for weeks. Large features are better split into several branches that each land independently — often behind a feature flag so incomplete work can ship disabled.

  1. Start from an up-to-date main.

    Terminal window
    git switch main
    git pull

    Branching from a stale main means starting with divergence you did not need.

  2. Create the branch.

    Terminal window
    git switch -c fix/parser-empty-input
  3. Work in focused commits.

    Terminal window
    git add parser.py
    git commit -m "Handle empty input in the parser"

    Commits within a branch are cheap and need not be perfect — you can tidy them before review. What matters is that each one is a coherent step.

  4. Push and set the upstream the first time.

    Terminal window
    git push -u origin fix/parser-empty-input

    After -u, later pushes are just git push.

  5. Keep the branch current if main moves and it matters. See staying in sync below.

  6. Open it for review, by whatever mechanism your team uses.

  7. Let CI run. Fix what it reports on the branch, not after integration.

  8. Integrate. Merge, squash or rebase depending on team policy — the Merging cluster covers the trade-offs.

  9. Delete the branch, locally and remotely.

    Terminal window
    git switch main
    git pull
    git branch -d fix/parser-empty-input
    git push origin --delete fix/parser-empty-input

While your branch exists, main keeps moving. At some point the divergence matters — because a conflict is coming, or because you want CI to test your change against current main rather than last week’s.

Two ways to bring main’s changes into your branch:

Merge main into your branch:

Terminal window
git switch fix/parser-empty-input
git fetch origin
git merge origin/main

This adds a merge commit to your branch. Nothing is rewritten, so it is always safe — including on a branch others have pulled.

Rebase your branch onto main:

Terminal window
git fetch origin
git rebase origin/main

This replays your commits on top of current main, producing new commits with new IDs and a linear history. It requires a force push afterwards, so it is safe only while the branch is yours alone.

Merge main inRebase onto main
HistoryExtra merge commits on the branchLinear
Commit IDsUnchangedAll rewritten
Force push neededNoYes (--force-with-lease)
Safe on a shared branchYesNo
ConflictsResolved oncePossibly once per replayed commit

Rebase vs Merge treats this properly. The short version: rebase while the branch is private; merge once anyone else is working on it.

A realistic sequence, with the reasoning at each step.

Terminal window
git switch main && git pull
git switch -c fix/parser-empty-input

What it doesUpdates your local main from the remote, then creates a new branch pointing at that commit and switches to it.

Why we run itBranching from current main minimises divergence from the outset.

Expected resultA pull summary, then Switched to a new branch 'fix/parser-empty-input'.

Make the change and review it before staging:

Terminal window
git diff

Stage and commit:

Terminal window
git add parser.py tests/test_parser.py
git commit -m "Handle empty input in the parser
Previously parse() dereferenced the first token without checking that
any tokens existed, raising IndexError on empty input. Return None and
add a regression test."

Push and open for review:

Terminal window
git push -u origin fix/parser-empty-input
remote: Create a pull request for 'fix/parser-empty-input' on GitHub by visiting:
remote: https://github.com/example/project/pull/new/fix/parser-empty-input
To github.com:example/project.git
* [new branch] fix/parser-empty-input -> fix/parser-empty-input
branch 'fix/parser-empty-input' set up to track 'origin/fix/parser-empty-input'.

Respond to review by adding commits — do not rewrite while reviewers are reading, or their comments lose their anchors:

Terminal window
git add parser.py
git commit -m "Extract the empty-input guard into a helper"
git push

After integration, clean up:

Terminal window
git switch main && git pull
git branch -d fix/parser-empty-input

If -d refuses, the work is not actually reachable from main — worth investigating before forcing. With squash or rebase integration, -d will refuse even when the change did land, because the commits on main are new objects. In that case verify the change is present and use -D.

The workflow does not specify how long a branch lives, and that omission is where teams get into trouble.

A branch open for a few hours integrates almost for free. A branch open for a month has absorbed every change to main in the meantime, and someone has to reconcile all of it at once — usually under time pressure, usually the person who understands the code least well.

There is no universal maximum, and any specific number is workflow guidance rather than a Git constraint. What is reliable is the direction: shorter is cheaper, and the cost is superlinear. Short-Lived Branches covers how teams keep them short in practice.

The Git mechanics are the easy part. A feature branch workflow only works when a handful of decisions are settled explicitly rather than left to individual habit:

DecisionWhy it needs an answer
Who may mergeAuthor, reviewer, or anyone? Ambiguity produces both stalled branches and unreviewed merges.
What must pass firstWhich CI checks are blocking versus advisory.
How many approvalsOne is common; more slows delivery, none makes review optional in practice.
Integration methodMerge commit, squash, or rebase — this shapes main’s history permanently. See Merging.
Branch namingA shared prefix scheme makes git branch -r readable.
When branches are deletedOn integration, ideally automatically.
Maximum comfortable branch ageNot a Git rule, but a shared expectation people can hold each other to.

Most of these can be enforced structurally rather than socially. Hosting platforms provide branch protection or repository rules that can require status checks, require a number of approving reviews, forbid direct pushes to main, and delete branches automatically after merge.

Two commands make reviewing your own branch before you ask anyone else far more productive:

Terminal window
git diff main...feature/parser

Three dots: compare against the merge base, so you see only what your branch changed, not changes that landed on main afterwards.

Terminal window
git log main..feature/parser --oneline

Two dots: the commits your branch adds. If that list contains “fix typo”, “wip”, and “actually fix it”, the branch is a candidate for tidying before review.

Reading your own diff before pushing catches a surprising proportion of review comments before a reviewer spends time on them.

The long-lived feature branch. Weeks of work, hundreds of files, a review nobody can do properly and an integration that takes days. The fix is decomposition — several small branches, often behind a flag.

The branch that becomes a second main. Several people commit to one long-running feature branch, which then diverges from main as a unit. You now have two mainlines and all the merge cost of both.

Reviews that arrive too late. A branch reviewed after a week of work invites either rubber-stamping or a rewrite. Smaller branches get real review.

Branches nobody deletes. git branch -r returns two hundred entries and nobody knows which are alive. Delete on integration; enable your host’s automatic branch deletion if it has one.

Committing to main “just this once”. The workflow’s value comes from main being reliably deployable. Branch protection makes the rule structural rather than cultural.

Mixing unrelated changes. A reviewer evaluating three unrelated things at once evaluates none of them well.

Rebasing a branch someone else has pulled. Every commit gets a new ID and their copy no longer matches. See When Not to Rebase.

It is a good default, not a universal one.

Solo work on a small project. The overhead may exceed the benefit; committing to main with discipline is defensible when there is no reviewer.

Trivial changes with strong automation. Teams with comprehensive tests and fast rollback sometimes commit small changes directly to main. That is trunk-based development, and it requires the automation to be genuinely good.

Work that cannot be decomposed. A migration that only makes sense as one atomic change may need a longer-lived branch. That is a real cost, taken deliberately rather than by drift.

A feature branch is a proposal.

It says: here is a change, complete and reviewable, that I think should become part of main. Until it is accepted it affects nobody else. Once accepted, the proposal has served its purpose and the branch is deleted — the work now lives in main’s history.

Branches you would not describe as a proposal — “my working area”, “sprint 14” — are the ones that cause problems, because nothing about them says when they should end.

  • The feature branch workflow keeps all work off main until it is reviewed and integrated.
  • One branch should hold one reviewable unit of work.
  • Branch from current main, commit in focused steps, push with -u, integrate, then delete.
  • Sync with main when there is a reason: merge if the branch is shared, rebase while it is private.
  • Branch lifetime drives integration cost, and the cost grows faster than linearly.
  • The workflow is the base pattern GitHub Flow, Git Flow and trunk-based development all build on.

Simulate the divergence problem in a disposable repository, without a remote.

  1. Create a repository with a file and one commit.
  2. Create feature/a and commit a change to line 1 of that file.
  3. Switch back to main and commit a different change to line 1.
  4. Run git log --oneline --graph --all and identify the shape.
  5. Run git merge-base main feature/a and confirm it is the first commit.
  6. Merge feature/a into main. Predict first: will it conflict?
  7. Resolve if needed, then run git branch --merged main. Is feature/a listed now?
  8. Delete it with git branch -d feature/a.

Step 6 conflicts because both sides changed the same line — the smallest possible version of what a long-lived branch produces at scale. Resolving Merge Conflicts covers the resolution properly.

The feature branch workflow says nothing about how a branch gets reviewed and integrated. GitHub Flow is the most widely used answer to that question.