Skip to main content
RunBook Academy

Git, CI/CD & GitOpsV · Branches, Refs and HEADBranches, Refs and HEAD

Refs and the refs namespace — refs/heads, refs/tags, refs/remotes

Intermediate⏱ ~18 mingit

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

Not yet marked complete on this device.

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-renew all 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/main is the ref Git recorded for main on origin the last time git fetch origin ran.
# 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

  1. Treat ref names as contracts. A rename of master to main is a multi-system migration. Plan, communicate, and coordinate; do not just rewrite the refs.
  2. 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.
  3. Audit the refs namespace. git for-each-ref is the scriptable way to enumerate every ref in a repository. A nightly job that diffs git for-each-ref against yesterday reveals unauthorised ref creation, deletion, and movement.
  4. Never edit .git/refs/ files by hand. Use git update-ref or 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-ref to 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

  1. Q1. What is the on-disk representation of a loose Git ref?

  2. Q2. Branch names containing slashes (such as feature/iam-rotation) are stored as nested directories under .git/refs/heads/.

  3. Q3. Which three top-level directories under .git/refs/ hold the three most common ref types, and what does each hold?

  4. 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.