Last active
February 16, 2025 17:34
-
-
Save pvdb/eb3b5bc54092b41953de05426f92a653 to your computer and use it in GitHub Desktop.
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 | |
# | |
# INSTALLATION | |
# | |
# ln -s ${PWD}/git-multi $(brew --prefix)/bin/ | |
# sudo ln -s ${PWD}/git-multi /usr/local/bin/ | |
# | |
# DEPENDENCIES | |
# | |
# brew install gh | |
# gh auth login | |
# | |
# stdlib dependencies | |
require 'json' | |
require 'English' | |
require 'tempfile' | |
require 'fileutils' | |
require 'shellwords' | |
require 'io/console' | |
require 'forwardable' | |
# rubocop:disable Style/TrailingCommaInArrayLiteral | |
# 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 type_for(owner) | |
`gh api users/#{owner}` | |
.then { JSON.parse(_1) } | |
.then { _1['type'] } | |
end | |
def repo_list(owner, options = []) | |
options << '--limit' << '1000' # TODO: what if more than 1000 repos? | |
options << '--json' << 'nameWithOwner' # we only need the full name! | |
`gh repo list #{owner} #{options.join(' ')}` | |
.then { JSON.parse(_1) } | |
.map { _1['nameWithOwner'] } | |
end | |
USER_REPOSITORIES_FOR = Hash.new { |repos, (user, type)| | |
repos[[user, type]] = repo_list(user) | |
# Spinner.for("Fetching repos (user: #{user}, type: #{type})") do | |
# gh api 'users/pvdb/repos?type=source' --paginate | jq '.[]|.full_name' | |
# end | |
} | |
private_constant :USER_REPOSITORIES_FOR | |
ORGANIZATION_REPOSITORIES_FOR = Hash.new { |repos, (org, type)| | |
repos[[org, type]] = repo_list(org, ['--source']) | |
# Spinner.for("Fetching repos (org: #{org}, type: #{type})") do | |
# gh api 'orgs/33N-Ltd/repos?type=source' --paginate | jq '.[]|.full_name' | |
# end | |
} | |
private_constant :ORGANIZATION_REPOSITORIES_FOR | |
def repositories_for(owner, type = :all) | |
case type_for(owner) | |
when 'User' | |
# type can be one of: all, owner, member | |
USER_REPOSITORIES_FOR[[owner, type]] | |
when 'Organization' | |
# type can be one of: all, public, member, sources, forks, private | |
ORGANIZATION_REPOSITORIES_FOR[[owner, type]] | |
end | |
end | |
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 repositories]') | |
@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| | |
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/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}" | |
owner = name.delete_prefix('++') | |
# get list of repo names from git config | |
repos = Git::Multi.repos_for(owner) | |
# get 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(name, repos, workarea, exclusions) | |
end | |
ARGV.find_all { |arg| arg.start_with?('@@') }.each do |name| | |
# remove "@@#{owner}" from ARGV | |
ARGV.delete(name) | |
# remove prefix from "@@#{owner}" | |
owner = name.delete_prefix('@@') | |
# get list of GitHub repo names (using `gh` command) | |
repos = Git::Hub.repositories_for(owner) | |
# 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(name, 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