Skip to content

Instantly share code, notes, and snippets.

@pvdb
Last active June 9, 2023 14:04
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 pvdb/eb3b5bc54092b41953de05426f92a653 to your computer and use it in GitHub Desktop.
Save pvdb/eb3b5bc54092b41953de05426f92a653 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# INSTALLATION
#
# ln -s ${PWD}/git-multi $(brew --prefix)/bin/
# sudo ln -s ${PWD}/git-multi /usr/local/bin/
#
# stdlib dependencies
require 'English'
require 'tempfile'
require 'fileutils'
require 'shellwords'
require 'io/console'
require 'forwardable'
# gem dependencies
begin
require 'octokit'
rescue LoadError
nil # it's optional
end
# rubocop:disable Style/TrailingCommaInArrayLiteral
# rubocop:disable Style/TrailingCommaInArguments
# rubocop:disable Style/NestedTernaryOperator
# rubocop:disable Style/EmptyCaseCondition
# rubocop:disable Style/SingleLineMethods
# rubocop:disable Style/CharacterLiteral
# rubocop:disable Style/BlockDelimiters
# rubocop:disable Style/FormatString
module Wordiness # :nodoc:
module WordinessMethods # :nodoc:
def quiet!() @quiet = true; end
def verbose!() @verbose = true; end
def quiet?() !!@quiet; end
def verbose?() !!@verbose; end
end
def self.included(base)
base.extend(WordinessMethods)
base.instance_variable_set(:@quiet, false)
base.instance_variable_set(:@verbose, false)
end
end
class String # :nodoc:
# https://en.wikipedia.org/wiki/ANSI_escape_code
def colorize(color_code) "\e[#{color_code}m#{self}\e[0m"; end
def bold() colorize('1'); end
def faint() colorize('2'); end
def italic() colorize('3'); end
def underline() colorize('4'); end
def invert() colorize('7'); end
def strike() colorize('9'); end
def red() colorize('31'); end
def green() colorize('32'); end
def blue() colorize('34'); end
def cyan() colorize('36'); end
end
module Spinner # :nodoc:
ANIMATION = [
'[ ]',
'[. ]',
'[.. ]',
'[ . ]',
'[ .. ]',
'[ . ]',
'[ .. ]',
'[ . ]',
'[ ..]',
'[ .]',
'[ ]',
'[ .]',
'[ ..]',
'[ . ]',
'[ .. ]',
'[ . ]',
'[ .. ]',
'[ . ]',
'[.. ]',
'[. ]',
].map(&:blue).freeze
private_constant :ANIMATION
module_function
def animator_for(task)
animation = ANIMATION.cycle
loop do
IO.console.print(?\r, task, ' ', animation.next)
sleep 0.1
end
end
private_class_method :animator_for
def report_duration(task, start_at, finish_at)
duration = format('(%.3f seconds)', finish_at - start_at)
IO.console.print(?\r, task.strike, ' ', "\e[0K", duration.green, ?\n)
end
private_class_method :report_duration
def spinner_for(task, &block)
animator = Thread.new { animator_for(task) }
block.call
ensure
animator.kill
end
private_class_method :spinner_for
def timer_for(task, &block)
start_at = Time.now
block.call
ensure
finish_at = Time.now
report_duration(task, start_at, finish_at)
end
private_class_method :timer_for
def for(task, &block)
timer_for(task) do
spinner_for(task) do
block.call
end
end
end
end
class IO # :nodoc:
def self.identical_ios?(stdout, stderr)
# see also: ruby/ruby/lib/fileutils.rb
out_stat = stdout.stat
err_stat = stderr.stat
(out_stat.dev == err_stat.dev) && (out_stat.ino == err_stat.ino)
end
end
class Tempfile # :nodoc:
def prefixed_lines_to(io, prefix = '')
rewind
lines = readlines
lines.each_with_object(prefix, &:prepend)
io.puts(lines)
io.flush
end
end
module Git # :nodoc:
module Config # :nodoc:
include Wordiness
module_function
def git(command, path = nil)
git = path ? "git -C #{path}" : 'git'
IO.console.puts "CMD: #{git} #{command}" if verbose?
`#{git} #{command}`.split($RS).each(&:strip!)
end
private_class_method :git
def git_var(name)
git("var #{name}").sample
end
def editor
@editor ||= git_var('GIT_EDITOR')
end
def git_config(options, file: nil)
source = file && File.file?(file) ? "--file #{file}" : '--global'
git("config #{source} #{options}")
end
private_class_method :git_config
def get_options(name, file: nil)
git_config("--get-all #{name}", file: file)
end
def delete_options(name, file: nil)
git_config("--unset-all #{name}", file: file)
end
def add_options(name, options, file: nil)
options.each do |option|
git_config("--add #{name} #{option}", file: file)
end
end
def replace_options(name, options, file: nil)
delete_options(name, file: file)
add_options(name, options, file: file)
end
def get_option(name, file: nil)
git_config("--get #{name}", file: file).first
end
def get_list(name, file: nil)
get_option(name, file: file).split(',')
end
end
module Hub # :nodoc:
module_function
def access_token
Config.get_option('github.token') || ENV['GITHUB_TOKEN']
end
private_class_method :access_token
def client
@client ||= ::Octokit::Client.new(
access_token: access_token,
auto_paginate: true,
)
end
private_class_method :client
USER_REPOSITORIES_FOR = Hash.new { |repos, (user, type)|
repos[[user, type]] = Spinner.for('Fetching user repos') do
client.repositories(user, type: type)
end
}
private_constant :USER_REPOSITORIES_FOR
ORGANIZATION_REPOSITORIES_FOR = Hash.new { |repos, (org, type)|
repos[[org, type]] = Spinner.for('Fetching org repos') do
client.organization_repositories(org, type: type)
end
}
private_constant :ORGANIZATION_REPOSITORIES_FOR
# rubocop:disable Metrics/MethodLength
def repositories_for(owner, type = :owner)
case client.user(owner).type
when 'User'
# type can be one of: all, owner, member
USER_REPOSITORIES_FOR[[owner, type]]
when 'Organization'
# type can be one of: all, public, private, forks, sources, member
ORGANIZATION_REPOSITORIES_FOR[[owner, type]]
end
rescue NameError
raise unless $ERROR_INFO.message == 'uninitialized constant Octokit'
IO.console.puts '(please run `gem install octokit` first)'.red
[] # octokit gem not installed
rescue ::Octokit::NotFound
IO.console.puts "(user/org \"#{owner}\" doesn't exist)".red
[] # user/org doesn't exist
end
# rubocop:enable Metrics/MethodLength
end
module Multi # :nodoc:
CONFIG_DIR = File.join(Dir.home, '.config', 'git', 'multi')
module_function
def config_file_for(name)
File.join(CONFIG_DIR, "#{name}.config")
end
def superprojects
@superprojects ||= Config.get_list('git.multi.superprojects')
end
def default_workarea
@default_workarea ||= Config.get_option('git.multi.workarea')
end
def default_exclusions
@default_exclusions ||= Config.get_options('git.multi.exclude')
end
def workarea_for(name)
key = "superproject.#{name}.workarea"
workarea = Config.get_option(key, file: config_file_for(name))
workarea || default_workarea
end
def exclusions_for(name)
key = "superproject.#{name}.exclude"
exclusions = Config.get_options(key, file: config_file_for(name))
exclusions + default_exclusions
end
def repos_for(name)
key = "superproject.#{name}.repo"
Config.get_options(key, file: config_file_for(name))
end
def add_repos_to(name, repos)
Config.add_options("superproject.#{name}.repo", repos, file: config_file_for(name))
end
def replace_repos_for(name, repos)
Config.replace_options("superproject.#{name}.repo", repos, file: config_file_for(name))
end
def set_repos_for(name, repos)
FileUtils.touch(config_file_for(name)) # ensure config file exists
replace_repos_for(name, repos)
end
def diff(base, head)
added = head - base
removed = base - head
(base + head).uniq.sort.map { |entry|
case
when added.include?(entry) then "(+) #{entry}".green
when removed.include?(entry) then "(-) #{entry}".red
else " #{entry}"
end
}
end
def compare_repos_to(name, repos)
IO.console.puts(
if (base = repos_for(name)) == repos
'Lists of repos are identical'
else
diff(base, repos)
end
)
end
class Repo # :nodoc:
attr_reader :full_name
def initialize(full_name, workarea, fractional_index, excluded = nil)
@full_name = full_name
@workarea = workarea
@fractional_index = fractional_index
@excluded = !!excluded
end
def work_tree() File.join(@workarea, full_name); end
def git_dir() @git_dir ||= File.join(work_tree, '.git'); end
def excluded?() @excluded; end
def exists?() @exists ||= File.directory?(git_dir); end
def missing?() !excluded? && !exists?; end
TICK = "\u2714".green.freeze
CROSS = "\u2718".red.freeze
ARROW = "\u2794".blue.freeze
EXCLUDE = "\u233D".cyan.freeze
def icon
@icon ||= excluded? ? EXCLUDE : exists? ? TICK : CROSS
end
def to_s
@to_s ||= "(#{@fractional_index} - #{@full_name})"
end
def label
@label ||= "#{to_s.invert} #{icon}"
end
def report
full_name = exists? ? @full_name : @full_name.strike
IO.console.puts "#{icon} #{full_name}"
end
def gh_clone_cmd
"gh repo clone #{full_name} #{work_tree}"
end
end
module Exec # :nodoc:
include Wordiness
module_function
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
def do_it(args, prefix: '')
IO.console.puts "CMD: #{args.join(' ')}" if verbose?
prefix += ': ' unless prefix.empty?
stdout_tty = (stdout = $stdout).tty?
stderr_tty = (stderr = $stderr).tty?
temp_out = temp_err = temp_ios = nil
out, err = if stdout_tty && stderr_tty
[stdout, stderr]
elsif !stdout_tty && !stderr_tty
if IO.identical_ios?(stdout, stderr)
temp_ios = Tempfile.new('temp_ios_')
[temp_ios, temp_ios]
else
temp_out = Tempfile.new('temp_out_')
temp_err = Tempfile.new('temp_err_')
[temp_out, temp_err]
end
elsif stdout_tty
temp_err = Tempfile.new('temp_err_')
[stdout, temp_err]
elsif stderr_tty
temp_out = Tempfile.new('temp_out_')
[temp_out, stderr]
else
raise 'FIX ME!'
end
system(*args, out: out, err: err)
ensure
temp_out&.prefixed_lines_to(stdout, prefix)
temp_out&.close
temp_err&.prefixed_lines_to(stderr, prefix)
temp_err&.close
temp_ios&.prefixed_lines_to([stdout, stderr].sample, prefix)
temp_ios&.close
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/CyclomaticComplexity
private_class_method :do_it
def git_in(repo, args)
IO.console.puts(repo.label) unless quiet?
args = args.dup.unshift('git', '-C', repo.work_tree, '--no-pager')
do_it(args, prefix: repo.full_name)
end
def shell_in(repo, args)
IO.console.puts(repo.label) unless quiet?
args = args.dup.unshift(ENV.fetch('SHELL', '/bin/sh'), '-l')
Dir.chdir(repo.work_tree) { do_it(args, prefix: repo.full_name) }
end
def raw_in(repo, args)
IO.console.puts(repo.label) unless quiet?
args = args.dup # no changes!
Dir.chdir(repo.work_tree) { do_it(args, prefix: repo.full_name) }
end
def editor_for(file)
editor = Config.editor
IO.console.puts "CMD: #{editor} #{file}" if verbose?
system("#{editor} #{file}")
end
end
class SuperProject # :nodoc:
@all = {}
def self.for(name, repos, workarea, exclusions = [])
@all[name] = new(name, repos, workarea, exclusions)
end
def self.all
@all.values
end
def self.named(name)
@all[name]
end
attr_reader :name, :workarea, :repos
def initialize(name, repos, workarea, exclusions = [])
@name = name
@workarea = workarea
@repos = repos.each_with_index.map { |full_name, index|
excluded = exclusions.include?(full_name)
fractional_index = "#{@name}: ##{index + 1}/#{repos.count}"
Repo.new(full_name, workarea, fractional_index, excluded)
}
end
private :initialize
def to_s
@to_s ||= "[#{name}: ##{repos.count} repos]"
end
def config_file
Git::Multi.config_file_for(name)
end
def edit_config_file
Exec.editor_for(config_file)
end
extend Forwardable
def_delegators :@repos, :size
def exec_git(args)
each_repo(filter: :exists?, & ->(repo) { Exec.git_in(repo, args) })
end
def exec_shell(args)
each_repo(filter: :exists?, & ->(repo) { Exec.shell_in(repo, args) })
end
def exec_raw(args)
each_repo(filter: :exists?, & ->(repo) { Exec.raw_in(repo, args) })
end
def each_repo(filter: :itself, &block)
@repos.select(&filter).each(&block)
end
end
class ForEach # :nodoc:
include Wordiness
def self.in(superprojects)
new(superprojects)
end
def initialize(superprojects)
@superprojects = superprojects
end
private :initialize
def each(format: '[%s: %d-repo superproject]')
@superprojects.each do |superproject|
title = sprintf(format, superproject.name, superproject.size)
IO.console.puts(title.invert.bold) unless self.class.quiet?
yield superproject
end
end
private :each
def edit_config_file
each(&:edit_config_file)
end
def report_repo_status
each { |superproject| superproject.each_repo(&:report) }
end
def list_all_repos
each { |superproject| superproject.each_repo { puts _1.full_name } }
end
def list_repo_paths
each { |superproject| superproject.each_repo { puts _1.work_tree } }
end
def list_existing_repos
each(format: '[%s: existing repos]') { |superproject|
superproject.each_repo(filter: :exists?, & -> { puts _1.full_name })
}
end
def list_excluded_repos
each(format: '[%s: excluded repos]') { |superproject|
superproject.each_repo(filter: :excluded?, & -> { puts _1.full_name })
}
end
def list_missing_repos
each(format: '[%s: missing repos]') { |superproject|
superproject.each_repo(filter: :missing?, & -> { puts _1.full_name })
}
end
def clone_missing_repos
each(format: '[%s: clone repos]') { |superproject|
puts 'export GITHUB_TOKEN=$(git config --global --get github.token)'
superproject.each_repo(filter: :missing?, & -> { puts _1.gh_clone_cmd })
}
end
def run_git_command(args)
each { _1.exec_git(args) }
end
def run_shell_command(args)
each { _1.exec_shell(args) }
end
def run_raw_command(args)
each { _1.exec_raw(args) }
end
end
end
end
# rubocop:enable Style/FormatString
# rubocop:enable Style/BlockDelimiters
# rubocop:enable Style/CharacterLiteral
# rubocop:enable Style/SingleLineMethods
# rubocop:enable Style/EmptyCaseCondition
# rubocop:enable Style/NestedTernaryOperator
# rubocop:enable Style/TrailingCommaInArguments
# rubocop:enable Style/TrailingCommaInArrayLiteral
superprojects = begin
project_list = []
SuperProject = Git::Multi::SuperProject
unless $stdin.tty?
# read list of repo names from $stdin
repos = $stdin.readlines.map(&:strip)
# reopen $stdin (to ensure `Kernel.system` works correctly)
$stdin.reopen('/dev/tty')
# get default workarea & exclusions from git config
workarea = Git::Multi.default_workarea
exclusions = Git::Multi.default_exclusions
# add "pseudo" superproject to the list
project_list << SuperProject.for('$stdin', repos, workarea, exclusions)
end
ARGV.find_all { |arg| arg.start_with?('++') }.each do |name|
# remove "++#{name}" from ARGV
ARGV.delete(name)
# remove prefix from "++#{name}"
name = name.delete_prefix('++')
# get list of repo names from git config
repos = Git::Multi.repos_for(name)
# get workarea & exclusions from git config
workarea = Git::Multi.workarea_for(name)
exclusions = Git::Multi.exclusions_for(name)
# add "real" superproject to the list
project_list << SuperProject.for(name, repos, workarea, exclusions)
end
ARGV.find_all { |arg| arg.start_with?('@@') }.each do |owner|
# remove "@@#{owner}" from ARGV
ARGV.delete(owner)
# remove prefix from "@@#{owner}"
owner = owner.delete_prefix('@@')
# get list of repo names from the GitHub API
repos = Git::Hub.repositories_for(owner).map(&:full_name)
# get superproject workarea & exclusions from git config
workarea = Git::Multi.workarea_for(owner)
exclusions = Git::Multi.exclusions_for(owner)
# add "real" superproject to the list
project_list << SuperProject.for(owner, repos, workarea, exclusions)
end
if project_list.empty?
# default to *all* superprojects defined in ~/.gitconfig
Git::Multi.superprojects.each do |name|
# get list of repo names from ~/.gitconfig
repos = Git::Multi.repos_for(name)
# get superproject workarea & exclusions from git config
workarea = Git::Multi.workarea_for(name)
exclusions = Git::Multi.exclusions_for(name)
# add "real" superproject to the list
project_list << SuperProject.for(name, repos, workarea, exclusions)
end
end
project_list
end
if __FILE__ == $PROGRAM_NAME
# rubocop:disable Lint/AssignmentInCondition
global_options = %w[--quiet --verbose].freeze
while global_options.include? ARGV.first
case option = ARGV.shift
when '--quiet'
Git::Config.quiet!
Git::Multi::Exec.quiet!
Git::Multi::ForEach.quiet!
when '--verbose'
Git::Config.verbose!
Git::Multi::Exec.verbose!
Git::Multi::ForEach.verbose!
else
raise "Unsupported option: #{option}"
end
end
if custom = Git::Multi::SuperProject.named('$stdin')
list = custom.repos.map(&:full_name)
# FIXME: this is a hack to get the name of the superproject
name = (superprojects.map(&:name) - ['$stdin']).sample
case cmd = ARGV.shift
when '--diff' then Git::Multi.compare_repos_to(name, list)
when '--update' then Git::Multi.add_repos_to(name, list)
when '--create' then Git::Multi.set_repos_for(name, list)
when '--replace' then Git::Multi.replace_repos_for(name, list)
else raise "Unknown command: #{cmd}"
end
end
each_superproject = Git::Multi::ForEach.in(superprojects)
while ARGV.first&.start_with?('--')
case ARGV.shift
when '--edit' then each_superproject.edit_config_file
when '--report' then each_superproject.report_repo_status
when '--list' then each_superproject.list_all_repos
when '--paths' then each_superproject.list_repo_paths
when '--existing' then each_superproject.list_existing_repos
when '--excluded' then each_superproject.list_excluded_repos
when '--missing' then each_superproject.list_missing_repos
when '--clone' then each_superproject.clone_missing_repos
when '--git' then each_superproject.run_git_command(ARGV.shift(ARGV.size))
when '--shell' then each_superproject.run_shell_command(ARGV.shift(ARGV.size))
when '--raw' then each_superproject.run_raw_command(ARGV.shift(ARGV.size))
end
end
each_superproject.run_git_command(ARGV) if ARGV.any?
# rubocop:enable Lint/AssignmentInCondition
end
# That's all Folks!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment