Skip to content

Instantly share code, notes, and snippets.

@jmnsf
Last active March 2, 2020 12:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jmnsf/49f1d319237f69ea860962c9b2f117be to your computer and use it in GitHub Desktop.
Save jmnsf/49f1d319237f69ea860962c9b2f117be to your computer and use it in GitHub Desktop.
Clean Git Branches
# @author jmnsf
#
# This script attempts to clean the local repository of stale branches. This
# goes in a few steps:
#
# 0. Fetch with `-p` is run to remove references to deleted remote branches.
# 1. Merged branches are removed (except for `master`).
# 2. Branches with no conflicts or changes vs `master` are removed.
# 3. Branches that have the same head as a merged PR in GitHub will be merged.
#
# Typically 0 and 1 would suffice, but with Squash & Merge local branches never
# get "merged" to master, so git doesn't know they can be removed. During step
# 3 force deletion is run with a prompt each time.
require 'excon'
require 'json'
# Simple API wrapper
class GitHub
def initialize(username, password, organization, repository)
@org = organization
@repo = repository
@connection = Excon.new(
'https://api.github.com',
user: username, password: password, persistent: true
)
end
# Grabs a branch's closed Pull Requests from GitHub.
#
# @param branch [String] The name of the branch to get pulls from.
# @return [Array] An array of closed pull requests with the branch as
# HEAD.
def fetch_pulls(branch)
res = get "/repos/#{@org}/#{@repo}/pulls?state=closed&head=#{@org}:#{branch}"
raise 'Bad credentials' if res.status == 401
JSON.parse res.body
end
private
# Make a GET request to some path in GH. If the socket is closed, tries once
# more.
#
# @param path [String] The path to GET.
# @param tried [Boolean] Whether it's been tried.
# @return [Response] The Response object from Excon
def get(path, tried: false)
@connection.get path: path
rescue Excon::Errors::SocketError
# Excon fails with socket-error instead of reviving the connection so it
# needs a manual retry
fail err if tried
return get path, tried: true
end
end
def clean_all_merged
`git branch --merged | grep -P '^(?![\\* ] master)' | xargs git branch -d`
end
# Checks if a branch has conflicts with `master`.
#
# @param branch [String] Name of the branch to check for conflicts
# @return [Boolean] Whether it's got conflicts
def has_conflicts? branch
merge_base = /\w+/.match `git merge-base master #{branch}`
merge_tree = `git merge-tree #{merge_base} master #{branch}`
/>>>>>/ =~ merge_tree
end
# Attempts to rebase a branch onto `master` and then remove it safely (`-d`).
# Returns true or false whether that was possible.
#
# @param branch [String] Name of the branch to check for conflicts.
# @return [Boolean] Whether the branch was removed successfully.
def rebase_and_remove branch
`git checkout #{branch} -q`
if system 'git rebase master -q'
`git checkout master -q`
return system "git branch -d #{branch} -q"
else
puts "Could not rebase #{branch}. Aborting."
`git rebase --abort`
`git checkout master -q`
return false
end
end
# Check `branch` for conflicts with master. If none exist, try rebasing onto
# master and removing safely.
#
# @param branch [String] The name of the merged branch to be removed.
# @return [Boolean] Whether the branch was removed.
def try_safe_removal branch
return false if has_conflicts? branch
rebase_and_remove branch
end
# Confirm branch removal with user and enforce it if allowed.
#
# @param pull [Object] The pull request object that merged the branch.
# @param branch [String] The name of the merged branch to be removed.
# @return [Boolean] Whether the branch was removed.
def prompt_force_remove pull, branch
puts "Head of '#{branch}' was merged in PR ##{pull['number']} - #{pull['title']}"
puts "Force remove it? [Y/n]"
answer = gets.chomp.downcase
return false if answer == 'n'
return `git branch -D #{branch}` if answer == '' || answer == 'y'
puts "Please answer 'y' or 'n'."
force_remove pull, branch
end
`git fetch --all -p -q`
`git checkout master -q`
clean_all_merged
local_branches = `git branch | cut -c 3- | grep -v master`.split("\n")
remote_branches = `git branch -r | cut -c 3-`.split("\n")
puts "Found #{local_branches.length} local branches and " +
"#{remote_branches.length} remotes."
non_remote_locals = local_branches.reject do |branch|
remote_branches.any? { |rb| rb.end_with? branch }
end
puts "#{local_branches.length - non_remote_locals.length} branches have live " \
'remotes and will be skipped.'
conflict_branches = local_branches.reject { |branch| try_safe_removal branch }
exit puts('All done.') if conflict_branches.length.zero?
# Some branches weren't safe for removal. Lets try GitHub
puts "#{conflict_branches.length} branches remain with conflicts. Trying GitHub."
_, org, repo = (/origin\s.+[\/:](.+)\/(.+)\.git/.match `git remote -v`).to_a
puts "Connecting to #{org}/#{repo}..."
username = `git config --get github.user`.chomp
if username.empty?
puts "What's your username?"
username = gets.chomp
end
puts "What's your password?"
system 'stty -echo'
password = gets.chomp
system 'stty echo'
gh = GitHub.new(username, password, org, repo)
not_deleted = conflict_branches.reject do |branch|
branch_sha = `git rev-parse #{branch}`.chomp
pulls = gh.fetch_pulls branch
pulls.delete_if do |pull|
pull['merged_at'].nil? || pull['head']['sha'] != branch_sha
end
if pulls.length == 0
puts "No merged PR was found for #{branch}, skipping."
next false
end
prompt_force_remove pulls[0], branch
end
puts 'Done. Branches that were not deleted:'
not_deleted.each { |branch| puts branch }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment