Working alone, you can commit straight to main forever. Add one teammate and that habit collapses the first time you both edit the same file. Git branching is the fix: grow each change on its own branch, review it in a pull request, and merge only when it is ready. This guide walks the whole workflow, real merge conflict and git bisect included, with every command’s actual output.
“A branch is a cheap experiment. Make it, break it, throw it away if you must.”
Last Updated: July 2026 | Tested on: Git 2.45, Python 3.14.6 | Difficulty: Intermediate | Reading Time: 17 minutes
Think of a branch like a photocopy of a shared recipe book. Say a cook named Aditi wants to try adding cardamom to the kheer recipe. She does not scribble in the master book that everyone else is cooking from. She photocopies the page, experiments on her copy, and only when the new version tastes right does she paste it back into the master. A Git branch is that photocopy: a private line of work that leaves main untouched until you are sure. Merging is pasting the finished page back in. And a merge conflict is what happens when two cooks edited the very same line and Git needs you to decide which words survive.
The diagram traces the whole life of a conflict. Two commits build the shared history, then main and a feature branch each change the same line of account.py. When you merge, Git cannot guess which version wins, so it pauses and marks the clash. You open the file, keep the one line you actually want, delete the markers, and commit. The merge commit stitches both lines of history back together with nothing lost.
Table of Contents
Prerequisites
You need Git installed and the basics from the Git basics tutorial: init, add, commit, and log. A free GitHub account is enough for the pull request and fork sections. We reuse the tiny account.py from the OOP project, so nothing new to install. If you want the automated git bisect at the end, have pytest available too.
Create a Branch for a New Feature
Here is our starting point: a bank Account class with deposit and withdraw, already committed on main. We want to add interest calculation, but we do not want half-finished code sitting on main where a teammate might pull it. So we branch. That is the heart of git branching: half-finished work lives on its own line of history. The command git switch -c creates a new branch and moves you onto it in one step (the -c stands for create).
📄 Terminal: create and switch to a feature branch
git switch -c feature/interest git branch
▶ Output
Switched to a new branch 'feature/interest' * feature/interest main
What happened here: The asterisk in git branch marks where you are standing right now, on feature/interest. The main branch still exists, frozen exactly as you left it. Nothing you do here touches it. The naming style feature/interest is a common convention: a category prefix, a slash, then a short description of the work. Now add the method and commit it on this branch.
📄 Terminal: add the feature, then inspect and commit
# after adding an add_interest() method to account.py git status -s git diff git commit -am "Add add_interest method"
▶ Output
M account.py
diff --git a/account.py b/account.py
index b0bfcd1..b3350de 100644
--- a/account.py
+++ b/account.py
@@ -6,3 +6,7 @@ class Account:
def deposit(self, amount):
self.balance += amount
return self.balance
+
+ def add_interest(self, rate):
+ self.balance += self.balance * rate
+ return self.balance
What happened here: git status -s shows a short view: M means account.py is modified. git diff shows the exact lines that changed, with + in front of every added line. The four new lines are our interest method, and none of the old lines were touched. The commit records this snapshot on feature/interest only. Switch back to main and the interest method vanishes, because on main it was never added.
Merge the Feature Back Into main
The feature works and is committed. Time to fold it back into main. You switch to the branch you want to merge into, then run git merge naming the branch you want to pull from. Because main has not moved since we branched, Git can simply slide its pointer forward. That shortcut is called a fast-forward.
📄 Terminal: merge the branch into main
git switch main git merge feature/interest git branch -d feature/interest
▶ Output
Switched to branch 'main' Updating 7bf6d40..512cba0 Fast-forward account.py | 4 ++++ 1 file changed, 4 insertions(+) Deleted branch feature/interest (was 512cba0).
What happened here: “Fast-forward” means main had no new commits of its own, so Git moved main forward to the branch tip with no extra merge commit needed. The interest method is now part of main. Once a branch is merged, git branch -d safely deletes it; the -d refuses to delete anything that has not been merged yet, so it doubles as a safety check. Feature branches are meant to be disposable, so deleting them after merge keeps your branch list clean.
Fixing a Real Merge Conflict
Fast-forwards are the easy case. The moment two branches change the same line, Git cannot decide for you, and this is the half of git branching people fear. Let us cause one on purpose. On a branch called feature/monthly we change interest to apply monthly, and separately on main we change the same line to round the result. Then we merge.
📄 Terminal: two branches edit the same line, then merge
git merge feature/monthly
▶ Output
Auto-merging account.py CONFLICT (content): Merge conflict in account.py Automatic merge failed; fix conflicts and then commit the result.
Do not panic when you see the word CONFLICT. It is not an error, it is Git asking you a question. Open account.py and it now contains both versions wrapped in markers.
📄 account.py: the conflict markers Git inserted
def add_interest(self, rate):
<<<<<<< HEAD
self.balance = round(self.balance + self.balance * rate, 2)
=======
self.balance += self.balance * (rate / 12)
>>>>>>> feature/monthly
return self.balance
What happened here: The three markers split the clash into two sides. Everything between <<<<<<< HEAD and ======= is the version already on your current branch (main, which rounds). Everything from ======= to >>>>>>> feature/monthly is the incoming version (monthly). Your job is to delete the markers and leave the one line you actually want. Often the best answer combines both ideas: round and apply monthly.
📄 account.py: resolved by keeping both ideas
def add_interest(self, rate):
self.balance = round(self.balance + self.balance * (rate / 12), 2)
return self.balance
📄 Terminal: mark it resolved and commit
git add account.py git commit --no-edit git log --oneline --graph
▶ Output
* be2e0d7 Merge branch 'feature/monthly' |\ | * 9af0422 Interest applied monthly * | 2333e0b Round interest to 2 decimals |/ * 512cba0 Add add_interest method * 7bf6d40 Add Account with deposit
What happened here: Once the markers are gone, git add tells Git “this file is fixed,” and the commit records a merge commit that ties both lines of history together. The graph shows it perfectly: the two branches split apart, each made its own commit, and the merge commit at the top rejoins them. Nothing was thrown away, and anyone reading the history later can see exactly how the two changes came together.
Push to GitHub and Open a Pull Request
On a team you rarely merge into main yourself. Git branching at work runs through review: you push your branch to GitHub and open a pull request (PR), which is a polite way of saying “please review my branch and merge it if it looks good.” The GitHub Command-Line Interface (CLI), gh, does this from the terminal. At the time of writing gh is version 2.x, and if it ever disappears the pure-web flow below does the exact same thing.
Quick rewind before you type anything: we deleted feature/interest after merging it earlier, so either recreate it with git switch -c feature/interest or picture yourself back at the moment just before that merge. On a team this is exactly the point where you would push the branch and open a PR instead of merging locally.
📄 Terminal: push the branch and open a PR with the GitHub CLI
git push -u origin feature/interest
gh pr create --title "Add interest calculation" \
--body "Adds add_interest() to Account. Tests pass."
gh pr view --web
▶ Example output (needs a GitHub remote and gh login)
branch 'feature/interest' set up to track 'origin/feature/interest'. Creating pull request for feature/interest into main in aditi/bank https://github.com/aditi/bank/pull/1
What happened here: git push -u origin feature/interest uploads your branch to GitHub and remembers the link, so future pushes are just git push. gh pr create opens the PR and prints its URL. If you would rather not touch the CLI, the web flow is identical: push the branch, open the repo on github.com, and GitHub shows a yellow “Compare & pull request” button. Click it, write the same title and body, and press Create. Add a reviewer from the right-hand sidebar and they get notified.
When the PR is approved, GitHub offers three merge buttons. The one you pick shapes your history. A quick guide:
| Merge option | What it does | Best for |
|---|---|---|
| Create a merge commit | Keeps every branch commit plus a merge commit | Long-lived branches where full history matters |
| Squash and merge | Flattens all branch commits into one tidy commit | Most feature branches; a clean, readable main |
| Rebase and merge | Replays branch commits onto main, no merge commit | Teams that want a strictly linear history |
For everyday feature work, Squash and merge is the friendly default. Your branch might have ten messy “wip” and “fix typo” commits, and squash turns them into one clear commit on main. Reviewers reading history later see intent, not your keystrokes.
Fork, Contribute, and Keep Your Fork Synced
You cannot push branches to a stranger’s repository, so open source git branching works through a fork: your own copy of someone else’s project under your account. You change your copy, then send a PR back to the original. The flow, from the project’s GitHub page: click Fork, clone your fork, branch, commit, push, and open a PR against the original repo. The one habit beginners miss is keeping that fork current. The original keeps moving, and your fork goes stale within days.
📄 Terminal: point at the original and pull its updates
# one-time: name the original repo "upstream" git remote add upstream https://github.com/original-owner/project.git # every time you start new work: sync your main with theirs git switch main git fetch upstream git merge upstream/main git push origin main
What happened here: Your fork has two remotes now. origin is your copy on GitHub, and upstream is the original project. git fetch upstream downloads their latest commits without changing your files, and git merge upstream/main folds them into your local main. Push that to origin and your fork matches the original again. Always branch off a freshly synced main so your PR does not arrive full of conflicts.
Find the Breaking Commit with git bisect
A test that passed last week is red today, and there are forty commits in between. Reading them one by one is slow. git bisect does a binary search through history: it checks out a commit halfway back, you tell it good or bad, and it halves the range each time. Forty commits become about six checks. Better still, if you have a test command, git bisect run automates every step. Here is a repo where one commit quietly flipped a minus into a plus in a discount function.
📄 Terminal: let Git hunt the bad commit for you
git bisect start git bisect bad HEAD # today's commit is broken git bisect good e5cd514 # this old commit was known good git bisect run python -m pytest -q git bisect reset # always end by leaving bisect mode
▶ Output
Bisecting: 1 revision left to test after this (roughly 1 step)
running 'python' '-m' 'pytest' '-q'
F [100%]
1 failed in 0.14s
Bisecting: 0 revisions left to test after this (roughly 0 steps)
running 'python' '-m' 'pytest' '-q'
. [100%]
1 passed in 0.02s
a267d12 is the first bad commit
Refactor discount math
bisect found first bad commit
What happened here: You marked one known-bad commit and one known-good one, then handed Git a test command. git bisect run checked out the midpoint, ran pytest, read the exit code (non-zero means bad, zero means good), and repeated. In two steps it pinned the culprit: “Refactor discount math.” That is the commit to open and inspect. The final git bisect reset returns you to where you started; forgetting it leaves you stranded on an old commit, which trips up a lot of first-timers.
A README and License Turn a Repo Into a Portfolio Piece
A public repo with no README is a locked shop with the lights off. The README is the first thing a recruiter or teammate reads, so it is where your project earns a second look. Keep it short and answer three questions: what is this, how do I run it, and what can it do. A LICENSE file matters just as much, because without one, legally no one may reuse your code. MIT is the friendliest choice for a portfolio project.
📄 README.md: the front door of your repo
# Bank Account
A small Python class modelling a bank account with deposits,
withdrawals, and monthly interest. Built while learning OOP and Git.
## Run it
python account.py
## Features
- Deposit and withdraw with an overdraft guard
- Monthly interest, rounded to two decimals
- Fully unit tested with pytest
## License
MIT, see [LICENSE](LICENSE).
What happened here: GitHub renders README.md automatically on the repo home page, so this becomes your project’s landing page with zero extra work. To add a license, GitHub has a built-in picker: open the repo, click Add file, name it LICENSE, and GitHub offers to fill in the full MIT text with your name and year. This account.py repo, with a README, a license, tests, and a clean branch history, is a real portfolio artifact you can link on a resume.
GitLab, Bitbucket, and Other Hosts
The Git commands are universal. Only the website wrapper around them changes, and mostly just the vocabulary. If your job uses GitLab instead of GitHub, this table maps the words so you are never lost:
| GitHub term | GitLab term | Same underlying idea |
|---|---|---|
| Pull Request (PR) | Merge Request (MR) | Propose merging one branch into another |
| Actions | CI/CD Pipelines | Run tests automatically on push |
| Fork | Fork | Your own copy of a project |
gh CLI | glab CLI | Drive the host from the terminal |
Bitbucket also uses “Pull Request.” The point to remember: git switch, git merge, and git push behave identically no matter who hosts your repo. Learn Git once and every host feels familiar.
Common Mistakes
Mistake 1: Committing straight to main
Why it hurts: Half-finished work on main blocks the whole team, because anyone who pulls gets your broken code. Always branch first. On real projects, a branch protection rule on GitHub can even forbid direct pushes to main, forcing every change through a reviewed PR.
Mistake 2: Deleting the conflict markers wrong
Why it hurts: If you leave even one ======= or >>>>>>> line behind, you commit broken syntax that will not even parse. After resolving, search the file for <<<< to be sure none survived, and run your tests before committing the merge.
Mistake 3: Letting a fork rot
Why it hurts: Branch off a fork you synced three weeks ago and your PR arrives buried in conflicts the maintainer has to untangle. Sync upstream into your main before you start any new contribution, every single time.
Best Practices
- One branch, one purpose. A branch that adds interest should not also rename three files. Small, focused branches are reviewed faster and revert cleanly.
- Write commit messages in the imperative. “Add interest method,” not “added” or “adding.” It reads like a command Git is carrying out, which matches Git’s own messages.
- Pull before you push. Sync
mainbefore opening a PR so your branch merges cleanly and you meet fewer conflicts. - Delete branches after merge. A merged branch has served its purpose. Clearing it keeps
git branchreadable. - Squash noisy feature branches. Ten “wip” commits become one clear story on
main.
Conclusion
You just ran the full team workflow on a real project: branch a feature, merge it, resolve an honest conflict by reading the markers, push to GitHub and open a pull request, contribute through a fork, and let git bisect find a broken commit in seconds. You also gave the repo a README and a license, which turns a folder of code into something you can proudly link on a resume.
The one git branching habit that carries all of this: branch for every change, keep main always working, and let reviewed pull requests be the only door into it. Next, learn to give and receive good feedback in the code review tutorial. For every other topic in this series, from beginner basics to AI/ML projects, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is the difference between git branch and git switch?
git branch by itself lists your branches, and git branch name creates one without moving to it. git switch actually moves you onto a branch, and git switch -c name both creates and moves in one step. On older Git you may see git checkout used for both jobs; switch and restore were introduced to split those two roles clearly.
How do I abort a merge that went wrong?
If you are mid-conflict and want to bail out entirely, run git merge –abort. It throws away the half-done merge and returns your files to exactly how they were before you ran git merge, so you can try again with a clear head.
What is the difference between a pull request and a merge?
A merge is the local Git command that combines two branches. A pull request is a GitHub feature that wraps a merge in a review: you propose the merge, teammates comment and approve, and then the merge happens through the website. PRs add discussion and approval on top of the plain merge.
When should I use squash and merge instead of a merge commit?
In everyday git branching, use squash and merge for feature branches, since it flattens messy work-in-progress commits into one clean commit on main. Use a full merge commit when the individual commits tell an important story you want to keep, such as a long-running branch with distinct milestones.
Does git bisect work without automated tests?
Yes. You can run git bisect manually: after each checkout Git hands you, test the behavior by hand and type git bisect good or git bisect bad. The automated git bisect run is just a convenience for when you have a test command that exits non-zero on failure.
Interview Questions on Git Branching
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: What actually is a branch in Git, under the hood?
A branch is just a lightweight, movable pointer to one commit. It is a single line in a file under .git/refs/heads/ holding a commit hash. That is why creating a branch is instant and cheap: Git copies nothing, it only writes a new pointer. When you commit on a branch, the pointer moves forward to the new commit. This is the mental model that makes everything else about branching click.
Q: What is the difference between a fast-forward merge and a merge commit?
A fast-forward happens when the target branch has not moved since you branched, so Git just slides its pointer forward with no new commit. A merge commit is created when both branches have new commits, because Git needs one commit with two parents to tie the diverged histories together. You can force a merge commit even in the fast-forward case with git merge --no-ff, which some teams do so every feature is visible as a distinct merge in the history.
Q: A teammate says “just rebase your branch onto main.” What does that do and what is the risk?
Rebasing replays your branch’s commits one by one on top of the latest main, giving a clean linear history with no merge commit. The catch is that it rewrites commit hashes, so you must never rebase a branch other people have already pulled, because their history and yours diverge and pushes turn ugly. The safe rule: rebase your own local, unshared branches to tidy them; merge anything already shared.
Q: How would you find which of the last 200 commits introduced a performance regression?
git bisect, with a script as the test. Mark a known-good old commit and the bad current one, then write a small script that runs the slow operation and exits non-zero if it takes longer than a threshold. Hand it to git bisect run and Git binary-searches the 200 commits in about eight steps instead of two hundred. The key insight is that bisect works on any yes/no question, not only pass/fail unit tests.
Q: Why do teams protect the main branch and require pull requests?
Because main is what ships, so it must always be working. Branch protection blocks direct pushes and forces every change through a PR that runs the test suite and gets a human review before merge. This catches bugs and bad design early, spreads knowledge across the team, and leaves an auditable trail of who approved what. It trades a little speed for a lot of safety, which is the right trade for shared code.
Go deeper: Official Git documentation covers every edge case of this topic.
Related Posts
Previous: Git Basics for Python Developers: Commits, Diffs, and Undo
Next: Code Review in Python: Reading and Improving Other People’s Code
Series Home: Python + AI/ML Tutorial Series

No comment