Deleting branches that have been merged

Posted on Sat 07 November 2015 in Repository maintenance

Once branches have been merged into the main branch, all that's effectively left of the branch is the ref pointing to an older commit. This ref may be worth keeping, but many people seem to want to get rid of those refs. Git does not provide a tool to do this, but does provide the toolkit to create such a command yourself.

  • git rev-parse can be used to find the full ref of the branch you want to take as destination branch, where branches should be merged into.
  • git for-each-ref --merged $branch --base refs/heads/ can be used to find stale branches (but beware: $branch itself will be part of its output)
  • git for-each-ref --merged $branch --base refs/remotes/$remote can be used to find stale branches (but beware: refs/remotes/$remote/$branch itself may be part of its output)
  • git branch and git push can be used to actually delete the branches.

You can hack this up in a shell script, but I prefer using python for my quick hacks. The python script below implements a git delete-merged-branches command that can delete merged branches either locally or remotely. If you put it somewhere on your $PATH, you can call it for example as git delete-merged-branches master and it will delete all branches merged into master.

It has a few safety measures:

  • Refs pointing to the same commit as $branch will not be deleted
  • When deleting from a remote, it does a fetch first (of course there is still a race condition there)
  • It has a --noop mode, which you really should use before actually deleting refs.

But first let's see it in action. We'll create some bogus branches pointing to ancestors of HEAD, essentially making them merged.

$ git branch yoink HEAD~1
$ git branch yoink2 HEAD~2
$ git branch yoink3 HEAD~3
$ git push origin yoink yoink2 yoink3
Total 0 (delta 0), reused 0 (delta 0)
To https://git.example.com/example.git
 * [new branch]      yoink -> yoink
 * [new branch]      yoink2 -> yoink2
 * [new branch]      yoink3 -> yoink3

Let's see what git delete-merged-branches would do.

$ git delete-merged-branches --noop
Would delete refs/heads/yoink
Would delete refs/heads/yoink2
Would delete refs/heads/yoink3
$ git delete-merged-branches --remote origin --noop master
Would delete refs/remotes/origin/yoink
Would delete refs/remotes/origin/yoink2
Would delete refs/remotes/origin/yoink3

That sounds sane, let's go ahead and delete them.

$ git delete-merged-branches --remote origin
To https://git.example.com/example.git
 - [deleted]         yoink
 - [deleted]         yoink2
 - [deleted]         yoink3

$ git delete-merged-branches 
Deleted branch yoink (was 259b5e6).
Deleted branch yoink2 (was aa826b6).
Deleted branch yoink3 (was c29024e).

So if you're someone who likes cleaning up stale refs that have been merged, here's a tool for you.

 Download git-delete-merged-branches
#!/usr/bin/python import docopt from whelk import Shell import sys usage = """git delete-merged-branches Delete merged branches, either remote or locally Usage: git delete-merged-branches [--noop] [--remote=REMOTE] [<branch>] """ # Make sure we exit when a git command fails def check(cmd, sp, res): if not res: if res.stderr: sys.stderr.write(res.stderr) sys.exit(res.returncode) shell = Shell(exit_callback=check) def main(): sys.argv.insert(1, 'delete-merged-branches') opts = docopt.docopt(usage) shell.git('rev-parse') dest = opts['<branch>'] or 'HEAD' dest = shell.git('rev-parse', '--symbolic-full-name', dest).stdout.strip() dest_sha = shell.git('rev-parse', dest).stdout.strip() base = 'refs/heads' if opts['--remote']: base = 'refs/remotes/%s' % opts['--remote'] # Make sure we're up to date with what the remote actually has shell.git('fetch', opts['--remote']) refs = shell.git('for-each-ref', '--merged', dest, base).stdout.splitlines() refs = [ref.split(None, 2) for ref in refs] to_delete = [] for oid, reftype, refname in refs: if oid == dest_sha or refname == dest: # As a precaution, don't delete branches that point to the current commit continue if opts['--noop']: print("Would delete %s" % refname) elif opts['--remote']: ref = refname.replace('refs/remotes/%s/' % opts['--remote'], 'refs/heads/') to_delete.append(ref) else: branch = refname.replace('refs/heads/', '') to_delete.append(branch) if to_delete and not opts['--noop']: kwargs = {'redirect': False} if opts['--remote']: shell.git('push', opts['--remote'], '--delete', *to_delete, **kwargs) else: shell.git('branch', '-d', *to_delete, **kwargs) if __name__ == '__main__': main()