Branching and Merging
Branching in Git is cheap to the point of being trivial — a branch is a file containing a 40-character hash. That cheapness is why Git-based workflows revolve around branches, and why understanding what a branch is makes merging and rebasing straightforward.
A branch is a movable pointer
Section titled “A branch is a movable pointer”A branch is not a copy of the code, a folder, or a container for commits. It is a single reference: a name pointing at one commit.
cat .git/refs/heads/main# => 3f2a1b04c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0When you commit, Git writes the new commit object and then rewrites that one file with the new hash. The branch “advances” — that is the entire mechanism.
“The commits on a branch” is not stored anywhere. It is derived: start at the branch’s commit and follow parent pointers backwards. This is why two branches can share history without duplication, and why deleting a branch does not delete commits — it only removes a label.
HEAD is a reference to where you are. Normally it holds the name of a branch:
cat .git/HEADThis indirection matters. When you commit, Git looks at HEAD, sees it points at main, and moves
main. When HEAD points at a commit directly instead of a branch, you are in detached HEAD
state — commits you make there belong to no branch and are easy to lose. See
detached HEAD.
HEAD ──► main ──► C3 ──► C2 ──► C1 feature ──► C4 ──► C2Creating and switching branches
Section titled “Creating and switching branches”Modern Git (2.23+) splits the old, overloaded git checkout into two focused commands:
git switch for branches, git restore for files.
git branch feature/parser # create, but stay where you aregit switch feature/parser # move onto itgit switch -c feature/parser # create and switch in one stepgit switch main # go backgit switch - # go back to the previous branchThe older equivalents still work everywhere and you will see them in most documentation:
git checkout feature/parser # switchgit checkout -b feature/parser # create and switchSwitching branches rewrites your working directory to match the target commit and updates HEAD. Git
refuses to switch if that would overwrite uncommitted changes to files that differ between the two
branches — commit, stash, or discard them first.
Listing and managing branches
Section titled “Listing and managing branches”git branch # local branches; * marks currentgit branch -v # with last commit subjectgit branch -a # include remote-tracking branchesgit branch --merged # branches fully contained in HEAD — safe to deletegit branch --no-merged # branches with commits not in HEAD
git branch -m old-name new-name # renamegit branch -d feature/parser # delete (refuses if unmerged)git branch -D feature/parser # delete regardless-d is a safety check: it verifies the branch’s commits are reachable from somewhere else before
removing the label. -D skips that check, which is how you orphan commits. They are still in the
reflog for a while, so it is recoverable, but prefer -d.
Merging
Section titled “Merging”git merge <branch> integrates another branch’s commits into the current one. There are two very
different outcomes depending on the shape of the history.
Fast-forward merge
Section titled “Fast-forward merge”If the current branch’s commit is an ancestor of the branch being merged — meaning you have made no commits since branching — Git has nothing to combine. It just slides the pointer forward.
before: A ── B (main) \ C ── D (feature)
git switch main && git merge feature
after: A ── B ── C ── D (main, feature)No merge commit is created. History stays linear.
git switch maingit merge feature# => Updating 4e2b7d1..9c1f0a3# => Fast-forward# => src/parser.js | 24 ++++++++++++++++++++----You can control this:
git merge --ff-only feature # abort unless a fast-forward is possiblegit merge --no-ff feature # always create a merge commit, even if FF was possible--no-ff is common on shared branches because it keeps a visible record that a feature branch
existed and makes the whole feature revertible as one commit.
Three-way merge
Section titled “Three-way merge”If both branches have new commits, Git performs a three-way merge using three snapshots: the tip of each branch, and their merge base — the most recent common ancestor.
before: A ── B ── E ── F (main) \ C ── D (feature)
git switch main && git merge feature
after: A ── B ── E ── F ── M (main) \ / C ─────── DM is a merge commit: a normal commit that happens to have two parents. Its first parent is the
branch you were on, its second is the branch you merged in. That ordering is what HEAD^1 and
HEAD^2 select, and what git revert -m 1 relies on.
git merge feature# => Merge made by the 'ort' strategy.Git’s default merge strategy since 2.34 is ort (“Ostensibly Recursive’s Twin”). It replaced the
older recursive strategy and produces the same results faster.
Find the merge base yourself when you are curious:
git merge-base main feature# => 4e2b7d1c...Merge conflicts
Section titled “Merge conflicts”A conflict happens when both branches changed the same region of the same file in different ways. Git can merge changes to different files, and different parts of the same file, automatically. It cannot decide which of two competing edits to the same lines you meant.
git merge featureAuto-merging src/config.jsCONFLICT (content): Merge conflict in src/config.jsAutomatic merge failed; fix conflicts and then commit the result.Git pauses mid-merge. git status now lists conflicted paths under “Unmerged paths”, and the file on
disk contains conflict markers:
export const config = {<<<<<<< HEAD timeout: 5000, retries: 3,======= timeout: 10000, retries: 5,>>>>>>> feature};Read them as: everything between <<<<<<< and ======= is your version (the branch you are on,
labelled HEAD); everything between ======= and >>>>>>> is theirs (the branch being merged).
Resolving
Section titled “Resolving”Edit the file to whatever the correct result is — you are not required to pick one side; you can combine them or write something new. Delete all three marker lines. Then stage the file to tell Git it is resolved, and commit.
# edit src/config.js until it is correctgit add src/config.jsgit status # confirm nothing is still unmergedgit merge --continue # or: git commitgit merge --continue opens the prepared merge message; git commit with no arguments does the same
thing during a merge.
If you want one side wholesale:
git checkout --ours src/config.js # keep the current branch's versiongit checkout --theirs src/config.js # keep the incoming branch's versiongit add src/config.jsgit restore --ours / --theirs do the same in modern syntax. Note that during a rebase, “ours”
and “theirs” are swapped relative to intuition — see the rebase section below.
Backing out
Section titled “Backing out”git merge --abortThis restores the working directory and index to exactly what they were before the merge started. It is completely safe as long as you had no uncommitted changes when you began.
Making conflicts easier to read
Section titled “Making conflicts easier to read”Set the diff3 conflict style and Git also shows the merge base — the original text both sides edited. Seeing what each side changed from usually makes the right resolution obvious.
git config --global merge.conflictStyle zdiff3zdiff3 (Git 2.35+) is diff3 plus removal of common lines that appear on both sides. Use diff3
on older versions.
export const config = {<<<<<<< HEAD timeout: 5000,||||||| merge base timeout: 3000,======= timeout: 10000,>>>>>>> feature};Now you can see main raised the timeout from 3000 to 5000 and the feature raised it to 10000 — this is a genuine decision, not a mechanical merge.
Rebase
Section titled “Rebase”git rebase <base> takes the commits unique to your branch, and replays them one at a time on
top of <base>. The result is a linear history that looks as if you had started your work from the
latest main.
before: A ── B ── E ── F (main) \ C ── D (feature)
git switch feature && git rebase main
after: A ── B ── E ── F (main) \ C' ── D' (feature)git switch featuregit rebase mainC' and D' are new commits. They have the same messages, authors, and (usually) the same
changes as C and D, but different parents, therefore different hashes. The originals still exist
in the reflog but nothing points at them.
If a replayed commit conflicts, the rebase stops at that commit:
# fix the conflicted filesgit add <files>git rebase --continue # replay the restgit rebase --skip # drop this commit entirelygit rebase --abort # give up; return to the pre-rebase stateRebase vs merge
Section titled “Rebase vs merge”Both integrate main into your feature branch. They differ in what history you end up with.
| Merge | Rebase | |
|---|---|---|
| History shape | Preserves the actual branching structure | Linear, as if written sequentially |
| Commit hashes | Unchanged | Rewritten |
| Conflicts | Resolved once, in one merge commit | Potentially once per replayed commit |
| Safe on shared branches | Yes | No |
| Record of “this was a feature branch” | Yes (merge commit) | Lost |
| Bisect / blame clarity | Merge commits add noise | Cleaner |
A pragmatic policy many teams use: rebase your private feature branch onto main to keep it current; merge the finished branch into main. You get linear, reviewable feature commits plus a merge commit that records the integration.
Squash merge
Section titled “Squash merge”A third option, used heavily on GitHub: collapse an entire branch into a single commit on the target branch.
git switch maingit merge --squash featuregit commit -m "Add the parser (#142)"--squash stages the combined result but deliberately does not commit and does not record feature
as a parent. main gets one clean commit; the branch’s individual commits are not part of main’s
history. Good for noisy branches (“wip”, “fix typo”, “fix typo again”), bad when the intermediate
commits carry real information.
git stash puts your uncommitted changes aside and returns your working directory to a clean state,
so you can switch branches or pull without committing half-finished work.
git stash # stash tracked, modified filesgit stash push -m "half-done parser" # with a messagegit stash -u # include untracked filesgit stash -a # include ignored files toogit stash push -- src/api.js # stash specific paths onlyRetrieving:
git stash list# => stash@{0}: On feature: half-done parser# => stash@{1}: WIP on main: 9c1f0a3 Add pagination
git stash show -p stash@{0} # view the diffgit stash apply stash@{0} # re-apply, keep it in the listgit stash pop # re-apply the most recent and delete itgit stash drop stash@{1} # delete without applyinggit stash clear # delete all — no confirmationPrefer apply over pop when the re-application might conflict: pop will refuse to drop the stash
if applying conflicts, but apply makes the “keep it safe” intent explicit.
A stash is stored as a commit (actually two or three) on a hidden ref, refs/stash, so it survives
branch switches. It is not pushed to remotes — a stash is strictly local.
Turn a stash into a branch when the code it was based on has moved on:
git stash branch fix-parser stash@{0}This creates a branch at the commit the stash was made from, checks it out, applies the stash, and drops it — sidestepping conflicts entirely.
Cherry-pick
Section titled “Cherry-pick”git cherry-pick <commit> applies the changes introduced by one commit onto your current branch, as
a new commit.
git switch maingit cherry-pick 9c1f0a3Useful when a bug fix landed on a feature branch and needs to go to a release branch immediately, or when you want one commit from a branch you are otherwise abandoning.
git cherry-pick A B C # several commits, in ordergit cherry-pick A..C # a range, excluding A itselfgit cherry-pick A^..C # a range, including Agit cherry-pick -n 9c1f0a3 # apply to the working tree/index without committinggit cherry-pick -x 9c1f0a3 # append "(cherry picked from commit …)" to the messageConflicts work exactly like merge conflicts:
git add <resolved files>git cherry-pick --continuegit cherry-pick --abort-x is worth using when picking onto a public branch — it leaves a breadcrumb saying where the
change came from.
Key points
Section titled “Key points”- A branch is a file holding one commit hash;
HEADsays which branch you are on. - Fast-forward merges just move the pointer; three-way merges create a commit with two parents.
- Conflict markers show yours (
HEAD) above, theirs below; resolve, remove the markers,git add, then continue. merge.conflictStyle = zdiff3shows the common ancestor and makes most conflicts self-explanatory.- Rebase rewrites commits into new ones with new hashes — great for private branches, dangerous for shared ones.
stashis local-only temporary storage;cherry-pickcopies a single commit’s changes elsewhere.