GitGroundworkGit
6 / 18 written chapters

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.

9 min read2 diagrams6 sections
06On this page
  1. What a branch really is on disk
  2. switch and checkout
  3. Detached HEAD
  4. Upstream tracking
  5. Renaming and deleting
  6. Asking questions about branches
Fresher Mid Senior

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.

ATTACHED .git/HEAD holds: ref: refs/heads/main HEAD main 1a0c3e 9c2d4e 7d21e0 c04b8a DETACHED .git/HEAD holds: 9c2d4e7a... HEAD main 1a0c3e 9c2d4e 7d21e0 c04b8a
Attached, HEAD names a branch and a new commit moves the branch. Detached, HEAD names a commit directly, so a new commit moves only HEAD and no branch remembers it.

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.

GoalOld wayNew way
Go to an existing branchgit checkout maingit switch main
Create a branch and go to itgit checkout -b feat/xgit switch -c feat/x
Create it from a given start pointgit checkout -b feat/x origin/maingit switch -c feat/x origin/main
Go back to the previous branchgit checkout -git switch -
Look at an old commit or taggit checkout v2.1.0git switch --detach v2.1.0
Discard edits to one filegit checkout -- app.jsgit 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'
4b7e21d 7c1a9e0 no branch points here git switch -c rescue 7c1a9e0 1a0c3e 9c2d4e main ← HEAD Until you name them, only the reflog remembers these two commits. gc may delete them after about 30 days.
Commits made while detached are real commits. They are just unreachable: no branch, tag or HEAD leads to them. Giving the tip a branch name makes the whole chain reachable again.

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

QuestionCommand
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 firstgit branch --sort=-committerdate
Every branch with its date and author, for a scriptgit for-each-ref --sort=-committerdate --format='%(committerdate:short) %(refname:short) %(authorname)' refs/heads
The same, as a git branch listinggit branch --format='%(refname:short) %(upstream:track)'
How far every branch is ahead of and behind main, in one passgit 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.

Two branches cannot share one checkout

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.

For seniors: when ref names collide

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.

Naming that pays off

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.