Git, CI/CD & GitOpsV · Branches, Refs and HEADBranches, Refs and HEAD
Refs and the refs namespace — refs/heads, refs/tags, refs/remotes
What you'll learn
- Explain what a ref is and why a ref is simply a 40-character SHA-1 pointing at a single object
- Locate a branch, a tag, and a remote-tracking ref under .git/refs and read its file contents
- Distinguish refs/heads, refs/tags, and refs/remotes by purpose and naming convention
- Use git for-each-ref and git update-ref to inspect and manipulate refs from the command line
- Recognise why a ref name is a contract and why branch names must be valid
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
A ref is the most basic named pointer in Git. A ref is a file
under .git/refs/ whose entire contents are a 40-character SHA-1
(or SHA-256 in newer Git) string followed by a newline. That is
the whole ref: a name, a file, and one line of text. From the
ref’s name you know what it points at; from the file’s contents
you know where it points to; from the OID it points at you can
reach the commit, the tree, the blobs, and the entire repository
state at that moment. Every branch, every tag, every
remote-tracking reference is a ref, and nothing else.
The refs namespace is a hierarchy
The ref namespace is a directory tree inside .git/refs/. The
top-level subdirectories are fixed by convention: heads/ for
branches, tags/ for tags, remotes/ for remote-tracking refs,
and a few more for special purposes (notes/, replace/,
bisect/). The path of a ref is just its path under .git/refs/
relative to the .git root.
flowchart LR
R[".git/refs/"] --> H["heads/"]
R --> T["tags/"]
R --> REM["remotes/"]
H --> H1["main"]
H --> H2["feature/iam"]
T --> T1["v1.0.0"]
T --> T2["v1.1.0"]
REM --> REM1["origin/main"]
REM --> REM2["origin/develop"]
Branch names are paths under refs/heads/. A branch called
feature/iam-rotation is stored at the file
.git/refs/heads/feature/iam-rotation. Slashes in branch names
become directory separators, which is why a branch name is
effectively a path and why /, .., and trailing slashes are
forbidden in branch names.
# Inspect the file under the ref namespace
cat "$GIT_DIR/refs/heads/main"
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
The output is one line: the 40-character SHA-1 of the commit the branch currently points at. That SHA is the branch’s tip — the most recent commit on the branch’s first-parent chain. The branch “is” that file; the branch history is the DAG reachable from that SHA.
The three namespaces in practice
The three namespaces you will touch most often serve different purposes:
refs/heads/— local branches. A branch here is a moving ref that advances each time you commit on it.main,develop,feature/iam-rotation,hotfix/cert-renewall live here.refs/tags/— tags. A tag here is intended to be immutable. Lightweight tags are a single line pointing at a commit; annotated tags point at a tag object first.refs/remotes/— remote-tracking refs. These mirror the branches on a remote as of the last fetch.origin/mainis the ref Git recorded formainonoriginthe last timegit fetch originran.
# List refs in each namespace
git for-each-ref --format='%(refname) -> %(objectname:short)' refs/heads
# refs/heads/main -> 8a3f9d2
# refs/heads/feature/iam-rotation -> 9f3c1d7
git for-each-ref --format='%(refname) -> %(objectname:short)' refs/tags
# refs/tags/v1.0.0 -> 4d2c8e0
git for-each-ref --format='%(refname) -> %(objectname:short)' refs/remotes
# refs/remotes/origin/main -> 8a3f9d2
The OIDs you see in the output are the only thing that matters about a ref. The ref name is the human-friendly handle; the OID is the cryptographic commitment. A tag OID commits to a commit OID the way a commit OID commits to a tree OID — transitively, and with no shared mutable state.
flowchart LR
BH["refs/heads/main\n8a3f9d2"] --> C["commit\n8a3f9d2"]
BT["refs/tags/v1.0.0\n4d2c8e0"] --> TC["tag object\n4d2c8e0"] --> CC["commit\n8a3f9d2"]
BR["refs/remotes/origin/main\n8a3f9d2"] --> C
Notice that refs/heads/main and refs/remotes/origin/main both
hashed to 8a3f9d2: the local branch and the remote-tracking
ref agree. When they disagree, the local branch has commits the
remote does not (or vice versa), and that is the case
git push and git fetch exist to reconcile.
Manipulating refs directly
git update-ref writes a ref directly, bypassing the working
tree, the index, and most of the safety that porcelain commands
provide. It is the plumbing layer for ref updates:
# Point refs/heads/main at a specific commit
git update-ref refs/heads/main "$COMMIT_OID"
# Create a new branch at a specific commit without checking it out
git update-ref refs/heads/feature/iam-rotation "$COMMIT_OID"
# Delete a ref by passing an empty new value
git update-ref -d refs/heads/feature/iam-rotation
The first argument is the ref name (the full path under the
refs namespace). The second is the OID to point it at. The
-d flag deletes the ref. Because git update-ref writes
whatever OID you give it, it is the right tool for automation
that needs to set refs to specific known values (for example,
pinning a release branch to a specific commit computed by an
external system) and the wrong tool for day-to-day branch work
(use git branch, git checkout, git reset for that).
git for-each-ref is the read-side counterpart. It iterates
through the refs namespace and prints them with a format
template:
# One line per ref, with the OID and the upstream (if any)
git for-each-ref --format='%(refname:short) %(objectname:short) %(upstream:short)' refs/heads
# main 8a3f9d2 origin/main
# feature/iam-rotation 9f3c1d7 origin/feature/iam-rotation
git rev-parse resolves a ref name to its OID and is the
foundation every other command builds on:
git rev-parse refs/heads/main
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
git rev-parse --short refs/heads/main
# 8a3f9d2
When git rev-parse cannot resolve a name, it exits non-zero
and prints an error. This is why every script that takes a
ref name as input should pass it through git rev-parse --verify first: it gives you a single, well-defined failure
mode for “I don’t know what you mean”.
Why a ref is just a 40-char SHA
The ref’s complete on-disk representation is a one-line file containing a 40-character SHA-1 and a newline. Git does not store timestamps in the ref, ownership metadata, signature, or anything else — those concerns are handled by the reflog and by the commit object the ref points at, not by the ref itself. This is the property that makes refs cheap to copy, cheap to compare, and cheap to verify: a ref is just a string, and the string is the answer to “what commit is this branch on?”.
flowchart LR
A["branch name: main"] --> B["ref path: refs/heads/main"]
B --> C["file contents: 40-char SHA + newline"]
C --> D["commit object: 8a3f9d2"]
D --> E["tree object"]
E --> F["blob objects"]
The 40-character SHA is also a security boundary. Because the SHA is a content hash of the commit (which contains a content hash of the tree, which contains content hashes of the blobs), the OID commits to the entire repository state at that commit. You cannot change a single byte of any file in the repository without changing every OID on the path back to the root commit. A ref that points at a specific OID is a ref that pins an entire repository state, byte-for-byte.
Production discipline
- Treat ref names as contracts. A rename of
mastertomainis a multi-system migration. Plan, communicate, and coordinate; do not just rewrite the refs. - Pin deployment pipelines by OID, not ref name. When a
pipeline needs to deploy a specific commit, resolve the ref
to an OID with
git rev-parse <ref>and use the OID downstream. The ref name can move; the OID cannot. - Audit the refs namespace.
git for-each-refis the scriptable way to enumerate every ref in a repository. A nightly job that diffsgit for-each-refagainst yesterday reveals unauthorised ref creation, deletion, and movement. - Never edit
.git/refs/files by hand. Usegit update-refor the porcelain commands. A hand-edited ref with a newline that is not at the end, or a SHA that is not exactly 40 hex characters, will be ignored by Git without warning.
Cross-course references
- Ansible for Production Sysadmins - Part XXXVIII (Review)
uses
git for-each-refto enumerate every branch in an Ansible repository before a release so the release notes capture every in-flight branch, not just the trunk. - Docker for Production Sysadmins - Part VII (Tagging) draws the parallel between Git refs and OCI image tags: both are mutable names that point at a content-addressed object, and both are intended to be either moved (branches) or fixed (tag and image manifest).
- Terraform for Production Sysadmins - Part IX (State) notes that Terraform state version numbers are append-only pointers similar to refs; the analogue of “pin by OID” is “pin by state version number”, not by workspace name.
Quiz
Knowledge check · 4 questions
Q1. What is the on-disk representation of a loose Git ref?
Q2. Branch names containing slashes (such as feature/iam-rotation) are stored as nested directories under .git/refs/heads/.
Q3. Which three top-level directories under .git/refs/ hold the three most common ref types, and what does each hold?
Q4. Diagnose a production readiness question about a ref's history, and choose the correct tool to answer it.
A security team is preparing for a SOX audit and needs to know, for every release in the last 18 months, the exact commit OID that was deployed. The CI pipeline deploys from the tag named `prod-<date>` (e.g. `prod-2024-09-15`). The pipeline currently logs the tag name but not the resolved OID. The audit asks: which OID was deployed on each date?
Passing score: 75%. Answers are checked in this browser.