Git Branching and Merging Explained
A branch in Git is a movable pointer to a commit. That is the entire concept, and understanding it literally makes the rest of branching straightforward.
Look inside a repository:
cat .git/refs/heads/main
# 3a7f2c1e9b4d8a5f6c2e1d9b8a7f6e5d4c3b2a19
That file is the branch. It holds one commit hash. Creating a branch writes a similar file, which is why it is instantaneous whether the project has ten commits or ten million.
When you commit, Git creates a new commit object pointing at the previous one, then updates the branch file to the new hash. The branch pointer moves forward; the commits form a graph behind it.
HEAD is a pointer to whichever branch you currently have checked out. That is what “you are on branch main” means.
Creating and switching
git branch # list local branches
git branch feature-login # create, but stay where you are
git switch feature-login # move to it
git switch -c feature-login # create and switch in one step
git checkout -b feature-login # older syntax, same result
git switch and git restore were introduced to split the responsibilities of git checkout, which did far too many unrelated things. checkout still works and is what most existing documentation uses.
Merging
Merging brings the commits from one branch into another.
git switch main
git merge feature-login
Two things can happen.
Fast-forward
If main has no new commits since you branched, Git just moves the pointer forward. No merge commit is created, and the history looks as though you committed directly to main.
before: A---B---C main
\
D---E feature
after: A---B---C---D---E main, feature
This is clean and it discards the information that a branch existed. If you want that recorded:
git merge --no-ff feature-login
Three-way merge
If both branches have new commits, Git finds their common ancestor, compares both sides against it, and creates a merge commit with two parents.
before: A---B---C---F main
\
D---E feature
after: A---B---C---F---M main
\ /
D---E
That merge commit M is what preserves the fact that work happened in parallel.
Conflicts
A conflict occurs when both branches changed the same lines of the same file. Git cannot know which version is correct, so it stops and asks.
git merge feature-login
# CONFLICT (content): Merge conflict in src/auth.py
# Automatic merge failed; fix conflicts and then commit the result.
Do not panic and do not delete anything. git status lists exactly which files need attention.
Open a conflicted file and you will see markers:
<<<<<<< HEAD
timeout = 30
=======
timeout = 60
>>>>>>> feature-login
Everything between <<<<<<< HEAD and ======= is the version on your current branch. Everything between ======= and >>>>>>> is the incoming version.
Resolve it by editing the file to contain what you actually want, and deleting all three marker lines. The result should be valid code with no trace of the markers. Leaving a marker in is the single most common mistake, and it usually surfaces as a syntax error in CI.
git add src/auth.py # marks the conflict resolved
git status # confirm nothing else is conflicted
git commit # completes the merge
If it is going badly:
git merge --abort
That returns everything to the pre-merge state. There is no shame in using it and reading the two versions properly before trying again.
For a conflict you keep hitting in the same file, git rerere records how you resolved it and replays that resolution automatically next time:
git config --global rerere.enabled true
Rebasing
Rebase takes your commits and replays them on top of a different base, producing a linear history.
git switch feature-login
git rebase main
before: A---B---C---F main
\
D---E feature
after: A---B---C---F main
\
D'---E' feature
Note D' and E'. Those are new commits with the same changes and different hashes. The originals still exist in the reflog but are no longer part of the branch.
Interactive rebase is where rebase earns its reputation, letting you clean up local work before anyone sees it:
git rebase -i HEAD~5
You get an editor listing the last five commits and can reorder them, squash several into one, reword messages, or drop commits entirely. Turning eight commits of “wip”, “fix typo”, and “actually fix it” into two coherent commits is a genuine courtesy to reviewers.
The rule about rebasing
Do not rebase commits that others have pulled.
Rebasing produces new commits with new hashes. If a colleague has the originals, their history and yours have diverged in a way Git cannot reconcile sensibly. When they pull, they get duplicated commits and conflicts that make no sense, and untangling it is considerably more work than the tidy history was worth.
The workable version of the rule: rebase freely on branches that exist only on your machine. Once you have pushed, use merge.
If you must force-push after a rebase on a branch others might have:
git push --force-with-lease
--force-with-lease refuses to push if the remote has commits you have not seen, which prevents the specific disaster of silently destroying someone else’s work. Plain --force does not check. There is essentially no reason to use plain --force.
Merge or rebase
Both are correct; they optimise for different things.
| Merge | Rebase | |
|---|---|---|
| History | Shows what actually happened | Linear and readable |
| Commit hashes | Unchanged | Rewritten |
| Safe on shared branches | Yes | No |
| Conflict resolution | Once | Potentially once per commit |
| Bisecting | Slightly noisier | Cleaner |
A common workflow that gets most of both: rebase your feature branch onto main to keep it current while you work, then merge it into main with --no-ff when it is ready. Your work is linear and readable, and the merge point is recorded.
Cleaning up
git branch -d feature-login # delete a merged branch
git branch -D feature-login # force delete an unmerged one
git push origin --delete feature-login
git fetch --prune # remove local refs to deleted remote branches
git branch --merged main # branches safe to delete
-d refuses to delete a branch with unmerged commits, which is a useful safety net. -D overrides it, and the commits remain in the reflog if you need them back.
Finding which commit broke something
Branching makes this tool possible, and it is one of Git’s genuinely great features:
git bisect start
git bisect bad # current state is broken
git bisect good v1.2.0 # this tag was fine
# Git checks out a commit halfway between
# test it, then:
git bisect good # or: git bisect bad
# repeat until Git identifies the culprit
git bisect reset
Binary search over history. Twenty steps finds the bad commit in a million-commit range. This is the strongest practical argument for committing frequently in small pieces: bisect can only be as precise as your commits are small.
Frequently Asked Questions
What is a Git branch really?
A branch is a movable pointer to a commit, stored as a file in .git/refs/heads containing a 40-character commit hash. Creating a branch writes one small file, which is why it is instant regardless of project size. The commits themselves form a graph, and a branch is just a label on one node of it.
Should I use merge or rebase?
Merge preserves the actual history including the fact that work happened in parallel. Rebase rewrites your commits to appear as though they were made after the target branch, producing a linear history. Use merge for shared branches and rebase for cleaning up your own local work before sharing it.
Why should I not rebase a pushed branch?
Rebasing creates new commits with new hashes, so the old commits your collaborators have no longer match yours. When they next pull, Git sees two divergent histories and produces duplicated commits and confusing conflicts. The rule of thumb is to rebase only commits that exist solely on your machine.
How do I resolve a merge conflict?
Open each conflicted file and look for the markers beginning with less-than signs. Everything between the first marker and the equals signs is your version, and everything after is theirs. Delete the markers, leave the content you want, then run git add on the file and git commit to complete the merge.
What is a fast-forward merge?
A fast-forward happens when the target branch has no new commits since you branched, so Git can simply move the branch pointer forward rather than creating a merge commit. It leaves no record that a branch existed, which is why some teams use git merge —no-ff to force a merge commit.
How do I abandon a merge that is going badly?
Run git merge —abort, which returns the repository to the state it was in before the merge started. This works as long as you have not yet committed the merge. If you have already committed it, git reset —hard HEAD~1 will remove the merge commit provided you have not pushed.