Skip to content

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 not a copy of the code, a folder, or a container for commits. It is a single reference: a name pointing at one commit.

Terminal window
cat .git/refs/heads/main
# => 3f2a1b04c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0

When 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:

refs/heads/main
cat .git/HEAD

This 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 ──► C2

Modern Git (2.23+) splits the old, overloaded git checkout into two focused commands: git switch for branches, git restore for files.

Terminal window
git branch feature/parser # create, but stay where you are
git switch feature/parser # move onto it
git switch -c feature/parser # create and switch in one step
git switch main # go back
git switch - # go back to the previous branch

The older equivalents still work everywhere and you will see them in most documentation:

Terminal window
git checkout feature/parser # switch
git checkout -b feature/parser # create and switch

Switching 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.

Terminal window
git branch # local branches; * marks current
git branch -v # with last commit subject
git branch -a # include remote-tracking branches
git branch --merged # branches fully contained in HEAD — safe to delete
git branch --no-merged # branches with commits not in HEAD
git branch -m old-name new-name # rename
git 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.

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.

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.

Terminal window
git switch main
git merge feature
# => Updating 4e2b7d1..9c1f0a3
# => Fast-forward
# => src/parser.js | 24 ++++++++++++++++++++----

You can control this:

Terminal window
git merge --ff-only feature # abort unless a fast-forward is possible
git 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.

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 ─────── D

M 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.

Terminal window
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:

Terminal window
git merge-base main feature
# => 4e2b7d1c...

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.

Terminal window
git merge feature
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic 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:

src/config.js
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).

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.

Terminal window
# edit src/config.js until it is correct
git add src/config.js
git status # confirm nothing is still unmerged
git merge --continue # or: git commit

git 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:

Terminal window
git checkout --ours src/config.js # keep the current branch's version
git checkout --theirs src/config.js # keep the incoming branch's version
git add src/config.js

git 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.

Terminal window
git merge --abort

This 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.

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.

Terminal window
git config --global merge.conflictStyle zdiff3

zdiff3 (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.

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)
Terminal window
git switch feature
git rebase main

C' 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:

Terminal window
# fix the conflicted files
git add <files>
git rebase --continue # replay the rest
git rebase --skip # drop this commit entirely
git rebase --abort # give up; return to the pre-rebase state

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.

A third option, used heavily on GitHub: collapse an entire branch into a single commit on the target branch.

Terminal window
git switch main
git merge --squash feature
git 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.

Terminal window
git stash # stash tracked, modified files
git stash push -m "half-done parser" # with a message
git stash -u # include untracked files
git stash -a # include ignored files too
git stash push -- src/api.js # stash specific paths only

Retrieving:

Terminal window
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 diff
git stash apply stash@{0} # re-apply, keep it in the list
git stash pop # re-apply the most recent and delete it
git stash drop stash@{1} # delete without applying
git stash clear # delete all — no confirmation

Prefer 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:

Terminal window
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.

git cherry-pick <commit> applies the changes introduced by one commit onto your current branch, as a new commit.

Terminal window
git switch main
git cherry-pick 9c1f0a3

Useful 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.

Terminal window
git cherry-pick A B C # several commits, in order
git cherry-pick A..C # a range, excluding A itself
git cherry-pick A^..C # a range, including A
git cherry-pick -n 9c1f0a3 # apply to the working tree/index without committing
git cherry-pick -x 9c1f0a3 # append "(cherry picked from commit …)" to the message

Conflicts work exactly like merge conflicts:

Terminal window
git add <resolved files>
git cherry-pick --continue
git cherry-pick --abort

-x is worth using when picking onto a public branch — it leaves a breadcrumb saying where the change came from.

  • A branch is a file holding one commit hash; HEAD says 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 = zdiff3 shows 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.
  • stash is local-only temporary storage; cherry-pick copies a single commit’s changes elsewhere.