bundle: refactor brew, cask, and tap classes

- Move `brew`, `cask`, and `tap` into single implementation files while keeping the existing checker, dumper, and installer entrypoints as compatibility requires for the current tests and callers.
- Keep the shared bundle checker, dumper, and installer flows generic over registered package types and extensions, and preserve the existing App check output when `--no-upgrade` is used.
This commit is contained in:
Mike McQuaid committed 2026-03-16 18:43:32 +00:00
1 parent 231640056e
commit cd65e94cef
22 files changed
+1399 -1173

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ module Homebrew
when :brew
Formulary.factory(arg)
when :cask
Cask::CaskLoader.load(arg)
::Cask::CaskLoader.load(arg)
end
"#{type} \"#{arg}\""
+603
View File
@@ -0,0 +1,603 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "json"
require "tsort"
require "utils/output"
require "bundle/package_type"
module Homebrew
module Bundle
# TODO: refactor into multiple modules
class Brew < Homebrew::Bundle::PackageType
extend Utils::Output::Mixin
PACKAGE_TYPE = :brew
PACKAGE_TYPE_NAME = "Formula"
class << self
def inherited(subclass)
return if subclass.name == "Homebrew::Bundle::Brew::Services"
super
end
def reset!
require "bundle/brew_services"
Homebrew::Bundle::Brew::Services.reset!
@installed_formulae = nil
@outdated_formulae = nil
@pinned_formulae = nil
@formulae = nil
@formulae_by_full_name = nil
@formulae_by_name = nil
@formula_aliases = nil
@formula_oldnames = nil
end
def preinstall!(name, no_upgrade: false, verbose: false, **options)
new(name, options).preinstall!(no_upgrade:, verbose:)
end
def install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options)
new(name, options).install!(preinstall:, no_upgrade:, verbose:, force:)
end
def install_verb(name, options = {})
_ = options
return "Installing" unless formula_upgradable?(name)
"Upgrading"
end
def formula_installed_and_up_to_date?(formula, no_upgrade: false)
return false unless formula_installed?(formula)
return true if no_upgrade_with_args?(no_upgrade, formula)
!formula_upgradable?(formula)
end
def no_upgrade_with_args?(no_upgrade, formula_name)
no_upgrade && Bundle.upgrade_formulae.exclude?(formula_name)
end
def formula_in_array?(formula, array)
return true if array.include?(formula)
return true if array.include?(formula.split("/").last)
old_name = formula_oldnames[formula]
old_name ||= formula_oldnames[formula.split("/").last]
return true if old_name && array.include?(old_name)
resolved_full_name = formula_aliases[formula]
return false unless resolved_full_name
return true if array.include?(resolved_full_name)
return true if array.include?(resolved_full_name.split("/").last)
false
end
def formula_installed?(formula)
formula_in_array?(formula, installed_formulae)
end
def formula_upgradable?(formula)
# Check local cache first and then authoritative Homebrew source.
formula_in_array?(formula, upgradable_formulae) && Formula[formula].outdated?
end
def installed_formulae
@installed_formulae ||= formulae.map { |f| f[:name] }
end
def upgradable_formulae
outdated_formulae - pinned_formulae
end
def outdated_formulae
@outdated_formulae ||= formulae.filter_map { |f| f[:name] if f[:outdated?] }
end
def pinned_formulae
@pinned_formulae ||= formulae.filter_map { |f| f[:name] if f[:pinned?] }
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 { add_formula(it) }
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)
require "bundle/brew_services"
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: :changed" if !no_restart && BrewServices.started?(f[:full_name])
brewline += ", link: #{f[:link?]}" unless f[:link?].nil?
brewline
end.join("\n")
end
def dump_output(describe: false, no_restart: false)
dump(describe:, no_restart:)
end
def fetchable_name(name, options = {}, no_upgrade: false)
_ = options
user, repository, = name.split("/", 3)
return if user.present? && repository.present? &&
Homebrew::Bundle::Tap.installed_taps.exclude?("#{user}/#{repository}")
return if formula_installed_and_up_to_date?(name, no_upgrade:)
name
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
private
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
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
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] if topo}
#{cycle_last}: #{topo[cycle_last] if topo}
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
end
def initialize(name = "", options = {})
super()
@full_name = name
@name = name.split("/").last
@args = options.fetch(:args, []).map { |arg| "--#{arg}" }
@conflicts_with_arg = options.fetch(:conflicts_with, [])
@restart_service = options[:restart_service]
@start_service = options.fetch(:start_service, @restart_service)
@link = options.fetch(:link, nil)
@postinstall = options.fetch(:postinstall, nil)
@version_file = options.fetch(:version_file, nil)
@changed = nil
end
def installed_and_up_to_date?(formula, no_upgrade: false)
self.class.formula_installed_and_up_to_date?(formula, no_upgrade:)
end
def preinstall!(no_upgrade: false, verbose: false)
if installed? && (self.class.no_upgrade_with_args?(no_upgrade, @name) || !upgradable?)
puts "Skipping install of #{@name} formula. It is already installed." if verbose
@changed = nil
return false
end
true
end
def install!(preinstall: true, no_upgrade: false, verbose: false, force: false)
install_result = if preinstall
install_change_state!(no_upgrade:, verbose:, force:)
else
true
end
result = install_result
if installed?
service_result = service_change_state!(verbose:)
result &&= service_result
link_result = link_change_state!(verbose:)
result &&= link_result
postinstall_result = postinstall_change_state!(verbose:)
result &&= postinstall_result
if result && @version_file.present?
# Use the version from the environment if it hasn't changed.
# Strip the revision number because it's not part of the non-Homebrew version.
version = if !changed? && (env_version = Bundle.formula_versions_from_env(@name))
PkgVersion.parse(env_version).version
else
Formula[@full_name].version
end.to_s
File.write(@version_file, "#{version}\n")
puts "Wrote #{@name} version #{version} to #{@version_file}" if verbose
end
end
result
end
def install_change_state!(no_upgrade:, verbose:, force:)
require "tap"
if (tap_with_name = ::Tap.with_formula_name(@full_name))
tap, = tap_with_name
tap.ensure_installed!
end
return false unless resolve_conflicts!(verbose:)
if installed?
upgrade_formula!(verbose:, force:)
else
install_formula!(verbose:, force:)
end
end
def start_service?
@start_service.present?
end
def start_service_needed?
require "bundle/brew_services"
start_service? && !BrewServices.started?(@full_name)
end
def restart_service?
@restart_service.present?
end
def restart_service_needed?
return false unless restart_service?
# Restart if `restart_service: :always`, or if the formula was installed or upgraded
@restart_service.to_s == "always" || changed?
end
def changed?
@changed.present?
end
def service_change_state!(verbose:)
require "bundle/brew_services"
file = BrewServices.versioned_service_file(@name)
if restart_service_needed?
puts "Restarting #{@name} service." if verbose
BrewServices.restart(@full_name, file:, verbose:)
elsif start_service_needed?
puts "Starting #{@name} service." if verbose
BrewServices.start(@full_name, file:, verbose:)
else
true
end
end
def link_change_state!(verbose: false)
link_args = []
link_args << "--force" if unlinked_and_keg_only?
cmd = case @link
when :overwrite
link_args << "--overwrite"
"link" unless linked?
when true
"link" unless linked?
when false
"unlink" if linked?
when nil
if keg_only?
"unlink" if linked?
else
"link" unless linked?
end
end
if cmd.present?
verb = "#{cmd}ing".capitalize
with_args = " with #{link_args.join(" ")}" if link_args.present?
puts "#{verb} #{@name} formula#{with_args}." if verbose
return Bundle.brew(cmd, *link_args, @name, verbose:)
end
true
end
def postinstall_change_state!(verbose:)
return true if @postinstall.blank?
return true unless changed?
puts "Running postinstall for #{@name}: #{@postinstall}" if verbose
Kernel.system(@postinstall)
end
private
def installed?
self.class.formula_installed?(@name)
end
def linked?
Formula[@full_name].linked?
end
def keg_only?
Formula[@full_name].keg_only?
end
def unlinked_and_keg_only?
!linked? && keg_only?
end
def upgradable?
self.class.formula_upgradable?(@full_name)
end
def conflicts_with
@conflicts_with ||= begin
conflicts_with = Set.new
conflicts_with += @conflicts_with_arg
if (formula = self.class.formulae_by_full_name(@full_name)) &&
(formula_conflicts_with = formula[:conflicts_with])
conflicts_with += formula_conflicts_with
end
conflicts_with.to_a
end
end
def resolve_conflicts!(verbose:)
conflicts_with.each do |conflict|
next unless self.class.formula_installed?(conflict)
if verbose
puts <<~EOS
Unlinking #{conflict} formula.
It is currently installed and conflicts with #{@name}.
EOS
end
return false unless Bundle.brew("unlink", conflict, verbose:)
next unless restart_service?
require "bundle/brew_services"
puts "Stopping #{conflict} service (if it is running)." if verbose
BrewServices.stop(conflict, verbose:)
end
true
end
def install_formula!(verbose:, force:)
install_args = @args.dup
install_args << "--force" << "--overwrite" if force
install_args << "--skip-link" if @link == false
with_args = " with #{install_args.join(" ")}" if install_args.present?
puts "Installing #{@name} formula#{with_args}. It is not currently installed." if verbose
unless Bundle.brew("install", "--formula", @full_name, *install_args, verbose:)
@changed = nil
return false
end
self.class.installed_formulae << @name
@changed = true
true
end
def upgrade_formula!(verbose:, force:)
upgrade_args = []
upgrade_args << "--force" if force
with_args = " with #{upgrade_args.join(" ")}" if upgrade_args.present?
puts "Upgrading #{@name} formula#{with_args}. It is installed but not up-to-date." if verbose
unless Bundle.brew("upgrade", "--formula", @name, *upgrade_args, verbose:)
@changed = nil
return false
end
@changed = true
true
end
class Topo < Hash
include TSort
def each_key(&block)
keys.each(&block)
end
alias tsort_each_node each_key
def tsort_each_child(node, &block)
fetch(node.downcase).sort.each(&block)
end
end
end
# TODO: Remove these compatibility aliases once bundle callers and tests
# stop requiring separate brew dumper/installer/checker constants.
FormulaInstaller = Brew
FormulaDumper = Brew
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate brew checker constant.
BrewChecker = Homebrew::Bundle::Brew
end
end
end
+1 -17
View File
@@ -1,20 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/formula_installer"
module Homebrew
module Bundle
module Checker
class BrewChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :brew
PACKAGE_TYPE_NAME = "Formula"
sig { params(formula: String, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(formula, no_upgrade: false)
Homebrew::Bundle::FormulaInstaller.formula_installed_and_up_to_date?(formula, no_upgrade:)
end
end
end
end
end
require "bundle/brew"
@@ -1,54 +1,4 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class BrewServiceChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :brew
PACKAGE_TYPE_NAME = "Service"
PACKAGE_ACTION_PREDICATE = "needs to be started."
def failure_reason(name, no_upgrade:)
"#{PACKAGE_TYPE_NAME} #{name} needs to be started."
end
def installed_and_up_to_date?(formula, no_upgrade: false)
return true unless formula_needs_to_start?(entry_to_formula(formula))
return true if service_is_started?(formula.name)
old_name = lookup_old_name(formula.name)
return true if old_name && service_is_started?(old_name)
false
end
def entry_to_formula(entry)
require "bundle/formula_installer"
Homebrew::Bundle::FormulaInstaller.new(entry.name, entry.options)
end
def formula_needs_to_start?(formula)
formula.start_service? || formula.restart_service?
end
def service_is_started?(service_name)
require "bundle/brew_services"
Homebrew::Bundle::BrewServices.started?(service_name)
end
def lookup_old_name(service_name)
require "bundle/formula_dumper"
@old_names ||= Homebrew::Bundle::FormulaDumper.formula_oldnames
old_name = @old_names[service_name]
old_name ||= @old_names[service_name.split("/").last]
old_name
end
def format_checkable(entries)
checkable_entries(entries)
end
end
end
end
end
require "bundle/brew_services"
+120 -70
View File
@@ -3,89 +3,139 @@
require "services/system"
require "utils/output"
require "bundle/brew"
module Homebrew
module Bundle
module BrewServices
extend Utils::Output::Mixin
class Brew
class Services < Homebrew::Bundle::Brew
extend Utils::Output::Mixin
def self.reset!
@started_services = nil
end
def self.stop(name, keep: false, verbose: false)
return true unless started?(name)
args = ["services", "stop", name]
args << "--keep" if keep
return unless Bundle.brew(*args, verbose:)
started_services.delete(name)
true
end
def self.start(name, file: nil, verbose: false)
args = ["services", "start", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def self.run(name, file: nil, verbose: false)
args = ["services", "run", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def self.restart(name, file: nil, verbose: false)
args = ["services", "restart", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def self.started?(name)
started_services.include? name
end
def self.started_services
@started_services ||= begin
if !Homebrew::Services::System.launchctl? && !Homebrew::Services::System.systemctl?
odie Homebrew::Services::System::MISSING_DAEMON_MANAGER_EXCEPTION_MESSAGE
class << self
def reset!
@started_services = nil
end
states_to_skip = %w[stopped none]
Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "list").lines.filter_map do |line|
name, state, _plist = line.split(/\s+/)
next if states_to_skip.include? state
name
def stop(name, keep: false, verbose: false)
return true unless started?(name)
args = ["services", "stop", name]
args << "--keep" if keep
return unless Bundle.brew(*args, verbose:)
started_services.delete(name)
true
end
def start(name, file: nil, verbose: false)
args = ["services", "start", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def run(name, file: nil, verbose: false)
args = ["services", "run", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def restart(name, file: nil, verbose: false)
args = ["services", "restart", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)
started_services << name
true
end
def started?(name)
started_services.include? name
end
def started_services
@started_services ||= begin
if !Homebrew::Services::System.launchctl? && !Homebrew::Services::System.systemctl?
odie Homebrew::Services::System::MISSING_DAEMON_MANAGER_EXCEPTION_MESSAGE
end
states_to_skip = %w[stopped none]
Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "list").lines.filter_map do |line|
name, state, _plist = line.split(/\s+/)
next if states_to_skip.include? state
name
end
end
end
def versioned_service_file(name)
env_version = Bundle.formula_versions_from_env(name)
return if env_version.nil?
formula = Formula[name]
prefix = formula.rack/env_version
return unless prefix.directory?
service_file = if Homebrew::Services::System.launchctl?
prefix/"#{formula.plist_name}.plist"
else
prefix/"#{formula.service_name}.service"
end
service_file if service_file.file?
end
end
end
def self.versioned_service_file(name)
env_version = Bundle.formula_versions_from_env(name)
return if env_version.nil?
def failure_reason(name, no_upgrade:)
_ = no_upgrade
formula = Formula[name]
prefix = formula.rack/env_version
return unless prefix.directory?
service_file = if Homebrew::Services::System.launchctl?
prefix/"#{formula.plist_name}.plist"
else
prefix/"#{formula.service_name}.service"
"Service #{name} needs to be started."
end
service_file if service_file.file?
def installed_and_up_to_date?(formula, no_upgrade: false)
_ = no_upgrade
return true unless formula_needs_to_start?(entry_to_formula(formula))
return true if self.class.started?(formula.name)
old_name = lookup_old_name(formula.name)
return true if old_name && self.class.started?(old_name)
false
end
def entry_to_formula(entry)
Homebrew::Bundle::Brew.new(entry.name, entry.options)
end
def formula_needs_to_start?(formula)
formula.start_service? || formula.restart_service?
end
def lookup_old_name(service_name)
@old_names ||= Homebrew::Bundle::Brew.formula_oldnames
old_name = @old_names[service_name]
old_name ||= @old_names[service_name.split("/").last]
old_name
end
def format_checkable(entries)
checkable_entries(entries)
end
end
end
BrewServices = Brew::Services
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate brew service checker constant.
BrewServiceChecker = Homebrew::Bundle::Brew::Services
end
end
end
+251
View File
@@ -0,0 +1,251 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "utils/output"
require "bundle/package_type"
module Homebrew
module Bundle
class Cask < Homebrew::Bundle::PackageType
extend ::Utils::Output::Mixin
PACKAGE_TYPE = :cask
PACKAGE_TYPE_NAME = "Cask"
class << self
def reset!
@casks = nil
@cask_names = nil
@cask_oldnames = nil
@installed_casks = nil
@outdated_casks = nil
end
private
def upgrading?(no_upgrade, name, options)
return false if no_upgrade
return true if cask_upgradable?(name)
return false unless options[:greedy]
cask_is_outdated_using_greedy?(name)
end
def postinstall_change_state!(name:, options:, verbose:)
postinstall = options.fetch(:postinstall, nil)
return true if postinstall.blank?
puts "Running postinstall for #{@name}: #{postinstall}" if verbose
Kernel.system(postinstall)
end
def casks
return [] unless Bundle.cask_installed?
require "cask/caskroom"
@casks ||= ::Cask::Caskroom.casks
end
def explicit_s(cask_config)
cask_config.explicit.map do |key, value|
# inverse of #env - converts :languages config key back to --language flag
if key == :languages
key = "language"
value = Array(cask_config.explicit.fetch(:languages, [])).join(",")
end
"#{key}: \"#{value.to_s.sub(/^#{Dir.home}/, "~")}\""
end.join(", ")
end
public
def install_verb(name, options = {})
return "Installing" if !cask_installed?(name) || !upgrading?(false, name, options)
"Upgrading"
end
def preinstall!(name, no_upgrade: false, verbose: false, **options)
if cask_installed?(name) && !upgrading?(no_upgrade, name, options)
puts "Skipping install of #{name} cask. It is already installed." if verbose
return false
end
true
end
def install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options)
return true unless preinstall
full_name = options.fetch(:full_name, name)
install_result = if cask_installed?(name) && upgrading?(no_upgrade, name, options)
status = "#{options[:greedy] ? "may not be" : "not"} up-to-date"
puts "Upgrading #{name} cask. It is installed but #{status}." if verbose
Bundle.brew("upgrade", "--cask", full_name, verbose:)
else
args = options.fetch(:args, []).filter_map do |k, v|
case v
when TrueClass
"--#{k}"
when FalseClass, NilClass
nil
else
"--#{k}=#{v}"
end
end
args << "--force" if force
args << "--adopt" unless args.include?("--force")
args.uniq!
with_args = " with #{args.join(" ")}" if args.present?
puts "Installing #{name} cask#{with_args}. It is not currently installed." if verbose
if Bundle.brew("install", "--cask", full_name, *args, verbose:)
installed_casks << name
true
else
false
end
end
result = install_result
if cask_installed?(name)
postinstall_result = postinstall_change_state!(name:, options:, verbose:)
result &&= postinstall_result
end
result
end
def installable_or_upgradable?(name, no_upgrade: false, **options)
!cask_installed?(name) || upgrading?(no_upgrade, name, options)
end
def fetchable_name(name, options = {}, no_upgrade: false)
full_name = T.cast(options.fetch(:full_name, name), String)
user, repository, = full_name.split("/", 3)
return if user.present? && repository.present? &&
Homebrew::Bundle::Tap.installed_taps.exclude?("#{user}/#{repository}")
return unless installable_or_upgradable?(name, no_upgrade:, **options)
full_name
end
def cask_installed_and_up_to_date?(cask, no_upgrade: false)
return false unless cask_installed?(cask)
return true if no_upgrade
!cask_upgradable?(cask)
end
def cask_in_array?(cask, array)
return true if array.include?(cask)
array.include?(cask.split("/").last)
end
def cask_installed?(cask)
return true if cask_in_array?(cask, installed_casks)
old_name = cask_oldnames[cask]
old_name ||= cask_oldnames[cask.split("/").last]
return false unless old_name
return false unless cask_in_array?(old_name, installed_casks)
opoo "#{cask} was renamed to #{old_name}"
true
end
def cask_upgradable?(cask)
cask_in_array?(cask, outdated_casks)
end
def installed_casks
@installed_casks ||= cask_names
end
def outdated_casks
@outdated_casks ||= outdated_cask_names
end
def cask_names
@cask_names ||= casks.map(&:to_s)
end
def outdated_cask_names
return [] unless Bundle.cask_installed?
casks.select { |c| c.outdated?(greedy: false) }
.map(&:to_s)
end
def cask_is_outdated_using_greedy?(cask_name)
return false unless Bundle.cask_installed?
cask = casks.find { |installed_cask| installed_cask.to_s == cask_name }
return false if cask.nil?
cask.outdated?(greedy: true)
end
def dump(describe: false)
casks.map do |cask|
description = "# #{cask.desc}\n" if describe && cask.desc.present?
config = ", args: { #{explicit_s(cask.config)} }" if cask.config.present? && cask.config.explicit.present?
"#{description}cask \"#{cask.full_name}\"#{config}"
end.join("\n")
end
def dump_output(describe: false, no_restart: false)
_ = no_restart
dump(describe:)
end
def cask_oldnames
@cask_oldnames ||= casks.each_with_object({}) do |c, hash|
oldnames = c.old_tokens
next if oldnames.blank?
oldnames.each do |oldname|
hash[oldname] = c.full_name
if c.full_name.include? "/" # tap cask
tap_name = c.full_name.rpartition("/").first
hash["#{tap_name}/#{oldname}"] = c.full_name
end
end
end
end
def formula_dependencies(cask_list)
return [] unless Bundle.cask_installed?
return [] if cask_list.blank?
casks.flat_map do |cask|
next unless cask_list.include?(cask.to_s)
cask.depends_on[:formula]
end.compact
end
end
def installed_and_up_to_date?(cask, no_upgrade: false)
self.class.cask_installed_and_up_to_date?(cask, no_upgrade:)
end
end
# TODO: Remove these compatibility aliases once bundle callers and tests
# stop requiring separate cask dumper/installer/checker constants.
CaskInstaller = Cask
CaskDumper = Cask
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate cask checker constant.
CaskChecker = Homebrew::Bundle::Cask
end
end
end
+1 -17
View File
@@ -1,20 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/cask_installer"
module Homebrew
module Bundle
module Checker
class CaskChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :cask
PACKAGE_TYPE_NAME = "Cask"
sig { params(cask: String, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(cask, no_upgrade: false)
Homebrew::Bundle::CaskInstaller.cask_installed_and_up_to_date?(cask, no_upgrade:)
end
end
end
end
end
require "bundle/cask"
+1 -92
View File
@@ -1,95 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module CaskDumper
sig { void }
def self.reset!
@casks = nil
@cask_names = nil
@cask_oldnames = nil
end
sig { returns(T::Array[String]) }
def self.cask_names
@cask_names ||= T.let(casks.map(&:to_s), T.nilable(T::Array[String]))
end
sig { returns(T::Array[String]) }
def self.outdated_cask_names
return [] unless Bundle.cask_installed?
casks.select { |c| c.outdated?(greedy: false) }
.map(&:to_s)
end
sig { params(cask_name: String).returns(T::Boolean) }
def self.cask_is_outdated_using_greedy?(cask_name)
return false unless Bundle.cask_installed?
cask = casks.find { |c| c.to_s == cask_name }
return false if cask.nil?
cask.outdated?(greedy: true)
end
sig { params(describe: T::Boolean).returns(String) }
def self.dump(describe: false)
casks.map do |cask|
description = "# #{cask.desc}\n" if describe && cask.desc.present?
config = ", args: { #{explicit_s(cask.config)} }" if cask.config.present? && cask.config.explicit.present?
"#{description}cask \"#{cask.full_name}\"#{config}"
end.join("\n")
end
sig { returns(T::Hash[String, String]) }
def self.cask_oldnames
@cask_oldnames ||= T.let(casks.each_with_object({}) do |c, hash|
oldnames = c.old_tokens
next if oldnames.blank?
oldnames.each do |oldname|
hash[oldname] = c.full_name
if c.full_name.include? "/" # tap cask
tap_name = c.full_name.rpartition("/").first
hash["#{tap_name}/#{oldname}"] = c.full_name
end
end
end, T.nilable(T::Hash[String, String]))
end
sig { params(cask_list: T::Array[String]).returns(T::Array[String]) }
def self.formula_dependencies(cask_list)
return [] unless Bundle.cask_installed?
return [] if cask_list.blank?
casks.flat_map do |cask|
next unless cask_list.include?(cask.to_s)
cask.depends_on[:formula]
end.compact
end
sig { returns(T::Array[Cask::Cask]) }
private_class_method def self.casks
return [] unless Bundle.cask_installed?
require "cask/caskroom"
@casks ||= T.let(Cask::Caskroom.casks, T.nilable(T::Array[Cask::Cask]))
end
sig { params(cask_config: Cask::Config).returns(String) }
private_class_method def self.explicit_s(cask_config)
cask_config.explicit.map do |key, value|
# inverse of #env - converts :languages config key back to --language flag
if key == :languages
key = "language"
value = Array(cask_config.explicit.fetch(:languages, [])).join(",")
end
"#{key}: \"#{value.to_s.sub(/^#{Dir.home}/, "~")}\""
end.join(", ")
end
end
end
end
require "bundle/cask"
+1 -129
View File
@@ -1,132 +1,4 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module CaskInstaller
extend ::Utils::Output::Mixin
def self.reset!
@installed_casks = nil
@outdated_casks = nil
end
private_class_method def self.upgrading?(no_upgrade, name, options)
return false if no_upgrade
return true if cask_upgradable?(name)
return false unless options[:greedy]
require "bundle/cask_dumper"
Homebrew::Bundle::CaskDumper.cask_is_outdated_using_greedy?(name)
end
def self.preinstall!(name, no_upgrade: false, verbose: false, **options)
if cask_installed?(name) && !upgrading?(no_upgrade, name, options)
puts "Skipping install of #{name} cask. It is already installed." if verbose
return false
end
true
end
def self.install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options)
return true unless preinstall
full_name = options.fetch(:full_name, name)
install_result = if cask_installed?(name) && upgrading?(no_upgrade, name, options)
status = "#{options[:greedy] ? "may not be" : "not"} up-to-date"
puts "Upgrading #{name} cask. It is installed but #{status}." if verbose
Bundle.brew("upgrade", "--cask", full_name, verbose:)
else
args = options.fetch(:args, []).filter_map do |k, v|
case v
when TrueClass
"--#{k}"
when FalseClass, NilClass
nil
else
"--#{k}=#{v}"
end
end
args << "--force" if force
args << "--adopt" unless args.include?("--force")
args.uniq!
with_args = " with #{args.join(" ")}" if args.present?
puts "Installing #{name} cask#{with_args}. It is not currently installed." if verbose
if Bundle.brew("install", "--cask", full_name, *args, verbose:)
installed_casks << name
true
else
false
end
end
result = install_result
if cask_installed?(name)
postinstall_result = postinstall_change_state!(name:, options:, verbose:)
result &&= postinstall_result
end
result
end
def self.installable_or_upgradable?(name, no_upgrade: false, **options)
!cask_installed?(name) || upgrading?(no_upgrade, name, options)
end
private_class_method def self.postinstall_change_state!(name:, options:, verbose:)
postinstall = options.fetch(:postinstall, nil)
return true if postinstall.blank?
puts "Running postinstall for #{@name}: #{postinstall}" if verbose
Kernel.system(postinstall)
end
def self.cask_installed_and_up_to_date?(cask, no_upgrade: false)
return false unless cask_installed?(cask)
return true if no_upgrade
!cask_upgradable?(cask)
end
def self.cask_in_array?(cask, array)
return true if array.include?(cask)
array.include?(cask.split("/").last)
end
def self.cask_installed?(cask)
return true if cask_in_array?(cask, installed_casks)
require "bundle/cask_dumper"
old_names = Homebrew::Bundle::CaskDumper.cask_oldnames
old_name = old_names[cask]
old_name ||= old_names[cask.split("/").last]
return false unless old_name
return false unless cask_in_array?(old_name, installed_casks)
opoo "#{cask} was renamed to #{old_name}"
true
end
def self.cask_upgradable?(cask)
cask_in_array?(cask, outdated_casks)
end
def self.installed_casks
require "bundle/cask_dumper"
@installed_casks ||= Homebrew::Bundle::CaskDumper.cask_names
end
def self.outdated_casks
require "bundle/cask_dumper"
@outdated_casks ||= Homebrew::Bundle::CaskDumper.outdated_cask_names
end
end
end
end
require "bundle/cask"
+58 -44
View File
@@ -3,7 +3,8 @@
require "bundle/checker/base"
require "bundle/dsl"
require "bundle/extensions"
require "bundle/package_types"
require "bundle/brew_services"
module Homebrew
module Bundle
@@ -15,6 +16,7 @@ module Homebrew
:taps_to_tap,
:casks_to_install,
:registered_extensions_to_install,
:apps_to_install,
:formulae_to_install,
:formulae_to_start,
].freeze, T::Array[CheckStep])
@@ -38,38 +40,8 @@ module Homebrew
CheckResult.new work_to_be_done, errors
end
def self.casks_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/cask_checker"
Homebrew::Bundle::Checker::CaskChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def self.formulae_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/brew_checker"
Homebrew::Bundle::Checker::BrewChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def self.taps_to_tap(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/tap_checker"
Homebrew::Bundle::Checker::TapChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def self.apps_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
_ = exit_on_first_error
_ = no_upgrade
_ = verbose
# TODO: Remove this legacy no-op once callers and tests stop referencing
# the old dedicated app check phase.
[]
extension_errors(:apps_to_install, exit_on_first_error:, no_upgrade:, verbose:)
end
def self.extensions_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
@@ -83,17 +55,52 @@ module Homebrew
end
def self.formulae_to_start(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/brew_service_checker"
Homebrew::Bundle::Checker::BrewServiceChecker.new.find_actionable(
# TODO: Keep this separate until service checks can be modeled as part of
# the `brew` package type without pretending services are standalone
# package entries.
Homebrew::Bundle::Brew::Services.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def self.taps_to_tap(exit_on_first_error: false, no_upgrade: false, verbose: false)
# TODO: Remove this legacy wrapper once callers and tests stop
# referencing the old dedicated tap check phase.
package_type_errors(:tap, exit_on_first_error:, no_upgrade:, verbose:)
end
def self.casks_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
# TODO: Remove this legacy wrapper once callers and tests stop
# referencing the old dedicated cask check phase.
package_type_errors(:cask, exit_on_first_error:, no_upgrade:, verbose:)
end
def self.formulae_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
# TODO: Remove this legacy wrapper once callers and tests stop
# referencing the old dedicated formula check phase.
package_type_errors(:brew, exit_on_first_error:, no_upgrade:, verbose:)
end
def self.registered_extensions_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
# TODO: Remove this legacy wrapper once callers and tests stop
# referencing the old dedicated extension check phase.
extension_errors(:registered_extensions_to_install, exit_on_first_error:, no_upgrade:, verbose:)
end
sig {
params(
step: Symbol,
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[Object])
}
def self.extension_errors(step, exit_on_first_error:, no_upgrade:, verbose:)
matching_extensions = Homebrew::Bundle.extensions.select { |extension| extension.legacy_check_step == step }
errors = T.let([], T::Array[Object])
Homebrew::Bundle.extensions.each do |extension|
matching_extensions.each do |extension|
check_errors = extension.check(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
@@ -109,16 +116,8 @@ module Homebrew
end
def self.reset!
require "bundle/cask_dumper"
require "bundle/formula_dumper"
require "bundle/tap_dumper"
require "bundle/brew_services"
@dsl = nil
Homebrew::Bundle::CaskDumper.reset!
Homebrew::Bundle::FormulaDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::BrewServices.reset!
Homebrew::Bundle.package_types.each(&:reset!)
Homebrew::Bundle.extensions.each(&:reset!)
end
@@ -127,6 +126,21 @@ module Homebrew
CORE_CHECKS
end
sig {
params(
type: Symbol,
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[Object])
}
def self.package_type_errors(type, exit_on_first_error:, no_upgrade:, verbose:)
package_type = Homebrew::Bundle.package_type(type)
return [] if package_type.nil?
package_type.check(@dsl.entries, exit_on_first_error:, no_upgrade:, verbose:)
end
sig {
params(
check_step: CheckStep,
+11 -12
View File
@@ -3,7 +3,7 @@
require "fileutils"
require "bundle/dsl"
require "bundle/extensions"
require "bundle/package_types"
module Homebrew
module Bundle
@@ -28,21 +28,20 @@ module Homebrew
}
def self.build_brewfile(describe:, no_restart:, formulae:, taps:, casks:, extension_types: {},
**extra_extension_types)
require "bundle/tap_dumper"
require "bundle/formula_dumper"
require "bundle/cask_dumper"
# TODO: Remove `extra_extension_types` once all callers pass a single
# `extension_types:` hash instead of legacy per-extension keywords.
extension_types = extension_types.merge(extra_extension_types)
selected_package_types = extension_types.merge(extra_extension_types)
# TODO: Remove this legacy core-type mapping once the dump command
# passes a single package-type selection hash instead of separate
# `taps`, `formulae`, and `casks` booleans.
selected_package_types[:tap] = taps
selected_package_types[:brew] = formulae
selected_package_types[:cask] = casks
content = []
content << TapDumper.dump if taps
content << FormulaDumper.dump(describe:, no_restart:) if formulae
content << CaskDumper.dump(describe:) if casks
Homebrew::Bundle.extensions.select(&:dump_supported?).each do |extension|
next unless extension_types.fetch(extension.type, false)
Homebrew::Bundle.dump_package_types.select(&:dump_supported?).each do |package_type|
next unless selected_package_types.fetch(package_type.type, false)
content << extension.dump
content << package_type.dump_output(describe:, no_restart:)
end
"#{content.reject(&:empty?).join("\n")}\n"
end
+166 -4
View File
@@ -83,17 +83,137 @@ module Homebrew
ExtensionTypes = T.type_alias { T::Hash[Symbol, T::Boolean] }
class Extension < Homebrew::Bundle::Checker::Base
class PackageType < Homebrew::Bundle::Checker::Base
extend T::Helpers
abstract!
sig { params(subclass: T.class_of(Homebrew::Bundle::PackageType)).void }
def self.inherited(subclass)
super
return if subclass.name == "Homebrew::Bundle::Extension"
Homebrew::Bundle.register_package_type(subclass)
end
sig { returns(Symbol) }
def self.type
T.cast(const_get(:PACKAGE_TYPE), Symbol)
end
sig { returns(T::Boolean) }
def self.dump_supported?
true
end
sig { returns(T::Boolean) }
def self.install_supported?
true
end
sig { params(_name: String, _options: T::Hash[Symbol, Object]).returns(String) }
def self.install_verb(_name = "", _options = {})
"Installing"
end
sig {
params(
name: String,
options: T::Hash[Symbol, Object],
no_upgrade: T::Boolean,
).returns(T.nilable(String))
}
def self.fetchable_name(name, options = {}, no_upgrade: false)
_ = name
_ = options
_ = no_upgrade
nil
end
sig { returns(T::Boolean) }
def self.check_supported?
true
end
sig { abstract.void }
def self.reset!; end
sig {
params(
entries: T::Array[Object],
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[Object])
}
def self.check(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
new.find_actionable(entries, exit_on_first_error:, no_upgrade:, verbose:)
end
sig { abstract.returns(String) }
def self.dump; end
sig { params(describe: T::Boolean, no_restart: T::Boolean).returns(String) }
def self.dump_output(describe: false, no_restart: false)
_ = describe
_ = no_restart
dump
end
end
class << self
sig { params(package_type: T.class_of(PackageType)).void }
def register_package_type(package_type)
@package_types ||= T.let([], T.nilable(T::Array[T.class_of(PackageType)]))
@package_types.reject! { |registered| registered.name == package_type.name }
@package_types << package_type
end
sig { returns(T::Array[T.class_of(PackageType)]) }
def package_types
@package_types ||= T.let([], T.nilable(T::Array[T.class_of(PackageType)]))
@package_types
end
sig { params(type: T.any(Symbol, String)).returns(T.nilable(T.class_of(PackageType))) }
def package_type(type)
requested_type = type.to_sym
package_types.find { |registered| registered.type == requested_type }
end
sig { returns(T::Array[T.class_of(PackageType)]) }
def dump_package_types
core_package_types = [:tap, :brew, :cask].filter_map { |type| package_type(type) }
(core_package_types + (package_types - core_package_types)).uniq
end
sig { returns(T::Array[T.class_of(PackageType)]) }
def check_package_types
# TODO: Remove this legacy ordering shim once package-type order can be
# inferred entirely from registration without changing `brew bundle check`
# behavior for taps, casks, extensions, and formulae.
taps = package_type(:tap)
casks = package_type(:cask)
formulae = package_type(:brew)
core_package_types = [taps, casks, formulae].compact
[taps, casks, *(package_types - core_package_types), formulae].compact
end
end
class Extension < Homebrew::Bundle::PackageType
extend T::Helpers
EntryOptions = T.type_alias { T::Hash[Symbol, Object] }
abstract!
sig { params(subclass: T.class_of(Homebrew::Bundle::Extension)).void }
sig { override.params(subclass: T.class_of(Homebrew::Bundle::PackageType)).void }
def self.inherited(subclass)
super
Homebrew::Bundle.register_extension(subclass)
Homebrew::Bundle.register_extension(T.cast(subclass, T.class_of(Homebrew::Bundle::Extension)))
end
sig { returns(Symbol) }
@@ -199,6 +319,26 @@ module Homebrew
true
end
sig { params(_name: String, _options: T::Hash[Symbol, Object]).returns(String) }
def self.install_verb(_name = "", _options = {})
"Installing"
end
sig {
params(
name: String,
options: T::Hash[Symbol, Object],
no_upgrade: T::Boolean,
).returns(T.nilable(String))
}
def self.fetchable_name(name, options = {}, no_upgrade: false)
_ = name
_ = options
_ = no_upgrade
nil
end
sig { returns(T.nilable(String)) }
def self.cleanup_heading
nil
@@ -245,11 +385,19 @@ module Homebrew
nil
end
sig { returns(String) }
sig { override.returns(String) }
def self.dump
packages.map { |package| dump_entry(package) }.join("\n")
end
sig { params(describe: T::Boolean, no_restart: T::Boolean).returns(String) }
def self.dump_output(describe: false, no_restart: false)
_ = describe
_ = no_restart
dump
end
sig {
params(
entries: T::Array[Object],
@@ -272,6 +420,11 @@ module Homebrew
nil
end
sig { returns(Symbol) }
def self.legacy_check_step
:registered_extensions_to_install
end
sig { params(_items: T::Array[String]).void }
def self.cleanup!(_items); end
@@ -395,6 +548,15 @@ module Homebrew
requested_type = type.to_sym
extensions.find { |registered| registered.type == requested_type }
end
sig {
params(
type: T.any(Symbol, String),
).returns(T.nilable(T.any(T.class_of(PackageType), T.class_of(Extension))))
}
def installable(type)
package_type(type) || extension(type)
end
end
end
end
@@ -13,6 +13,11 @@ module Homebrew
BANNER_NAME = "Mac App Store dependencies"
class << self
sig { override.returns(Symbol) }
def legacy_check_step
:apps_to_install
end
sig { override.returns(T::Boolean) }
def add_supported?
false
+1 -244
View File
@@ -1,247 +1,4 @@
# typed: true
# frozen_string_literal: true
require "json"
require "tsort"
require "utils/output"
module Homebrew
module Bundle
# TODO: refactor into multiple modules
module FormulaDumper
extend Utils::Output::Mixin
def self.reset!
require "bundle/brew_services"
Homebrew::Bundle::BrewServices.reset!
@formulae = nil
@formulae_by_full_name = nil
@formulae_by_name = nil
@formula_aliases = nil
@formula_oldnames = nil
end
def self.formulae
return @formulae if @formulae
formulae_by_full_name
@formulae
end
def self.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 { add_formula(it) }
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 self.formulae_by_name(name)
formulae_by_full_name(name) || @formulae_by_name[name]
end
def self.dump(describe: false, no_restart: false)
require "bundle/brew_services"
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: :changed" if !no_restart && BrewServices.started?(f[:full_name])
brewline += ", link: #{f[:link?]}" unless f[:link?].nil?
brewline
end.join("\n")
end
def self.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 self.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
private_class_method def self.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 def self.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
class Topo < Hash
include TSort
def each_key(&block)
keys.each(&block)
end
alias tsort_each_node each_key
def tsort_each_child(node, &block)
fetch(node.downcase).sort.each(&block)
end
end
private_class_method def self.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] if topo}
#{cycle_last}: #{topo[cycle_last] if topo}
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
end
end
end
require "bundle/brew"
+1 -318
View File
@@ -1,321 +1,4 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
class FormulaInstaller
def self.reset!
@installed_formulae = nil
@outdated_formulae = nil
@pinned_formulae = nil
end
def self.preinstall!(name, no_upgrade: false, verbose: false, **options)
new(name, options).preinstall!(no_upgrade:, verbose:)
end
def self.install!(name, preinstall: true, no_upgrade: false, verbose: false, force: false, **options)
new(name, options).install!(preinstall:, no_upgrade:, verbose:, force:)
end
def initialize(name, options = {})
@full_name = name
@name = name.split("/").last
@args = options.fetch(:args, []).map { |arg| "--#{arg}" }
@conflicts_with_arg = options.fetch(:conflicts_with, [])
@restart_service = options[:restart_service]
@start_service = options.fetch(:start_service, @restart_service)
@link = options.fetch(:link, nil)
@postinstall = options.fetch(:postinstall, nil)
@version_file = options.fetch(:version_file, nil)
@changed = nil
end
def preinstall!(no_upgrade: false, verbose: false)
if installed? && (self.class.no_upgrade_with_args?(no_upgrade, @name) || !upgradable?)
puts "Skipping install of #{@name} formula. It is already installed." if verbose
@changed = nil
return false
end
true
end
def install!(preinstall: true, no_upgrade: false, verbose: false, force: false)
install_result = if preinstall
install_change_state!(no_upgrade:, verbose:, force:)
else
true
end
result = install_result
if installed?
service_result = service_change_state!(verbose:)
result &&= service_result
link_result = link_change_state!(verbose:)
result &&= link_result
postinstall_result = postinstall_change_state!(verbose:)
result &&= postinstall_result
if result && @version_file.present?
# Use the version from the environment if it hasn't changed.
# Strip the revision number because it's not part of the non-Homebrew version.
version = if !changed? && (env_version = Bundle.formula_versions_from_env(@name))
PkgVersion.parse(env_version).version
else
Formula[@full_name].version
end.to_s
File.write(@version_file, "#{version}\n")
puts "Wrote #{@name} version #{version} to #{@version_file}" if verbose
end
end
result
end
def install_change_state!(no_upgrade:, verbose:, force:)
if (tap_with_name = Tap.with_formula_name(@full_name))
tap, = tap_with_name
tap.ensure_installed!
end
return false unless resolve_conflicts!(verbose:)
if installed?
upgrade_formula!(verbose:, force:)
else
install_formula!(verbose:, force:)
end
end
def start_service?
@start_service.present?
end
def start_service_needed?
require "bundle/brew_services"
start_service? && !BrewServices.started?(@full_name)
end
def restart_service?
@restart_service.present?
end
def restart_service_needed?
return false unless restart_service?
# Restart if `restart_service: :always`, or if the formula was installed or upgraded
@restart_service.to_s == "always" || changed?
end
def changed?
@changed.present?
end
def service_change_state!(verbose:)
require "bundle/brew_services"
file = Bundle::BrewServices.versioned_service_file(@name)
if restart_service_needed?
puts "Restarting #{@name} service." if verbose
BrewServices.restart(@full_name, file:, verbose:)
elsif start_service_needed?
puts "Starting #{@name} service." if verbose
BrewServices.start(@full_name, file:, verbose:)
else
true
end
end
def link_change_state!(verbose: false)
link_args = []
link_args << "--force" if unlinked_and_keg_only?
cmd = case @link
when :overwrite
link_args << "--overwrite"
"link" unless linked?
when true
"link" unless linked?
when false
"unlink" if linked?
when nil
if keg_only?
"unlink" if linked?
else
"link" unless linked?
end
end
if cmd.present?
verb = "#{cmd}ing".capitalize
with_args = " with #{link_args.join(" ")}" if link_args.present?
puts "#{verb} #{@name} formula#{with_args}." if verbose
return Bundle.brew(cmd, *link_args, @name, verbose:)
end
true
end
def postinstall_change_state!(verbose:)
return true if @postinstall.blank?
return true unless changed?
puts "Running postinstall for #{@name}: #{@postinstall}" if verbose
Kernel.system(@postinstall)
end
def self.formula_installed_and_up_to_date?(formula, no_upgrade: false)
return false unless formula_installed?(formula)
return true if no_upgrade_with_args?(no_upgrade, formula)
!formula_upgradable?(formula)
end
def self.no_upgrade_with_args?(no_upgrade, formula_name)
no_upgrade && Bundle.upgrade_formulae.exclude?(formula_name)
end
def self.formula_in_array?(formula, array)
return true if array.include?(formula)
return true if array.include?(formula.split("/").last)
require "bundle/formula_dumper"
old_names = Homebrew::Bundle::FormulaDumper.formula_oldnames
old_name = old_names[formula]
old_name ||= old_names[formula.split("/").last]
return true if old_name && array.include?(old_name)
resolved_full_name = Homebrew::Bundle::FormulaDumper.formula_aliases[formula]
return false unless resolved_full_name
return true if array.include?(resolved_full_name)
return true if array.include?(resolved_full_name.split("/").last)
false
end
def self.formula_installed?(formula)
formula_in_array?(formula, installed_formulae)
end
def self.formula_upgradable?(formula)
# Check local cache first and then authoritative Homebrew source.
formula_in_array?(formula, upgradable_formulae) && Formula[formula].outdated?
end
def self.installed_formulae
@installed_formulae ||= formulae.map { |f| f[:name] }
end
def self.upgradable_formulae
outdated_formulae - pinned_formulae
end
def self.outdated_formulae
@outdated_formulae ||= formulae.filter_map { |f| f[:name] if f[:outdated?] }
end
def self.pinned_formulae
@pinned_formulae ||= formulae.filter_map { |f| f[:name] if f[:pinned?] }
end
def self.formulae
require "bundle/formula_dumper"
Homebrew::Bundle::FormulaDumper.formulae
end
private
def installed?
FormulaInstaller.formula_installed?(@name)
end
def linked?
Formula[@full_name].linked?
end
def keg_only?
Formula[@full_name].keg_only?
end
def unlinked_and_keg_only?
!linked? && keg_only?
end
def upgradable?
FormulaInstaller.formula_upgradable?(@full_name)
end
def conflicts_with
@conflicts_with ||= begin
conflicts_with = Set.new
conflicts_with += @conflicts_with_arg
require "bundle/formula_dumper"
if (formula = Homebrew::Bundle::FormulaDumper.formulae_by_full_name(@full_name)) &&
(formula_conflicts_with = formula[:conflicts_with])
conflicts_with += formula_conflicts_with
end
conflicts_with.to_a
end
end
def resolve_conflicts!(verbose:)
conflicts_with.each do |conflict|
next unless FormulaInstaller.formula_installed?(conflict)
if verbose
puts <<~EOS
Unlinking #{conflict} formula.
It is currently installed and conflicts with #{@name}.
EOS
end
return false unless Bundle.brew("unlink", conflict, verbose:)
next unless restart_service?
require "bundle/brew_services"
puts "Stopping #{conflict} service (if it is running)." if verbose
BrewServices.stop(conflict, verbose:)
end
true
end
def install_formula!(verbose:, force:)
install_args = @args.dup
install_args << "--force" << "--overwrite" if force
install_args << "--skip-link" if @link == false
with_args = " with #{install_args.join(" ")}" if install_args.present?
puts "Installing #{@name} formula#{with_args}. It is not currently installed." if verbose
unless Bundle.brew("install", "--formula", @full_name, *install_args, verbose:)
@changed = nil
return false
end
FormulaInstaller.installed_formulae << @name
@changed = true
true
end
def upgrade_formula!(verbose:, force:)
upgrade_args = []
upgrade_args << "--force" if force
with_args = " with #{upgrade_args.join(" ")}" if upgrade_args.present?
puts "Upgrading #{@name} formula#{with_args}. It is installed but not up-to-date." if verbose
unless Bundle.brew("upgrade", "--formula", @name, *upgrade_args, verbose:)
@changed = nil
return false
end
@changed = true
true
end
end
end
end
require "bundle/brew"
+10 -52
View File
@@ -2,10 +2,7 @@
# frozen_string_literal: true
require "bundle/dsl"
require "bundle/formula_installer"
require "bundle/cask_installer"
require "bundle/tap_installer"
require "bundle/extensions"
require "bundle/package_types"
require "bundle/skipper"
module Homebrew
@@ -33,32 +30,12 @@ module Homebrew
name = entry.name
args = [name]
options = {}
verb = "Installing"
options = T.cast(entry.options, T::Hash[Symbol, Object])
type = entry.type
cls = case type
when :brew
options = entry.options
verb = "Upgrading" if Homebrew::Bundle::FormulaInstaller.formula_upgradable?(name)
Homebrew::Bundle::FormulaInstaller
when :cask
options = entry.options
verb = "Upgrading" if Homebrew::Bundle::CaskInstaller.cask_upgradable?(name)
Homebrew::Bundle::CaskInstaller
when :tap
verb = "Tapping"
options = entry.options
Homebrew::Bundle::TapInstaller
else
extension = Homebrew::Bundle.extension(type)
next if extension.nil? || !extension.install_supported?
cls = Homebrew::Bundle.installable(type)
next if cls.nil? || !cls.install_supported?
options = entry.options
extension
end
next if cls.nil?
{ name:, args:, options:, verb:, type:, cls: }
{ name:, args:, options:, verb: cls.install_verb(name, options), type:, cls: }
end
if (fetchable_names = fetchable_formulae_and_casks(installable_entries, no_upgrade:).presence)
@@ -114,10 +91,11 @@ module Homebrew
params(
entries: T::Array[{ name: String,
args: T::Array[T.anything],
options: T::Hash[Symbol, T.untyped],
options: T::Hash[Symbol, Object],
verb: String,
type: Symbol,
cls: T::Module[T.anything] }],
cls: T.any(T.class_of(Homebrew::Bundle::PackageType),
T.class_of(Homebrew::Bundle::Extension)) }],
no_upgrade: T::Boolean,
).returns(T::Array[String])
}
@@ -125,30 +103,10 @@ module Homebrew
entries.filter_map do |entry|
name = entry.fetch(:name)
options = entry.fetch(:options)
case entry.fetch(:type)
when :brew
next unless tap_installed?(name)
next if Homebrew::Bundle::FormulaInstaller.formula_installed_and_up_to_date?(name, no_upgrade:)
name
when :cask
full_name = options.fetch(:full_name, name)
next unless tap_installed?(full_name)
next unless Homebrew::Bundle::CaskInstaller.installable_or_upgradable?(name, no_upgrade:, **options)
full_name
end
cls = entry.fetch(:cls)
cls.fetchable_name(name, options, no_upgrade:)
end
end
sig { params(package_full_name: String).returns(T::Boolean) }
def self.tap_installed?(package_full_name)
user, repository, = package_full_name.split("/", 3)
return true if user.blank? || repository.blank?
Homebrew::Bundle::TapInstaller.installed_taps.include?("#{user}/#{repository}")
end
end
end
end
+6
View File
@@ -0,0 +1,6 @@
# typed: strict
# frozen_string_literal: true
# TODO: Remove this compatibility require once bundle callers load
# `bundle/extensions/extension` directly.
require "bundle/extensions/extension"
+8
View File
@@ -0,0 +1,8 @@
# typed: strict
# frozen_string_literal: true
require "bundle/package_type"
require "bundle/tap"
require "bundle/brew"
require "bundle/cask"
require "bundle/extensions"
+150
View File
@@ -0,0 +1,150 @@
# typed: strict
# frozen_string_literal: true
require "json"
require "bundle/package_type"
module Homebrew
module Bundle
class Tap < Homebrew::Bundle::PackageType
PACKAGE_TYPE = :tap
PACKAGE_TYPE_NAME = "Tap"
class << self
sig { override.void }
def reset!
@taps = T.let(nil, T.nilable(T::Array[::Tap]))
@installed_taps = T.let(nil, T.nilable(T::Array[String]))
end
sig { params(name: String, verbose: T::Boolean, _options: T.anything).returns(T::Boolean) }
def preinstall!(name, verbose: false, **_options)
if installed_taps.include? name
puts "Skipping install of #{name} tap. It is already installed." if verbose
return false
end
true
end
sig {
params(
name: String,
preinstall: T::Boolean,
verbose: T::Boolean,
force: T::Boolean,
clone_target: T.nilable(String),
_options: T.anything,
).returns(T::Boolean)
}
def install!(name, preinstall: true, verbose: false, force: false, clone_target: nil, **_options)
return true unless preinstall
puts "Installing #{name} tap. It is not currently installed." if verbose
args = []
official_tap = name.downcase.start_with? "homebrew/"
args << "--force" if force || (official_tap && Homebrew::EnvConfig.developer?)
success = if clone_target
Bundle.brew("tap", name, clone_target, *args, verbose:)
else
Bundle.brew("tap", name, *args, verbose:)
end
unless success
require "bundle/skipper"
Homebrew::Bundle::Skipper.tap_failed!(name)
return false
end
installed_taps << name
true
end
sig { override.params(_name: String, _options: T::Hash[Symbol, Object]).returns(String) }
def install_verb(_name = "", _options = {})
"Tapping"
end
sig { override.returns(String) }
def dump
taps.map do |tap|
remote = if tap.custom_remote? && (tap_remote = tap.remote)
if (api_token = ENV.fetch("HOMEBREW_GITHUB_API_TOKEN", false).presence)
# Replace the API token in the remote URL with interpolation.
# Rubocop's warning here is wrong; we intentionally want to not
# evaluate this string until the Brewfile is evaluated.
# rubocop:disable Lint/InterpolationCheck
tap_remote = tap_remote.gsub api_token, '#{ENV.fetch("HOMEBREW_GITHUB_API_TOKEN")}'
# rubocop:enable Lint/InterpolationCheck
end
", \"#{tap_remote}\""
end
"tap \"#{tap.name}\"#{remote}"
end.sort.uniq.join("\n")
end
sig { override.params(describe: T::Boolean, no_restart: T::Boolean).returns(String) }
def dump_output(describe: false, no_restart: false)
_ = describe
_ = no_restart
dump
end
sig { returns(T::Array[String]) }
def tap_names
taps.map(&:name)
end
sig { returns(T::Array[String]) }
def installed_taps
@installed_taps ||= T.let(tap_names, T.nilable(T::Array[String]))
end
sig { returns(T::Array[::Tap]) }
def taps
@taps ||= begin
require "tap"
::Tap.select(&:installed?).to_a
end
end
private :taps
end
sig {
override.params(entries: T::Array[Object], exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[String])
}
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
_ = exit_on_first_error
_ = no_upgrade
_ = verbose
requested_taps = format_checkable(entries)
return [] if requested_taps.empty?
current_taps = self.class.tap_names
(requested_taps - current_taps).map { |entry| "Tap #{entry} needs to be tapped." }
end
sig { override.params(package: T.untyped, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(package, no_upgrade: false)
_ = no_upgrade
self.class.installed_taps.include?(T.cast(package, String))
end
end
# TODO: Remove these compatibility aliases once bundle callers and tests
# stop requiring separate tap dumper/installer/checker constants.
TapInstaller = Tap
TapDumper = Tap
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate tap checker constant.
TapChecker = Homebrew::Bundle::Tap
end
end
end
+1 -23
View File
@@ -1,26 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class TapChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :tap
PACKAGE_TYPE_NAME = "Tap"
sig {
params(entries: T::Array[Object], exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[String])
}
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
requested_taps = format_checkable(entries)
return [] if requested_taps.empty?
require "bundle/tap_dumper"
current_taps = Homebrew::Bundle::TapDumper.tap_names
(requested_taps - current_taps).map { |entry| "Tap #{entry} needs to be tapped." }
end
end
end
end
end
require "bundle/tap"
+1 -44
View File
@@ -1,47 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "json"
module Homebrew
module Bundle
module TapDumper
sig { void }
def self.reset!
@taps = nil
end
sig { returns(String) }
def self.dump
taps.map do |tap|
remote = if tap.custom_remote? && (tap_remote = tap.remote)
if (api_token = ENV.fetch("HOMEBREW_GITHUB_API_TOKEN", false).presence)
# Replace the API token in the remote URL with interpolation.
# Rubocop's warning here is wrong; we intentionally want to not
# evaluate this string until the Brewfile is evaluated.
# rubocop:disable Lint/InterpolationCheck
tap_remote = tap_remote.gsub api_token, '#{ENV.fetch("HOMEBREW_GITHUB_API_TOKEN")}'
# rubocop:enable Lint/InterpolationCheck
end
", \"#{tap_remote}\""
end
"tap \"#{tap.name}\"#{remote}"
end.sort.uniq.join("\n")
end
sig { returns(T::Array[String]) }
def self.tap_names
taps.map(&:name)
end
sig { returns(T::Array[Tap]) }
private_class_method def self.taps
@taps ||= T.let(nil, T.nilable(T::Array[Tap]))
@taps ||= begin
require "tap"
Tap.select(&:installed?).to_a
end
end
end
end
end
require "bundle/tap"
+1 -55
View File
@@ -1,58 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module TapInstaller
sig { params(name: String, verbose: T::Boolean, _options: T.anything).returns(T::Boolean) }
def self.preinstall!(name, verbose: false, **_options)
if installed_taps.include? name
puts "Skipping install of #{name} tap. It is already installed." if verbose
return false
end
true
end
sig {
params(
name: String,
preinstall: T::Boolean,
verbose: T::Boolean,
force: T::Boolean,
clone_target: T.nilable(String),
_options: T.anything,
).returns(T::Boolean)
}
def self.install!(name, preinstall: true, verbose: false, force: false, clone_target: nil, **_options)
return true unless preinstall
puts "Installing #{name} tap. It is not currently installed." if verbose
args = []
official_tap = name.downcase.start_with? "homebrew/"
args << "--force" if force || (official_tap && Homebrew::EnvConfig.developer?)
success = if clone_target
Bundle.brew("tap", name, clone_target, *args, verbose:)
else
Bundle.brew("tap", name, *args, verbose:)
end
unless success
require "bundle/skipper"
Homebrew::Bundle::Skipper.tap_failed!(name)
return false
end
installed_taps << name
true
end
sig { returns(T::Array[String]) }
def self.installed_taps
require "bundle/tap_dumper"
@installed_taps ||= T.let(Homebrew::Bundle::TapDumper.tap_names, T.nilable(T::Array[String]))
end
end
end
end
require "bundle/tap"