Branches Are Pointers

What creating, switching and deleting a branch actually does to one file, why a fast-forward produces no merge commit, and how to work safely in detached HEAD.

beginner 18 min lesson hands-on task included

Branching is where Git’s model pays for itself. A branch is not a copy of the code, not a directory, and not a server-side object. It is a file containing one hash — which is why creating one is instantaneous and why “which branch am I on” is a question with a precise answer.


Topic 1: What a Branch Is

A BRANCH IS A 41-BYTE FILE CONTAINING ONE COMMIT HASH A B C D E each arrow points to the PARENT main feature HEAD → FAST-FORWARD main has no commits feature does not have, so merging just MOVES the main pointer to E. No merge commit. No new objects. Nothing to conflict. DETACHED HEAD HEAD points at a commit instead of a branch. Commits made here belong to no branch — switch away and only the reflog remembers them.
Two branch pointers and HEAD, all pointing into the same commit graph. Once you see branches as movable labels rather than as containers, fast-forward and detached HEAD both stop being mysterious.
git branch feature/retry
cat .git/refs/heads/feature/retry
# 9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c    ← the whole branch

Creating a branch writes 41 bytes. Deleting one removes 41 bytes. The commits belong to no branch — they are objects in a shared graph, and a branch is a label on one of them.

git switch -c feature/retry           # create and switch (modern)
git checkout -b feature/retry         # the same thing, older spelling
git switch main                       # switch to an existing branch
git branch -d feature/retry           # delete — refuses if unmerged
git branch -D feature/retry           # delete anyway
git branch -m old-name new-name       # rename
git branch --merged main              # what is safe to delete
git branch --no-merged main           # what still has unique work

switch and restore were introduced because checkout did too many unrelated jobs — switching branches, restoring files, and detaching HEAD. Both spellings work; the new ones make a script’s intent obvious.

Committing does two things: writes a commit object whose parent is the current HEAD commit, then updates the branch file to the new hash. “Advancing a branch” is that second step, and nothing more.


Topic 2: HEAD, and Being Detached

HEAD answers “where am I”. It is usually a symbolic ref — it names a branch:

cat .git/HEAD
# ref: refs/heads/main

When you check out a commit directly, HEAD holds the hash instead. That is detached HEAD, and Git’s warning is more alarming than the situation:

git checkout 9f8e7d
# You are in 'detached HEAD' state...

Detached HEAD is not broken. It is genuinely useful for looking at history, building an old revision, or testing a commit during a bisect. The one real hazard: commits made while detached belong to no branch, so switching away leaves them unreferenced — visible only in the reflog until garbage collection.

# Safe pattern: if you committed while detached, name it before leaving
git switch -c rescue/experiment

# Already switched away? The reflog still has it
git reflog
git branch rescue 3c4d5e

git switch --detach <commit> is the explicit version, and it makes the intent clear in a script or a demo.


Topic 3: Fast-Forward vs Merge Commit

This is the distinction behind a great deal of history noise.

Fast-forward happens when the target branch has no commits the source lacks — the branches have not diverged. Git simply moves the pointer forward. No merge commit, no new objects, nothing to conflict.

before:   A ─ B ─ C          main
                   ╲
                    D ─ E    feature

git switch main && git merge feature

after:    A ─ B ─ C ─ D ─ E  main, feature      ← the pointer moved

A merge commit is created when both branches have new commits — the histories diverged and something must join them.

before:   A ─ B ─ C ─ F      main
                   ╲
                    D ─ E    feature

after:    A ─ B ─ C ─ F ─ M  main               ← M has two parents
                   ╲       ╱
                    D ─ E

Controlling which you get:

git merge feature            # fast-forward when possible
git merge --no-ff feature    # always create a merge commit
git merge --ff-only feature  # fail rather than create one

Why teams pick one: --no-ff keeps a visible record that a set of commits was a single feature, which makes reverting the whole feature one command. --ff-only keeps history perfectly linear. Both are defensible; mixing them at random is what produces a graph nobody can read. Set it in the repository config and let the tooling enforce it:

git config merge.ff false     # this repo: always create merge commits

Topic 4: Naming and Housekeeping

Branch names are free-form, and a convention costs nothing:

feature/retry-logic       what kind of work
fix/token-expiry          plus a short description
chore/bump-deps
release/2026.08
hotfix/auth-bypass
alice/spike-caching       personal experiments, clearly owned

Slashes create a hierarchy that tooling and tab-completion understand. Avoid spaces, uppercase, and ticket numbers alone — JIRA-4127 tells a reviewer nothing at a glance.

Stale branches accumulate. Cheap to create is not cheap to keep: forty branches makes tab-completion useless and hides the three that matter.

# Branches already merged into main — safe to delete
git branch --merged main | grep -v '^\*\|main' | xargs -r git branch -d

# Sorted by how recently they were touched (most useful listing)
git branch --sort=-committerdate --format='%(committerdate:short) %(refname:short) %(authorname)'

# Remove remote-tracking refs for branches deleted on the forge
git fetch --prune

--merged is the safe filter, and git branch -d refuses to delete unmerged work — the capital -D is the one that needs a reason.


Topic 5: The Long-Lived Branch Problem

A branch that lives for weeks has a cost that compounds:

  • Conflicts grow superlinearly. Every day of divergence is more overlapping edits.
  • The merge becomes an event with its own risk, scheduled, feared, discussed.
  • CI tests something nobody is running. The branch is green against a main from three weeks ago.
  • Review quality falls off a cliff. A 40-file diff receives “looks good to me” and nothing more.

Two ways out, and they are complementary:

Merge or rebase from main frequently — daily, not at the end. Small, continuous conflict resolution instead of one large one. With rerere enabled, repeated conflicts resolve themselves.

Make the branch smaller. If a change cannot merge in two days, the usual answer is not a better branching strategy but a different decomposition: merge the refactoring first, then the behaviour change; or merge the code behind a feature flag that is off. A flag turns “is this ready to merge” into “is this safe to merge”, which is a much easier question.


Topic 6: Comparing Branches Before You Act

Never merge something you have not looked at:

git log main..feature            # commits on feature, not on main
git log feature..main            # the reverse — how far behind you are
git log --left-right --graph --oneline main...feature   # both sides at once
git diff main...feature          # the changes feature introduces since diverging

Two dots versus three is a real distinction, and getting it wrong gives a misleading review:

  • git diff main..feature — the total difference between the two tips, including anything that landed on main since you branched.
  • git diff main...feature — the difference introduced by feature alone, measured from the merge base. This is what a pull request shows you, and it is almost always the one you want.
git merge-base main feature      # the commit they diverged from
git log --oneline --graph --all --decorate -20   # the shape of everything

Try it yourself: create a branch, commit on it, and merge back — a fast-forward. Then do it again, but commit on main first so the histories diverge. Compare git log --graph for both. Seeing the two shapes side by side is what makes every later rebase-versus-merge discussion concrete.

Common mistake: deleting a branch after a squash merge with git branch -d and being told it is not merged. It is not, in Git’s sense — the squash created a new commit with the same content but no ancestry link, so Git correctly reports that your branch’s commits are unreachable from main. Verify the change actually landed (git log main --oneline | head), then delete with -D. Doing that without checking is how work gets lost when the squash silently dropped something.