lib/bundle/brew_dumper.rb
# frozen_string_literal: true
require "json"
require "tsort"
module Bundle
# TODO: refactor into multiple modules
module BrewDumper
module_function
def reset!
Bundle::BrewServices.reset!
@formulae = nil
@formulae_by_full_name = nil
@formulae_by_name = nil
@formula_aliases = nil
@formula_oldnames = nil
end
def formulae
return @formulae if @formulae
formulae_by_full_name
@formulae
end
def formulae_by_full_name(name = nil)
return @formulae_by_full_name[name] if name.present? && @formulae_by_full_name&.key?(name)
require "formula"
require "formulary"
Formulary.enable_factory_cache!
@formulae_by_name ||= {}
@formulae_by_full_name ||= {}
if name.nil?
formulae = Formula.installed.map(&method(:add_formula))
sort!(formulae)
return @formulae_by_full_name
end
formula = Formula[name]
add_formula(formula)
rescue FormulaUnavailableError => e
opoo "'#{name}' formula is unreadable: #{e}"
{}
end
def formulae_by_name(name)
formulae_by_full_name(name) || @formulae_by_name[name]
end
def dump(describe: false, no_restart: false)
requested_formula = formulae.select do |f|
f[:installed_on_request?] || !f[:installed_as_dependency?]
end
requested_formula.map do |f|
brewline = if describe && f[:desc].present?
f[:desc].split("\n").map { |s| "# #{s}\n" }.join
else
""
end
brewline += "brew \"#{f[:full_name]}\""
args = f[:args].map { |arg| "\"#{arg}\"" }.sort.join(", ")
brewline += ", args: [#{args}]" unless f[:args].empty?
brewline += ", restart_service: true" if !no_restart && BrewServices.started?(f[:full_name])
brewline += ", link: #{f[:link?]}" unless f[:link?].nil?
brewline
end.join("\n")
end
def formula_aliases
return @formula_aliases if @formula_aliases
@formula_aliases = {}
formulae.each do |f|
aliases = f[:aliases]
next if aliases.blank?
aliases.each do |a|
@formula_aliases[a] = f[:full_name]
if f[:full_name].include? "/" # tap formula
tap_name = f[:full_name].rpartition("/").first
@formula_aliases["#{tap_name}/#{a}"] = f[:full_name]
end
end
end
@formula_aliases
end
def formula_oldnames
return @formula_oldnames if @formula_oldnames
@formula_oldnames = {}
formulae.each do |f|
oldnames = f[:oldnames]
next if oldnames.blank?
oldnames.each do |oldname|
@formula_oldnames[oldname] = f[:full_name]
if f[:full_name].include? "/" # tap formula
tap_name = f[:full_name].rpartition("/").first
@formula_oldnames["#{tap_name}/#{oldname}"] = f[:full_name]
end
end
end
@formula_oldnames
end
def add_formula(formula)
hash = formula_to_hash formula
@formulae_by_name[hash[:name]] = hash
@formulae_by_full_name[hash[:full_name]] = hash
hash
end
private_class_method :add_formula
def formula_to_hash(formula)
keg = if formula.linked?
link = true if formula.keg_only?
formula.linked_keg
else
link = false unless formula.keg_only?
formula.any_installed_prefix
end
if keg
require "tab"
tab = Tab.for_keg(keg)
args = tab.used_options.map(&:name)
version = begin
keg.realpath.basename
rescue
# silently handle broken symlinks
nil
end.to_s
args << "HEAD" if version.start_with?("HEAD")
installed_as_dependency = tab.installed_as_dependency
installed_on_request = tab.installed_on_request
runtime_dependencies = if (runtime_deps = tab.runtime_dependencies)
runtime_deps.filter_map { |d| d["full_name"] }
end
poured_from_bottle = tab.poured_from_bottle
end
runtime_dependencies ||= formula.runtime_dependencies.map(&:name)
bottled = if (stable = formula.stable) && stable.bottle_defined?
bottle_hash = formula.bottle_hash.deep_symbolize_keys
stable.bottled?
end
{
name: formula.name,
desc: formula.desc,
oldnames: formula.oldnames,
full_name: formula.full_name,
aliases: formula.aliases,
any_version_installed?: formula.any_version_installed?,
args: Array(args).uniq,
version:,
installed_as_dependency?: installed_as_dependency || false,
installed_on_request?: installed_on_request || false,
dependencies: runtime_dependencies,
build_dependencies: formula.deps.select(&:build?).map(&:name).uniq,
conflicts_with: formula.conflicts.map(&:name),
pinned?: formula.pinned? || false,
outdated?: formula.outdated? || false,
link?: link,
poured_from_bottle?: poured_from_bottle || false,
bottle: bottle_hash || false,
bottled: bottled || false,
official_tap: formula.tap&.official? || false,
}
end
private_class_method :formula_to_hash
class Topo < Hash
include TSort
alias tsort_each_node each_key
def tsort_each_child(node, &block)
fetch(node.downcase).sort.each(&block)
end
end
def sort!(formulae)
# Step 1: Sort by formula full name while putting tap formulae behind core formulae.
# So we can have a nicer output.
formulae = formulae.sort do |a, b|
if a[:full_name].exclude?("/") && b[:full_name].include?("/")
-1
elsif a[:full_name].include?("/") && b[:full_name].exclude?("/")
1
else
a[:full_name] <=> b[:full_name]
end
end
# Step 2: Sort by formula dependency topology.
topo = Topo.new
formulae.each do |f|
topo[f[:name]] = topo[f[:full_name]] = f[:dependencies].filter_map do |dep|
ff = formulae_by_name(dep)
next if ff.blank?
next unless ff[:any_version_installed?]
ff[:full_name]
end
end
@formulae = topo.tsort
.map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
.uniq { |f| f[:full_name] }
rescue TSort::Cyclic => e
e.message =~ /\["([^"]*)".*"([^"]*)"\]/
cycle_first = Regexp.last_match(1)
cycle_last = Regexp.last_match(2)
odie e.message if !cycle_first || !cycle_last
odie <<~EOS
Formulae dependency graph sorting failed (likely due to a circular dependency):
#{cycle_first}: #{topo[cycle_first]}
#{cycle_last}: #{topo[cycle_last]}
Please run the following commands and try again:
brew update
brew uninstall --ignore-dependencies --force #{cycle_first} #{cycle_last}
brew install #{cycle_first} #{cycle_last}
EOS
end
private_class_method :sort!
end
end