Skip to content

Instantly share code, notes, and snippets.

@pvdb
Last active July 19, 2025 06:53
Show Gist options
  • Save pvdb/6c6e8868ae48210657834c230b61be22 to your computer and use it in GitHub Desktop.
Save pvdb/6c6e8868ae48210657834c230b61be22 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# INSTALLATION (2 aliases)
#
# ln -s ${PWD}/git-branch---stray $(brew --prefix)/bin/git-branch--delete--stray
# sudo ln -s ${PWD}/git-branch---stray /usr/local/bin/git-branch--delete--stray
#
# ln -s ${PWD}/git-branch---stray $(brew --prefix)/bin/git-branch--list--stray
# sudo ln -s ${PWD}/git-branch---stray /usr/local/bin/git-branch--list--stray
#
# CONFIGURATION (2 aliases)
#
# git config --global alias.bls branch--list--stray
# git config --global alias.bds branch--delete--stray
#
%w[TERM INT].each do |signal|
Signal.trap(signal) do
exit(0)
end
end
require 'English'
require 'consenter'
class String # :nodoc:
def colorize(color_code)
"\e[#{color_code}m#{self}\e[0m"
end
def red
colorize('31')
end
def yellow
colorize('33')
end
end
module Kernel # :nodoc:
def git(command)
`git #{command}`.split($RS).each(&:strip!)
end
end
module Git # :nodoc:
module_function
def local_head_refs
git('for-each-ref --format="%(refname)" refs/heads')
end
def head_refs_for(remote)
# alternative implementation that requires
# access to the remote tracking repository
#
# git("ls-remote --quiet --refs --heads #{remote}")
# .map { |sha_and_ref| sha_and_ref.split(/\s+/).last }
#
# but we assume that this utility will mainly be
# used as part of a "git workflow" that includes
# regular pruning of remote tracking references,
# which means we don't have to go over the wire!
git("for-each-ref --format=\"%(refname)\" refs/remotes/#{remote}")
end
def upstream_reference_for(branch)
git("rev-parse --verify --abbrev-ref #{branch}@{upstream}").first
end
def remote_for(branch)
git("config --local --get branch.#{branch}.remote").first
end
def merge_ref_for(branch)
git("config --local --get branch.#{branch}.merge").first
end
def branch_name_from(symbolic_ref)
# extract the branch name from a given symbolic ref
# local: e.g. "refs/heads/master"
# remote: e.g. "refs/remotes/origin/master"
symbolic_ref.sub(%r{\Arefs/(heads|remotes/[^/]+)/}, '')
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
# rubocop:disable Style/BlockDelimiters
def stray_branches
to_branch_name = method(:branch_name_from)
local_branch_names = local_head_refs.map(&to_branch_name)
remote_branch_names = Hash.new { |branch_names, remote|
branch_names[remote] = head_refs_for(remote).map(&to_branch_name)
}
local_branch_names.find_all { |local_branch|
(remote = remote_for(local_branch)) &&
(merge_ref = merge_ref_for(local_branch)) &&
(upstream_branch = to_branch_name[merge_ref]) &&
!remote_branch_names[remote].include?(upstream_branch)
}
end
# rubocop:enable Style/BlockDelimiters
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
def delete_stray_branches
stray_branches.each_consented('Delete stray branch "%s"', inspector: :yellow) do |stray|
system("git branch -d #{stray}")
next if $CHILD_STATUS.success?
Array(stray).each_consented('Delete unmerged branch "%s"', inspector: :red) do |unmerged|
system("git branch -D #{unmerged}")
end
end
end
def list_stray_branches
stray_branches.each do |stray|
system("git --no-pager branch -vv --list #{stray}")
end
end
end
if git('rev-parse --show-toplevel').first
case File.basename($PROGRAM_NAME)
when 'git-branch--list--stray' then Git.list_stray_branches
when 'git-branch--delete--stray' then Git.delete_stray_branches
end
exit(0)
else
puts "Please run #{File.basename(__FILE__)} from inside a git repo!"
exit(-1)
end
# That's all Folks!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment