Skip to content

Instantly share code, notes, and snippets.

@mtancoigne
Last active February 28, 2023 10:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mtancoigne/d7e78aa9bdc74d6d929fb950db59fc2a to your computer and use it in GitHub Desktop.
Save mtancoigne/d7e78aa9bdc74d6d929fb950db59fc2a to your computer and use it in GitHub Desktop.
Display statuses of local git branches
#!/usr/bin/env ruby
# frozen_string_literal: true
# Display branch statuses
#
# @author Manuel Tancoigne <m.tancoigne@experimentslabs.com>
# @license MIT
# @version 0.2.3
#
# Requirements
# gem install rugged tty-table tty-option pastel skittlize
#
# Usage
# git bs --help
#
# Changelog:
# 0.2.3 - More fixes
# 0.2.2 - Fixes
# 0.2.1 - Improvements
# - Fail when reference branch does not exist
# - Fail with nicer error messages instead of exceptions
# 0.2.0 - Support for options
# - Comparison with remote branches is now optional (-r, --with-remotes)
# - Comparison with a limited set of branches (-w, --with)
# - Help (-h, --help) and usage examples
# - Code: comments
# 0.1.0 - Initial creation
require 'tty-table'
require 'tty-option'
require 'pastel'
require 'rugged'
require 'shellwords'
require 'skittlize'
module ExperimentsLabs
# Class with methods to compare branches display colored output.
class GitBs
class Error < StandardError; end
def initialize(dir = nil, reference_branch: nil, with: [], show_remotes: false)
@pastel = Pastel.new
@repo = Rugged::Repository.discover dir
raise Error, 'Repo is not in a normal state' unless @repo.head.branch?
@dir = dir
@show_remotes = show_remotes
@with = with || []
# Fallback to current branch
self.reference = reference_branch
rescue Rugged::RepositoryError
raise Error, 'Initialization error. Are you in a repository ?'
end
# Create the summary table
#
# @return [String]
def summarize
puts "Comparing #{@pastel.bold(@reference)} with #{branches.size} branches"
table = TTY::Table.new ['Branch ... is', 'ahead', 'behind'], branch_statuses
table.render(:unicode) do |renderer|
renderer.alignments = [:left, :right, :right]
renderer.padding = [0, 1, 0, 1]
if @show_remotes
place = @repo.branches.each_name(:local).grep_v(%r{/HEAD$}).count
renderer.border.separator = [0, place]
end
end
end
private
# Sets the reference branch with a fallback to current branch
def reference=(value)
@reference = value || @repo.head.name.sub(%r{^refs/heads/}, '')
raise Error, "Branch #{@pastel.bold @reference} does not exist" unless branches.include? @reference
end
# Remotes list, names only
#
# @return [Array<String>]
def remotes
# No time to dig in Rugged to check if results are cached somehow
return @remotes if @remotes
@remotes = @repo.remotes.each_name.sort
end
# Filtered branches list, names only
#
# @return [Array<String>]
def branches # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
return @branches if @branches
remote_branches = @repo.branches.each_name(:remote).sort
# Remove "/HEAD" pointer
@branches = @repo.branches.each_name
.grep_v(%r{/HEAD$})
# Only select required branches if necessary
@branches.select! { |branch| @with.include? branch } unless @with.empty?
# Remove remotes if necessary
@branches.reject! { |branch| remote_branches.include? branch } unless @show_remotes
# Add reference if necessary
@branches.push @reference unless @with.empty? || @branches.include?(@reference)
# Sort
@branches.sort! { |a, b| compare_branch_names a, b }
@branches
end
# @return [String]
def colorize_branch(name)
name = @pastel.underline name if name == @reference
return name unless @show_remotes
colorize_remote name
end
# @return [String]
def colorize_remote(name)
matches = name.match(%r{^(?<remote>[^/]+)/(?<branch>.*)$})
name = "#{matches[:remote].skittlize}/#{matches[:branch]}" if matches && remotes.include?(matches[:remote])
name
end
# Amount of commits behind the other branch, colorized
#
# @return [String]
def commits_behind(other:, ref: nil)
ref ||= @reference
return @pastel.dark('-') if ref == other
missing_commits = `cd #{@dir}; git log #{Shellwords.escape(ref)} --not #{Shellwords.escape(other)} --oneline -- | wc -l`.strip.to_i
return @pastel.green('✓') if missing_commits.zero?
@pastel.yellow("#{missing_commits} ⇣") if missing_commits.positive?
end
# Amount of commits ahead of the other branch, colorized
#
# @return [String]
def commits_ahead(other:, ref: nil)
ref ||= @reference
return @pastel.dark('-') if ref == other
new_commits = `cd #{@dir}; git log #{Shellwords.escape(other)} --not #{Shellwords.escape(ref)} --oneline -- | wc -l`.strip.to_i
return @pastel.green('✓') if new_commits.zero?
@pastel.yellow("#{new_commits} ⇡") if new_commits.positive?
end
# Branch statuses, to create a table row
#
# @return [Array<Array<String>>]
def branch_statuses
branches.map do |branch|
[
colorize_branch(branch),
commits_ahead(other: branch),
commits_behind(other: branch),
]
end
end
# Sort alphabetically, moving remotes branches at the end
#
# @return [Integer]
def compare_branch_names(branch_a, branch_b)
matches_a = branch_a.match(%r{^(?<remote>[^/]+)/.+$})
matches_a &&= remotes.include?(matches_a[:remote])
matches_b = branch_b.match(%r{^(?<remote>[^/]+)/.+$})
matches_b &&= remotes.include?(matches_b[:remote])
return 1 if matches_a && !matches_b
return -1 if !matches_a && matches_b
branch_a <=> branch_b
end
end
# CLI
class GitBsCli
include TTY::Option
usage do
program 'git-bs'
desc 'Check commit differences between branches'
no_command
example <<~TXT
# On branch main
git bs # compares "main" with other branches
git bs main # same as "git bs"
git bs prod # use "prod" as base branch
git bs --with feat1 # compare "main" with "feat1"
git bs prod --with feat1, feat2 # compare "prod" with "feat1" and "feat2"
TXT
end
argument :'reference-branch' do
optional
desc 'Custom reference branch. Current branch will be used if not specified'
end
option :with do
arity one_or_more
long '--with branch1, branch2'
short '-w'
convert :list
desc 'Only compare with these branches'
end
flag 'with-remotes' do
short '-r'
desc 'Whether to compare with remote branches too'
end
flag :help do
short '-h'
long '--help'
desc 'Print usage'
end
def run # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
if params[:help]
print help
exit 0
end
git = ExperimentsLabs::GitBs.new Dir.pwd,
reference_branch: params['reference-branch'],
show_remotes: params['with-remotes'],
with: params['with']
puts git.summarize
rescue ExperimentsLabs::GitBs::Error => e
puts e
exit 1
end
end
end
cli = ExperimentsLabs::GitBsCli.new
cli.parse
cli.run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment