Git, CI/CD & GitOpsXX · RemotesRemotes
Remote-tracking branches — the local cache of remote state
What you'll learn
- Locate a remote-tracking ref on disk and explain why it lives under refs/remotes/ and not refs/heads/
- Predict what git fetch writes into the refs/remotes/ namespace and why the writes are local, not remote
- Use git branch -r and git ls-remote to inspect the remote-tracking refs and the remote's actual refs
- Explain why checking out a remote-tracking ref produces a detached HEAD and why direct commits on it are rejected
- Diagnose stale remote-tracking refs as the cause of "behind by N commits" surprises in git status
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
After git clone and a few git fetch operations, your local
clone has two parallel sets of branches: the ones you work on,
under refs/heads/, and the ones the remote has, under
refs/remotes/. The second set is the focus of this lesson.
These are not branches in the usual sense; they are a local
mirror of remote state, written by git fetch, read-only in
normal use, and conceptually closer to a cache than to a
working branch.
Where remote-tracking refs live on disk
A remote-tracking ref is a regular Git ref that lives under
refs/remotes/<remote-name>/<branch-name>. The convention is
that the <remote-name> is the name of the remote (typically
origin), and the <branch-name> is the name of the branch on
that remote. So refs/remotes/origin/main is the local mirror
of the main branch on the origin remote.
# List every remote-tracking ref known to the local clone
git branch -r
# origin/HEAD -> origin/main
# origin/feature/iam-rotation
# origin/feature/oidc-patch
# origin/main
# origin/release/v1.4.0
# Show the OID stored in one of them
git rev-parse origin/main
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# Show the file on disk (loose ref)
ls .git/refs/remotes/origin/
# HEAD
# feature
# main
# release
The origin/HEAD ref is a special case: it is a symbolic ref
that points at whatever branch the remote considers its default
(typically origin/main after a clone). It is updated when you
git remote set-head origin main (or --auto, to ask the
remote), and it is the branch Git checks out when you git clone
without specifying a branch name.
A remote-tracking ref is a ref, not a branch. The distinction is
that under refs/heads/ Git allows commits and fast-forwards; under
refs/remotes/ Git only allows the local git fetch to write to
those refs, and only with the data the remote returned.
flowchart LR
A["git fetch origin"] --> B["contact remote\ngit@github.com:acme/infra.git"]
B --> C["remote sends\nrefs/heads/main = 8a3f9d2..."]
C --> D["apply refspec\nrefs/heads/main -> refs/remotes/origin/main"]
D --> E["refs/remotes/origin/main\nnow points at 8a3f9d2..."]
E --> F["local refs/heads/main\nunchanged"]
This split is why git fetch is safe: it writes into one
namespace (refs/remotes/) and never touches the other
(refs/heads/). The local branch and the remote-tracking ref
coexist in the same clone and update independently.
What git fetch actually writes
git fetch <remote> performs three steps:
- Contact the remote at the URL configured for
<remote>. Negotiate the protocol (smart HTTP, SSH, or Git). - Negotiate which objects to transfer. Using the
haves/wantsexchange or the thin-pack optimisation, the remote computes the minimum set of objects the local clone is missing to have every ref the remote advertises. - Update the remote-tracking refs using the configured
refspec. For the default
origin, that is+refs/heads/*:refs/remotes/origin/*. The+makes the update unconditional; without it, non-fast-forward updates would be rejected.
The local branches under refs/heads/ are not touched. git fetch is a read of remote state and a write of the local cache;
it is not a merge.
# What fetch wrote (typical output)
git fetch origin
# remote: Counting objects: 7, done.
# remote: Compressing objects: 100% (4/4), done.
# remote: Total 5 (delta 2), reused 3 (delta 0)
# From github.com:acme/infra
# 8a3f9d2..4d2c8e0 main -> origin/main
# * [new branch] feature/oidc -> origin/feature/oidc
# - [deleted] (none)
# Verify origin/main moved
git rev-parse origin/main
# 4d2c8e0...
# Verify local main did not move
git rev-parse main
# 8a3f9d2...
The From github.com:acme/infra line is the URL the remote
announced during the smart-HTTP exchange; it is the canonical
name of the remote, not a copy of the URL in .git/config. If
the two differ, something has been misconfigured.
git ls-remote versus git branch -r
Two commands are commonly used to inspect remote state, and they return different things:
git branch -rreads the local refs underrefs/remotes/. It shows the cache, which may be stale relative to the remote. It requires no network and is instantaneous.git ls-remote <remote>contacts the remote and prints the refs the remote currently advertises. It returns fresh data but requires a network round-trip.
# Local cache (no network)
git branch -r
# origin/main
# origin/feature/iam-rotation
# Live remote (network round-trip)
git ls-remote origin
# 8a3f9d2... refs/heads/main
# 9f3c1d7... refs/heads/feature/iam-rotation
# a1b2c3d... refs/tags/v1.4.0
git ls-remote is the truth; git branch -r is the cached
truth. In production, git branch -r is what every offline
command reads (status checks, ahead/behind reporting, log
filters). If the cache is stale, those commands are stale. The
fix is git fetch (with --prune if the remote has deleted
branches the cache still names).
Why you cannot commit on a remote-tracking ref
A git checkout origin/main puts you in a detached HEAD state —
your working tree is at the commit origin/main points at, but
HEAD is not attached to any local branch. The reason is that
remote-tracking refs are owned by git fetch; letting an
engineer commit on one would mean the commit has nowhere to go:
- The remote-tracking ref would advance to the new commit, and
the next
git fetchwould silently overwrite that local commit with whatever the remote returned — a silent data loss. - The remote repository never sees the commit, because
git fetchdoes not push.
Git therefore rejects commits on remote-tracking refs by default. The intended workflow is to check out the remote branch as a new local branch:
# Correct way to start working on a remote branch
git switch -c feature/iam-rotation origin/feature/iam-rotation
# Switched to a new branch 'feature/iam-ration'
# branch.feature/iam-rotation.remote = origin
# branch.feature/iam-rotation.merge = refs/heads/feature/iam-rotation
# Wrong way (detached HEAD, no commit allowed)
git checkout origin/feature/iam-rotation
# Note: switching to 'origin/feature/iam-rotation'.
# You are in 'detached HEAD' state...
The new local branch has its upstream preconfigured by git switch -c ... <remote-branch>. After this, git fetch will
update origin/feature/iam-rotation in the background, and
git status will compare the new local branch against that
remote-tracking ref.
Inspecting the remote-tracking refs
Three useful commands for inspecting the namespace:
# List all remote-tracking refs
git branch -r
# Show the OID and summary of one ref
git log -1 --oneline origin/main
# 4d2c8e0 bump terraform module
# Compare a local branch to its remote-tracking counterpart
git log --oneline main..origin/main
# 4d2c8e0 bump terraform module
# 7e8f9a0 add cert renewal hook
# Diff the working tree against a remote-tracking ref
git diff origin/main
The .. and ... triple-dot notations apply to any two
refs and are particularly useful for remote-tracking refs
because they answer the question “what would I be merging in
if I pulled now?”.
Production discipline
- Fetch before you read the cache. A status check that
reads a stale cache is a status check that does not reflect
the remote.
git fetchis the first step of every CI job and of every interactive session that depends on remote state. - Treat
git branch -ras a snapshot. It is the local cache; it is not the remote. For live state, usegit ls-remote, but only when the network cost is justified. - Never commit on a remote-tracking ref. It is read-only by
design. If you want to work on a remote branch, check it out
as a new local branch with
git switch -c <name> <remote-branch>. - Prune the cache when branches disappear on the remote.
git fetch --pruneremoves remote-tracking refs that no longer correspond to anything on the remote; without it, the cache accumulates phantom refs. - Do not script against
origin/mainblindly. A clone may have renamedoriginto something else. Scripts that operate on the remote-tracking namespace should accept the remote name as a parameter or read it from the branch’s upstream configuration.
Cross-course references
- GitOps with Argo CD - Part III (SyncWaves) reads the remote-tracking namespace to decide which commits are present in a clone; an Argo CD pod that has not fetched recently will read a stale cache and miss new commits.
- Ansible for Production Sysadmins - Part XXXIX
(MirrorRefresh) uses
git fetch(without merge) to refresh the remote-tracking refs in a mirror clone, then triggers a separate sync workflow based on the fresh cache. - Terraform for Production Sysadmins - Part XVI
(ModuleVers) uses
git ls-remotein pre-commit hooks to verify that a referenced module version exists on the remote before the commit lands; this catches typos in version pins without cloning the upstream.
Quiz
Knowledge check · 4 questions
Q1. Where does `git fetch origin` write the data it retrieves from the remote?
Q2. `git branch -r` shows the live state of the remote's branches, because it is a remote-aware command that contacts the remote.
Q3. Explain why `git checkout origin/main` puts you in a detached HEAD state, and what the correct workflow is for working on a remote branch.
Q4. An engineer says "git status says I'm behind origin/main by 12 commits, but I haven't pulled in weeks and the GitHub UI shows commits I haven't seen." Diagnose and recommend a fix.
A developer returns from a two-week vacation. `git status` on their `main` branch reports `Your branch is behind 'origin/main' by 12 commits, and can be fast-forwarded.` They run `git pull` and 12 commits merge in. They check the GitHub UI and notice there are 30 new commits on `main` since they left. They ask why the local status only showed 12.
Passing score: 75%. Answers are checked in this browser.