Git, CI/CD & GitOpsV · Branches, Refs and HEADBranches, Refs and HEAD
HEAD and current state — what HEAD actually is
What you'll learn
- Explain that HEAD is a symbolic ref pointing at another ref, not directly at a commit
- Distinguish the symbolic HEAD from the branch it points at and from the commit the branch points at
- Read a symbolic HEAD with git symbolic-ref and resolve it with git rev-parse
- Recognise how HEAD determines the working tree and the index, and why the next commit lands where HEAD points
- Use git rev-parse --abbrev-ref to print the branch name HEAD points at
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
HEAD is the ref that points at the branch the working tree is
currently on. It is not a commit; it is a pointer to a pointer.
The normal value of HEAD is a symbolic reference like
refs/heads/main, which means “the branch I am currently on is
main, and the commit I am currently on is whatever main points
at”. The chain is HEAD -> branch -> commit, and the next commit
you create will update the branch step of that chain, leaving
HEAD pointing at the new tip.
HEAD is a symbolic ref
A symbolic ref is a ref whose value is the name of another
ref, not an OID. HEAD is the most important symbolic ref; there
are a few others (notably ORIG_HEAD, MERGE_HEAD, and
FETCH_HEAD), but HEAD is the one every Git command reads
constantly.
# Read HEAD as a symbolic ref
git symbolic-ref HEAD
# refs/heads/main
# Read HEAD as the OID it resolves to (through the branch ref)
git rev-parse HEAD
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# Read HEAD as the branch name (without refs/heads/)
git rev-parse --abbrev-ref HEAD
# main
# Read HEAD as a short OID
git rev-parse --short HEAD
# 8a3f9d2
The three commands answer three different questions.
git symbolic-ref HEAD asks “what ref does HEAD point at?” and
returns the symbolic name. git rev-parse HEAD asks “what OID
does HEAD ultimately resolve to?” and follows the chain to the
commit. git rev-parse --abbrev-ref HEAD asks “what is the
branch name?” and returns the short name without the
refs/heads/ prefix.
flowchart LR
H["HEAD (symbolic)\nrefs/heads/main"] --> B["refs/heads/main (file)\n8a3f9d2"]
B --> C["commit 8a3f9d2"]
The diagram shows the three layers. HEAD is a pointer to a name.
The name resolves to a file under .git/refs/heads/. The file
contains an OID. Every layer is a single step; the chain has
exactly the length two in the normal case.
Why HEAD is a pointer to a ref, not a commit
If HEAD pointed directly at a commit, then a commit would have no way to be “on the current branch”. The branch would be a derivation: “the branch HEAD is on is the branch whose tip matches HEAD”. That sounds equivalent, but the semantics differ in the case that matters: when you create a new commit, where does the branch move?
flowchart LR
B["refs/heads/main\n8a3f9d2"] --> C1["commit 8a3f9d2"]
C1 --> P1["commit 6f4e5a6"]
NW["new commit"] --> C1
NW2["refs/heads/main moves"] --> B
The diagram shows the moment after git commit runs. The new
commit’s parent is the commit HEAD resolved to before the
commit. The new commit’s OID is its content hash. Then the
branch file is rewritten to point at the new commit. HEAD did
not move — it still points at refs/heads/main. The branch
moved.
If HEAD pointed directly at the commit, the branch would not move. The next commit would have HEAD as its parent, and HEAD would be updated to the new commit, but the branch ref would be left at the old commit. The result is a commit that is not on any branch — an unreachable commit. The symbolic-ref design prevents that by making the branch ref the thing that moves.
HEAD and the working tree
HEAD is not just a ref; it is the boundary between the committing history and the working tree. The chain HEAD -> branch -> commit is the contract that determines what the working tree looks like:
# What commit is the working tree based on?
git rev-parse HEAD
# 8a3f9d2...
# What is the tree of that commit?
git ls-tree HEAD
# 100644 blob e69de29b... README.md
# 040000 tree 4d2c8e0... terraform/
# What is the working tree's current state?
git status
# On branch main
# Changes not staged for commit:
# modified: terraform/main.tf
git status reports “On branch main” because HEAD points at
refs/heads/main. The working tree is the result of checking
out the tree at commit 8a3f9d2 and then applying the diff
between the index and the working tree. When you commit, the
new commit’s parent is the commit HEAD resolves to, and the
branch HEAD points at is updated.
A command that switches branches — git checkout, git switch — does three things: it updates HEAD to point at the
new branch, it updates the index to match the new branch’s
tip, and it updates the working tree to match the index. The
order is important: HEAD is updated first, the index second,
the working tree last. If the working tree update fails (because
of a conflict with local changes), HEAD and the index are
already updated, and the failure is recoverable by checking
out the previous branch.
Reading HEAD in scripts
A script that needs “what branch am I on?” has three options, each with different semantics:
# Option 1: the symbolic ref (most informative)
git symbolic-ref HEAD
# refs/heads/main
# Option 2: the short branch name (most common)
git rev-parse --abbrev-ref HEAD
# main
# Option 3: the upstream branch (most useful for push/pull)
git rev-parse --abbrev-ref --symbolic-full-name HEAD@{upstream}
# origin/main
Option 1 returns the full ref path. Option 2 strips the
refs/heads/ prefix. Option 3 asks Git “what branch on a
remote does this branch track?” — the answer is the upstream
in the form origin/main. For automation that decides whether
to push, the third option is the right one: it tells you the
remote branch that will be updated.
A script that needs the commit OID HEAD resolves to uses
git rev-parse HEAD:
# Pin the deployment to the current commit
DEPLOY_OID=$(git rev-parse HEAD)
echo "Deploying $DEPLOY_OID"
This is the discipline every production deployment script should follow: resolve HEAD to an OID at the moment of deployment, log the OID, and use it as the unit of identity for the rest of the pipeline. The ref name can move; the OID cannot.
What HEAD is not
HEAD is not a special kind of commit. It is not a “current state indicator” that holds anything beyond a ref name. It is not stored in the object store, not signed, and not propagated between clones. Different clones have different HEADs; HEAD is a per-clone state.
# Clone A's HEAD
git -C /path/to/clone-a symbolic-ref HEAD
# refs/heads/main
# Clone B's HEAD (different branch)
git -C /path/to/clone-b symbolic-ref HEAD
# refs/heads/feature/iam-rotation
Two clones of the same repository can have HEADs pointing at different branches at the same moment. The commits are shared (the object store is the same), but the working tree and the index are local to each clone. HEAD is the line between the shared (commits) and the local (working tree, index, current branch).
Moving HEAD safely
The safe way to move HEAD is git checkout or git switch,
which update HEAD, the index, and the working tree in the
correct order. The unsafe way is git symbolic-ref HEAD, which
updates HEAD only and leaves the index and the working tree
out of sync:
# Safe: update HEAD, index, and working tree in one command
git checkout feature/iam-rotation
# Unsafe: update HEAD only, leaving index and working tree on the old branch
git symbolic-ref HEAD refs/heads/feature/iam-rotation
The unsafe version is the right tool for some plumbing tasks
(for example, fixing a corrupted .git/HEAD file), but it is
not a substitute for git checkout. A working tree whose HEAD
points at one branch but whose index and files match another
is a corrupted state that Git will refuse to commit on.
Production discipline
- Resolve HEAD to an OID at deployment time. Pin the commit, not the branch. The branch can move; the OID cannot.
- Use
git rev-parse --abbrev-ref HEADto determine “where am I?” That is the canonical answer. Do not parse the output ofgit branch; do not check the working tree directory; do not assumemainis the current branch. - Treat HEAD as a per-clone state. Two clones can have different HEADs. A pipeline that assumes “the deployment clone is on main” without checking should be rewritten to check.
- Never write to HEAD directly.
git symbolic-ref HEADis a plumbing operation for fixing a corrupted.git/HEAD. For day-to-day branch switching, usegit checkoutorgit switch.
Cross-course references
- Ansible for Production Sysadmins - Part XXXVIII (Review)
uses
git rev-parse HEADto pin the commit in CI artifacts so that a deployment is reproducible from the log, not from the branch state at the moment of the deploy. - GitOps with Argo CD - Part IV (SyncPhases) reads HEAD from the GitOps repo to determine which commit to sync to; the production discipline is to pin by OID, not by HEAD, to prevent a half-finished sync from jumping to a different commit.
- Container and Kubernetes - Part III (ImageTags) draws the analogy between HEAD and the latest tag: both are “what is current in this clone”, and both are unsafe to pin in a pipeline because they can move. Pin by digest, not by pointer.
Quiz
Knowledge check · 4 questions
Q1. What does HEAD point at in a normal (non-detached) Git working tree?
Q2. When you run `git commit`, HEAD moves to the new commit.
Q3. Which three commands read HEAD, and what does each return?
Q4. Diagnose a deployment pipeline that is racing HEAD and recommend the correct pinning discipline.
A deployment pipeline runs `git pull && deploy.sh` on a tag. The pipeline logs 'Deploying from main' but the deployment is non-reproducible: subsequent runs of the same pipeline produce different binaries because the latest pull advanced main between the first run and the second. The audit asks: which commit was deployed on each date?
Passing score: 75%. Answers are checked in this browser.