Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXX · RemotesRemotes

Remote pruning and cleanup — keeping the local cache honest

Intermediate⏱ ~18 mingit

What you'll learn

  • Explain what git fetch --prune does, which refs it removes, and which it leaves alone
  • Run git remote prune to remove stale refs without performing a full fetch
  • Inspect a ref with git for-each-ref before pruning to confirm it is stale and not a local branch
  • Configure fetch.prune = true so every fetch in the clone prunes by default
  • Diagnose "phantom branch" errors caused by stale remote-tracking refs that survived a deletion on the remote

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 Git clone accumulates stale data over time: branches that existed on the remote six months ago but have since been deleted will still be represented in the local remote-tracking refs until something explicitly cleans them up. The cleanup operation is pruning, and it is one of the safest operations in Git: it only touches refs under refs/remotes/<name>/, it never deletes a local branch, and it never deletes a commit or an object. A pruned ref is a ref that has been removed because the remote no longer reports it; nothing more.

What gets stale, and why

Every git fetch <remote> updates the remote-tracking refs under refs/remotes/<remote>/ to reflect the current state of the remote. But the default fetch is additive: it adds new refs and updates existing ones, but it does not remove refs whose remote counterparts have disappeared.

# A branch is deleted on the remote
# (someone ran `git push origin --delete feature/old-thing`)

# The next git fetch does NOT remove the local mirror
git fetch origin
# (no output for the deleted branch — fetch is silent on deletions)

git branch -r
# origin/main
# origin/feature/old-thing     <-- still here, but the remote doesn't have it

The reason for the additive default is safety. A branch that existed on the remote yesterday should not be silently removed from the local cache today just because the network call succeeded — there might be a network or DNS issue that made the remote’s advertisement incomplete, and silently deleting a ref that turns out to be valid is worse than keeping it. The opt-in to remove stale refs is --prune.

flowchart LR
    A["remote has: main, feature/x"] --> B["local after fetch:\nrefs/remotes/origin/main\nrefs/remotes/origin/feature/x"]
    C["feature/x deleted on remote"] --> D["local after additive fetch:\norigin/main (updated)\norigin/feature/x (stale)"]
    C --> E["local after prune:\norigin/main (updated)\norigin/feature/x removed"]

The additive behaviour is defensive: it assumes the network view might be incomplete and prefers to keep a stale ref over deleting a valid one. The cost is that the local cache accumulates phantom refs over time; the cleanup is --prune.

git fetch —prune

git fetch --prune (or git fetch -p) is the standard way to refresh the local cache and remove refs that the remote no longer advertises. The operation has two phases:

  1. Fetch phase — the same as a normal git fetch: contact the remote, transfer objects, update existing remote-tracking refs to their new OIDs, add new remote-tracking refs for any branches the remote advertises but the local clone does not have.
  2. Prune phase — for every ref currently under refs/remotes/&lt;name&gt;/, check whether the remote advertises it. If the remote does not advertise the ref, delete it from the local namespace.
# Pruning the origin remote
git fetch --prune origin
# remote: Enumerating objects: 12, done.
# From github.com:acme/infra
#    8a3f9d2..4d2c8e0  main           -> origin/main
#  * [new branch]      feature/oidc    -> origin/feature/oidc
#  - [deleted]         (none)          -> origin/feature/old-thing

# Verify the deletion
git branch -r
# origin/main
# origin/feature/oidc
# (origin/feature/old-thing is gone)

The - [deleted] line in the fetch output is the only indication that pruning occurred; it is the visible record of the removal.

Pruning is safe because:

  • It only touches refs under refs/remotes/&lt;name&gt;/. Local branches under refs/heads/ are not affected.
  • It only deletes a ref that the remote did not advertise in this fetch’s response. A network or DNS failure that made the remote’s advertisement incomplete would result in pruning false positives (refs being deleted that are still valid on the remote), but in that case the next successful fetch would add them back.
  • The commits and objects those refs pointed at are not deleted. Pruning removes the ref; the underlying commits remain in the object database and will be cleaned up later by git gc if nothing else references them.

git remote prune

git remote prune &lt;name&gt; runs the prune phase only, without performing a full fetch. It contacts the remote to learn its current ref advertisement, then deletes local remote-tracking refs that the remote no longer advertises.

# Prune without fetching
git remote prune origin
# From github.com:acme/infra
#  - [deleted]         (none)          -> origin/feature/old-thing

The difference from git fetch --prune is that remote prune does not transfer new objects. It is useful in three situations:

  • The local cache is known to be current (a recent fetch ran), and only the deletion needs to be applied.
  • The network is slow and a full fetch is too expensive; a prune-only operation transfers no objects, only the ref advertisement.
  • The operation needs to be scripted as a periodic cleanup step, separate from the regular fetch cadence.
flowchart LR
    A["git remote prune origin"] --> B["contact remote\nadvertise refs only"]
    B --> C["for each refs/remotes/origin/*\nnot in remote advertisement:\ndelete ref"]
    C --> D["no object transfer\nno local branch touched"]

In production, git fetch --prune is the right command for a periodic refresh-and-clean step; git remote prune is the right command when the cleanup needs to be independent of the fetch cadence (e.g., a nightly job that just cleans stale refs without re-pulling everything).

What pruning does NOT do

Three things that look like pruning but are not:

  • It does not delete local branches. A local branch under refs/heads/ is unaffected by any prune operation. A local feature/old-thing branch is independent of the origin/feature/old-thing remote-tracking ref. To delete a local branch, use git branch -d &lt;name&gt; or git branch -D &lt;name&gt; (the -d is for merged branches; -D is forced).
  • It does not delete commits or objects. Pruning deletes a ref (a name that points at an object). The object the ref pointed at remains in the object database until git gc collects it as unreachable. If the deleted ref was the only handle to a chain of commits, those commits will become unreachable and will eventually be garbage-collected.
  • It does not touch upstream configurations. A local branch whose upstream was origin/feature/old-thing does not have its upstream configuration cleared by a prune. The configuration still names the (now-pruned) ref; git pull will fail with “couldn’t find remote ref” until the upstream is reset with git branch --unset-upstream or --set-upstream-to.

The third point is important: after a prune, every local branch whose upstream was pointing at a pruned ref has become orphaned. The fix is to repair the upstream configuration per branch:

# Find orphaned branches after a prune
git for-each-ref --format='%(refname:short) %(upstream:short)' refs/heads | grep -v ' $'
# feature/old-thing (no upstream — good)
# feature/iam-rotation origin/feature/old-thing (orphaned)

# Repair the orphaned branch
git branch --set-upstream-to=origin/feature/iam-rotation feature/iam-rotation

Inspecting before pruning

For high-stakes remotes, the right workflow is to inspect the refs that would be pruned before pruning them. git for-each-ref with a format string can list every remote-tracking ref under a remote, and the remote’s live advertisement can be read with git ls-remote. The diff between the two is the set of refs that --prune would remove.

# What the local clone currently has
git for-each-ref --format='%(refname:short)' refs/remotes/origin
# origin/HEAD
# origin/main
# origin/feature/old-thing

# What the remote currently advertises
git ls-remote origin | awk '{print $2}' | sed 's|^refs/heads/|origin/|'
# origin/HEAD
# origin/main

# The diff is the prune set
diff <(git for-each-ref --format='%(refname:short)' refs/remotes/origin | sort) \
     <(git ls-remote origin | awk '{print $2}' | sed 's|^refs/heads/|origin/|' | sort)
# < origin/feature/old-thing

The diff shows origin/feature/old-thing is on the local side but not on the remote side; that is the ref --prune will remove. Once the inspection confirms the prune set is correct, git fetch --prune (or git remote prune) can be run.

Configuring prune-by-default

For clones that fetch regularly, the additive behaviour is more work than it is worth: every fetch leaves phantom refs that have to be cleaned by a subsequent --prune. Configuring fetch.prune = true in the clone’s config (or the user’s global config) makes every fetch prune by default.

# Configure for one clone (in .git/config)
git config fetch.prune true

# Configure globally for all clones (in ~/.gitconfig)
git config --global fetch.prune true

# Confirm
git config --get fetch.prune
# true

With fetch.prune = true, every git fetch (and git pull) removes stale remote-tracking refs as part of the operation; the additive behaviour is opt-out per-clone by fetch.prune = false. In production, fetch.prune = true is the right default for any clone that depends on remote state staying current; the safety property (only refs/remotes/&lt;name&gt;/* is touched) makes the default safe to leave on.

Production discipline

  1. Configure fetch.prune = true in clone bootstrap. A periodic fetch that leaves phantom refs is a periodic fetch that is doing half its job. The safety property of refs/remotes/&lt;name&gt;/* being the only namespace touched makes the opt-in safe by default.
  2. Inspect before pruning high-stakes remotes. The diff between git for-each-ref refs/remotes/&lt;remote&gt; and git ls-remote &lt;remote&gt; is the exact prune set; review it before running the prune for any remote whose deletions would be costly to recover from.
  3. Prune does not fix orphaned upstream configurations. After a prune, repair every local branch whose upstream was pruned, with git branch --unset-upstream or --set-upstream-to.
  4. Treat a pruned ref as a deletion of the local cache, not of the remote branch. The remote branch was deleted by the upstream; the prune simply updated the local mirror. Confusing the two leads to engineers thinking Git deleted a branch they cared about.
  5. Run git fetch --prune (or fetch.prune = true) as part of every CI pipeline. A CI job that depends on the remote-tracking namespace should not accumulate phantom refs across runs; the cleanup should happen on every fetch.

Cross-course references

  • GitOps with Argo CD - Part VII (PrunePolicy) uses the same idea of distinguishing “delete on the remote” from “remove the local handle”. Argo CD’s prune policy mirrors the Git prune operation: it removes the local Kubernetes resource when the corresponding remote manifest disappears, but only if the prune policy is enabled.
  • Ansible for Production Sysadmins - Part XLI (MirrorSync) uses git fetch --prune on a mirror clone to keep the mirror’s remote-tracking refs aligned with the primary repository’s current branch set.
  • Terraform for Production Sysadmins - Part XVII (ModCache) uses git fetch --prune in the module cache to invalidate stale Terraform module references; a stale ref is a phantom that the next terraform init will resolve to a missing branch.

Quiz

Knowledge check · 4 questions

  1. Q1. What does `git fetch --prune origin` remove from the local clone?

  2. Q2. After `git fetch --prune origin` removes a remote-tracking ref, any local branch whose upstream configuration referenced that ref will continue to work normally with `git pull`.

  3. Q3. Explain the difference between `git fetch --prune origin` and `git remote prune origin`. When would you use each?

  4. Q4. After a teammate deletes a long-lived feature branch on the remote and an engineer runs `git fetch --prune`, several local branches on the engineer's clone start failing. Diagnose and recommend a fix.

    An engineer has been working on `feature/iam-rotation` which tracked `origin/feature/iam-rotation`. A teammate deleted `feature/iam-rotation` on the remote (perhaps after a squash-merge). The engineer runs `git fetch --prune origin` to clean up. Now their `git status` reports `Your branch is based on 'origin/feature/iam-rotation', but the upstream is gone.` and `git pull` fails.

Passing score: 75%. Answers are checked in this browser.