Git Branches Explained: How Branching Really Works
A Git branch is a movable reference to a commit. On disk it is a file containing one 40-character object ID. That is the whole data structure.
Everything else people mean by “branching” — feature branches, release branches, branching strategy — is convention built on top of that one file. Getting the data structure clear first makes the conventions far easier to reason about.
The short answer
Section titled “The short answer”refs/heads/main is a file. Its contents are a commit ID:
cat .git/refs/heads/mainff3c99c8a1b2c3d4e5f60718293a4b5c6d7e8f90Creating a branch writes 41 bytes. Committing on it overwrites those bytes with a new ID. Deleting it removes the file. No project files are copied at any point, which is why branching in Git is effectively instantaneous regardless of repository size.
Branches and commits
Section titled “Branches and commits”Commits form a directed graph: each commit records its parent, and following parents walks backwards through history. A branch is a label attached to one node in that graph — its tip.
A single lane of four commits A, B, C and D connected left to right, with the label main attached to commit D at the right-hand end. The branch points only at D; commits A, B and C are reachable by following parent links backwards.
The phrase “the commits on main” is shorthand for “commits reachable from main by following parent
links”. A branch does not contain a list of commits. It holds one ID, and the graph supplies the rest.
Two consequences follow, and both matter later in this cluster:
A commit can be on many branches at once. If main and feature both descend from commit B, then
B is reachable from both. It is not duplicated; both branches simply reach it.
Deleting a branch deletes a label. The commits remain in the object database until garbage collection removes those nothing can reach. This is why an accidentally deleted branch is usually recoverable.
What happens when you commit
Section titled “What happens when you commit”Committing does not move HEAD directly. It moves the branch HEAD points at.
- Git reads
.git/HEADto find the current branch — normallyref: refs/heads/main. - Git creates the commit object, with the branch’s current commit as its parent.
- Git writes the new commit’s ID into the branch’s ref file.
- HEAD is unchanged. It still says
ref: refs/heads/main; that branch now names a different commit.
This indirection is what makes branches feel like “where you are”. HEAD tracks a branch; the branch tracks a commit; committing slides the branch forward and carries HEAD along.
Creating a branch
Section titled “Creating a branch”The modern command is git switch. git checkout still works and is not deprecated, but it does two
unrelated jobs — moving between commits and restoring files — which is exactly the ambiguity switch
and restore were introduced to remove.
git switch -c feature/parserWhat it doesCreates a new branch pointing at the current commit and moves HEAD onto it.
Why we run itIt is the single-step way to start work on a new branch. The -c flag means create.
Expected resultOne line confirming the switch. Your working tree does not change, because the new branch points at the commit you were already on.
Switched to a new branch 'feature/parser'The equivalent forms, for reference:
| Command | Effect |
|---|---|
git switch -c <name> | Create and switch (modern) |
git checkout -b <name> | Create and switch (older spelling) |
git branch <name> | Create without switching — HEAD stays put |
git switch -c <name> <start-point> | Create from a specific commit, tag or branch |
git branch <name> catches people out: it creates the ref but leaves you where you were. Running it and
then committing puts the commit on your current branch, not the new one.
Switching branches
Section titled “Switching branches”git switch mainWhat it doesPoints HEAD at the named branch, then updates the index and working tree to match that branch's commit.
Why we run itIt is how you move between lines of work. Your files change on disk to match the target commit.
Expected resultA confirmation line. If the branch tracks a remote branch, Git also reports whether you are ahead or behind.
Switched to branch 'main'Your branch is up to date with 'origin/main'.Switching rewrites files. Git refuses when that would destroy uncommitted work it has no copy of:
error: Your local changes to the following files would be overwritten by checkout: app.pyPlease commit your changes or stash them before you switch branches.That is a safety feature. Commit the work, stash it with git stash, or discard it deliberately.
Branches are not directories
Section titled “Branches are not directories”This is the misconception that causes the most trouble.
Switching branches does not move you into a different folder. You stay in the same directory; Git
rewrites its contents to match the target commit. pwd returns the same path before and after.
Two states of the same project directory at the path slash home slash you slash project. On branch main it contains README.md, app.py and config.yml. After switching to branch feature it contains README.md, a modified app.py, config.yml and a new file parser.py. The directory path is identical in both states; only the contents differ.
If you genuinely want two branches on disk simultaneously, that is what worktrees are for. Cloning the repository twice also works and is what many people reach for first; the productivity cluster covers why worktrees are usually better.
Listing and inspecting branches
Section titled “Listing and inspecting branches”git branch* feature/parser mainThe asterisk marks the branch HEAD points at. More useful variants:
| Command | Shows |
|---|---|
git branch -v | Each branch with its tip commit and subject line |
git branch -vv | The same, plus upstream tracking and ahead/behind counts |
git branch -a | Local and remote-tracking branches |
git branch -r | Remote-tracking branches only |
git branch --merged | Branches already merged into the current branch |
git branch --no-merged | Branches with work not yet integrated |
git branch --sort=-committerdate | Most recently active first |
git branch --merged main is the one worth remembering: it lists branches that are safe to delete,
because everything on them is already reachable from main.
Remote-tracking branches and upstreams
Section titled “Remote-tracking branches and upstreams”Three distinct things share the word “branch”, and confusing them causes a specific class of frustration.
| Kind | Example ref | What it is |
|---|---|---|
| Local branch | refs/heads/main | A branch you commit to |
| Remote-tracking branch | refs/remotes/origin/main | Your local record of where origin/main was at your last fetch |
| Upstream | configured, not a ref | The link saying which remote-tracking branch your local branch compares against |
origin/main is not the branch on the server. It is a cached snapshot, updated only when you
fetch, pull or push. If a colleague pushes and you have not fetched, your origin/main is stale
and git status will happily report you are up to date.
git fetch originWhat it doesDownloads new commits and updates your remote-tracking branches. It does not change your local branches or working tree.
Why we run itIt refreshes what you know about the remote without integrating anything, so you can inspect before deciding.
Expected resultA summary of updated refs, or no output when nothing changed.
Setting an upstream lets Git tell you how you compare:
git push -u origin feature/parsergit branch -vv* feature/parser 4ff2767 [origin/feature/parser] Add parser skeleton main ff3c99c [origin/main: behind 3] Update READMEbehind 3 means origin/main has three commits your local main does not — as of your last fetch.
Branch divergence
Section titled “Branch divergence”Two branches have diverged when each has commits the other lacks. This is the normal state during parallel work, and it is what merging and rebasing exist to resolve.
A trunk lane labelled main with commits A, B, C and D. A branch lane labelled feature leaves main after commit B and has its own commits X and Y. Commit B is the last commit both branches share, making it the merge base.
The shared commit is the merge base, and Git can compute it directly:
git merge-base main feature1ad6ec688fac739e42eacdf91905c8ff73f27005Every integration operation starts here. Merging reconciles both sets of changes relative to the merge base; rebasing replays one side onto the other’s tip. Understanding the merge base is what makes both predictable.
To see the divergence as counts:
git rev-list --left-right --count main...feature2 2Two commits on main that feature lacks; two on feature that main lacks. Note the three dots —
main..feature with two dots means something different (commits on feature only).
Renaming and deleting
Section titled “Renaming and deleting”git branch -m old-name new-name-m renames. Without arguments it renames the current branch. Renaming is purely local — the old name
still exists on the remote until you push the new one and delete the old one there.
git branch -d feature/parsergit branch -d feature/parserWhat it doesDeletes the branch ref, but refuses if the branch has commits not reachable from your current branch.
Why we run itThe refusal is the safety check. -d only removes labels whose work is already integrated somewhere.
Expected resultA confirmation line naming the deleted branch and the commit it pointed at, or a refusal explaining that the branch is not fully merged.
Deleted branch feature/parser (was 4ff2767).If the branch has unintegrated work:
error: The branch 'feature/parser' is not fully merged.If you are sure you want to delete it, run 'git branch -D feature/parser'.Deleting a remote branch is a separate operation:
git push origin --delete feature/parserComparing branches
Section titled “Comparing branches”Once branches diverge, four questions come up constantly. Each has a precise command.
What commits are on this branch that are not on main?
git log main..feature --onelineTwo dots. Read it as “reachable from feature, not from main” — the commits your branch adds.
What changed in the files?
git diff main...featureThree dots in a diff means something specific and useful: compare feature against the merge base,
not against main’s current tip. That shows only what your branch changed, excluding commits that landed
on main after you branched. It is almost always what you want when reviewing your own branch.
Which files does this branch touch?
git diff --stat main...featureHas this branch already been integrated?
git branch --merged mainIf the branch is listed, everything on it is reachable from main and it is safe to delete.
Recovering a deleted branch
Section titled “Recovering a deleted branch”Deleting a branch removes a label. Until garbage collection prunes unreachable objects, the commits are still there — and the reflog remembers where the branch pointed.
-
Find the commit in the reflog. The branch name appears in the operation description:
Terminal window git reflog --all | grep feature/parser4ff2767 refs/heads/feature/parser@{0}: commit: Add parser skeleton -
Recreate the branch at that commit:
Terminal window git branch feature/parser 4ff2767 -
Confirm the work is back:
Terminal window git log --oneline feature/parser -3
Branch naming
Section titled “Branch naming”Names are conventions; Git only forbids a few characters and patterns. What matters is that a name tells a colleague what the branch is for.
Common patterns:
feature/user-authenticationfix/null-pointer-in-parserchore/upgrade-dependenciesrelease/2.4A slash creates a real directory under .git/refs/heads/, which has one practical consequence: you
cannot have both a branch named feature and one named feature/login, because feature would need to
be a file and a directory simultaneously.
Two habits worth adopting:
- Include a ticket reference if your team uses one —
fix/PROJ-412-null-parserconnects the branch to its context without anyone having to ask. - Keep it short enough to type. You will type it into
switch,pushandmergerepeatedly.
A practical workflow
Section titled “A practical workflow”The everyday loop, end to end:
-
Start from up-to-date
main.Terminal window git switch maingit pull -
Create the branch.
Terminal window git switch -c fix/parser-empty-input -
Work and commit in focused increments.
Terminal window git add parser.pygit commit -m "Handle empty input in the parser" -
Push and set the upstream the first time.
Terminal window git push -u origin fix/parser-empty-input -
Integrate, by whatever route your team uses — a pull request, a direct merge, a rebase.
-
Delete the branch once the work is integrated.
Terminal window git switch maingit pullgit branch -d fix/parser-empty-input
Step 6 matters more than it looks. Deleted branches keep git branch readable, and git branch -d’s
refusal to delete unmerged work makes it a free check that the integration actually happened.
Common mistakes
Section titled “Common mistakes”“Creating a branch copies my files.” It writes one file containing one commit ID. Nothing is copied.
“Branches are folders.” One directory; Git rewrites its contents when you switch.
“git branch <name> switches to the new branch.” It creates the ref and leaves HEAD where it was.
Use git switch -c.
“origin/main is the branch on the server.” It is your last-fetched snapshot of it. Run git fetch
before trusting ahead/behind counts.
“Deleting a branch deletes the commits.” It deletes a label. The commits survive until garbage collection removes what nothing can reach — and the reflog can usually find them first.
“I need a second clone to work on two branches.” You need a worktree. Same repository, two checked-out directories.
“A long-lived branch is fine as long as I merge main in occasionally.” Syncing helps, but the
branch still accumulates divergence, and repeated merges from main make the eventual review harder to
read. Short-lived branches covers the trade-off.
Mental Model
Section titled “Mental Model”A branch is a sticky note with a commit ID written on it.
Committing peels the note off and sticks it on the new commit. Switching branches means reading a different note and rewriting your files to match what it names. Deleting a branch throws the note away — the commit it named is still there, just harder to find.
That model predicts the real behaviour: why branching is instant, why a commit can be on several
branches, why deleting a branch does not lose work, and why “the commits on main” is really a
reachability question.
Try It Yourself
Section titled “Try It Yourself”In a disposable repository with at least two commits. Predict each answer before running it.
- Run
cat .git/HEAD, thencat .git/refs/heads/main. Confirm the two-step indirection. - Run
git switch -c experiment. Now runcat .git/refs/heads/experimentand compare it withmain. Predict first: will the IDs match? - Make a commit on
experiment. Re-read both ref files. Which one changed? - Run
git branch --merged main. Isexperimentlisted? Why not? - Run
git merge-base main experimentand find that commit ingit log --oneline. - Run
git rev-list --left-right --count main...experimentand interpret the two numbers. - Try
git branch -d experiment. Read the refusal, then note the commit ID fromgit log experiment -1. - Force it with
git branch -D experiment, then restore it:git branch experiment <id>.
Step 2 is the point: both refs hold the same commit ID, because creating a branch does not create a commit. Step 8 demonstrates that deletion removed a label, not the work.
Next Lesson
Section titled “Next Lesson”You know what a branch is. The next lesson covers the workflow nearly every team builds on top of it: develop each change on its own branch, review it, integrate it, delete it.