Last active
February 28, 2023 10:16
-
-
Save mtancoigne/d7e78aa9bdc74d6d929fb950db59fc2a to your computer and use it in GitHub Desktop.
Display statuses of local 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
#!/usr/bin/env ruby | |
# frozen_string_literal: true | |
# Display branch statuses | |
# | |
# @author Manuel Tancoigne <[email protected]> | |
# @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