G6 · Part 2 · Working together
Branches, HEAD, and detached HEAD
A branch is a 41-byte file that names a commit, and HEAD is a file that names a branch. Everything else follows.
06On this page
HEAD is Git's answer to "where am I right
now?" Normally it points at a branch name, and that branch
points at a commit. Two levels of indirection:
HEAD → main → a3f9c1. When you commit,
main advances and HEAD comes along for the ride,
because HEAD never named the commit in the first place. It
named the branch.
What a branch really is on disk
A branch is a ref: a name that resolves to one commit
ID. In a fresh repository each branch is a small text file under
.git/refs/heads/ containing a 40-character hash and
a newline. Creating a branch writes that file. It copies no
code and takes no time, which is why Git people branch for
everything.
look inside .git
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
c04b8a91e2d7f3a6b5c4d9e8f7a6b5c4d3e2f1a0
$ git symbolic-ref HEAD
refs/heads/main
$ git rev-parse HEAD
c04b8a91e2d7f3a6b5c4d9e8f7a6b5c4d3e2f1a0
$ git rev-parse --abbrev-ref HEAD
main
The ref: prefix is what makes HEAD a
symbolic ref: a ref that points at another ref
rather than at a commit. When you commit, Git follows HEAD to
refs/heads/main and rewrites that file
with the new commit's hash. HEAD itself is untouched.
Do not rely on reading those files in scripts. Git packs old
refs into a single .git/packed-refs file, so a
branch can exist with no file under refs/heads/ at
all, and newer repositories can use the reftable
format, which stores refs in a binary table. Use
git rev-parse, git symbolic-ref and
git for-each-ref. They work whatever the storage.
switch and checkout
git checkout does two unrelated jobs: it moves HEAD
between branches, and it overwrites files in your working tree
from some commit. The same command with slightly different
arguments can either change branch or silently throw away your
edits to a file. Git 2.23 split it into two commands with
narrower jobs: git switch for branches and
git restore for files. checkout still
works and is not going away, but the new pair is harder to
misuse.
| Goal | Old way | New way |
|---|---|---|
| Go to an existing branch | git checkout main | git switch main |
| Create a branch and go to it | git checkout -b feat/x | git switch -c feat/x |
| Create it from a given start point | git checkout -b feat/x origin/main | git switch -c feat/x origin/main |
| Go back to the previous branch | git checkout - | git switch - |
| Look at an old commit or tag | git checkout v2.1.0 | git switch --detach v2.1.0 |
| Discard edits to one file | git checkout -- app.js | git restore app.js |
One detail makes switch safer: it will not detach
HEAD unless you ask. git switch v2.1.0 fails with
fatal: a branch is expected, got tag 'v2.1.0' and a
hint to add --detach, where checkout
would quietly detach you. And if you
run git switch feat/search when only
origin/feat/search exists, both commands create a
local branch that tracks it. That shortcut is called
DWIM ("do what I mean"), and it only fires when exactly
one remote has a branch by that name.
Detached HEAD
Check out a commit directly and HEAD now holds a commit ID with no branch in between. It is not an error. Git puts you there on purpose in several places:
- checking out a tag or a commit hash to look at old code;
- during
git bisect, which walks you across commits; - in the middle of a rebase, while commits are replayed one by one;
- inside a submodule, which records a commit, not a branch;
- in most CI systems, which check out the exact commit under test.
The only risk is commits you make there. They belong to no branch, so once HEAD moves away, nothing points at them. Git warns you as you leave:
leaving a detached HEAD with work on it
$ git switch main
Warning: you are leaving 2 commits behind, not connected to
any of your branches:
7c1a9e0 Try a bigger read buffer
4b7e21d Log the queue depth
If you want to keep them by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> 7c1a9e0
Switched to branch 'main'
keeping work made in a detached HEAD
$ git switch -c rescue/read-buffer
Switched to a new branch 'rescue/read-buffer'
$ git reflog -5
2f6a0b3 (HEAD -> main) HEAD@{0}: checkout: moving from 7c1a9e0 to main
7c1a9e0 HEAD@{1}: commit: Try a bigger read buffer
4b7e21d HEAD@{2}: commit: Log the queue depth
9c2d4e7 (tag: v2.1.0) HEAD@{3}: checkout: moving from main to v2.1.0
2f6a0b3 (HEAD -> main) HEAD@{4}: commit: Update changelog
$ git branch rescue/read-buffer 7c1a9e0
The first command is for when you are still detached: it names
the commits where they stand. The other two are for when you
already switched away: the reflog is your local diary of every
place HEAD has been, so the lost tip is sitting in it.
Unreachable reflog entries expire after 30 days by default
(gc.reflogExpireUnreachable), and only then can
git gc delete the commits, so you have a generous
window. The reflog is local, though: a teammate's clone knows
nothing about your lost commits.
Upstream tracking
A local branch can have an upstream: a
remote-tracking branch it is paired with. The pairing lives in
.git/config as two keys,
branch.<name>.remote and
branch.<name>.merge. It is what lets a bare
git pull and git push know where to go,
and it powers the "ahead 2, behind 3" counts you see in
git status.
setting and reading the upstream
$ git push -u origin feat/coupons
branch 'feat/coupons' set up to track 'origin/feat/coupons'.
$ git branch --set-upstream-to=origin/main main
branch 'main' set up to track 'origin/main'.
$ git config --get-regexp '^branch\.feat/coupons\.'
branch.feat/coupons.remote origin
branch.feat/coupons.merge refs/heads/feat/coupons
$ git rev-parse --abbrev-ref @{upstream}
origin/feat/coupons
$ git rev-list --left-right --count HEAD...@{u}
2 0
@{u} is short for @{upstream}, and you
can use it anywhere Git expects a commit:
git log @{u}.. lists what you have not pushed yet,
and git log ..@{u} lists what you have fetched but
not merged. The last command above prints two numbers:
commits only on your side, then commits only on the upstream
side. Remember that the counts compare against your last
fetch, not against the server right now.
git branch -vv
$ git branch -vv
* feat/coupons 3e1f0a2 [origin/feat/coupons: ahead 2] Validate coupon expiry
fix/null-cart 9b04c11 [origin/fix/null-cart: gone] Guard empty cart
main c04b8a9 [origin/main: behind 3] Merge pull request #412
spike/cache 51d7e3a Try an LRU in front of the price API
Read each line as: branch, tip commit, upstream and how far
apart they are, subject line. gone means the
upstream was deleted on the server and you fetched with
--prune, which usually means the pull request was
merged and this branch can go. A branch with no bracket has
never been pushed.
Renaming and deleting
rename, locally and on the remote
$ git branch -m feat/coupon feat/coupon-stacking
$ git push origin -u feat/coupon-stacking
$ git push origin --delete feat/coupon
A remote has no rename operation, so a rename there is always
"push the new name, delete the old one". Any open pull request
on the old name is closed on most hosts, so rename before you
open one. -M is the forced form of
-m, which overwrites a branch that already has the
new name.
-d versus -D
$ git branch -d spike/cache
error: the branch 'spike/cache' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D spike/cache'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
$ git branch -D spike/cache
Deleted branch spike/cache (was 51d7e3a).
-d only deletes a branch whose commits are all
reachable from its upstream, or from HEAD if it has no upstream.
That refusal is a safety net, not a nuisance. Note the trap:
if your team squash-merges pull requests, the branch's
original commits never land on main. Main gets one new commit
with the same changes, so Git correctly reports the branch as
unmerged and you need -D. Even after
-D, the tip hash is printed and stays in the
reflog, so the delete is recoverable with
git branch spike/cache 51d7e3a.
Asking questions about branches
| Question | Command |
|---|---|
| Which branches are already in main and safe to delete? | git branch --merged main |
| Which still have work that is not in main? | git branch --no-merged main |
| Which branches contain this bug-fix commit? | git branch -a --contains 8f2c9d1 |
| Which release tags contain it? | git tag --contains 8f2c9d1 |
| Most recently touched branches first | git branch --sort=-committerdate |
| Every branch with its date and author, for a script | git for-each-ref --sort=-committerdate --format='%(committerdate:short) %(refname:short) %(authorname)' refs/heads |
The same, as a git branch listing | git branch --format='%(refname:short) %(upstream:track)' |
| How far every branch is ahead of and behind main, in one pass | git for-each-ref --format='%(refname:short) %(ahead-behind:main)' refs/heads (Git 2.41 and later) |
| Is branch A an ancestor of branch B? | git merge-base --is-ancestor A B (exit code 0 means yes) |
cleaning up merged branches
$ git fetch --prune
$ git branch --merged main | grep -vE '^\*|^[[:space:]]*(main|develop)$' | xargs -r git branch -d
Deleted branch fix/tax-round (was 44a09be).
Deleted branch feat/search-box (was e2c81d0).
The pipeline keeps the current branch and your long-lived
branches out of the list, then deletes the rest with the safe
-d. Since it uses -d, the worst case
is a refusal. xargs -r is the GNU spelling for
"do nothing if the list is empty"; on macOS xargs
already behaves that way.
Git refuses to check out a branch that is already checked out
in another worktree
(fatal: 'main' is already used by worktree at '../shop-main').
That rule is what keeps two working directories from moving
the same branch under each other. If you want to look at
main while a feature is half done, add a worktree with
git worktree add ../shop-main main rather than
stashing.
With the default files ref storage, a branch name is a
path under .git/refs/heads/. That has two
consequences teams trip over. On macOS and Windows, whose file
systems ignore case, Fix/login and
fix/login are the same file, so a fetch that brings
both from a Linux user fails or makes one shadow the other. And
feat and feat/x cannot both exist,
because one would have to be a file and a directory at once.
The reftable backend fixes the case problem, since names are no
longer paths, but Git keeps refusing feat next to
feat/x on every backend so that repositories stay
portable. Enforce lower-case prefixes with a server-side rule,
and run
git check-ref-format --branch "name" in tooling that
creates branches, to reject names Git would refuse anyway.
Use a prefix and a ticket ID:
feat/PAY-231-coupon-stacking,
fix/PAY-288-null-cart,
chore/bump-node-20. Six months later, a branch
list is a readable index of what the team has been doing,
and tooling can filter on the prefix. One limit: once
feat exists as a branch, feat/x
cannot be created, and the other way round.
Finished reading?
0 of 18 chapters read in this series