Skip to content

Git Branches Explained: How Branching Really Works

Lesson 1 of 8Beginner → Intermediate11 min readModern Git Workflows · BranchingVerified: Git 2.43.0 on Ubuntu 24.04

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.

refs/heads/main is a file. Its contents are a commit ID:

Terminal window
cat .git/refs/heads/main
ff3c99c8a1b2c3d4e5f60718293a4b5c6d7e8f90

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

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 branch labels one commit; history is everything reachable from it

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.

ABCDmainmain points at D only. A, B and C are reachable through parent links, not listed by the branch.

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.

Committing does not move HEAD directly. It moves the branch HEAD points at.

  1. Git reads .git/HEAD to find the current branch — normally ref: refs/heads/main.
  2. Git creates the commit object, with the branch’s current commit as its parent.
  3. Git writes the new commit’s ID into the branch’s ref file.
  4. 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.

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.

Terminal window
git switch -c feature/parser

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

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

Terminal window
git switch main

What 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.py
Please 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.

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.

One directory, contents rewritten on switch

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.

on branch mainafter: git switch feature~/project/~/project/README.mdapp.pyconfig.ymlREADME.mdconfig.ymlapp.py ✎parser.py +same pathone directory — Git rewrites its contents

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.

Terminal window
git branch
* feature/parser
main

The asterisk marks the branch HEAD points at. More useful variants:

CommandShows
git branch -vEach branch with its tip commit and subject line
git branch -vvThe same, plus upstream tracking and ahead/behind counts
git branch -aLocal and remote-tracking branches
git branch -rRemote-tracking branches only
git branch --mergedBranches already merged into the current branch
git branch --no-mergedBranches with work not yet integrated
git branch --sort=-committerdateMost 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.

Three distinct things share the word “branch”, and confusing them causes a specific class of frustration.

KindExample refWhat it is
Local branchrefs/heads/mainA branch you commit to
Remote-tracking branchrefs/remotes/origin/mainYour local record of where origin/main was at your last fetch
Upstreamconfigured, not a refThe 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.

Terminal window
git fetch origin

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

Terminal window
git push -u origin feature/parser
git branch -vv
* feature/parser 4ff2767 [origin/feature/parser] Add parser skeleton
main ff3c99c [origin/main: behind 3] Update README

behind 3 means origin/main has three commits your local main does not — as of your last fetch.

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.

Diverged branches share history up to the merge base

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.

ABCDXYmainfeatureB is the merge base: the most recent commit reachable from both branch tips.

The shared commit is the merge base, and Git can compute it directly:

Terminal window
git merge-base main feature
1ad6ec688fac739e42eacdf91905c8ff73f27005

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

Terminal window
git rev-list --left-right --count main...feature
2 2

Two commits on main that feature lacks; two on feature that main lacks. Note the three dotsmain..feature with two dots means something different (commits on feature only).

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

Terminal window
git branch -d feature/parser
Terminal window
git branch -d feature/parser

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

Terminal window
git push origin --delete feature/parser

Once branches diverge, four questions come up constantly. Each has a precise command.

What commits are on this branch that are not on main?

Terminal window
git log main..feature --oneline

Two dots. Read it as “reachable from feature, not from main” — the commits your branch adds.

What changed in the files?

Terminal window
git diff main...feature

Three 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?

Terminal window
git diff --stat main...feature

Has this branch already been integrated?

Terminal window
git branch --merged main

If the branch is listed, everything on it is reachable from main and it is safe to delete.

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.

  1. Find the commit in the reflog. The branch name appears in the operation description:

    Terminal window
    git reflog --all | grep feature/parser
    4ff2767 refs/heads/feature/parser@{0}: commit: Add parser skeleton
  2. Recreate the branch at that commit:

    Terminal window
    git branch feature/parser 4ff2767
  3. Confirm the work is back:

    Terminal window
    git log --oneline feature/parser -3

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-authentication
fix/null-pointer-in-parser
chore/upgrade-dependencies
release/2.4

A 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 onefix/PROJ-412-null-parser connects the branch to its context without anyone having to ask.
  • Keep it short enough to type. You will type it into switch, push and merge repeatedly.

The everyday loop, end to end:

  1. Start from up-to-date main.

    Terminal window
    git switch main
    git pull
  2. Create the branch.

    Terminal window
    git switch -c fix/parser-empty-input
  3. Work and commit in focused increments.

    Terminal window
    git add parser.py
    git commit -m "Handle empty input in the parser"
  4. Push and set the upstream the first time.

    Terminal window
    git push -u origin fix/parser-empty-input
  5. Integrate, by whatever route your team uses — a pull request, a direct merge, a rebase.

  6. Delete the branch once the work is integrated.

    Terminal window
    git switch main
    git pull
    git 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.

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

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.

In a disposable repository with at least two commits. Predict each answer before running it.

  1. Run cat .git/HEAD, then cat .git/refs/heads/main. Confirm the two-step indirection.
  2. Run git switch -c experiment. Now run cat .git/refs/heads/experiment and compare it with main. Predict first: will the IDs match?
  3. Make a commit on experiment. Re-read both ref files. Which one changed?
  4. Run git branch --merged main. Is experiment listed? Why not?
  5. Run git merge-base main experiment and find that commit in git log --oneline.
  6. Run git rev-list --left-right --count main...experiment and interpret the two numbers.
  7. Try git branch -d experiment. Read the refusal, then note the commit ID from git log experiment -1.
  8. 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.

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.