Skip to content

Instantly share code, notes, and snippets.

@brand-it
Last active July 25, 2019 15:53
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 brand-it/ea4a09f55504a080672feca8f7d7b57c to your computer and use it in GitHub Desktop.
Save brand-it/ea4a09f55504a080672feca8f7d7b57c to your computer and use it in GitHub Desktop.
This lets you destroy branches that are older then a given amount of time.
#!/usr/bin/env ruby
# frozen_string_literal: true
require File.expand_path('ruby_bash', __dir__).to_s
require 'date'
class ProcessData
attr_accessor(
:all, :debug, :changes_stashed, :primary_remote_name, :remotes, :current_branch, :git_logs,
:min_print_length, :since, :list_branches, :dry_run, :remote_branch_list, :local_branches
)
def initialize
self.dry_run = false
self.list_branches = false
self.since = system!("gdate '+%s' -d '2 weeks ago'").stdout_str.to_i
self.min_print_length = 0
self.git_logs = []
self.current_branch = system!('git rev-parse --abbrev-ref HEAD').stdout_str.strip
self.remotes = system!('git remote').stdout_str.split("\n").map(&:strip)
self.primary_remote_name = remotes.first
self.changes_stashed = false
self.debug = false
self.all = false # all the branches including the remote branches
self.remote_branch_list = {}
self.debug = false
self.local_branches = system!('git branch').stdout_str.split("\n").map { |b| b.gsub('* ', '').strip }
OptionParser.new do |opts|
opts.banner = 'Usage: branch_cleaning [options]'
opts.on('-d', '--dry-run', 'Display the output of what it is gong to delete without actually doing it') do
self.dry_run = true
self.list_branches = true
end
opts.on(
'-l', '--list-branches', 'Show all the brances and there time stamps. Will display what will be deleted'
) do
self.list_branches = true
end
opts.on(
'-s',
"--since #{since}",
String,
'How long ago you want to go back in time. --since="2 weeks ago"'
) do |value|
self.since = system!("gdate '+%s' -d '#{value}'").stdout_str
puts "Destroy anything older then #{since_to_datetime}"
end
opts.on(
'-r', "--remote-name #{primary_remote_name}",
String,
'The Primary Remote branch. The primary will not append the remote name'
) do |value|
self.primary_remote_name = value
end
opts.on('-b', '--debug', 'Output all the console commands that are being executed') do |_value|
self.debug = true
end
opts.on(
'-a', '--all', 'Include remote branches as well as local branches, remote branches will not be deleted'
) do
self.all = true
end
opts.on('-h', '--help', 'Prints this help') do
puts opts
exit
end
end.parse!
end
def since=(value)
@since = value.to_i
end
def remote_branch_list
return @remote_branch_list unless @remote_branch_list.empty?
remotes.each do |key|
@remote_branch_list[key] = system!("git ls-remote --heads #{key}").stdout_str.split("\n").map { |x| x.split("\t")[1] }
end
@remote_branch_list
end
def since_to_datetime
DateTime.strptime(since.to_s, '%s').strftime('%F %R')
end
def stash_changes
if capture3('git diff-index --quiet HEAD --').status.success?
system! 'git add --all'
system! 'git stash save backup'
changes_stashed = true
end
end
def create_git_log(branch)
git_log = GitLog.new(self, branch)
return if git_logs.any? { |git| git.key == git_log.key }
puts "Getting Branch Data for #{git_log.key}"
self.min_print_length = git_log.branch_name.length if git_log.branch_name.length > min_print_length
git_log.switch_branch!
git_log.update!
git_logs.push(git_log)
end
def fixed_width(string, length)
string = string.to_s
string_length = string.length
append_size = length.to_i - string_length
(0..append_size).to_a.each { string += ' ' }
string
end
def display_header
puts "min_print_length set to #{min_print_length}" if debug
puts "#{fixed_width('', 8)}#{fixed_width('remote', 10)}#{fixed_width('Branch Name', min_print_length)}#{fixed_width('SHA', 40)}#{fixed_width('Date', 30)}#{fixed_width('Time Ago', 30)}"
end
def display_delete_preview
return unless list_branches
display_header
git_logs.each(&:display_preview)
end
def cleanup
system! 'git add --all'
system! 'git stash'
system! "git checkout #{current_branch}"
system! 'git stash pop stash^{/backup}' if changes_stashed
end
def branches
system_response = all ? system!('git branch -vva') : system!('git branch -vv')
system_response.stdout_str.split("\n").map { |b| resolve_git_branch(b) }.compact
end
def resolve_git_branch(string)
sha = string.match(/\s\S*\s(\w{10})\s/).to_a[1]
return if sha.nil?
data = string.split(sha)
from_remote = data.first.include?('remotes/')
if from_remote
remote = remotes.find { |r| data[0].include?("/#{r}/") }
{
name: data.first.strip.gsub("remotes/#{remote}/", ''),
sha: sha,
remote: remote,
from_remote: from_remote
}
else
remote = remotes.find { |r| data[1].include?("[#{r}") }
{
name: data.first.strip.gsub('* ', ''),
sha: sha,
remote: remote,
from_remote: from_remote
}
end
end
end
class GitLog
class CheckoutError < StandardError; end
attr_accessor :remote, :branch_name, :sha, :time_ago, :subject, :data, :debug, :new_branch, :from_remote
attr_reader :date
def initialize(data, branch_info = {})
self.branch_name = branch_info[:name]
self.data = data
self.remote = branch_info[:remote]
self.sha = branch_info[:sha]
self.debug = data.debug
self.from_remote = branch_info[:from_remote]
end
def key
[remote, branch_name].compact.join('/')
end
def display_preview
status = "#{data.fixed_width(remote, 10)}#{data.fixed_width(branch_name, data.min_print_length)}#{data.fixed_width(sha, 40)}#{data.fixed_width(frendly_date, 30)}#{data.fixed_width(time_ago, 30)}"
if can_delete?
puts "\033[0;31m#{data.fixed_width('delete', 8)}#{status}\033[0m"
elsif remote_branch_exists? && !local_branch_exists?
puts "\033[0;33m#{data.fixed_width('deleted', 8)}#{status}\033[0m"
else
puts "#{data.fixed_width('keep', 8)}#{status}"
end
end
def checkout_branch_name
return branch_name if local_branch_exists?
return "#{remote}-#{branch_name}" if data.primary_remote_name != remote
branch_name
end
def switch_branch!
if remote_branch_exists? && local_branch_exists?
system! "git checkout -B #{checkout_branch_name} #{remote}/#{branch_name}"
elsif remote_branch_exists?
system! "git checkout -B #{checkout_branch_name} #{remote}/#{branch_name}"
puts " \033[0;33mCreated New Branch #{checkout_branch_name}\033[0m"
data.local_branches.push(checkout_branch_name)
elsif local_branch_exists?
system! "git checkout #{checkout_branch_name}"
else
raise CheckoutError, "could not checkout branch #{remote} #{branch_name} does not exist"
end
end
def date=(value)
@date = value.to_i
end
def update!
self.sha, self.date, self.time_ago, self.subject = system!(
"git log --pretty=format:'%H,%ct,%cr,%s' -n 1"
).stdout_str.split(',')
end
def delete!
return unless can_delete?
system!("git branch -D #{checkout_branch_name}")
end
def friendly_date
DateTime.strptime(date.to_s, '%s').strftime('%F %R')
end
def can_delete?
data.since > date && branch_name != 'master' && local_branch_exists?
end
def local_branch_exists?
data.local_branches.include?(branch_name)
end
def remote_branch_exists?
data.remotes.include?(remote) && data.remote_branch_list[remote].include?("refs/heads/#{branch_name}")
end
end
process_data = ProcessData.new
process_data.remotes.each do |remote|
system! "git fetch #{remote}"
end
begin
process_data.branches.each do |branch|
process_data.create_git_log(branch)
end
process_data.git_logs.sort_by!(&:date)
process_data.display_delete_preview
puts 'This was a dry run' if process_data.dry_run
exit if process_data.dry_run
system!('git checkout -B master origin/master')
process_data.display_header
process_data.git_logs.each do |git_log|
git_log.display_preview
git_log.delete!
end
rescue GitLog::CheckoutError => e
abort e.message
ensure
process_data.cleanup
end
# frozen_string_literal: true
# This is some top level systems for creating bash scripts in ruby
require 'pathname'
require 'optparse'
require 'open3'
require 'timeout'
def escape_path(path)
path.gsub(/ /, '\ ')
end
def text(message)
auth_token = ENV['TWILIO_AUTH_TOKEN']
user_id = ENV['TWILIO_USER_ID']
params = [
'-X POST',
"--data-urlencode 'To=+12105353532'",
"--data-urlencode 'From=+15592060364'",
"--data-urlencode 'Body=#{message}'",
"-u #{user_id}:#{auth_token}"
]
system!(
"curl 'https://api.twilio.com/2010-04-01/"\
"Accounts/#{user_id}/Messages.json'"\
" #{params.join(' ')}"
)
end
begin
require 'pry'
rescue LoadError => exception
# puts "\033[0;33m#{exception.message}\033[0m"
end
begin
require 'awesome_print'
rescue LoadError => exception
# puts "\033[0;33m#{exception.message}\033[0m"
end
class Errors
@messages = []
class << self
attr_reader :messages
end
def self.add(value)
@messages << value
end
def self.reset
@messages = []
end
end
# If there become to many arguments move into a initalize
Open3::Response = Struct.new(:stdout_str, :stderr_str, :status, :cmd)
def capture3(*cmd)
puts " \033[0;33mrunning #{cmd}\033[0m" if @debug
stdout_str, stderr_str, status = Open3.capture3(*cmd)
Open3::Response.new(stdout_str, stderr_str, status, *cmd)
end
def system!(*cmd)
response = capture3(*cmd)
puts caller unless response.status.success?
abort "\033[0;33mFailure #{cmd}\n\033[0;31m #{response.stderr_str}\033[0m" unless response.status.success?
response
end
def print_error(message)
puts "\033[0;31mError: #{message}\033[0m"
end
def print_warning(message)
puts "\033[0;33mWarning: #{message}\033[0m"
end
def puts_success(message)
puts "\033[1;32mSuccess: #{message}\033[0m"
end
def display_errors
Errors.messages.each do |message|
print_error(message)
end
end
def abort_and_display_errors
return if Errors.messages.empty?
display_errors
abort "\033[0;33mResolve Error message above before you continue\033[0m"
end
@brand-it
Copy link
Author

brand-it commented Jul 23, 2019

this requires gdate from coreutils. To install run brew install coreutils after that the gdate command should be available

Usage: branch_cleaning [options]
    -d, --dry-run                    Display the output of what it is going to delete without actually doing it
    -l, --list-branches              Show all the branches and their timestamps. Will display what will be deleted
    -s, --since 1562688830           How long ago you want to go back in time. --since="2 weeks ago"
    -r, --remote-name origin         The Primary Remote branch. The primary will not append the remote name
    -b, --debug                      Output all the console commands that are being executed
    -a, --all                        Include remote branches as well as local branches, remote branches will not be deleted
    -h, --help                       Prints this help

@brand-it
Copy link
Author

This little tool is super helpful for removing branches that have not been changed in a given amount of time. It only destroys branches on your local computer and does not push those change up to GitHub or whatever git service your using.

@brand-it
Copy link
Author

Reminder to myself. / slashes in branch name creates some odd problems... got to fix that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment