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 -rorgit tagoutput. - 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/ 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/ 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 }' 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.
Related Jump to heading
- Scripting Git with Plumbing Commands β the parent topic.
- Parsing git status Porcelain Output β the same discipline for working-tree state.
- Recording Backports with cherry-pick -x β where --contains answers release questions.
- Limiting Feature Branch Lifetime β the policy a stale report supports.