Every few months I run git branch in a repository I work with every day and see a long list: fix-..., add-..., try-.... Most of these branches are pull requests that were merged weeks ago. Git doesn’t delete them by itself, so they just stay there.
Until now, I cleaned them up with a one-liner like this:
git branch --merged origin/main | grep -vE '^\*|^ *main$' | xargs git branch -d
It works, but it’s easy to get wrong. If you forget the grep, main is in the list too. Every branch is compared with the one ref you typed. And there is no way to say “please keep this one”.
Git 2.56 has a built-in command for this:
git branch --delete-merged origin
It deletes the local branches whose work has already landed in the branch they track. You can check the list with --dry-run before anything is deleted, and a few safety rules protect the branches you most likely want to keep.
In this article, I show how it works step by step: a small project, a “server”, four pull requests, and a maintainer who merges them in different ways. Every step has the commands and the real output, so you can repeat it in your own terminal.
If you don’t have Git 2.56 yet, in my previous article about git add --resolved I showed how I installed it with mise without compiling anything, and made a short list of what else is new in this release.
How it decides what to delete
git branch [--dry-run] --delete-merged <pattern> [<branch-pattern>...]
<pattern> is compared with the upstream of every local branch. It can be a ref (origin/main), a remote (origin, which means the branch origin/HEAD points to), or a glob ('origin/*'). A matching branch is deleted only if its tip is reachable from its upstream. In other words, all its commits are already there. The optional <branch-pattern> limits which local branches are checked at all.
Some branches are never deleted:
- the branch that is checked out, in any worktree;
- a branch whose upstream doesn’t exist anymore;
- a branch that tracks a remote branch with the same name, for example after
git push -u(I explain why in step 14); - a branch with
branch.<name>.deleteMergedset tofalse; - a branch that is not merged yet. It is just skipped, without an error.
So the most important thing is the upstream of your branches. Let’s see what it means in practice.
Step by step
The commit IDs in your terminal will be different from mine, and that’s normal.
Step 1: A server and a clone
I don’t need GitHub for this. A bare repository plays the server, and I work in a clone of it.
demo $ git init --bare shop.git
Initialized empty Git repository in …/shop.git/
demo $ git clone shop.git shop
Cloning into 'shop'...
warning: You appear to have cloned an empty repository.
done.shop.git is the server. Git warns that I cloned an empty repository. That’s fine, there is nothing in it yet.
demo $ cd shop
shop (main) $ git status
On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)Inside the clone, git status confirms there are no commits yet. From now on, the prompt shows the current branch: shop (main).
Step 2: The first files
A README and a small config, both written in vim.
shop (main) $ vim README.md
1 # shop
2
3 Customers recieve an email for every order.
"README.md" [New] 3L, 52B writtenAnd yes, there is a typo in the README: recieve. We’ll fix it in a separate branch, like a real pull request.
shop (main) $ vim config.ini
1 [server]
2 port = 8080
3 workers = 2
"config.ini" [New] 3L, 33B written
shop (main) $ git status --short
?? README.md
?? config.inigit status --short lists both new files as untracked (??).
Step 3: The first commit
shop (main) $ git add README.md config.ini
shop (main) $ git commit -m "Initial version"
[main (root-commit) 9442ceb] Initial version
2 files changed, 6 insertions(+)
create mode 100644 README.md
create mode 100644 config.ini9442ceb is the first commit of our history.
shop (main) $ git push
To …/shop.git
* [new branch] main -> main
shop (main) $ git branch -vv
* main 9442ceb [origin/main] Initial versionAfter the push, git branch -vv shows that main tracks origin/main, the main branch on the server.
Step 4: A topic branch from origin/main
This step is the most important one for the rest of the article.
shop (main) $ git switch -c fix-typo origin/main
branch 'fix-typo' set up to track 'origin/main'.
Switched to a new branch 'fix-typo'I create the branch from origin/main, and Git answers set up to track 'origin/main'. So origin/main becomes the upstream of fix-typo, and this is exactly the ref --delete-merged will check later.
shop (fix-typo) $ vim README.md
1 # shop
2
3 Customers receive an email for every order.
"README.md" 3L, 52B written
shop (fix-typo) $ git commit -am "Fix a typo in the README"
[fix-typo 1c2e98c] Fix a typo in the README
1 file changed, 1 insertion(+), 1 deletion(-)The fix itself: one word in the README.
shop (fix-typo) $ git push origin fix-typo
To …/shop.git
* [new branch] fix-typo -> fix-typo
shop (fix-typo) $ git status
On branch fix-typo
Your branch is ahead of 'origin/main' by 1 commit.
(use "git push" to publish your local commits)
nothing to commit, working tree cleanI push the branch under its own name, as you do for a pull request. The upstream doesn’t change, so git status says ahead of 'origin/main' by 1 commit.
Step 5: Two more pull requests
The same pattern twice. Both branches start from origin/main, so both track it.
shop (fix-typo) $ git switch -c add-healthcheck origin/main
branch 'add-healthcheck' set up to track 'origin/main'.
Switched to a new branch 'add-healthcheck'
shop (add-healthcheck) $ vim config.ini
1 [server]
2 port = 8080
3 workers = 2
4 healthcheck = /health
"config.ini" 4L, 55B written
shop (add-healthcheck) $ git commit -am "Add a healthcheck endpoint"
[add-healthcheck 0d720b6] Add a healthcheck endpoint
1 file changed, 1 insertion(+)
shop (add-healthcheck) $ git push origin add-healthcheck
To …/shop.git
* [new branch] add-healthcheck -> add-healthcheckadd-healthcheck adds one line to the config.
shop (add-healthcheck) $ git switch -c add-license origin/main
branch 'add-license' set up to track 'origin/main'.
Switched to a new branch 'add-license'
shop (add-license) $ vim LICENSE
1 MIT License
2
3 Copyright (c) 2026 The shop authors
"LICENSE" [New] 3L, 49B written
shop (add-license) $ git add LICENSE
shop (add-license) $ git commit -m "Add a license"
[add-license 42410d5] Add a license
1 file changed, 3 insertions(+)
create mode 100644 LICENSE
shop (add-license) $ git push origin add-license
To …/shop.git
* [new branch] add-license -> add-licenseadd-license adds a new LICENSE file. Keep an eye on this one: it will be squash-merged.
Step 6: The usual way to push a new branch
Most of us push a new branch with -u.
shop (add-license) $ git switch -c add-login origin/main
branch 'add-login' set up to track 'origin/main'.
Switched to a new branch 'add-login'
shop (add-login) $ vim auth.ini
1 [auth]
2 login = required
"auth.ini" [New] 2L, 24B written
shop (add-login) $ git add auth.ini
shop (add-login) $ git commit -m "Require a login"
[add-login 0410e28] Require a login
1 file changed, 2 insertions(+)
create mode 100644 auth.iniadd-login starts from origin/main, like the others.
shop (add-login) $ git push -u origin add-login
To …/shop.git
* [new branch] add-login -> add-login
branch 'add-login' set up to track 'origin/add-login'.Look at the last line of the push: -u changed the upstream of add-login from origin/main to origin/add-login. Remember it, because the cleanup treats this branch differently.
Step 7: Unfinished work
shop (add-login) $ git switch -c wip-metrics origin/main
branch 'wip-metrics' set up to track 'origin/main'.
Switched to a new branch 'wip-metrics'
shop (wip-metrics) $ vim config.ini
1 [server]
2 port = 8080
3 workers = 2
4
5 [metrics]
6 enabled = true
"config.ini" 6L, 59B written
shop (wip-metrics) $ git commit -am "WIP: metrics"
[wip-metrics fe51a4f] WIP: metrics
1 file changed, 3 insertions(+)wip-metrics is work in progress: it’s committed, but not pushed and not merged.
shop (wip-metrics) $ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
shop (main) $ git branch -vv
add-healthcheck 0d720b6 [origin/main: ahead 1] Add a healthcheck endpoint
add-license 42410d5 [origin/main: ahead 1] Add a license
add-login 0410e28 [origin/add-login] Require a login
fix-typo 1c2e98c [origin/main: ahead 1] Fix a typo in the README
* main 9442ceb [origin/main] Initial version
wip-metrics fe51a4f [origin/main: ahead 1] WIP: metricsBack on main, git branch -vv gives the full picture. Four topic branches track origin/main, and add-login tracks origin/add-login.
Step 8: The maintainer merges three pull requests
Somebody has to press the “Merge” button. I use a second clone of the server as the maintainer.
shop (main) $ cd ..
demo $ git clone shop.git maintainer
Cloning into 'maintainer'...
done.
demo $ cd maintainer
maintainer (main) $ git branch -r
origin/HEAD -> origin/main
origin/add-healthcheck
origin/add-license
origin/add-login
origin/fix-typo
origin/maingit branch -r shows the four pushed branches.
maintainer (main) $ git merge --no-ff origin/fix-typo -m "Merge pull request #1"
Merge made by the 'ort' strategy.
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
maintainer (main) $ git merge --no-ff origin/add-healthcheck -m "Merge pull request #2"
Merge made by the 'ort' strategy.
config.ini | 1 +
1 file changed, 1 insertion(+)
maintainer (main) $ git merge --no-ff origin/add-login -m "Merge pull request #3"
Merge made by the 'ort' strategy.
auth.ini | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 auth.iniThree of them get a merge commit with --no-ff, like “Create a merge commit” on GitHub.
Step 9: The fourth one is squash-merged
The license goes in with “Squash and merge”.
maintainer (main) $ git merge --squash origin/add-license
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
maintainer (main) $ git commit -m "Add a license (#4)"
[main 45d0089] Add a license (#4)
1 file changed, 3 insertions(+)
create mode 100644 LICENSE
maintainer (main) $ git push
To …/shop.git
9442ceb..45d0089 main -> main--squash takes the changes but doesn’t merge the branch, so the commit is a brand-new one: 45d0089. The maintainer pushes main.
maintainer (main) $ git log --oneline --graph
* 45d0089 (HEAD -> main, origin/main, origin/HEAD) Add a license (#4)
* a5fd06e Merge pull request #3
|\
| * 0410e28 (origin/add-login) Require a login
* | 4c1bd4e Merge pull request #2
|\ \
| * | 0d720b6 (origin/add-healthcheck) Add a healthcheck endpoint
| |/
* | 214d7cc Merge pull request #1
|\ \
| |/
|/|
| * 1c2e98c (origin/fix-typo) Fix a typo in the README
|/
* 9442ceb Initial versionThe graph shows the difference. The three merged branches are connected to main with their own commits, but our 42410d5 from add-license is not in main at all.
Step 10: Back in your clone
maintainer (main) $ cd ../shop
shop (main) $ git pull
From …/shop
9442ceb..45d0089 main -> origin/main
Updating 9442ceb..45d0089
Fast-forward
LICENSE | 3 +++
README.md | 2 +-
auth.ini | 2 ++
config.ini | 1 +
4 files changed, 7 insertions(+), 1 deletion(-)
create mode 100644 LICENSE
create mode 100644 auth.inigit pull brings all the merged work into main.
shop (main) $ git branch -vv
add-healthcheck 0d720b6 [origin/main: behind 6] Add a healthcheck endpoint
add-license 42410d5 [origin/main: ahead 1, behind 7] Add a license
add-login 0410e28 [origin/add-login] Require a login
fix-typo 1c2e98c [origin/main: behind 6] Fix a typo in the README
* main 45d0089 [origin/main] Add a license (#4)
wip-metrics fe51a4f [origin/main: ahead 1, behind 7] WIP: metricsWhich branches can go now? git branch -vv gives hints: behind 6 without ahead means all the work is already in origin/main. But you have to read it line by line, and the squash-merged add-license still says ahead 1.
Step 11: The old way
shop (main) $ git branch --merged origin/main
add-healthcheck
add-login
fix-typo
* maingit branch --merged origin/main lists the branches whose tips are in origin/main, and main itself is in the list. That’s why the one-liner from the beginning needs a grep before xargs git branch -d.
Step 12: Look before you delete
shop (main) $ git symbolic-ref --short refs/remotes/origin/HEAD
origin/mainorigin as a pattern means the branch origin/HEAD points to, and it’s origin/main.
shop (main) $ git branch --dry-run --delete-merged origin
Would delete branch add-healthcheck (was 0d720b6).
Would delete branch fix-typo (was 1c2e98c).
shop (main) $ git branch --dry-run --delete-merged origin 'fix-*'
Would delete branch fix-typo (was 1c2e98c).The dry run shows exactly the two branches I expected. A branch pattern at the end ('fix-*') makes the list shorter. Nothing is deleted yet.
Step 13: Delete them
shop (main) $ git branch --delete-merged origin
Deleted branch add-healthcheck (was 0d720b6).
Deleted branch fix-typo (was 1c2e98c).Two branches are gone.
shop (main) $ git branch -vv
add-license 42410d5 [origin/main: ahead 1, behind 7] Add a license
add-login 0410e28 [origin/add-login] Require a login
* main 45d0089 [origin/main] Add a license (#4)
wip-metrics fe51a4f [origin/main: ahead 1, behind 7] WIP: metricsFour are left, and each one for its own reason:
wip-metrics: its commit is not merged;add-license: it was squash-merged, so its commit is not inorigin/maineither;add-login: it tracksorigin/add-login, and the patternorigindoesn’t match it;main: it is checked out.
Step 14: A same-named upstream is protected on purpose
shop (main) $ git branch --dry-run --delete-merged 'origin/*'Even the widest pattern, 'origin/*', doesn’t touch add-login: the dry run prints nothing at all. The reason is not obvious. git push would update this branch’s own upstream, origin/add-login, so right after a push or a pull the branch always looks fully merged into it, even if the pull request was never merged. Git can’t tell one case from the other, so it keeps the branch.
shop (main) $ git branch -d add-login
Deleted branch add-login (was 0410e28).For such branches the old git branch -d is still the right tool. It checks the branch against its upstream and refuses if the work is not there.
Step 15: A squash merge is not a merge
How do you know that a squash-merged branch is safe to delete?
shop (main) $ git cherry -v --abbrev origin/main add-license
- 42410d5 Add a licensegit cherry compares the changes, not the commits. A - in front of a commit means that the same change is already upstream.
shop (main) $ git branch -d add-license
error: the branch 'add-license' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D add-license'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
shop (main) $ git branch -D add-license
Deleted branch add-license (was 42410d5).git branch -d refuses, because for Git the branch is “not fully merged”. But git cherry already told us the change is there, so -D is safe.
Step 16: A new, empty branch counts as merged
Sometimes I create a branch for tomorrow’s task in advance.
shop (main) $ git branch next-task origin/main
branch 'next-task' set up to track 'origin/main'.
shop (main) $ git branch --dry-run --delete-merged origin
Would delete branch next-task (was 45d0089).It has no commits of its own yet, so its tip is in origin/main, and the dry run would delete it.
shop (main) $ git config set branch.next-task.deleteMerged false
shop (main) $ git branch --delete-merged origin
Skipping 'next-task' (branch.next-task.deleteMerged is false)To keep it, there is a setting per branch. Git now says Skipping 'next-task'.
shop (main) $ git branch -vv
* main 45d0089 [origin/main] Add a license (#4)
next-task 45d0089 [origin/main] Add a license (#4)
wip-metrics fe51a4f [origin/main: ahead 1, behind 7] WIP: metricsWhat’s left: main, next-task and the unfinished wip-metrics.
What to remember
- It checks every branch against its own upstream. If you create topic branches with
git switch -c topic origin/mainand push them withgit push origin topic, the upstream staysorigin/main, and the cleanup works as expected. - Only branches whose tip is reachable from the upstream are deleted. Squash merges and “Rebase and merge” create new commits, so those branches stay. Check them with
git cherryand delete them with-D. - Branches that track a remote branch with the same name (
git push -u) are kept. Usegit branch -dfor them. - The checked-out branch and branches with
branch.<name>.deleteMerged=falseare never deleted. - Start with
--dry-run, especially with a wide pattern like'origin/*'.
I also added an alias, so the cleanup is one short command:
git config --global alias.tidy "branch --delete-merged origin"
git tidy
Conclusion
In summary, git branch --delete-merged is a small command, but it removes one more little piece of routine work. The cleanup that needed a pipe with grep and xargs is now one command with a dry run, and the safety rules keep the branches that are not really done: unfinished work, squash merges, and the branches you mark as “keep”. The only thing you need to care about is the upstream of your branches. If they track origin/main, Git knows where their work should land.
Links: