Understanding Branches
A branch in Git is simply a movable, lightweight pointer to a single commit. When you create a branch, Git doesn’t copy any files or duplicate your project — it just writes a small pointer that says “this branch currently points at commit X.” Branches let you work on new features, bug fixes, or experiments in complete isolation from your stable code, then bring that work back together later with a merge. Understanding what a branch actually is under the hood — rather than thinking of it as a separate “copy” of your project — is the key to using Git confidently.
Overview: How Branches Work
Every time you run git commit, Git creates a new commit object in its internal database. That commit object stores a few things: a pointer to a tree object (a snapshot of every file and directory at that point in time), a pointer to the parent commit (or commits, for a merge), the author and committer information, and the commit message. Each file’s actual content is stored separately as a blob object, and the tree is what maps file names to blobs. Every object — blob, tree, or commit — is identified by a SHA-1 hash of its content, which is why two commits with identical content anywhere in your history end up sharing the same underlying objects.
A branch is nothing more than a file inside .git/refs/heads/ containing the 40-character SHA-1 of a commit. When you run git branch feature/login-page, Git writes a new file at .git/refs/heads/feature/login-page containing the same commit SHA that your current branch points to. That’s the entire operation — no files are copied, so creating a branch is essentially instantaneous even in a huge repository.
HEAD is a special reference that tells Git which branch — and therefore which commit — you currently have checked out. Normally HEAD is a “symbolic ref” that points at a branch name, e.g. ref: refs/heads/main, and that branch in turn points at a commit. When you make a new commit, Git updates the branch that HEAD points to so it now points at the new commit — this is what makes a branch “move forward” as you work. If instead you check out a specific commit SHA or a tag rather than a branch name, HEAD points directly at that commit instead of at a branch; this state is called detached HEAD. You can still make commits in detached HEAD, but since no branch pointer follows along, those commits can become unreachable and get garbage-collected unless you create a branch to keep pointing at them.
Because a branch is just a pointer, switching branches with git switch or git checkout does two things: it moves HEAD to point at the target branch, and it rewrites your working directory and the index (Git’s staging area) to match the snapshot recorded in that branch’s latest commit. This is also why Git normally refuses to switch branches if you have uncommitted changes that would be overwritten — it protects your working tree from silently losing data.
Syntax
The general forms for working with branches:
git branch # list local branches
git branch "<branch-name>" # create a new branch
git switch "<branch-name>" # switch to an existing branch
git switch -c "<branch-name>" # create and switch to a new branch
git branch -d "<branch-name>" # delete a branch already merged in
git branch -D "<branch-name>" # force-delete an unmerged branch
git checkout "<branch-name>" # switch to an existing branch (older syntax)
git checkout -b "<branch-name>" # create and switch (older syntax)
| Flag / Command | Meaning |
|---|---|
git branch |
List local branches; the current branch is marked with *. |
git branch -a |
List local and remote-tracking branches. |
git branch -v |
List branches with their latest commit summary. |
git branch -vv |
List branches with the remote branch each one tracks. |
git branch "<name>" |
Create a new branch pointing at the current commit; does not switch to it. |
git switch -c "<name>" |
Create a new branch and switch to it in one step. |
git switch "<name>" |
Switch to an existing branch. |
git branch -m "<old>" "<new>" |
Rename a branch. |
git branch -d "<name>" |
Delete a branch, but only if it is fully merged. |
git branch -D "<name>" |
Force-delete a branch even if it has unmerged commits. |
Examples
Example 1: Creating and switching to a new branch
git branch
git switch -c feature/login-page
git branch
Output:
* main
Switched to a new branch 'feature/login-page'
* feature/login-page
main
The first git branch shows only main, with the asterisk marking it as current. git switch -c feature/login-page creates a new ref file at .git/refs/heads/feature/login-page pointing at the same commit as main, then moves HEAD to point at it. The second git branch now lists both branches, with feature/login-page marked current.
Example 2: Watching branches diverge
git switch feature/login-page
echo "console.log('login page');" > login.js
git add login.js
git commit -m "feat: add initial login page script"
git switch main
git log --oneline --graph --all
Output:
Switched to branch 'feature/login-page'
[feature/login-page a1b2c3d] feat: add initial login page script
1 file changed, 1 insertion(+)
create mode 100644 login.js
Switched to branch 'main'
* a1b2c3d (feature/login-page) feat: add initial login page script
* e4f5g6h (HEAD -> main) Initial commit
Committing on feature/login-page creates a new commit object and moves only the feature/login-page ref forward to point at it — main is untouched. Switching back to main restores the working directory to the snapshot at e4f5g6h, so login.js disappears from the working tree until you switch back or merge. The --graph --all flags on git log visualize both branch tips at once.
Example 3: Merging, then deleting a branch
git switch main
git merge feature/login-page
git branch -d feature/login-page
Output:
Already on 'main'
Updating e4f5g6h..a1b2c3d
Fast-forward
login.js | 1 +
1 file changed, 1 insertion(+)
create mode 100644 login.js
Deleted branch feature/login-page (was a1b2c3d).
Because no new commits had been made on main since the branches diverged, Git performs a fast-forward merge: it simply moves the main pointer up to a1b2c3d instead of creating a merge commit. Once the branch’s work is fully incorporated into main, git branch -d safely deletes the now-unneeded feature/login-page ref — Git refuses this command if the branch contains commits main doesn’t have yet, which is exactly the safety check that protects you from losing work.
How It Works Step by Step
When you run git switch -c feature/login-page, Git performs these steps internally:
- Reads the commit SHA that the current branch (e.g.
main) points to. - Writes a new file,
.git/refs/heads/feature/login-page, containing that same SHA. - Updates
.git/HEADso it now containsref: refs/heads/feature/login-page. - Leaves the working directory and index untouched, because the new branch points at the identical commit you were already on.
When you later run git commit on that branch, Git:
- Builds a tree object (and any necessary sub-trees) from what’s currently staged in the index.
- Creates a new commit object whose parent is the commit
HEADcurrently points to, and whose tree is the one just built. - Overwrites
.git/refs/heads/feature/login-pagewith the SHA of this new commit — this is the “branch moves forward” behavior.
Switching branches with git switch main reverses the direction: Git compares the tree of the commit you’re leaving with the tree of the commit you’re moving to, updates every file in your working directory and the index to match the target tree, and finally repoints HEAD at the target branch.
Common Mistakes
Mistake 1: Committing directly on main
It’s easy to forget to create a branch before starting work:
# Wrong: working straight on main
git switch main
echo "fix" >> app.js
git add app.js
git commit -m "fix: correct off-by-one error"
Now the fix is stuck on main, mixed in with whatever else lands there next, with no isolated history for review. The fix is to create a branch for the commit you just made, then move main back to where it was before:
# Right: move the mistaken commit onto its own branch
git branch fix/off-by-one-error
git reset --hard HEAD~1
git switch fix/off-by-one-error
git branch fix/off-by-one-error creates a ref pointing at the commit you just made, then git reset --hard HEAD~1 moves main back one commit — because the fix is now safely preserved by the new branch, resetting main doesn’t lose anything. git reset --hard discards working-tree changes, so always confirm the commit you want is preserved on another ref first.
Mistake 2: Force-deleting a branch with unmerged work
git branch -d experiment/new-cache
Output:
error: The branch 'experiment/new-cache' is not fully merged.
If you are sure you want to delete it, run 'git branch -D experiment/new-cache'.
Git’s -d flag is refusing to delete a branch that has commits not yet merged anywhere else — that error is a safety net, not a bug. Reaching for git branch -D experiment/new-cache out of frustration force-deletes the branch, and its unmerged commits become unreachable, typically lost for good once garbage collection runs. Before forcing the delete, first confirm you really don’t need that work — check git log experiment/new-cache, merge it, or push it somewhere safe.
Mistake 3: Confusing git branch with git switch -c
git branch feature/reports only creates the branch — it does not switch you to it, so the very next commit still lands on whatever branch you were already on. Reach for git switch -c feature/reports (or the older git checkout -b feature/reports) when you want to create and start working on a branch in one step; use plain git branch feature/reports only when you deliberately want to create a branch without moving to it.
Best Practices
- Branch from an up-to-date
main(git switch mainthengit pull) so your new branch starts from the latest history. - Use descriptive, prefixed names such as
feature/user-login,fix/null-pointer, orchore/update-depsso the branch’s purpose is obvious in listings and pull requests. - Keep branches short-lived and focused on one piece of work; long-lived branches accumulate painful merge conflicts.
- Prefer
git switchandgit restorefor everyday branch and file operations — they’re purpose-built and less error-prone than the overloadedgit checkout. - Delete branches after they’re merged (
git branch -d) to keepgit branch -areadable; GitHub can do this automatically for merged pull requests. - Protect
mainon GitHub with a branch protection rule so nobody can push directly or force-push to it. - Run
git branch -vvperiodically to see which local branches are tracking which remote branches, and which are ahead or behind.
Practice Exercises
- Inside an existing Git repository, create a branch named
fix/readme-typo, fix a typo inREADME.md, and commit it. Switch back tomainand confirm the typo is still there, then merge your fix in. - Create two branches from
main,feature/aandfeature/b, and make one commit on each that touches a different file. Rungit log --oneline --graph --alland identify where the history diverges. - Create a throwaway branch called
experiment/scratch, make a commit on it, then try to delete it withgit branch -d experiment/scratchwithout merging first. Read the error Git gives you, then decide whether to merge it or force-delete it with-D.
Summary
- A branch is a lightweight, movable pointer to a commit, stored as a small file under
.git/refs/heads/. HEADtracks which branch — or, in detached HEAD state, which commit — you currently have checked out.git switch -c "<name>"creates and switches to a branch in one step; plaingit branch "<name>"only creates it.- Committing moves the current branch’s pointer forward to the new commit; it never affects other branches.
git branch -dsafely refuses to delete branches with unmerged work;git branch -Dforce-deletes and can lose commits.- Merging brings another branch’s commits into your current branch, fast-forwarding when possible or creating a merge commit otherwise.
