Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXX · RemotesRemotes

Remote-tracking branches — the local cache of remote state

Intermediate⏱ ~18 mingit

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

Not yet marked complete on this device.

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:

  1. Contact the remote at the URL configured for <remote>. Negotiate the protocol (smart HTTP, SSH, or Git).
  2. Negotiate which objects to transfer. Using the haves/wants exchange 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.
  3. 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 -r reads the local refs under refs/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 fetch would silently overwrite that local commit with whatever the remote returned — a silent data loss.
  • The remote repository never sees the commit, because git fetch does 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

  1. 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 fetch is the first step of every CI job and of every interactive session that depends on remote state.
  2. Treat git branch -r as a snapshot. It is the local cache; it is not the remote. For live state, use git ls-remote, but only when the network cost is justified.
  3. 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>.
  4. Prune the cache when branches disappear on the remote. git fetch --prune removes remote-tracking refs that no longer correspond to anything on the remote; without it, the cache accumulates phantom refs.
  5. Do not script against origin/main blindly. A clone may have renamed origin to 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-remote in 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

  1. Q1. Where does `git fetch origin` write the data it retrieves from the remote?

  2. Q2. `git branch -r` shows the live state of the remote's branches, because it is a remote-aware command that contacts the remote.

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

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