Iterating refs with for-each-ref Jump to heading

Scripts that work with branches and tags β€” cleanup jobs, release tooling, reports on stale work β€” usually start by parsing git branch -a or git tag. Those commands decorate their output for people: an asterisk for the current branch, arrows for symbolic refs, colour, column layout, translated β€œ(HEAD detached at …)” lines. git for-each-ref is the plumbing equivalent and far more capable. It lists any refs you name, prints exactly the fields you ask for in a format you define, sorts them, and filters by whether they are merged into or contain a given commit. Most branch-management automation is one for-each-ref call followed by a simple loop. This page covers the fields and filters that matter, within scripting Git with plumbing commands.

When to use this approach Jump to heading

  • A script parses git branch, git branch -r or git tag output.
  • You need branch metadata β€” last commit date, author, upstream, ahead/behind counts β€” in a script.
  • You are building cleanup or reporting, such as deleting merged branches automatically.
  • You want to list tags in version order for release tooling.

Step 1 β€” Choose refs and fields Jump to heading

Name the ref namespaces to list and the fields to print. Fields are %(name) placeholders with modifiers; %09 is a tab, which makes the output easy to split.

# Local branches: name, commit, committer date, author
git for-each-ref --format='%(refname:short)%09%(objectname:short)%09%(committerdate:short)%09%(authorname)' refs/heads/

# Remote-tracking branches for one remote
git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/

# Tags with their tagged commit, whether annotated or lightweight
git for-each-ref --format='%(refname:short)%09%(*objectname)%(objectname)%09%(objecttype)' refs/tags/
Field groups in for-each-refName fields give the ref in full, short or stripped form. Object fields give the commit or tag the ref points to, and the asterisk form dereferences annotated tags. Date and person fields describe the last commit. Upstream fields give the tracking branch and ahead-behind counts.Namerefname:shortrefname:lstrip=NObjectobjectname*objectnameDate / personcommitterdateauthornameUpstreamupstream:shortupstream:track%09 between fields gives tab-separated output that survives spaces in values

Tab-separated output is safe because ref names cannot contain tabs (Git forbids control characters in ref names), so splitting on tab never breaks a name.

Step 2 β€” Sort and limit Jump to heading

--sort accepts any field, with a leading - for descending, and version:refname sorts tags as versions rather than strings. --count limits the output.

# Ten most recently updated branches
git for-each-ref --sort=-committerdate --count=10 --format='%(committerdate:relative)%09%(refname:short)' refs/heads/

# Tags in version order, newest first (v1.10.0 after v1.9.0)
git for-each-ref --sort=-version:refname --format='%(refname:short)' 'refs/tags/v*'
# Verification: version sort places v1.10.0 above v1.9.0
git for-each-ref --sort=-version:refname --format='%(refname:short)' 'refs/tags/v1.*' | head -3

Step 3 β€” Filter with merged, no-merged and contains Jump to heading

The filters answer the questions branch automation actually asks, without loops over merge-base.

# Branches fully merged into main (safe to delete, for merge-commit workflows)
git for-each-ref --merged=origin/main --format='%(refname:short)' refs/heads/ | grep -vx main

# Branches not yet merged into main
git for-each-ref --no-merged=origin/main --format='%(refname:short)' refs/heads/

# Release tags that contain a fix commit
git for-each-ref --contains=4e7a91c --format='%(refname:short)' 'refs/tags/v*'

# Branches pointing at exactly one commit
git for-each-ref --points-at=HEAD --format='%(refname:short)' refs/heads/
Filters and the question each answers--merged lists refs whose tips are reachable from a commit, which identifies fully merged branches. --no-merged lists the rest. --contains lists refs whose history includes a commit, which answers which releases contain a fix. --points-at lists refs at exactly one commit.FilterQuestion answered--merged=maintip reachable from mainwhich branches are done?--no-merged=maintip not reachablewhat is still open?--contains=<sha>history includes shawhich tags have the fix?--points-at=<sha>tip equals shawhich refs are here?squash-merged branches are not 'merged' by ancestry β€” see the detection guide

Squash-merged branches do not appear under --merged, because their commits never become ancestors of main; detecting them needs the techniques in detecting squash-merged branches.

Step 4 β€” Report on upstream state Jump to heading

Upstream fields show each branch’s tracking branch and how far ahead or behind it is, or whether the upstream is gone β€” the usual sign of a branch deleted on the server after merging.

git for-each-ref --format='%(refname:short)%09%(upstream:short)%09%(upstream:track)' refs/heads/
# feature/export     origin/feature/export   [ahead 2]
# feature/old-search origin/feature/old-search [gone]
# scratch                                     (no upstream)

# Local branches whose upstream was deleted
git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ | awk '$2=="[gone]" {print $1}'

Step 5 β€” Build a stale-branch report Jump to heading

Combining fields, sorting and filters gives a useful report in a few lines: unmerged branches with no commits for a month, oldest first, with their authors.

#!/bin/sh
# stale-branches.sh β€” unmerged remote branches idle for 30+ days
cutoff=$(date -d '30 days ago' +%s)
git fetch -q --prune origin
git for-each-ref --no-merged=origin/main --sort=committerdate \
  --format='%(committerdate:unix)%09%(committerdate:short)%09%(authoremail)%09%(refname:lstrip=3)' \
  refs/remotes/origin/ |
awk -F'\t' -v c="$cutoff" '$1 < c && $4 != "HEAD" { printf "%s  %-28s %s\n", $2, $3, $4 }'
A stale-branch report from one commandAfter fetching with prune, for-each-ref lists remote branches not merged into main, sorted by last commit date, with date and author fields. An awk filter keeps those idle for more than thirty days, producing a report that can be posted to a channel or used to notify authors.fetch --prunecurrent refsfor-each-ref--no-merged, sortedFieldsdate, author, nameFilteridle > 30 daysReportnotify authorsno loops over branches, no merge-base calls β€” one Git process does the work

The report pairs naturally with closing stale branches and pull requests.

Step 6 β€” Feed refs into other commands safely Jump to heading

The output of for-each-ref is often input to another command: delete these branches, push these tags, fetch these refs. Pass full ref names rather than short ones β€” refs/heads/fix is unambiguous where fix could also be a tag β€” and use --stdin modes where the next command offers them, so a list of thousands of refs is one process rather than thousands.

# Delete fully merged local branches in one atomic transaction
git for-each-ref --merged=origin/main --format='delete %(refname)' refs/heads/ |
  grep -v ' refs/heads/main$' |
  { echo start; cat; echo commit; } | git update-ref --stdin

# Push a selected set of tags by full name
git for-each-ref --format='%(refname)' 'refs/tags/v2.4.*' | xargs git push origin

git update-ref --stdin with start and commit applies every deletion or none, so a failure part-way leaves no half-cleaned state. Run the same pipeline without the final command first, to review exactly which refs it will touch.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does --merged not list a branch I just squash-merged? Jump to heading

Ancestry-based filters cannot see squash merges, because the squash commit on main is a new object. Use content-based detection for squash-merging workflows.

Can for-each-ref show ahead/behind against main rather than the upstream? Jump to heading

%(ahead-behind:main) (Git 2.41+) prints both counts relative to any commit. On older versions, use git rev-list --left-right --count main...branch per branch.

Is git branch --format the same thing? Jump to heading

git branch and git tag accept the same --format placeholders, so they are safe with an explicit format. for-each-ref is more flexible because it can list any namespace β€” notes, stashes, custom refs β€” in one call.