Last active
March 2, 2020 12:31
-
-
Save jmnsf/49f1d319237f69ea860962c9b2f117be to your computer and use it in GitHub Desktop.
Clean Git Branches
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# @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