Feature Branch Workflow
The feature branch workflow is the standard way teams use Git to collaborate without stepping on each other’s work. Instead of committing directly to main, every new piece of work — a feature, a bug fix, a refactor — gets its own branch. Changes are reviewed on GitHub via a pull request before they are merged back into main, which keeps the shared branch stable and always deployable. This lesson walks through the full lifecycle: creating a branch, committing, pushing, opening a pull request, keeping the branch in sync with main, resolving conflicts, and cleaning up afterward.
Overview / How it works
Under the hood, a Git branch is nothing more than a lightweight, movable pointer to a single commit. When you run git switch -c feature/login-page, Git does not copy any files or duplicate history — it simply creates a new pointer named feature/login-page that starts out referencing the exact same commit as main, and it moves HEAD to point at that new branch instead. From that moment on, every commit you make advances the feature/login-page pointer forward, while main stays exactly where it was. This is what makes branching in Git so cheap: creating a branch is a few bytes written to .git/refs/heads/, not a copy of your project.
Each commit you create is an object containing a pointer to a tree (a snapshot of the directory structure), a pointer to its parent commit, an author, a message, and a SHA-1 (or SHA-256, on newer repositories) hash of all of that content. The tree in turn points to blobs, which store the actual file contents, and to other trees for subdirectories. When you run git add, you are not modifying the working tree or the commit history yet — you are updating the index (also called the staging area), a snapshot-in-progress that records exactly which blob should represent each file in the next commit. git commit then takes whatever is in the index, writes a new tree object for it, wraps it in a commit object whose parent is the current commit, and moves the current branch pointer to the new commit.
The feature branch workflow exploits this cheapness: because branching costs almost nothing and merging is a well-understood operation on this object graph, you can freely isolate risky or in-progress work without touching main. When the work is ready, opening a pull request on GitHub lets teammates review the diff, run automated checks (via GitHub Actions), and discuss changes before the branch is merged — at which point Git creates a new commit whose tree combines both histories (a merge commit), or replays/squashes the commits depending on the merge strategy chosen.
Syntax
There is no single Git subcommand called “feature branch workflow” — it’s a pattern built from ordinary commands used in a specific sequence:
git switch -c <branch-name> # create and switch to a new branch
git add <file>... # stage changes
git commit -m "<message>" # record a snapshot on the branch
git push -u origin <branch-name> # publish the branch and set upstream tracking
git switch -c— creates a new branch and checks it out in one step; the older equivalent isgit checkout -b.git add— stages one or more files into the index so they will be included in the next commit.git commit -m— creates a commit object from the current index contents, with the given message.git push -u origin <branch>— uploads the branch’s commits to theoriginremote and, with-u(--set-upstream), remembers the remote branch so future plaingit push/git pullcalls know where to go.
Examples
Example 1: Start a feature branch and commit
git switch -c feature/login-page
git add src/login.html src/login.css
git commit -m "feat: add initial login page markup and styles"
Output:
Switched to a new branch 'feature/login-page'
[feature/login-page 3f9c1a2] feat: add initial login page markup and styles
2 files changed, 48 insertions(+)
create mode 100644 src/login.html
create mode 100644 src/login.css
The branch pointer for feature/login-page was created at the same commit as main, then advanced to a brand-new commit (3f9c1a2) as soon as work was committed. main itself has not moved.
Example 2: Push the branch and open a pull request
git push -u origin feature/login-page
gh pr create --title "feat: add login page" --body "Adds the initial login page markup and styles."
Output:
Enumerating objects: 6, done.
To github.com:yourname/website.git
* [new branch] feature/login-page -> feature/login-page
branch 'feature/login-page' set up to track 'origin/feature/login-page'.
https://github.com/yourname/website/pull/42
The first command uploads the new commits and creates a matching remote branch. The gh pr create command (the GitHub CLI) opens a pull request comparing feature/login-page against main, returning its URL — the same thing you could do by clicking “Compare & pull request” on GitHub’s website.
Example 3: Keep the feature branch in sync with main
git switch main
git pull origin main
git switch feature/login-page
git merge main
Output:
Switched to branch 'main'
Updating 8a1b2c3..d4e5f6a
Fast-forward
README.md | 3 +++
1 file changed, 3 insertions(+)
Switched to branch 'feature/login-page'
Merging main into feature/login-page
Merge made by the 'ort' strategy.
README.md | 3 +++
1 file changed, 3 insertions(+)
While you were working, someone else merged changes into main. Pulling those changes locally and merging main into your feature branch brings your branch up to date and lets you resolve any conflicts early, on your own branch, instead of surprising a reviewer later. If the feature branch is still only on your machine (never pushed, or pushed but not yet built on by anyone else), rebasing onto main instead of merging is also a reasonable option — it keeps history linear.
How it works step by step
When you merge main into feature/login-page, Git looks at the two branch tips and finds their common ancestor commit. It then computes what changed on each side since that ancestor and combines both sets of changes into a new tree. If the changes don’t overlap, this happens automatically and Git writes a new merge commit with two parents: the previous tip of feature/login-page and the tip of main. The feature/login-page pointer moves to this new commit; main is untouched. If the same lines were changed on both sides, Git cannot pick automatically — it pauses the merge, writes conflict markers (<<<<<<<, =======, >>>>>>>) directly into the affected files in your working tree, and waits for you to edit them, git add the resolved files, and run git commit to finish the merge.
Later, when the pull request is merged on GitHub, the same underlying operation happens on the server: GitHub either creates a merge commit on main, replays your commits one by one (rebase and merge), or squashes them into a single new commit (squash and merge), depending on the option chosen.
| Merge strategy | What happens to history | When to prefer it |
|---|---|---|
| Merge commit | Keeps every original commit plus a new merge commit joining both parents | You want full traceability of exactly how the branch evolved |
| Squash and merge | Collapses all commits on the branch into a single new commit on main |
The branch has messy or exploratory commits and only the final result matters |
| Rebase and merge | Replays each commit individually onto main, no merge commit |
You want a linear history but still want each commit preserved separately |
Common Mistakes
Mistake 1: Committing new work directly on main.
git switch main
# ...edit files...
git commit -am "wip"
This mixes unfinished work into the branch everyone else pulls from, and gives nobody a chance to review it. Fix it by branching first, then moving the commit if it already happened:
git branch feature/oops-fix
git reset --hard origin/main
git switch feature/oops-fix
The git branch feature/oops-fix creates a new pointer at the current (mistaken) commit before main is reset, so the work is preserved on its own branch instead of lost.
Mistake 2: Force-pushing over a shared branch after a rebase.
git rebase main
git push --force
Rebasing rewrites every commit’s hash on the branch. If a teammate already pulled the old version of the branch, a bare --force silently overwrites the remote history, and their local branch now points to commits the server no longer has — their next push can wipe out your rebased work or fail confusingly. This is the Git rebase golden rule: never rebase (and force-push) a branch that others have already pulled from. If you must force-push your own feature branch, prefer the safer flag:
git push --force-with-lease
--force-with-lease refuses to push if the remote branch has commits you haven’t fetched yet, protecting against overwriting work you haven’t even seen.
Mistake 3: Letting a feature branch live for weeks without syncing with main. The longer a branch diverges, the more likely a painful, unfamiliar conflict shows up right before merging. Pull main into your feature branch (or rebase onto it) regularly, in small increments, rather than once at the very end.
Best Practices
- Keep branches small and focused on one logical change — easier to review, easier to revert.
- Use descriptive branch names with a type prefix, such as
feature/login-page,fix/null-pointer-checkout, orchore/upgrade-deps. - Write commit messages in Conventional Commits style (
feat: ...,fix: ...,docs: ...) so history and changelogs stay readable. - Open the pull request early, even as a draft, so teammates and CI can give feedback before the branch grows.
- Sync with
mainfrequently to keep merge conflicts small and easy to resolve. - Prefer
git push --force-with-leaseover bare--force, and only rebase branches nobody else has based work on. - Delete the branch (locally and on the remote) once its pull request is merged, to keep the branch list clean.
Practice Exercises
Exercise 1: In a repository with a main branch, create a branch named feature/contributing-guide, add a new CONTRIBUTING.md file, commit it with a Conventional Commits message, and push it with upstream tracking set up. Hint: you’ll need git switch -c, git add, git commit, and git push -u.
Exercise 2: Simulate a conflict: on main, edit line 1 of README.md and commit; on a feature branch (branched before that commit), edit the same line 1 differently and commit. Merge main into the feature branch and resolve the resulting conflict markers by hand, then complete the merge. Expected end state: a clean working tree with a new merge commit on the feature branch.
Exercise 3: After a pull request for feature/contributing-guide is merged on GitHub, clean up: switch back to main, pull the latest changes, then delete both the local and remote copies of the now-merged feature branch. Hint: look at git branch -d and git push origin --delete.
Summary
- A branch is a movable pointer to a commit — creating one is cheap and doesn’t copy files.
- The feature branch workflow isolates work: branch, commit, push, open a pull request, get it reviewed, then merge.
- Merging combines two branch histories using their common ancestor; conflicts happen when the same lines changed on both sides.
- Never rebase or force-push over a branch others have already pulled — prefer
--force-with-leasewhen you must force-push your own branch. - GitHub offers three PR merge strategies — merge commit, squash and merge, and rebase and merge — each with different history tradeoffs.
- Sync your feature branch with
mainoften to keep conflicts small, and delete branches after they’re merged.
