Feature Branch Workflow: A Practical Guide
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.
The shape of it
Section titled “The shape of it”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.
main stays deployable. Work in progress lives on branches. Integration is a deliberate, reviewable
event rather than something that happens continuously by accident.
Why teams use it
Section titled “Why teams use it”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.
What counts as one feature
Section titled “What counts as one feature”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.
The workflow, end to end
Section titled “The workflow, end to end”-
Start from an up-to-date main.
Terminal window git switch maingit pullBranching from a stale
mainmeans starting with divergence you did not need. -
Create the branch.
Terminal window git switch -c fix/parser-empty-input -
Work in focused commits.
Terminal window git add parser.pygit 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.
-
Push and set the upstream the first time.
Terminal window git push -u origin fix/parser-empty-inputAfter
-u, later pushes are justgit push. -
Keep the branch current if
mainmoves and it matters. See staying in sync below. -
Open it for review, by whatever mechanism your team uses.
-
Let CI run. Fix what it reports on the branch, not after integration.
-
Integrate. Merge, squash or rebase depending on team policy — the Merging cluster covers the trade-offs.
-
Delete the branch, locally and remotely.
Terminal window git switch maingit pullgit branch -d fix/parser-empty-inputgit push origin --delete fix/parser-empty-input
Staying in sync with main
Section titled “Staying in sync with main”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:
git switch fix/parser-empty-inputgit fetch origingit merge origin/mainThis 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:
git fetch origingit rebase origin/mainThis 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 in | Rebase onto main | |
|---|---|---|
| History | Extra merge commits on the branch | Linear |
| Commit IDs | Unchanged | All rewritten |
| Force push needed | No | Yes (--force-with-lease) |
| Safe on a shared branch | Yes | No |
| Conflicts | Resolved once | Possibly 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 complete worked example
Section titled “A complete worked example”A realistic sequence, with the reasoning at each step.
git switch main && git pullgit switch -c fix/parser-empty-inputWhat 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:
git diffStage and commit:
git add parser.py tests/test_parser.pygit commit -m "Handle empty input in the parser
Previously parse() dereferenced the first token without checking thatany tokens existed, raising IndexError on empty input. Return None andadd a regression test."Push and open for review:
git push -u origin fix/parser-empty-inputremote: Create a pull request for 'fix/parser-empty-input' on GitHub by visiting:remote: https://github.com/example/project/pull/new/fix/parser-empty-inputTo github.com:example/project.git * [new branch] fix/parser-empty-input -> fix/parser-empty-inputbranch '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:
git add parser.pygit commit -m "Extract the empty-input guard into a helper"git pushAfter integration, clean up:
git switch main && git pullgit branch -d fix/parser-empty-inputIf -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.
Branch lifetime
Section titled “Branch lifetime”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.
What the team has to agree
Section titled “What the team has to agree”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:
| Decision | Why it needs an answer |
|---|---|
| Who may merge | Author, reviewer, or anyone? Ambiguity produces both stalled branches and unreviewed merges. |
| What must pass first | Which CI checks are blocking versus advisory. |
| How many approvals | One is common; more slows delivery, none makes review optional in practice. |
| Integration method | Merge commit, squash, or rebase — this shapes main’s history permanently. See Merging. |
| Branch naming | A shared prefix scheme makes git branch -r readable. |
| When branches are deleted | On integration, ideally automatically. |
| Maximum comfortable branch age | Not 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.
Reviewing a branch effectively
Section titled “Reviewing a branch effectively”Two commands make reviewing your own branch before you ask anyone else far more productive:
git diff main...feature/parserThree dots: compare against the merge base, so you see only what your branch changed, not changes
that landed on main afterwards.
git log main..feature/parser --onelineTwo 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.
Common failure modes
Section titled “Common failure modes”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.
When this workflow is not the right fit
Section titled “When this workflow is not the right fit”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.
Mental Model
Section titled “Mental Model”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 inmain’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.
What You Learned
Section titled “What You Learned”- The feature branch workflow keeps all work off
mainuntil 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
mainwhen 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.
Try It Yourself
Section titled “Try It Yourself”Simulate the divergence problem in a disposable repository, without a remote.
- Create a repository with a file and one commit.
- Create
feature/aand commit a change to line 1 of that file. - Switch back to
mainand commit a different change to line 1. - Run
git log --oneline --graph --alland identify the shape. - Run
git merge-base main feature/aand confirm it is the first commit. - Merge
feature/aintomain. Predict first: will it conflict? - Resolve if needed, then run
git branch --merged main. Isfeature/alisted now? - 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.
Next Lesson
Section titled “Next Lesson”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.