Merge pull request #19487 from Homebrew/bundle

Migrate Homebrew/bundle to Homebrew/brew
This commit is contained in:
Mike McQuaid 2025-03-19 06:59:07 +00:00 committed by GitHub
commit 81313133f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 6433 additions and 36 deletions

View File

@ -6,15 +6,6 @@
"workspaceMount": "source=${localWorkspaceFolder},target=/home/linuxbrew/.linuxbrew/Homebrew,type=bind,consistency=cached",
"onCreateCommand": ".devcontainer/on-create-command.sh",
"customizations": {
"codespaces": {
"repositories": {
"Homebrew/homebrew-bundle": {
"permissions": {
"contents": "write"
}
}
}
},
"vscode": {
// Installing all necessary extensions for vscode
// Taken from: .vscode/extensions.json

View File

@ -23,8 +23,6 @@ brew cleanup
# actually tap homebrew/core, no longer done by default
brew tap --force homebrew/core
# tap some other repos so codespaces can be used for developing multiple taps
brew tap homebrew/bundle
# install some useful development things
sudo apt-get update

View File

@ -113,7 +113,6 @@ jobs:
- name: Set up all Homebrew taps
run: |
brew tap homebrew/bundle
brew tap homebrew/command-not-found
brew tap homebrew/portable-ruby
@ -122,8 +121,7 @@ jobs:
- name: Run brew style on official taps
run: |
brew style homebrew/bundle \
homebrew/test-bot
brew style homebrew/test-bot
brew style homebrew/command-not-found \
homebrew/portable-ruby

View File

@ -297,6 +297,7 @@ Sorbet/StrictSigil:
- "Homebrew/utils/ruby_check_version_script.rb" # A standalone script.
- "Homebrew/{standalone,startup}/*.rb" # These are loaded before sorbet-runtime
- "Homebrew/test/**/*.rb"
- "Homebrew/bundle/{brew_dumper,checker,commands/exec}.rb" # These aren't typed: true yet.
Sorbet/TrueSigil:
Enabled: true

View File

@ -0,0 +1,40 @@
# typed: strict
# frozen_string_literal: true
require "bundle/brewfile"
require "bundle/bundle"
require "bundle/dsl"
require "bundle/adder"
require "bundle/checker"
require "bundle/remover"
require "bundle/skipper"
require "bundle/brew_services"
require "bundle/brew_service_checker"
require "bundle/brew_installer"
require "bundle/brew_checker"
require "bundle/cask_installer"
require "bundle/mac_app_store_installer"
require "bundle/mac_app_store_checker"
require "bundle/tap_installer"
require "bundle/brew_dumper"
require "bundle/cask_dumper"
require "bundle/cask_checker"
require "bundle/mac_app_store_dumper"
require "bundle/tap_dumper"
require "bundle/tap_checker"
require "bundle/dumper"
require "bundle/installer"
require "bundle/lister"
require "bundle/commands/install"
require "bundle/commands/dump"
require "bundle/commands/cleanup"
require "bundle/commands/check"
require "bundle/commands/exec"
require "bundle/commands/list"
require "bundle/commands/add"
require "bundle/commands/remove"
require "bundle/whalebrew_installer"
require "bundle/whalebrew_dumper"
require "bundle/vscode_extension_checker"
require "bundle/vscode_extension_dumper"
require "bundle/vscode_extension_installer"

View File

@ -0,0 +1,31 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Adder
module_function
def add(*args, type:, global:, file:)
brewfile = Brewfile.read(global:, file:)
content = brewfile.input
# TODO: - support `:describe`
new_content = args.map do |arg|
case type
when :brew
Formulary.factory(arg)
when :cask
Cask::CaskLoader.load(arg)
end
"#{type} \"#{arg}\""
end
content << new_content.join("\n") << "\n"
path = Dumper.brewfile_path(global:, file:)
Dumper.write_file path, content
end
end
end
end

View File

@ -0,0 +1,17 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class BrewChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :brew
PACKAGE_TYPE_NAME = "Formula"
def installed_and_up_to_date?(formula, no_upgrade: false)
Homebrew::Bundle::BrewInstaller.formula_installed_and_up_to_date?(formula, no_upgrade:)
end
end
end
end
end

View File

@ -0,0 +1,240 @@
# typed: false # rubocop:todo Sorbet/TrueSigil
# frozen_string_literal: true
require "json"
require "tsort"
module Homebrew
module Bundle
# TODO: refactor into multiple modules
module BrewDumper
module_function
def reset!
Homebrew::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: :changed" 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
end

View File

@ -0,0 +1,289 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
class BrewInstaller
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)
@changed = nil
end
def preinstall(no_upgrade: false, verbose: false)
if installed? && (no_upgrade || !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
end
result
end
def install_change_state!(no_upgrade:, verbose:, force:)
return false unless resolve_conflicts!(verbose:)
if installed?
upgrade!(verbose:, force:)
else
install!(verbose:, force:)
end
end
def start_service?
@start_service.present?
end
def start_service_needed?
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:)
if restart_service_needed?
puts "Restarting #{@name} service." if verbose
BrewServices.restart(@full_name, verbose:)
elsif start_service_needed?
puts "Starting #{@name} service." if verbose
BrewServices.start(@full_name, 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
!formula_upgradable?(formula)
end
def self.formula_in_array?(formula, array)
return true if array.include?(formula)
return true if array.include?(formula.split("/").last)
old_names = Homebrew::Bundle::BrewDumper.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::BrewDumper.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
Homebrew::Bundle::BrewDumper.formulae
end
private
def installed?
BrewInstaller.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?
BrewInstaller.formula_upgradable?(@name)
end
def conflicts_with
@conflicts_with ||= begin
conflicts_with = Set.new
conflicts_with += @conflicts_with_arg
if (formula = Homebrew::Bundle::BrewDumper.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 BrewInstaller.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:)
if restart_service?
puts "Stopping #{conflict} service (if it is running)." if verbose
BrewServices.stop(conflict, verbose:)
end
end
true
end
def install!(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
BrewInstaller.installed_formulae << @name
@changed = true
true
end
def upgrade!(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

View File

@ -0,0 +1,51 @@
# 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)
Homebrew::Bundle::BrewInstaller.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)
Homebrew::Bundle::BrewServices.started?(service_name)
end
def lookup_old_name(service_name)
@old_names ||= Homebrew::Bundle::BrewDumper.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

View File

@ -0,0 +1,55 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module BrewServices
module_function
def reset!
@started_services = nil
end
def stop(name, verbose: false)
return true unless started?(name)
return unless Bundle.brew("services", "stop", name, verbose:)
started_services.delete(name)
true
end
def start(name, verbose: false)
return unless Bundle.brew("services", "start", name, verbose:)
started_services << name
true
end
def restart(name, verbose: false)
return unless Bundle.brew("services", "restart", name, verbose:)
started_services << name
true
end
def started?(name)
started_services.include? name
end
def started_services
@started_services ||= if Bundle.services_installed?
states_to_skip = %w[stopped none]
Utils.safe_popen_read("brew", "services", "list").lines.filter_map do |line|
name, state, _plist = line.split(/\s+/)
next if states_to_skip.include? state
name
end
else
[]
end
end
end
end
end

View File

@ -0,0 +1,56 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Brewfile
module_function
def path(dash_writes_to_stdout: false, global: false, file: nil)
env_bundle_file_global = ENV.fetch("HOMEBREW_BUNDLE_FILE_GLOBAL", nil)
env_bundle_file = ENV.fetch("HOMEBREW_BUNDLE_FILE", nil)
user_config_home = ENV.fetch("HOMEBREW_USER_CONFIG_HOME", nil)
filename = if global
if env_bundle_file_global.present?
env_bundle_file_global
else
raise "'HOMEBREW_BUNDLE_FILE' cannot be specified with '--global'" if env_bundle_file.present?
if user_config_home && File.exist?("#{user_config_home}/Brewfile")
"#{user_config_home}/Brewfile"
else
Bundle.exchange_uid_if_needed! do
"#{Dir.home}/.Brewfile"
end
end
end
elsif file.present?
handle_file_value(file, dash_writes_to_stdout)
elsif env_bundle_file.present?
env_bundle_file
else
"Brewfile"
end
Pathname.new(filename).expand_path(Dir.pwd)
end
def read(global: false, file: nil)
Homebrew::Bundle::Dsl.new(Brewfile.path(global:, file:))
rescue Errno::ENOENT
raise "No Brewfile found"
end
def handle_file_value(filename, dash_writes_to_stdout)
if filename != "-"
filename
elsif dash_writes_to_stdout
"/dev/stdout"
else
"/dev/stdin"
end
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module Brewfile
include Kernel
end
end

View File

@ -0,0 +1,86 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "English"
module Homebrew
module Bundle
class << self
def system(cmd, *args, verbose: false)
return super cmd, *args if verbose
logs = []
success = T.let(nil, T.nilable(T::Boolean))
IO.popen([cmd, *args], err: [:child, :out]) do |pipe|
while (buf = pipe.gets)
logs << buf
end
Process.wait(pipe.pid)
success = $CHILD_STATUS.success?
pipe.close
end
puts logs.join unless success
success
end
def brew(*args, verbose: false)
system(HOMEBREW_BREW_FILE, *args, verbose:)
end
def mas_installed?
@mas_installed ||= which_formula("mas")
end
def vscode_installed?
@vscode_installed ||= which("code").present?
end
def whalebrew_installed?
@whalebrew_installed ||= which_formula("whalebrew")
end
def cask_installed?
@cask_installed ||= File.directory?("#{HOMEBREW_PREFIX}/Caskroom") &&
(File.directory?("#{HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask") ||
!Homebrew::EnvConfig.no_install_from_api?)
end
def services_installed?
@services_installed ||= which("services.rb").present?
end
def which_formula(name)
formula = Formulary.factory(name)
ENV["PATH"] = "#{formula.opt_bin}:#{ENV.fetch("PATH", nil)}" if formula.any_version_installed?
which(name).present?
end
def exchange_uid_if_needed!(&block)
euid = Process.euid
uid = Process.uid
return yield if euid == uid
old_euid = euid
process_reexchangeable = Process::UID.re_exchangeable?
if process_reexchangeable
Process::UID.re_exchange
else
Process::Sys.seteuid(uid)
end
home = T.must(Etc.getpwuid(Process.uid)).dir
return_value = with_env("HOME" => home, &block)
if process_reexchangeable
Process::UID.re_exchange
else
Process::Sys.seteuid(old_euid)
end
return_value
end
end
end
end
require "extend/os/bundle/bundle"

View File

@ -0,0 +1,17 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class CaskChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :cask
PACKAGE_TYPE_NAME = "Cask"
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

View File

@ -0,0 +1,74 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module CaskDumper
module_function
def reset!
@casks = nil
@cask_names = nil
@cask_hash = nil
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 { |c| c.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}\"#{config}"
end.join("\n")
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
def casks
return [] unless Bundle.cask_installed?
require "cask/caskroom"
@casks ||= Cask::Caskroom.casks
end
private_class_method :casks
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 = cask_config.explicit.fetch(:languages, []).join(",")
end
"#{key}: \"#{value.to_s.sub(/^#{Dir.home}/, "~")}\""
end.join(", ")
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module CaskDumper
include Kernel
end
end

View File

@ -0,0 +1,110 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module CaskInstaller
module_function
def reset!
@installed_casks = nil
@outdated_casks = nil
end
def upgrading?(no_upgrade, name, options)
return false if no_upgrade
return true if outdated_casks.include?(name)
return false unless options[:greedy]
Homebrew::Bundle::CaskDumper.cask_is_outdated_using_greedy?(name)
end
def preinstall(name, no_upgrade: false, verbose: false, **options)
if installed_casks.include?(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)
p [:installed_casks, installed_casks]
p [:upgrading?, upgrading?(no_upgrade, name, options)]
install_result = if installed_casks.include?(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
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 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 cask_installed?(cask)
installed_casks.include? cask
end
def cask_upgradable?(cask)
outdated_casks.include? cask
end
def installed_casks
@installed_casks ||= Homebrew::Bundle::CaskDumper.cask_names
end
def outdated_casks
@outdated_casks ||= Homebrew::Bundle::CaskDumper.outdated_cask_names
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module CaskInstaller
include Kernel
end
end

View File

@ -0,0 +1,144 @@
# typed: false # rubocop:todo Sorbet/TrueSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class Base
# Implement these in any subclass
# PACKAGE_TYPE = :pkg
# PACKAGE_TYPE_NAME = "Package"
def exit_early_check(packages, no_upgrade:)
work_to_be_done = packages.find do |pkg|
!installed_and_up_to_date?(pkg, no_upgrade:)
end
Array(work_to_be_done)
end
def failure_reason(name, no_upgrade:)
reason = if no_upgrade
"needs to be installed."
else
"needs to be installed or updated."
end
"#{self.class::PACKAGE_TYPE_NAME} #{name} #{reason}"
end
def full_check(packages, no_upgrade:)
packages.reject { |pkg| installed_and_up_to_date?(pkg, no_upgrade:) }
.map { |pkg| failure_reason(pkg, no_upgrade:) }
end
def checkable_entries(all_entries)
all_entries.select { |e| e.type == self.class::PACKAGE_TYPE }
.reject(&Bundle::Skipper.method(:skip?))
end
def format_checkable(entries)
checkable_entries(entries).map(&:name)
end
def installed_and_up_to_date?(_pkg, no_upgrade: false)
raise NotImplementedError
end
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
requested = format_checkable entries
if exit_on_first_error
exit_early_check(requested, no_upgrade:)
else
full_check(requested, no_upgrade:)
end
end
end
module_function
CheckResult = Struct.new :work_to_be_done, :errors
CHECKS = {
taps_to_tap: "Taps",
casks_to_install: "Casks",
extensions_to_install: "VSCode Extensions",
apps_to_install: "Apps",
formulae_to_install: "Formulae",
formulae_to_start: "Services",
}.freeze
def check(global: false, file: nil, exit_on_first_error: false, no_upgrade: false, verbose: false)
@dsl ||= Brewfile.read(global:, file:)
check_method_names = CHECKS.keys
errors = []
enumerator = exit_on_first_error ? :find : :map
work_to_be_done = check_method_names.public_send(enumerator) do |check_method|
check_errors =
send(check_method, exit_on_first_error:, no_upgrade:, verbose:)
any_errors = check_errors.any?
errors.concat(check_errors) if any_errors
any_errors
end
work_to_be_done = Array(work_to_be_done).flatten.any?
CheckResult.new work_to_be_done, errors
end
def casks_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::CaskChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def formulae_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::BrewChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def taps_to_tap(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::TapChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def apps_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::MacAppStoreChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def extensions_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::VscodeExtensionChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def formulae_to_start(exit_on_first_error: false, no_upgrade: false, verbose: false)
Homebrew::Bundle::Checker::BrewServiceChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def reset!
@dsl = nil
Homebrew::Bundle::CaskDumper.reset!
Homebrew::Bundle::BrewDumper.reset!
Homebrew::Bundle::MacAppStoreDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::BrewServices.reset!
end
end
end
end

View File

@ -0,0 +1,16 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module Add
module_function
def run(*args, type:, global:, file:)
Homebrew::Bundle::Adder.add(*args, type:, global:, file:)
end
end
end
end
end

View File

@ -0,0 +1,34 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module Check
module_function
ARROW = ""
FAILURE_MESSAGE = "brew bundle can't satisfy your Brewfile's dependencies."
def run(global: false, file: nil, no_upgrade: false, verbose: false)
output_errors = verbose
exit_on_first_error = !verbose
check_result = Homebrew::Bundle::Checker.check(
global:, file:,
exit_on_first_error:, no_upgrade:, verbose:
)
if check_result.work_to_be_done
puts FAILURE_MESSAGE
check_result.errors.each { |package| puts "#{ARROW} #{package}" } if output_errors
puts "Satisfy missing dependencies with `brew bundle install`."
exit 1
else
puts "The Brewfile's dependencies are satisfied."
end
end
end
end
end
end

View File

@ -0,0 +1,191 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "utils/formatter"
module Homebrew
module Bundle
module Commands
# TODO: refactor into multiple modules
module Cleanup
module_function
def reset!
@dsl = nil
@kept_casks = nil
@kept_formulae = nil
Homebrew::Bundle::CaskDumper.reset!
Homebrew::Bundle::BrewDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::VscodeExtensionDumper.reset!
Homebrew::Bundle::BrewServices.reset!
end
def run(global: false, file: nil, force: false, zap: false, dsl: nil)
@dsl ||= dsl
casks = casks_to_uninstall(global:, file:)
formulae = formulae_to_uninstall(global:, file:)
taps = taps_to_untap(global:, file:)
vscode_extensions = vscode_extensions_to_uninstall(global:, file:)
if force
if casks.any?
args = zap ? ["--zap"] : []
Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--cask", *args, "--force", *casks
puts "Uninstalled #{casks.size} cask#{(casks.size == 1) ? "" : "s"}"
end
if formulae.any?
Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", *formulae
puts "Uninstalled #{formulae.size} formula#{(formulae.size == 1) ? "" : "e"}"
end
Kernel.system HOMEBREW_BREW_FILE, "untap", *taps if taps.any?
Bundle.exchange_uid_if_needed! do
vscode_extensions.each do |extension|
Kernel.system "code", "--uninstall-extension", extension
end
end
cleanup = system_output_no_stderr(HOMEBREW_BREW_FILE, "cleanup")
puts cleanup unless cleanup.empty?
else
would_uninstall = false
if casks.any?
puts "Would uninstall casks:"
puts Formatter.columns casks
would_uninstall = true
end
if formulae.any?
puts "Would uninstall formulae:"
puts Formatter.columns formulae
would_uninstall = true
end
if taps.any?
puts "Would untap:"
puts Formatter.columns taps
would_uninstall = true
end
if vscode_extensions.any?
puts "Would uninstall VSCode extensions:"
puts Formatter.columns vscode_extensions
would_uninstall = true
end
cleanup = system_output_no_stderr(HOMEBREW_BREW_FILE, "cleanup", "--dry-run")
unless cleanup.empty?
puts "Would `brew cleanup`:"
puts cleanup
end
puts "Run `brew bundle cleanup --force` to make these changes." if would_uninstall || !cleanup.empty?
exit 1 if would_uninstall
end
end
def casks_to_uninstall(global: false, file: nil)
Homebrew::Bundle::CaskDumper.cask_names - kept_casks(global:, file:)
end
def formulae_to_uninstall(global: false, file: nil)
kept_formulae = self.kept_formulae(global:, file:)
current_formulae = Homebrew::Bundle::BrewDumper.formulae
current_formulae.reject! do |f|
Homebrew::Bundle::BrewInstaller.formula_in_array?(f[:full_name], kept_formulae)
end
current_formulae.map { |f| f[:full_name] }
end
def kept_formulae(global: false, file: nil)
@kept_formulae ||= begin
@dsl ||= Brewfile.read(global:, file:)
kept_formulae = @dsl.entries.select { |e| e.type == :brew }.map(&:name)
kept_formulae += Homebrew::Bundle::CaskDumper.formula_dependencies(kept_casks)
kept_formulae.map! do |f|
Homebrew::Bundle::BrewDumper.formula_aliases[f] ||
Homebrew::Bundle::BrewDumper.formula_oldnames[f] ||
f
end
kept_formulae + recursive_dependencies(Homebrew::Bundle::BrewDumper.formulae, kept_formulae)
end
end
def kept_casks(global: false, file: nil)
return @kept_casks if @kept_casks
@dsl ||= Brewfile.read(global:, file:)
@kept_casks = @dsl.entries.select { |e| e.type == :cask }.map(&:name)
end
def recursive_dependencies(current_formulae, formulae_names, top_level: true)
@checked_formulae_names = [] if top_level
dependencies = T.let([], T::Array[Formula])
formulae_names.each do |name|
next if @checked_formulae_names.include?(name)
formula = current_formulae.find { |f| f[:full_name] == name }
next unless formula
f_deps = formula[:dependencies]
unless formula[:poured_from_bottle?]
f_deps += formula[:build_dependencies]
f_deps.uniq!
end
next unless f_deps
next if f_deps.empty?
@checked_formulae_names << name
f_deps += recursive_dependencies(current_formulae, f_deps, top_level: false)
dependencies += f_deps
end
dependencies.uniq
end
IGNORED_TAPS = %w[homebrew/core homebrew/bundle].freeze
def taps_to_untap(global: false, file: nil)
@dsl ||= Brewfile.read(global:, file:)
kept_formulae = self.kept_formulae(global:, file:).filter_map(&method(:lookup_formula))
kept_taps = @dsl.entries.select { |e| e.type == :tap }.map(&:name)
kept_taps += kept_formulae.filter_map(&:tap).map(&:name)
current_taps = Homebrew::Bundle::TapDumper.tap_names
current_taps - kept_taps - IGNORED_TAPS
end
def lookup_formula(formula)
Formulary.factory(formula)
rescue TapFormulaUnavailableError
# ignore these as an unavailable formula implies there is no tap to worry about
nil
end
def vscode_extensions_to_uninstall(global: false, file: nil)
@dsl ||= Brewfile.read(global:, file:)
kept_extensions = @dsl.entries.select { |e| e.type == :vscode }.map { |x| x.name.downcase }
# To provide a graceful migration from `Brewfile`s that don't yet or
# don't want to use `vscode`: don't remove any extensions if we don't
# find any in the `Brewfile`.
return [].freeze if kept_extensions.empty?
current_extensions = Homebrew::Bundle::VscodeExtensionDumper.extensions
current_extensions - kept_extensions
end
def system_output_no_stderr(cmd, *args)
IO.popen([cmd, *args], err: :close).read
end
end
end
end
end

View File

@ -0,0 +1,29 @@
# typed: strict
module Homebrew::Bundle
module Commands
module Check
include Kernel
end
module Cleanup
include Kernel
end
module Dump
include Kernel
end
module Exec
include Kernel
end
module Install
include Kernel
end
module List
include Kernel
end
end
end

View File

@ -0,0 +1,18 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module Dump
module_function
def run(global:, file:, describe:, force:, no_restart:, taps:, brews:, casks:, mas:, whalebrew:, vscode:)
Homebrew::Bundle::Dumper.dump_brewfile(
global:, file:, describe:, force:, no_restart:, taps:, brews:, casks:, mas:, whalebrew:, vscode:,
)
end
end
end
end
end

View File

@ -0,0 +1,170 @@
# typed: false # rubocop:todo Sorbet/TrueSigil
# frozen_string_literal: true
require "exceptions"
require "extend/ENV"
require "utils"
require "PATH"
module Homebrew
module Bundle
module Commands
module Exec
module_function
# Homebrew's global environment variables that we don't want to leak into
# the `brew bundle exec` environment.
HOMEBREW_ENV_CLEANUP = %w[
HOMEBREW_HELP_MESSAGE
HOMEBREW_API_DEFAULT_DOMAIN
HOMEBREW_BOTTLE_DEFAULT_DOMAIN
HOMEBREW_BREW_DEFAULT_GIT_REMOTE
HOMEBREW_CORE_DEFAULT_GIT_REMOTE
HOMEBREW_DEFAULT_CACHE
HOMEBREW_DEFAULT_LOGS
HOMEBREW_DEFAULT_TEMP
HOMEBREW_REQUIRED_RUBY_VERSION
HOMEBREW_PRODUCT
HOMEBREW_SYSTEM
HOMEBREW_PROCESSOR
HOMEBREW_PHYSICAL_PROCESSOR
HOMEBREW_BREWED_CURL_PATH
HOMEBREW_USER_AGENT_CURL
HOMEBREW_USER_AGENT
HOMEBREW_GENERIC_DEFAULT_PREFIX
HOMEBREW_GENERIC_DEFAULT_REPOSITORY
HOMEBREW_DEFAULT_PREFIX
HOMEBREW_DEFAULT_REPOSITORY
HOMEBREW_AUTO_UPDATE_COMMAND
HOMEBREW_BREW_GIT_REMOTE
HOMEBREW_COMMAND_DEPTH
HOMEBREW_CORE_GIT_REMOTE
HOMEBREW_MACOS_VERSION_NUMERIC
HOMEBREW_MINIMUM_GIT_VERSION
HOMEBREW_MACOS_NEWEST_UNSUPPORTED
HOMEBREW_MACOS_OLDEST_SUPPORTED
HOMEBREW_MACOS_OLDEST_ALLOWED
HOMEBREW_GITHUB_PACKAGES_AUTH
].freeze
PATH_LIKE_ENV_REGEX = /.+#{File::PATH_SEPARATOR}/
def run(*args, global: false, file: nil, subcommand: "")
# Cleanup Homebrew's global environment
HOMEBREW_ENV_CLEANUP.each { |key| ENV.delete(key) }
# Setup Homebrew's ENV extensions
ENV.activate_extensions!
raise UsageError, "No command to execute was specified!" if args.blank?
command = args.first
# For commands which aren't either absolute or relative
if command.exclude? "/"
# Save the command path, since this will be blown away by superenv
command_path = which(command)
raise "command was not found in your PATH: #{command}" if command_path.blank?
command_path = command_path.dirname.to_s
end
@dsl = Brewfile.read(global:, file:)
require "formula"
require "formulary"
ENV.deps = @dsl.entries.filter_map do |entry|
next if entry.type != :brew
Formulary.factory(entry.name)
end
# Allow setting all dependencies to be keg-only
# (i.e. should be explicitly in HOMEBREW_*PATHs ahead of HOMEBREW_PREFIX)
ENV.keg_only_deps = if ENV["HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS"].present?
ENV.delete("HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS")
ENV.deps
else
ENV.deps.select(&:keg_only?)
end
ENV.setup_build_environment
# Enable compiler flag filtering
ENV.refurbish_args
# Set up `nodenv`, `pyenv` and `rbenv` if present.
env_formulae = %w[nodenv pyenv rbenv]
ENV.deps.each do |dep|
dep_name = dep.name
next unless env_formulae.include?(dep_name)
dep_root = ENV.fetch("HOMEBREW_#{dep_name.upcase}_ROOT", "#{Dir.home}/.#{dep_name}")
ENV.prepend_path "PATH", Pathname.new(dep_root)/"shims"
end
# Setup pkg-config, if present, to help locate packages
# Only need this on Linux as Homebrew provides a shim on macOS
# TODO: use extend/OS here
# rubocop:todo Homebrew/MoveToExtendOS
if OS.linux? && (pkgconf = Formulary.factory("pkgconf")) && pkgconf.any_version_installed?
ENV.prepend_path "PATH", pkgconf.opt_bin.to_s
end
# rubocop:enable Homebrew/MoveToExtendOS
# Ensure the Ruby path we saved goes before anything else, if the command was in the PATH
ENV.prepend_path "PATH", command_path if command_path.present?
# Replace the formula versions from the environment variables
formula_versions = {}
ENV.each do |key, value|
match = key.match(/^HOMEBREW_BUNDLE_EXEC_FORMULA_VERSION_(.+)$/)
next if match.blank?
formula_name = match[1]
next if formula_name.blank?
ENV.delete(key)
formula_versions[formula_name.downcase] = value
end
formula_versions.each do |formula_name, formula_version|
ENV.each do |key, value|
opt = %r{/opt/#{formula_name}([/:$])}
next unless value.match(opt)
cellar = "/Cellar/#{formula_name}/#{formula_version}\\1"
# Look for PATH-like environment variables
ENV[key] = if key.include?("PATH") && value.match?(PATH_LIKE_ENV_REGEX)
rejected_opts = []
path = PATH.new(ENV.fetch("PATH"))
.reject do |value|
rejected_opts << value if value.match?(opt)
end
rejected_opts.each do |value|
path.prepend(value.gsub(opt, cellar))
end
path.to_s
else
value.gsub(opt, cellar)
end
end
end
# Ensure brew bundle sh/env commands have access to other tools in the PATH
if ["sh", "env"].include?(subcommand) && (homebrew_path = ENV.fetch("HOMEBREW_PATH", nil))
ENV.append_path "PATH", homebrew_path
end
if subcommand == "env"
ENV.each do |key, value|
puts "export #{key}=\"#{value}\""
end
return
end
exec(*args)
end
end
end
end
end

View File

@ -0,0 +1,25 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module Install
module_function
def run(global: false, file: nil, no_lock: false, no_upgrade: false, verbose: false, force: false,
quiet: false)
@dsl = Brewfile.read(global:, file:)
Homebrew::Bundle::Installer.install(
@dsl.entries,
global:, file:, no_lock:, no_upgrade:, verbose:, force:, quiet:,
) || exit(1)
end
def dsl
@dsl
end
end
end
end
end

View File

@ -0,0 +1,20 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module List
module_function
def run(global:, file:, brews:, casks:, taps:, mas:, whalebrew:, vscode:)
parsed_entries = Brewfile.read(global:, file:).entries
Homebrew::Bundle::Lister.list(
parsed_entries,
brews:, casks:, taps:, mas:, whalebrew:, vscode:,
)
end
end
end
end
end

View File

@ -0,0 +1,16 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Commands
module Remove
module_function
def run(*args, type:, global:, file:)
Homebrew::Bundle::Remover.remove(*args, type:, global:, file:)
end
end
end
end
end

View File

@ -0,0 +1,134 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
class Dsl
class Entry
attr_reader :type, :name, :options
def initialize(type, name, options = {})
@type = type
@name = name
@options = options
end
def to_s
name
end
end
attr_reader :entries, :cask_arguments, :input
def initialize(path)
@path = path
@input = path.read
@entries = []
@cask_arguments = {}
begin
process
# Want to catch all exceptions for e.g. syntax errors.
rescue Exception => e # rubocop:disable Lint/RescueException
error_msg = "Invalid Brewfile: #{e.message}"
raise RuntimeError, error_msg, e.backtrace
end
end
def process
instance_eval(@input, @path.to_s)
end
def cask_args(args)
raise "cask_args(#{args.inspect}) should be a Hash object" unless args.is_a? Hash
@cask_arguments = args
end
def brew(name, options = {})
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
raise "options(#{options.inspect}) should be a Hash object" unless options.is_a? Hash
name = Homebrew::Bundle::Dsl.sanitize_brew_name(name)
@entries << Entry.new(:brew, name, options)
end
def cask(name, options = {})
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
raise "options(#{options.inspect}) should be a Hash object" unless options.is_a? Hash
options[:full_name] = name
name = Homebrew::Bundle::Dsl.sanitize_cask_name(name)
options[:args] = @cask_arguments.merge options.fetch(:args, {})
@entries << Entry.new(:cask, name, options)
end
def mas(name, options = {})
id = options[:id]
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
raise "options[:id](#{id}) should be an Integer object" unless id.is_a? Integer
@entries << Entry.new(:mas, name, id:)
end
def whalebrew(name)
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
@entries << Entry.new(:whalebrew, name)
end
def vscode(name)
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
@entries << Entry.new(:vscode, name)
end
def tap(name, clone_target = nil, options = {})
raise "name(#{name.inspect}) should be a String object" unless name.is_a? String
if clone_target && !clone_target.is_a?(String)
raise "clone_target(#{clone_target.inspect}) should be nil or a String object"
end
options[:clone_target] = clone_target
name = Homebrew::Bundle::Dsl.sanitize_tap_name(name)
@entries << Entry.new(:tap, name, options)
end
HOMEBREW_TAP_ARGS_REGEX = %r{^([\w-]+)/(homebrew-)?([\w-]+)$}
HOMEBREW_CORE_FORMULA_REGEX = %r{^homebrew/homebrew/([\w+-.@]+)$}i
HOMEBREW_TAP_FORMULA_REGEX = %r{^([\w-]+)/([\w-]+)/([\w+-.@]+)$}
def self.sanitize_brew_name(name)
name = name.downcase
if name =~ HOMEBREW_CORE_FORMULA_REGEX
Regexp.last_match(1)
elsif name =~ HOMEBREW_TAP_FORMULA_REGEX
user = Regexp.last_match(1)
repo = T.must(Regexp.last_match(2))
name = Regexp.last_match(3)
"#{user}/#{repo.sub("homebrew-", "")}/#{name}"
else
name
end
end
def self.sanitize_tap_name(name)
name = name.downcase
if name =~ HOMEBREW_TAP_ARGS_REGEX
"#{Regexp.last_match(1)}/#{Regexp.last_match(3)}"
else
name
end
end
def self.sanitize_cask_name(name)
name = name.split("/").last if name.include?("/")
name.downcase
end
def self.pluralize_dependency(installed_count)
(installed_count == 1) ? "dependency" : "dependencies"
end
end
end
end

View File

@ -0,0 +1,52 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "fileutils"
require "pathname"
module Homebrew
module Bundle
module Dumper
module_function
def can_write_to_brewfile?(brewfile_path, force: false)
raise "#{brewfile_path} already exists" if should_not_write_file?(brewfile_path, overwrite: force)
true
end
def build_brewfile(describe:, no_restart:, brews:, taps:, casks:, mas:, whalebrew:, vscode:)
content = []
content << TapDumper.dump if taps
content << BrewDumper.dump(describe:, no_restart:) if brews
content << CaskDumper.dump(describe:) if casks
content << MacAppStoreDumper.dump if mas
content << WhalebrewDumper.dump if whalebrew
content << VscodeExtensionDumper.dump if vscode
"#{content.reject(&:empty?).join("\n")}\n"
end
def dump_brewfile(global:, file:, describe:, force:, no_restart:, brews:, taps:, casks:, mas:, whalebrew:,
vscode:)
path = brewfile_path(global:, file:)
can_write_to_brewfile?(path, force:)
content = build_brewfile(describe:, no_restart:, taps:, brews:, casks:, mas:, whalebrew:, vscode:)
write_file path, content
end
def brewfile_path(global: false, file: nil)
Brewfile.path(dash_writes_to_stdout: true, global:, file:)
end
def should_not_write_file?(file, overwrite: false)
file.exist? && !overwrite && file.to_s != "/dev/stdout"
end
def write_file(file, content)
Bundle.exchange_uid_if_needed! do
file.open("w") { |io| io.write content }
end
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module Dumper
include Kernel
end
end

View File

@ -0,0 +1,77 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Installer
module_function
def install(entries, global: false, file: nil, no_lock: false, no_upgrade: false, verbose: false, force: false,
quiet: false)
success = 0
failure = 0
entries.each do |entry|
name = entry.name
args = [name]
options = {}
verb = "Installing"
type = entry.type
cls = case type
when :brew
options = entry.options
verb = "Upgrading" if Homebrew::Bundle::BrewInstaller.formula_upgradable?(name)
Homebrew::Bundle::BrewInstaller
when :cask
options = entry.options
verb = "Upgrading" if Homebrew::Bundle::CaskInstaller.cask_upgradable?(name)
Homebrew::Bundle::CaskInstaller
when :mas
args << entry.options[:id]
Homebrew::Bundle::MacAppStoreInstaller
when :whalebrew
Homebrew::Bundle::WhalebrewInstaller
when :vscode
Homebrew::Bundle::VscodeExtensionInstaller
when :tap
verb = "Tapping"
options = entry.options
Homebrew::Bundle::TapInstaller
end
next if cls.nil?
next if Homebrew::Bundle::Skipper.skip? entry
preinstall = if cls.preinstall(*args, **options, no_upgrade:, verbose:)
puts Formatter.success("#{verb} #{name}")
true
else
puts "Using #{name}" unless quiet
false
end
if cls.install(*args, **options,
preinstall:, no_upgrade:, verbose:, force:)
success += 1
else
$stderr.puts Formatter.error("#{verb} #{name} has failed!")
failure += 1
end
end
unless failure.zero?
dependency = Homebrew::Bundle::Dsl.pluralize_dependency(failure)
$stderr.puts Formatter.error "Homebrew Bundle failed! #{failure} Brewfile #{dependency} failed to install"
return false
end
unless quiet
dependency = Homebrew::Bundle::Dsl.pluralize_dependency(success)
puts Formatter.success "Homebrew Bundle complete! #{success} Brewfile #{dependency} now installed."
end
true
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module Installer
include Kernel
end
end

View File

@ -0,0 +1,27 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Lister
module_function
def list(entries, brews:, casks:, taps:, mas:, whalebrew:, vscode:)
entries.each do |entry|
puts entry.name if show?(entry.type, brews:, casks:, taps:, mas:, whalebrew:, vscode:)
end
end
def show?(type, brews:, casks:, taps:, mas:, whalebrew:, vscode:)
return true if brews && type == :brew
return true if casks && type == :cask
return true if taps && type == :tap
return true if mas && type == :mas
return true if whalebrew && type == :whalebrew
return true if vscode && type == :vscode
false
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module Lister
include Kernel
end
end

View File

@ -0,0 +1,34 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class MacAppStoreChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :mas
PACKAGE_TYPE_NAME = "App"
def installed_and_up_to_date?(id, no_upgrade: false)
Homebrew::Bundle::MacAppStoreInstaller.app_id_installed_and_up_to_date?(id, no_upgrade:)
end
def format_checkable(entries)
checkable_entries(entries).to_h { |e| [e.options[:id], e.name] }
end
def exit_early_check(app_ids_with_names, no_upgrade:)
work_to_be_done = app_ids_with_names.find do |id, _name|
!installed_and_up_to_date?(id, no_upgrade:)
end
Array(work_to_be_done)
end
def full_check(app_ids_with_names, no_upgrade:)
app_ids_with_names.reject { |id, _name| installed_and_up_to_date?(id, no_upgrade:) }
.map { |_id, name| failure_reason(name, no_upgrade:) }
end
end
end
end
end

View File

@ -0,0 +1,41 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "json"
module Homebrew
module Bundle
module MacAppStoreDumper
module_function
def reset!
@apps = nil
end
def apps
@apps ||= if Bundle.mas_installed?
`mas list 2>/dev/null`.split("\n").map do |app|
app_details = app.match(/\A(?<id>\d+)\s+(?<name>.*?)\s+\((?<version>[\d.]*)\)\Z/)
# Only add the application details should we have a valid match.
# Strip unprintable characters
if app_details
name = T.must(app_details[:name])
[app_details[:id], name.gsub(/[[:cntrl:]]|[\p{C}]/, "")]
end
end
else
[]
end.compact
end
def app_ids
apps.map { |id, _| id.to_i }
end
def dump
apps.sort_by { |_, name| name.downcase }.map { |id, name| "mas \"#{name}\", id: #{id}" }.join("\n")
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module MacAppStoreDumper
include Kernel
end
end

View File

@ -0,0 +1,80 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "os"
module Homebrew
module Bundle
module MacAppStoreInstaller
module_function
def reset!
@installed_app_ids = nil
@outdated_app_ids = nil
end
def preinstall(name, id, no_upgrade: false, verbose: false)
unless Bundle.mas_installed?
puts "Installing mas. It is not currently installed." if verbose
Bundle.brew("install", "mas", verbose:)
raise "Unable to install #{name} app. mas installation failed." unless Bundle.mas_installed?
end
if app_id_installed?(id) &&
(no_upgrade || !app_id_upgradable?(id))
puts "Skipping install of #{name} app. It is already installed." if verbose
return false
end
true
end
def install(name, id, preinstall: true, no_upgrade: false, verbose: false, force: false)
return true unless preinstall
if app_id_installed?(id)
puts "Upgrading #{name} app. It is installed but not up-to-date." if verbose
return false unless Bundle.system "mas", "upgrade", id.to_s, verbose: verbose
return true
end
puts "Installing #{name} app. It is not currently installed." if verbose
return false unless Bundle.system "mas", "install", id.to_s, verbose: verbose
installed_app_ids << id
true
end
def self.app_id_installed_and_up_to_date?(id, no_upgrade: false)
return false unless app_id_installed?(id)
return true if no_upgrade
!app_id_upgradable?(id)
end
def app_id_installed?(id)
installed_app_ids.include? id
end
def app_id_upgradable?(id)
outdated_app_ids.include? id
end
def installed_app_ids
@installed_app_ids ||= Homebrew::Bundle::MacAppStoreDumper.app_ids
end
def outdated_app_ids
@outdated_app_ids ||= if Bundle.mas_installed?
`mas outdated 2>/dev/null`.split("\n").map do |app|
app.split(" ", 2).first.to_i
end
else
[]
end
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module MacAppStoreInstaller
include Kernel
end
end

View File

@ -0,0 +1,47 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Remover
module_function
def remove(*args, type:, global:, file:)
brewfile = Brewfile.read(global:, file:)
content = brewfile.input
entry_type = type.to_s if type != :none
escaped_args = args.flat_map do |arg|
names = if type == :brew
possible_names(arg)
else
[arg]
end
names.uniq.map { |a| Regexp.escape(a) }
end
new_content = content.split("\n")
.grep_v(/#{entry_type}(\s+|\(\s*)"(#{escaped_args.join("|")})"/)
.join("\n") << "\n"
if content.chomp == new_content.chomp &&
type == :none &&
args.any? { |arg| possible_names(arg, raise_error: false).count > 1 }
opoo "No matching entries found in Brewfile. Try again with `--formula` to match formula " \
"aliases and old formula names."
return
end
path = Dumper.brewfile_path(global:, file:)
Dumper.write_file path, new_content
end
def possible_names(formula_name, raise_error: true)
formula = Formulary.factory(formula_name)
[formula_name, formula.name, formula.full_name, *formula.aliases, *formula.oldnames].compact.uniq
rescue FormulaUnavailableError
raise if raise_error
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module Remover
include Kernel
end
end

View File

@ -0,0 +1,64 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "hardware"
module Homebrew
module Bundle
module Skipper
class << self
def skip?(entry, silent: false)
# TODO: use extend/OS here
# rubocop:todo Homebrew/MoveToExtendOS
if (Hardware::CPU.arm? || OS.linux?) &&
Homebrew.default_prefix? &&
entry.type == :brew && entry.name.exclude?("/") &&
(formula = BrewDumper.formulae_by_full_name(entry.name)) &&
formula[:official_tap] &&
!formula[:bottled]
reason = Hardware::CPU.arm? ? "Apple Silicon" : "Linux"
puts Formatter.warning "Skipping #{entry.name} (no bottle for #{reason})" unless silent
return true
end
# rubocop:enable Homebrew/MoveToExtendOS
return true if @failed_taps&.any? do |tap|
prefix = "#{tap}/"
entry.name.start_with?(prefix) || entry.options[:full_name]&.start_with?(prefix)
end
entry_type_skips = Array(skipped_entries[entry.type])
return false if entry_type_skips.empty?
# Check the name or ID particularly for Mac App Store entries where they
# can have spaces in the names (and the `mas` output format changes on
# occasion).
entry_ids = [entry.name, entry.options[:id]&.to_s].compact
return false unless entry_type_skips.intersect?(entry_ids)
puts Formatter.warning "Skipping #{entry.name}" unless silent
true
end
def tap_failed!(tap_name)
@failed_taps ||= []
@failed_taps << tap_name
end
private
def skipped_entries
return @skipped_entries if @skipped_entries
@skipped_entries = {}
[:brew, :cask, :mas, :tap, :whalebrew].each do |type|
@skipped_entries[type] =
ENV["HOMEBREW_BUNDLE_#{type.to_s.upcase}_SKIP"]&.split
end
@skipped_entries
end
end
end
end
end
require "extend/os/bundle/skipper"

View File

@ -0,0 +1,21 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class TapChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :tap
PACKAGE_TYPE_NAME = "Tap"
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
requested_taps = format_checkable(entries)
return [] if requested_taps.empty?
current_taps = Homebrew::Bundle::TapDumper.tap_names
(requested_taps - current_taps).map { |entry| "Tap #{entry} needs to be tapped." }
end
end
end
end
end

View File

@ -0,0 +1,45 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
require "json"
module Homebrew
module Bundle
module TapDumper
module_function
def reset!
@taps = nil
end
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
def tap_names
taps.map(&:name)
end
def taps
@taps ||= begin
require "tap"
Tap.select(&:installed?).to_a
end
end
private_class_method :taps
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module TapDumper
include Kernel
end
end

View File

@ -0,0 +1,46 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module TapInstaller
module_function
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
def install(name, preinstall: true, verbose: false, force: false, **options)
return true unless preinstall
puts "Installing #{name} tap. It is not currently installed." if verbose
args = []
args << "--force" if force
args.append("--force-auto-update") if options[:force_auto_update]
success = if options[:clone_target]
Bundle.brew("tap", name, options[:clone_target], *args, verbose:)
else
Bundle.brew("tap", name, *args, verbose:)
end
unless success
Homebrew::Bundle::Skipper.tap_failed!(name)
return false
end
installed_taps << name
true
end
def installed_taps
@installed_taps ||= Homebrew::Bundle::TapDumper.tap_names
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module TapInstaller
include Kernel
end
end

View File

@ -0,0 +1,21 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class VscodeExtensionChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :vscode
PACKAGE_TYPE_NAME = "VSCode Extension"
def failure_reason(extension, no_upgrade:)
"#{PACKAGE_TYPE_NAME} #{extension} needs to be installed."
end
def installed_and_up_to_date?(extension, no_upgrade: false)
Homebrew::Bundle::VscodeExtensionInstaller.extension_installed?(extension)
end
end
end
end
end

View File

@ -0,0 +1,28 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module VscodeExtensionDumper
module_function
def reset!
@extensions = nil
end
def extensions
@extensions ||= if Bundle.vscode_installed?
Bundle.exchange_uid_if_needed! do
`code --list-extensions 2>/dev/null`
end.split("\n").map(&:downcase)
else
[]
end
end
def dump
extensions.map { |name| "vscode \"#{name}\"" }.join("\n")
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module VscodeExtensionDumper
include Kernel
end
end

View File

@ -0,0 +1,53 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module VscodeExtensionInstaller
module_function
def reset!
@installed_extensions = nil
end
def preinstall(name, no_upgrade: false, verbose: false)
if !Bundle.vscode_installed? && Bundle.cask_installed?
puts "Installing visual-studio-code. It is not currently installed." if verbose
Bundle.brew("install", "--cask", "visual-studio-code", verbose:)
end
if extension_installed?(name)
puts "Skipping install of #{name} VSCode extension. It is already installed." if verbose
return false
end
raise "Unable to install #{name} VSCode extension. VSCode is not installed." unless Bundle.vscode_installed?
true
end
def install(name, preinstall: true, no_upgrade: false, verbose: false, force: false)
return true unless preinstall
return true if extension_installed?(name)
puts "Installing #{name} VSCode extension. It is not currently installed." if verbose
return false unless Bundle.exchange_uid_if_needed! do
Bundle.system("code", "--install-extension", name, verbose:)
end
installed_extensions << name
true
end
def extension_installed?(name)
installed_extensions.include? name.downcase
end
def installed_extensions
@installed_extensions ||= Homebrew::Bundle::VscodeExtensionDumper.extensions
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module VscodeExtensionInstaller
include Kernel
end
end

View File

@ -0,0 +1,27 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module WhalebrewDumper
module_function
def reset!
@images = nil
end
def images
return [] unless Bundle.whalebrew_installed?
@images ||= `whalebrew list 2>/dev/null`.split("\n")
.reject { |line| line.start_with?("COMMAND ") }
.map { |line| line.split(/\s+/).last }
.uniq
end
def dump
images.map { |image| "whalebrew \"#{image}\"" }.join("\n")
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module WhalebrewDumper
include Kernel
end
end

View File

@ -0,0 +1,48 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module Homebrew
module Bundle
module WhalebrewInstaller
module_function
def reset!
@installed_images = nil
end
def preinstall(name, verbose: false, **_options)
unless Bundle.whalebrew_installed?
puts "Installing whalebrew. It is not currently installed." if verbose
Bundle.brew("install", "--formula", "whalebrew", verbose:)
raise "Unable to install #{name} app. Whalebrew installation failed." unless Bundle.whalebrew_installed?
end
if image_installed?(name)
puts "Skipping install of #{name} app. It is already installed." if verbose
return false
end
true
end
def install(name, preinstall: true, verbose: false, force: false, **_options)
return true unless preinstall
puts "Installing #{name} image. It is not currently installed." if verbose
return false unless Bundle.system "whalebrew", "install", name, verbose: verbose
installed_images << name
true
end
def image_installed?(image)
installed_images.include? image
end
def installed_images
@installed_images ||= Homebrew::Bundle::WhalebrewDumper.images
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: strict
module Homebrew::Bundle
module WhalebrewInstaller
include Kernel
end
end

272
Library/Homebrew/cmd/bundle.rb Executable file
View File

@ -0,0 +1,272 @@
# typed: strict
# frozen_string_literal: true
require "abstract_command"
module Homebrew
module Cmd
class Bundle < AbstractCommand
cmd_args do
usage_banner <<~EOS
`bundle` [<subcommand>]
Bundler for non-Ruby dependencies from Homebrew, Homebrew Cask, Mac App Store, Whalebrew and Visual Studio Code.
`brew bundle` [`install`]:
Install and upgrade (by default) all dependencies from the `Brewfile`.
You can specify the `Brewfile` location using `--file` or by setting the `$HOMEBREW_BUNDLE_FILE` environment variable.
You can skip the installation of dependencies by adding space-separated values to one or more of the following environment variables: `$HOMEBREW_BUNDLE_BREW_SKIP`, `$HOMEBREW_BUNDLE_CASK_SKIP`, `$HOMEBREW_BUNDLE_MAS_SKIP`, `$HOMEBREW_BUNDLE_WHALEBREW_SKIP`, `$HOMEBREW_BUNDLE_TAP_SKIP`.
`brew bundle upgrade`:
Shorthand for `brew bundle install --upgrade`.
`brew bundle dump`:
Write all installed casks/formulae/images/taps into a `Brewfile` in the current directory or to a custom file specified with the `--file` option.
`brew bundle cleanup`:
Uninstall all dependencies not present in the `Brewfile`.
This workflow is useful for maintainers or testers who regularly install lots of formulae.
Unless `--force` is passed, this returns a 1 exit code if anything would be removed.
`brew bundle check`:
Check if all dependencies present in the `Brewfile` are installed.
This provides a successful exit code if everything is up-to-date, making it useful for scripting.
`brew bundle list`:
List all dependencies present in the `Brewfile`.
By default, only Homebrew formula dependencies are listed.
`brew bundle edit`:
Edit the `Brewfile` in your editor.
`brew bundle add` <name> [...]:
Add entries to your `Brewfile`. Adds formulae by default. Use `--cask`, `--tap`, `--whalebrew` or `--vscode` to add the corresponding entry instead.
`brew bundle remove` <name> [...]:
Remove entries that match `name` from your `Brewfile`. Use `--formula`, `--cask`, `--tap`, `--mas`, `--whalebrew` or `--vscode` to remove only entries of the corresponding type. Passing `--formula` also removes matches against formula aliases and old formula names.
`brew bundle exec` <command>:
Run an external command in an isolated build environment based on the `Brewfile` dependencies.
This sanitized build environment ignores unrequested dependencies, which makes sure that things you didn't specify in your `Brewfile` won't get picked up by commands like `bundle install`, `npm install`, etc. It will also add compiler flags which will help with finding keg-only dependencies like `openssl`, `icu4c`, etc.
`brew bundle sh`:
Run your shell in a `brew bundle exec` environment.
`brew bundle env`:
Print the environment variables that would be set in a `brew bundle exec` environment.
EOS
flag "--file=",
description: "Read from or write to the `Brewfile` from this location. " \
"Use `--file=-` to pipe to stdin/stdout."
switch "--global",
description: "Read from or write to the `Brewfile` from `$HOMEBREW_BUNDLE_FILE_GLOBAL` (if set), " \
"`${XDG_CONFIG_HOME}/homebrew/Brewfile` (if `$XDG_CONFIG_HOME` is set), " \
"`~/.homebrew/Brewfile` or `~/.Brewfile` otherwise."
switch "-v", "--verbose",
description: "`install` prints output from commands as they are run. " \
"`check` lists all missing dependencies."
switch "--no-upgrade",
env: :bundle_no_upgrade,
description: "`install` does not run `brew upgrade` on outdated dependencies. " \
"`check` does not check for outdated dependencies. " \
"Note they may still be upgraded by `brew install` if needed. " \
"This is enabled by default if `$HOMEBREW_BUNDLE_NO_UPGRADE` is set."
switch "--upgrade",
description: "`install` runs `brew upgrade` on outdated dependencies, " \
"even if `$HOMEBREW_BUNDLE_NO_UPGRADE` is set. "
switch "--install",
description: "Run `install` before continuing to other operations e.g. `exec`."
switch "-f", "--force",
description: "`install` runs with `--force`/`--overwrite`. " \
"`dump` overwrites an existing `Brewfile`. " \
"`cleanup` actually performs its cleanup operations."
switch "--cleanup",
env: :bundle_install_cleanup,
description: "`install` performs cleanup operation, same as running `cleanup --force`. " \
"This is enabled by default if `$HOMEBREW_BUNDLE_INSTALL_CLEANUP` is set and " \
"`--global` is passed."
switch "--all",
description: "`list` all dependencies."
switch "--formula", "--brews",
description: "`list` or `dump` Homebrew formula dependencies."
switch "--cask", "--casks",
description: "`list` or `dump` Homebrew cask dependencies."
switch "--tap", "--taps",
description: "`list` or `dump` Homebrew tap dependencies."
switch "--mas",
description: "`list` or `dump` Mac App Store dependencies."
switch "--whalebrew",
description: "`list` or `dump` Whalebrew dependencies."
switch "--vscode",
description: "`list` or `dump` VSCode extensions."
switch "--no-vscode",
env: :bundle_dump_no_vscode,
description: "`dump` without VSCode extensions. " \
"This is enabled by default if `$HOMEBREW_BUNDLE_DUMP_NO_VSCODE` is set."
switch "--describe",
env: :bundle_dump_describe,
description: "`dump` adds a description comment above each line, unless the " \
"dependency does not have a description. " \
"This is enabled by default if `$HOMEBREW_BUNDLE_DUMP_DESCRIBE` is set."
switch "--no-restart",
description: "`dump` does not add `restart_service` to formula lines."
switch "--zap",
description: "`cleanup` casks using the `zap` command instead of `uninstall`."
conflicts "--all", "--no-vscode"
conflicts "--vscode", "--no-vscode"
conflicts "--install", "--upgrade"
named_args %w[install dump cleanup check exec list sh env edit]
end
sig { override.void }
def run
# Keep this inside `run` to keep --help fast.
require "bundle"
subcommand = args.named.first.presence
if ["exec", "add", "remove"].exclude?(subcommand) && args.named.size > 1
raise UsageError, "This command does not take more than 1 subcommand argument."
end
global = args.global?
file = args.file
args.zap?
no_upgrade = if args.upgrade? || subcommand == "upgrade"
false
else
args.no_upgrade?
end
verbose = args.verbose?
force = args.force?
zap = args.zap?
no_type_args = !args.brews? && !args.casks? && !args.taps? && !args.mas? && !args.whalebrew? && !args.vscode?
if args.install?
if [nil, "install", "upgrade"].include?(subcommand)
raise UsageError, "`--install` cannot be used with `install`, `upgrade` or no subcommand."
end
redirect_stdout($stderr) do
Homebrew::Bundle::Commands::Install.run(global:, file:, no_upgrade:, verbose:, force:, quiet: true)
end
end
case subcommand
when nil, "install", "upgrade"
Homebrew::Bundle::Commands::Install.run(global:, file:, no_upgrade:, verbose:, force:, quiet: args.quiet?)
cleanup = if ENV.fetch("HOMEBREW_BUNDLE_INSTALL_CLEANUP", nil)
args.global?
else
args.cleanup?
end
if cleanup
Homebrew::Bundle::Commands::Cleanup.run(
global:, file:, zap:,
force: true,
dsl: Homebrew::Bundle::Commands::Install.dsl
)
end
when "dump"
vscode = if args.no_vscode?
false
elsif args.vscode?
true
else
no_type_args
end
Homebrew::Bundle::Commands::Dump.run(
global:, file:, force:,
describe: args.describe?,
no_restart: args.no_restart?,
taps: args.taps? || no_type_args,
brews: args.brews? || no_type_args,
casks: args.casks? || no_type_args,
mas: args.mas? || no_type_args,
whalebrew: args.whalebrew? || no_type_args,
vscode:
)
when "edit"
exec_editor(Homebrew::Bundle::Brewfile.path(global:, file:))
when "cleanup"
Homebrew::Bundle::Commands::Cleanup.run(global:, file:, force:, zap:)
when "check"
Homebrew::Bundle::Commands::Check.run(global:, file:, no_upgrade:, verbose:)
when "exec", "sh", "env"
named_args = case subcommand
when "exec"
_subcommand, *named_args = args.named
named_args
when "sh"
preferred_shell = Utils::Shell.preferred_path(default: "/bin/bash")
subshell = case Utils::Shell.preferred
when :zsh
"PS1='brew bundle %B%F{green}%~%f%b$ ' #{preferred_shell} -d -f"
when :bash
"PS1=\"brew bundle \\[\\033[1;32m\\]\\w\\[\\033[0m\\]$ \" #{preferred_shell} --noprofile --norc"
else
"PS1=\"brew bundle \\[\\033[1;32m\\]\\w\\[\\033[0m\\]$ \" #{preferred_shell}"
end
$stdout.flush
ENV["HOMEBREW_FORCE_API_AUTO_UPDATE"] = nil
[subshell]
when "env"
["env"]
end
Homebrew::Bundle::Commands::Exec.run(*named_args, global:, file:, subcommand:)
when "list"
Homebrew::Bundle::Commands::List.run(
global:,
file:,
brews: args.brews? || args.all? || no_type_args,
casks: args.casks? || args.all?,
taps: args.taps? || args.all?,
mas: args.mas? || args.all?,
whalebrew: args.whalebrew? || args.all?,
vscode: args.vscode? || args.all?,
)
when "add", "remove"
# We intentionally omit the `s` from `brews`, `casks`, and `taps` for ease of handling later.
type_hash = {
brew: args.brews?,
cask: args.casks?,
tap: args.taps?,
mas: args.mas?,
whalebrew: args.whalebrew?,
vscode: args.vscode?,
none: no_type_args,
}
selected_types = type_hash.select { |_, v| v }.keys
raise UsageError, "`#{subcommand}` supports only one type of entry at a time." if selected_types.count != 1
_, *named_args = args.named
if subcommand == "add"
type = case (t = selected_types.first)
when :none then :brew
when :mas then raise UsageError, "`add` does not support `--mas`."
else t
end
Homebrew::Bundle::Commands::Add.run(*named_args, type:, global:, file:)
else
Homebrew::Bundle::Commands::Remove.run(*named_args, type: selected_types.first, global:, file:)
end
else
raise UsageError, "unknown subcommand: #{subcommand}"
end
end
end
end
end

View File

@ -568,6 +568,10 @@ module Homebrew
def check_deprecated_official_taps
tapped_deprecated_taps =
Tap.select(&:official?).map(&:repository) & DEPRECATED_OFFICIAL_TAPS
# TODO: remove this once it's no longer in the default GitHub Actions image
tapped_deprecated_taps -= ["bundle"] if GitHub::Actions.env_set?
return if tapped_deprecated_taps.empty?
<<~EOS

View File

@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "extend/os/linux/bundle/bundle" if OS.linux?

View File

@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "extend/os/linux/bundle/skipper" if OS.linux?

View File

@ -0,0 +1,17 @@
# typed: strict
# frozen_string_literal: true
module OS
module Linux
module Bundle
module ClassMethods
sig { returns(T::Boolean) }
def mas_installed?
false
end
end
end
end
end
Homebrew::Bundle.singleton_class.prepend(OS::Linux::Bundle::ClassMethods)

View File

@ -0,0 +1,31 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true
module OS
module Linux
module Bundle
module Skipper
module ClassMethods
def macos_only_entry?(entry)
[:cask, :mas].include?(entry.type)
end
def macos_only_tap?(entry)
entry.type == :tap && entry.name == "homebrew/cask"
end
def skip?(entry, silent: false)
if macos_only_entry?(entry) || macos_only_tap?(entry)
::Kernel.puts Formatter.warning "Skipping #{entry.type} #{entry.name} (on Linux)" unless silent
true
else
super(entry)
end
end
end
end
end
end
end
Homebrew::Bundle::Skipper.singleton_class.prepend(OS::Linux::Bundle::Skipper::ClassMethods)

View File

@ -6,7 +6,6 @@ OFFICIAL_CASK_TAPS = %w[
].freeze
OFFICIAL_CMD_TAPS = T.let({
"homebrew/bundle" => ["bundle"],
"homebrew/command-not-found" => ["command-not-found-init", "which-formula", "which-update"],
"homebrew/test-bot" => ["test-bot"],
}.freeze, T::Hash[String, T::Array[String]])
@ -15,6 +14,7 @@ DEPRECATED_OFFICIAL_TAPS = %w[
aliases
apache
binary
bundle
cask-drivers
cask-eid
cask-fonts

View File

@ -0,0 +1,79 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Homebrew::Cmd::Bundle`.
# Please instead update this file by running `bin/tapioca dsl Homebrew::Cmd::Bundle`.
class Homebrew::Cmd::Bundle
sig { returns(Homebrew::Cmd::Bundle::Args) }
def args; end
end
class Homebrew::Cmd::Bundle::Args < Homebrew::CLI::Args
sig { returns(T::Boolean) }
def all?; end
sig { returns(T::Boolean) }
def brews?; end
sig { returns(T::Boolean) }
def cask?; end
sig { returns(T::Boolean) }
def casks?; end
sig { returns(T::Boolean) }
def cleanup?; end
sig { returns(T::Boolean) }
def describe?; end
sig { returns(T::Boolean) }
def f?; end
sig { returns(T.nilable(String)) }
def file; end
sig { returns(T::Boolean) }
def force?; end
sig { returns(T::Boolean) }
def formula?; end
sig { returns(T::Boolean) }
def global?; end
sig { returns(T::Boolean) }
def install?; end
sig { returns(T::Boolean) }
def mas?; end
sig { returns(T::Boolean) }
def no_restart?; end
sig { returns(T::Boolean) }
def no_upgrade?; end
sig { returns(T::Boolean) }
def no_vscode?; end
sig { returns(T::Boolean) }
def tap?; end
sig { returns(T::Boolean) }
def taps?; end
sig { returns(T::Boolean) }
def upgrade?; end
sig { returns(T::Boolean) }
def vscode?; end
sig { returns(T::Boolean) }
def whalebrew?; end
sig { returns(T::Boolean) }
def zap?; end
end

View File

@ -0,0 +1,267 @@
# frozen_string_literal: true
require "ostruct"
require "bundle"
require "tsort"
require "formula"
require "tab"
require "utils/bottles"
# TODO: remove OpenStruct usage
# rubocop:todo Style/OpenStructUse
RSpec.describe Homebrew::Bundle::BrewDumper do
subject(:dumper) { described_class }
let(:foo) do
instance_double(Formula,
name: "foo",
desc: "foobar",
oldnames: ["oldfoo"],
full_name: "qux/quuz/foo",
any_version_installed?: true,
aliases: ["foobar"],
runtime_dependencies: [],
deps: [],
conflicts: [],
any_installed_prefix: nil,
linked?: false,
keg_only?: true,
pinned?: false,
outdated?: false,
stable: OpenStruct.new(bottle_defined?: false, bottled?: false),
tap: OpenStruct.new(official?: false))
end
let(:foo_hash) do
{
aliases: ["foobar"],
any_version_installed?: true,
args: [],
bottle: false,
bottled: false,
build_dependencies: [],
conflicts_with: [],
dependencies: [],
desc: "foobar",
full_name: "qux/quuz/foo",
installed_as_dependency?: false,
installed_on_request?: false,
link?: nil,
name: "foo",
oldnames: ["oldfoo"],
outdated?: false,
pinned?: false,
poured_from_bottle?: false,
version: nil,
official_tap: false,
}
end
let(:bar) do
linked_keg = Pathname("/usr/local").join("var").join("homebrew").join("linked").join("bar")
instance_double(Formula,
name: "bar",
desc: "barfoo",
oldnames: [],
full_name: "bar",
any_version_installed?: true,
aliases: [],
runtime_dependencies: [],
deps: [],
conflicts: [],
any_installed_prefix: nil,
linked?: true,
keg_only?: false,
pinned?: true,
outdated?: true,
linked_keg:,
stable: OpenStruct.new(bottle_defined?: true, bottled?: true),
tap: OpenStruct.new(official?: true),
bottle_hash: {
cellar: ":any",
files: {
big_sur: {
sha256: "abcdef",
url: "https://brew.sh//foo-1.0.big_sur.bottle.tar.gz",
},
},
})
end
let(:bar_hash) do
{
aliases: [],
any_version_installed?: true,
args: [],
bottle: {
cellar: ":any",
files: {
big_sur: {
sha256: "abcdef",
url: "https://brew.sh//foo-1.0.big_sur.bottle.tar.gz",
},
},
},
bottled: true,
build_dependencies: [],
conflicts_with: [],
dependencies: [],
desc: "barfoo",
full_name: "bar",
installed_as_dependency?: false,
installed_on_request?: false,
link?: nil,
name: "bar",
oldnames: [],
outdated?: true,
pinned?: true,
poured_from_bottle?: true,
version: "1.0",
official_tap: true,
}
end
let(:baz) do
instance_double(Formula,
name: "baz",
desc: "",
oldnames: [],
full_name: "bazzles/bizzles/baz",
any_version_installed?: true,
aliases: [],
runtime_dependencies: [OpenStruct.new(name: "bar")],
deps: [OpenStruct.new(name: "bar", build?: true)],
conflicts: [],
any_installed_prefix: nil,
linked?: false,
keg_only?: false,
pinned?: false,
outdated?: false,
stable: OpenStruct.new(bottle_defined?: false, bottled?: false),
tap: OpenStruct.new(official?: false))
end
let(:baz_hash) do
{
aliases: [],
any_version_installed?: true,
args: [],
bottle: false,
bottled: false,
build_dependencies: ["bar"],
conflicts_with: [],
dependencies: ["bar"],
desc: "",
full_name: "bazzles/bizzles/baz",
installed_as_dependency?: false,
installed_on_request?: false,
link?: false,
name: "baz",
oldnames: [],
outdated?: false,
pinned?: false,
poured_from_bottle?: false,
version: nil,
official_tap: false,
}
end
before do
described_class.reset!
end
describe "#formulae" do
it "returns an empty array when no formulae are installed" do
expect(dumper.formulae).to be_empty
end
end
describe "#formulae_by_full_name" do
it "returns an empty hash when no formulae are installed" do
expect(dumper.formulae_by_full_name).to eql({})
end
it "returns an empty hash for an unavailable formula" do
expect(Formula).to receive(:[]).with("bar").and_raise(FormulaUnavailableError.new("bar"))
expect(dumper.formulae_by_full_name("bar")).to eql({})
end
it "exits on cyclic exceptions" do
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
expect_any_instance_of(Homebrew::Bundle::BrewDumper::Topo).to receive(:tsort).and_raise(
TSort::Cyclic,
'topological sort failed: ["foo", "bar"]',
)
expect { dumper.formulae_by_full_name }.to raise_error(SystemExit)
end
it "returns a hash for a formula" do
expect(Formula).to receive(:[]).with("qux/quuz/foo").and_return(foo)
expect(dumper.formulae_by_full_name("qux/quuz/foo")).to eql(foo_hash)
end
it "returns an array for all formulae" do
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
expect(bar.linked_keg).to receive(:realpath).and_return(OpenStruct.new(basename: "1.0"))
expect(Tab).to receive(:for_keg).with(bar.linked_keg).and_return(
instance_double(Tab,
installed_as_dependency: false,
installed_on_request: false,
poured_from_bottle: true,
runtime_dependencies: [],
used_options: []),
)
expect(dumper.formulae_by_full_name).to eql({
"bar" => bar_hash,
"qux/quuz/foo" => foo_hash,
"bazzles/bizzles/baz" => baz_hash,
})
end
end
describe "#formulae_by_name" do
it "returns a hash for a formula" do
expect(Formula).to receive(:[]).with("foo").and_return(foo)
expect(dumper.formulae_by_name("foo")).to eql(foo_hash)
end
end
describe "#dump" do
it "returns a dump string with installed formulae" do
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
allow(Utils).to receive(:safe_popen_read).and_return("")
expected = <<~EOS
# barfoo
brew "bar"
brew "bazzles/bizzles/baz", link: false
# foobar
brew "qux/quuz/foo"
EOS
expect(dumper.dump(describe: true)).to eql(expected.chomp)
end
end
describe "#formula_aliases" do
it "returns an empty string when no formulae are installed" do
expect(dumper.formula_aliases).to eql({})
end
it "returns a hash with installed formulae aliases" do
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
expect(dumper.formula_aliases).to eql({
"qux/quuz/foobar" => "qux/quuz/foo",
"foobar" => "qux/quuz/foo",
})
end
end
describe "#formula_oldnames" do
it "returns an empty string when no formulae are installed" do
expect(dumper.formula_oldnames).to eql({})
end
it "returns a hash with installed formulae old names" do
expect(Formula).to receive(:installed).and_return([foo, bar, baz])
expect(dumper.formula_oldnames).to eql({
"qux/quuz/oldfoo" => "qux/quuz/foo",
"oldfoo" => "qux/quuz/foo",
})
end
end
end
# rubocop:enable Style/OpenStructUse

View File

@ -0,0 +1,555 @@
# frozen_string_literal: true
require "bundle"
require "formula"
RSpec.describe Homebrew::Bundle::BrewInstaller do
let(:formula_name) { "mysql" }
let(:options) { { args: ["with-option"] } }
let(:installer) { described_class.new(formula_name, options) }
before do
# don't try to load gcc/glibc
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
stub_formula_loader formula(formula_name) { url "mysql-1.0" }
end
context "when the formula is installed" do
before do
allow_any_instance_of(described_class).to receive(:installed?).and_return(true)
end
context "with a true start_service option" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
allow_any_instance_of(described_class).to receive(:installed?).and_return(true)
end
context "when service is already running" do
before do
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with(formula_name).and_return(true)
end
context "with a successful installation" do
it "start service" do
expect(Homebrew::Bundle::BrewServices).not_to receive(:start)
described_class.preinstall(formula_name, start_service: true)
described_class.install(formula_name, start_service: true)
end
end
context "with a skipped installation" do
it "start service" do
expect(Homebrew::Bundle::BrewServices).not_to receive(:start)
described_class.install(formula_name, preinstall: false, start_service: true)
end
end
end
context "when service is not running" do
before do
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with(formula_name).and_return(false)
end
context "with a successful installation" do
it "start service" do
expect(Homebrew::Bundle::BrewServices).to \
receive(:start).with(formula_name, verbose: false).and_return(true)
described_class.preinstall(formula_name, start_service: true)
described_class.install(formula_name, start_service: true)
end
end
context "with a skipped installation" do
it "start service" do
expect(Homebrew::Bundle::BrewServices).to \
receive(:start).with(formula_name, verbose: false).and_return(true)
described_class.install(formula_name, preinstall: false, start_service: true)
end
end
end
end
context "with an always restart_service option" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
allow_any_instance_of(described_class).to receive(:installed?).and_return(true)
end
context "with a successful installation" do
it "restart service" do
expect(Homebrew::Bundle::BrewServices).to \
receive(:restart).with(formula_name, verbose: false).and_return(true)
described_class.preinstall(formula_name, restart_service: :always)
described_class.install(formula_name, restart_service: :always)
end
end
context "with a skipped installation" do
it "restart service" do
expect(Homebrew::Bundle::BrewServices).to \
receive(:restart).with(formula_name, verbose: false).and_return(true)
described_class.install(formula_name, preinstall: false, restart_service: :always)
end
end
end
context "when the link option is true" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
end
it "links formula" do
allow_any_instance_of(described_class).to receive(:linked?).and_return(false)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "link", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: true)
described_class.install(formula_name, link: true)
end
it "force-links keg-only formula" do
allow_any_instance_of(described_class).to receive(:linked?).and_return(false)
allow_any_instance_of(described_class).to receive(:keg_only?).and_return(true)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "link", "--force", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: true)
described_class.install(formula_name, link: true)
end
end
context "when the link option is :overwrite" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
end
it "overwrite links formula" do
allow_any_instance_of(described_class).to receive(:linked?).and_return(false)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "link", "--overwrite", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: :overwrite)
described_class.install(formula_name, link: :overwrite)
end
end
context "when the link option is false" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
end
it "unlinks formula" do
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: false)
described_class.install(formula_name, link: false)
end
end
context "when the link option is nil and formula is unlinked and not keg-only" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
allow_any_instance_of(described_class).to receive(:linked?).and_return(false)
allow_any_instance_of(described_class).to receive(:keg_only?).and_return(false)
end
it "links formula" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "link", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: nil)
described_class.install(formula_name, link: nil)
end
end
context "when the link option is nil and formula is linked and keg-only" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
allow_any_instance_of(described_class).to receive(:keg_only?).and_return(true)
end
it "unlinks formula" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql",
verbose: false).and_return(true)
described_class.preinstall(formula_name, link: nil)
described_class.install(formula_name, link: nil)
end
end
context "when the conflicts_with option is provided" do
before do
allow(Homebrew::Bundle::BrewDumper).to receive(:formulae_by_full_name).and_call_original
allow(Homebrew::Bundle::BrewDumper).to receive(:formulae_by_full_name).with("mysql").and_return(
name: "mysql",
conflicts_with: ["mysql55"],
)
allow(described_class).to receive(:formula_installed?).and_return(true)
allow_any_instance_of(described_class).to receive(:install!).and_return(true)
allow_any_instance_of(described_class).to receive(:upgrade!).and_return(true)
end
it "unlinks conflicts and stops their services" do
verbose = false
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql55",
verbose:).and_return(true)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql56",
verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:stop).with("mysql55", verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:stop).with("mysql56", verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:restart).with(formula_name, verbose:).and_return(true)
described_class.preinstall(formula_name, restart_service: :always, conflicts_with: ["mysql56"])
described_class.install(formula_name, restart_service: :always, conflicts_with: ["mysql56"])
end
it "prints a message" do
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
allow_any_instance_of(described_class).to receive(:puts)
verbose = true
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql55",
verbose:).and_return(true)
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql56",
verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:stop).with("mysql55", verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:stop).with("mysql56", verbose:).and_return(true)
expect(Homebrew::Bundle::BrewServices).to receive(:restart).with(formula_name, verbose:).and_return(true)
described_class.preinstall(formula_name, restart_service: :always, conflicts_with: ["mysql56"], verbose: true)
described_class.install(formula_name, restart_service: :always, conflicts_with: ["mysql56"], verbose: true)
end
end
context "when the postinstall option is provided" do
before do
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true)
allow_any_instance_of(described_class).to receive(:installed?).and_return(true)
end
context "when formula has changed" do
before do
allow_any_instance_of(described_class).to receive(:changed?).and_return(true)
end
it "runs the postinstall command" do
expect(Kernel).to receive(:system).with("custom command").and_return(true)
described_class.preinstall(formula_name, postinstall: "custom command")
described_class.install(formula_name, postinstall: "custom command")
end
it "reports a failure" do
expect(Kernel).to receive(:system).with("custom command").and_return(false)
described_class.preinstall(formula_name, postinstall: "custom command")
expect(described_class.install(formula_name, postinstall: "custom command")).to be(false)
end
end
context "when formula has not changed" do
before do
allow_any_instance_of(described_class).to receive(:changed?).and_return(false)
end
it "does not run the postinstall command" do
expect(Kernel).not_to receive(:system)
described_class.preinstall(formula_name, postinstall: "custom command")
described_class.install(formula_name, postinstall: "custom command")
end
end
end
end
context "when a formula isn't installed" do
before do
allow_any_instance_of(described_class).to receive(:installed?).and_return(false)
allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(false)
end
it "did not call restart service" do
expect(Homebrew::Bundle::BrewServices).not_to receive(:restart)
described_class.preinstall(formula_name, restart_service: true)
end
end
describe ".outdated_formulae" do
it "calls Homebrew" do
described_class.reset!
expect(Homebrew::Bundle::BrewDumper).to receive(:formulae).and_return(
[
{ name: "a", outdated?: true },
{ name: "b", outdated?: true },
{ name: "c", outdated?: false },
],
)
expect(described_class.outdated_formulae).to eql(%w[a b])
end
end
describe ".pinned_formulae" do
it "calls Homebrew" do
described_class.reset!
expect(Homebrew::Bundle::BrewDumper).to receive(:formulae).and_return(
[
{ name: "a", pinned?: true },
{ name: "b", pinned?: true },
{ name: "c", pinned?: false },
],
)
expect(described_class.pinned_formulae).to eql(%w[a b])
end
end
describe ".formula_installed_and_up_to_date?" do
before do
Homebrew::Bundle::BrewDumper.reset!
described_class.reset!
allow(described_class).to receive(:outdated_formulae).and_return(%w[bar])
allow_any_instance_of(Formula).to receive(:outdated?).and_return(true)
allow(Homebrew::Bundle::BrewDumper).to receive(:formulae).and_return [
{
name: "foo",
full_name: "homebrew/tap/foo",
aliases: ["foobar"],
args: [],
version: "1.0",
dependencies: [],
requirements: [],
},
{
name: "bar",
full_name: "bar",
aliases: [],
args: [],
version: "1.0",
dependencies: [],
requirements: [],
},
]
stub_formula_loader formula("foo") { url "foo-1.0" }
stub_formula_loader formula("bar") { url "bar-1.0" }
end
it "returns result" do
expect(described_class.formula_installed_and_up_to_date?("foo")).to be(true)
expect(described_class.formula_installed_and_up_to_date?("foobar")).to be(true)
expect(described_class.formula_installed_and_up_to_date?("bar")).to be(false)
expect(described_class.formula_installed_and_up_to_date?("baz")).to be(false)
end
end
context "when brew is installed" do
context "when no formula is installed" do
before do
allow(described_class).to receive(:installed_formulae).and_return([])
allow_any_instance_of(described_class).to receive(:conflicts_with).and_return([])
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
end
it "install formula" do
expect(Homebrew::Bundle).to receive(:system)
.with(HOMEBREW_BREW_FILE, "install", "--formula", formula_name, "--with-option", verbose: false)
.and_return(true)
expect(installer.preinstall).to be(true)
expect(installer.install).to be(true)
end
it "reports a failure" do
expect(Homebrew::Bundle).to receive(:system)
.with(HOMEBREW_BREW_FILE, "install", "--formula", formula_name, "--with-option", verbose: false)
.and_return(false)
expect(installer.preinstall).to be(true)
expect(installer.install).to be(false)
end
end
context "when formula is installed" do
before do
allow(described_class).to receive(:installed_formulae).and_return([formula_name])
allow_any_instance_of(described_class).to receive(:conflicts_with).and_return([])
allow_any_instance_of(described_class).to receive(:linked?).and_return(true)
allow_any_instance_of(Formula).to receive(:outdated?).and_return(true)
end
context "when formula upgradable" do
before do
allow(described_class).to receive(:outdated_formulae).and_return([formula_name])
end
it "upgrade formula" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "upgrade", "--formula", formula_name, verbose: false)
.and_return(true)
expect(installer.preinstall).to be(true)
expect(installer.install).to be(true)
end
it "reports a failure" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "upgrade", "--formula", formula_name, verbose: false)
.and_return(false)
expect(installer.preinstall).to be(true)
expect(installer.install).to be(false)
end
context "when formula pinned" do
before do
allow(described_class).to receive(:pinned_formulae).and_return([formula_name])
end
it "does not upgrade formula" do
expect(Homebrew::Bundle).not_to \
receive(:system).with(HOMEBREW_BREW_FILE, "upgrade", "--formula", formula_name, verbose: false)
expect(installer.preinstall).to be(false)
end
end
context "when formula not upgraded" do
before do
allow(described_class).to receive(:outdated_formulae).and_return([])
end
it "does not upgrade formula" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(installer.preinstall).to be(false)
end
end
end
end
end
describe "#changed?" do
it "is false by default" do
expect(described_class.new(formula_name).changed?).to be(false)
end
end
describe "#start_service?" do
it "is false by default" do
expect(described_class.new(formula_name).start_service?).to be(false)
end
context "when the start_service option is true" do
it "is true" do
expect(described_class.new(formula_name, start_service: true).start_service?).to be(true)
end
end
end
describe "#start_service_needed?" do
context "when a service is already started" do
before do
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with(formula_name).and_return(true)
end
it "is false by default" do
expect(described_class.new(formula_name).start_service_needed?).to be(false)
end
it "is false with {start_service: true}" do
expect(described_class.new(formula_name, start_service: true).start_service_needed?).to be(false)
end
it "is false with {restart_service: true}" do
expect(described_class.new(formula_name, restart_service: true).start_service_needed?).to be(false)
end
it "is false with {restart_service: :changed}" do
expect(described_class.new(formula_name, restart_service: :changed).start_service_needed?).to be(false)
end
it "is false with {restart_service: :always}" do
expect(described_class.new(formula_name, restart_service: :always).start_service_needed?).to be(false)
end
end
context "when a service is not started" do
before do
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with(formula_name).and_return(false)
end
it "is false by default" do
expect(described_class.new(formula_name).start_service_needed?).to be(false)
end
it "is true if {start_service: true}" do
expect(described_class.new(formula_name, start_service: true).start_service_needed?).to be(true)
end
it "is true if {restart_service: true}" do
expect(described_class.new(formula_name, restart_service: true).start_service_needed?).to be(true)
end
it "is true if {restart_service: :changed}" do
expect(described_class.new(formula_name, restart_service: :changed).start_service_needed?).to be(true)
end
it "is true if {restart_service: :always}" do
expect(described_class.new(formula_name, restart_service: :always).start_service_needed?).to be(true)
end
end
end
describe "#restart_service?" do
it "is false by default" do
expect(described_class.new(formula_name).restart_service?).to be(false)
end
context "when the restart_service option is true" do
it "is true" do
expect(described_class.new(formula_name, restart_service: true).restart_service?).to be(true)
end
end
context "when the restart_service option is always" do
it "is true" do
expect(described_class.new(formula_name, restart_service: :always).restart_service?).to be(true)
end
end
context "when the restart_service option is changed" do
it "is true" do
expect(described_class.new(formula_name, restart_service: :changed).restart_service?).to be(true)
end
end
end
describe "#restart_service_needed?" do
it "is false by default" do
expect(described_class.new(formula_name).restart_service_needed?).to be(false)
end
context "when a service is unchanged" do
before do
allow_any_instance_of(described_class).to receive(:changed?).and_return(false)
end
it "is false with {restart_service: true}" do
expect(described_class.new(formula_name, restart_service: true).restart_service_needed?).to be(false)
end
it "is true with {restart_service: :always}" do
expect(described_class.new(formula_name, restart_service: :always).restart_service_needed?).to be(true)
end
it "is false if {restart_service: :changed}" do
expect(described_class.new(formula_name, restart_service: :changed).restart_service_needed?).to be(false)
end
end
context "when a service is changed" do
before do
allow_any_instance_of(described_class).to receive(:changed?).and_return(true)
end
it "is true with {restart_service: true}" do
expect(described_class.new(formula_name, restart_service: true).restart_service_needed?).to be(true)
end
it "is true with {restart_service: :always}" do
expect(described_class.new(formula_name, restart_service: :always).restart_service_needed?).to be(true)
end
it "is true if {restart_service: :changed}" do
expect(described_class.new(formula_name, restart_service: :changed).restart_service_needed?).to be(true)
end
end
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::BrewServices do
describe ".started_services" do
before do
described_class.reset!
end
it "is empty when brew services not installed" do
allow(Homebrew::Bundle).to receive(:services_installed?).and_return(false)
expect(described_class.started_services).to be_empty
end
it "returns started services" do
allow(Homebrew::Bundle).to receive(:services_installed?).and_return(true)
allow(Utils).to receive(:safe_popen_read).and_return <<~EOS
nginx started homebrew.mxcl.nginx.plist
apache stopped homebrew.mxcl.apache.plist
mysql started homebrew.mxcl.mysql.plist
EOS
expect(described_class.started_services).to contain_exactly("nginx", "mysql")
end
end
context "when brew-services is installed" do
context "when the service is stopped" do
it "when the service is started" do
allow(described_class).to receive(:started_services).and_return(%w[nginx])
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "services", "stop", "nginx",
verbose: false).and_return(true)
expect(described_class.stop("nginx")).to be(true)
expect(described_class.started_services).not_to include("nginx")
end
it "when the service is already stopped" do
allow(described_class).to receive(:started_services).and_return(%w[])
expect(Homebrew::Bundle).not_to receive(:system).with(HOMEBREW_BREW_FILE, "services", "stop", "nginx",
verbose: false)
expect(described_class.stop("nginx")).to be(true)
expect(described_class.started_services).not_to include("nginx")
end
end
it "starts the service" do
allow(described_class).to receive(:started_services).and_return([])
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "services", "start", "nginx",
verbose: false).and_return(true)
expect(described_class.start("nginx")).to be(true)
expect(described_class.started_services).to include("nginx")
end
it "restarts the service" do
allow(described_class).to receive(:started_services).and_return([])
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "services", "restart", "nginx",
verbose: false).and_return(true)
expect(described_class.restart("nginx")).to be(true)
expect(described_class.started_services).to include("nginx")
end
end
end

View File

@ -0,0 +1,199 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Brewfile do
describe "path" do
subject(:path) do
described_class.path(dash_writes_to_stdout:, global: has_global, file: file_value)
end
let(:dash_writes_to_stdout) { false }
let(:env_bundle_file_global_value) { nil }
let(:env_bundle_file_value) { nil }
let(:env_user_config_home_value) { "/Users/username/.homebrew" }
let(:file_value) { nil }
let(:has_global) { false }
let(:config_dir_brewfile_exist) { false }
before do
allow(ENV).to receive(:fetch).and_return(nil)
allow(ENV).to receive(:fetch).with("HOMEBREW_BUNDLE_FILE_GLOBAL", any_args)
.and_return(env_bundle_file_global_value)
allow(ENV).to receive(:fetch).with("HOMEBREW_BUNDLE_FILE", any_args)
.and_return(env_bundle_file_value)
allow(ENV).to receive(:fetch).with("HOMEBREW_USER_CONFIG_HOME", any_args)
.and_return(env_user_config_home_value)
allow(File).to receive(:exist?).with("/Users/username/.homebrew/Brewfile")
.and_return(config_dir_brewfile_exist)
end
context "when `file` is specified with a relative path" do
let(:file_value) { "path/to/Brewfile" }
let(:expected_pathname) { Pathname.new(file_value).expand_path(Dir.pwd) }
it "returns the expected path" do
expect(path).to eq(expected_pathname)
end
context "with a configured HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "with an empty HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
end
context "when `file` is specified with an absolute path" do
let(:file_value) { "/tmp/random_file" }
let(:expected_pathname) { Pathname.new(file_value) }
it "returns the expected path" do
expect(path).to eq(expected_pathname)
end
context "with a configured HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "with an empty HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
end
context "when `file` is specified with `-`" do
let(:file_value) { "-" }
let(:expected_pathname) { Pathname.new("/dev/stdin") }
it "returns stdin by default" do
expect(path).to eq(expected_pathname)
end
context "with a configured HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "with an empty HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "when `dash_writes_to_stdout` is true" do
let(:expected_pathname) { Pathname.new("/dev/stdout") }
let(:dash_writes_to_stdout) { true }
it "returns stdout" do
expect(path).to eq(expected_pathname)
end
context "with a configured HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "with an empty HOMEBREW_BUNDLE_FILE" do
let(:env_bundle_file_value) { "" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
end
end
context "when `global` is true" do
let(:has_global) { true }
let(:expected_pathname) { Pathname.new("#{Dir.home}/.Brewfile") }
it "returns the expected path" do
expect(path).to eq(expected_pathname)
end
context "when HOMEBREW_BUNDLE_FILE_GLOBAL is set" do
let(:env_bundle_file_global_value) { "/path/to/Brewfile" }
let(:expected_pathname) { Pathname.new(env_bundle_file_global_value) }
it "returns the value specified by the environment variable" do
expect(path).to eq(expected_pathname)
end
end
context "when HOMEBREW_BUNDLE_FILE is set" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the value specified by the variable" do
expect { path }.to raise_error(RuntimeError)
end
end
context "when HOMEBREW_BUNDLE_FILE is `` (empty)" do
let(:env_bundle_file_value) { "" }
it "returns the value specified by `file` path" do
expect(path).to eq(expected_pathname)
end
end
context "when HOMEBREW_USER_CONFIG_HOME/Brewfile exists" do
let(:config_dir_brewfile_exist) { true }
let(:expected_pathname) { Pathname.new("#{env_user_config_home_value}/Brewfile") }
it "returns the expected path" do
expect(path).to eq(expected_pathname)
end
end
end
context "when HOMEBREW_BUNDLE_FILE has a value" do
let(:env_bundle_file_value) { "/path/to/Brewfile" }
it "returns the expected path" do
expect(path).to eq(Pathname.new(env_bundle_file_value))
end
describe "that is `` (empty)" do
let(:env_bundle_file_value) { "" }
it "defaults to `${PWD}/Brewfile`" do
expect(path).to eq(Pathname.new("Brewfile").expand_path(Dir.pwd))
end
end
describe "that is `nil`" do
let(:env_bundle_file_value) { nil }
it "defaults to `${PWD}/Brewfile`" do
expect(path).to eq(Pathname.new("Brewfile").expand_path(Dir.pwd))
end
end
end
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle do
context "when the system call succeeds" do
it "omits all stdout output if verbose is false" do
expect { described_class.system "echo", "foo", verbose: false }.not_to output.to_stdout_from_any_process
end
it "emits all stdout output if verbose is true" do
expect { described_class.system "echo", "foo", verbose: true }.to output("foo\n").to_stdout_from_any_process
end
end
context "when the system call fails" do
it "emits all stdout output even if verbose is false" do
expect do
described_class.system "/bin/bash", "-c", "echo foo && false",
verbose: false
end.to output("foo\n").to_stdout_from_any_process
end
it "emits all stdout output only once if verbose is true" do
expect do
described_class.system "/bin/bash", "-c", "echo foo && true",
verbose: true
end.to output("foo\n").to_stdout_from_any_process
end
end
context "when checking for homebrew/cask", :needs_macos do
it "finds it when present" do
allow(File).to receive(:directory?).with("#{HOMEBREW_PREFIX}/Caskroom").and_return(true)
allow(File).to receive(:directory?)
.with("#{HOMEBREW_LIBRARY}/Taps/homebrew/homebrew-cask")
.and_return(true)
expect(described_class.cask_installed?).to be(true)
end
end
context "when checking for brew services", :needs_macos do
it "finds it when present" do
allow(described_class).to receive(:which).and_return(true)
expect(described_class.services_installed?).to be(true)
end
end
context "when checking for mas", :needs_macos do
it "finds it when present" do
stub_formula_loader formula("mas") { url "mas-1.0" }
allow(described_class).to receive(:which).and_return(true)
expect(described_class.mas_installed?).to be(true)
end
end
end

View File

@ -0,0 +1,122 @@
# frozen_string_literal: true
require "bundle"
require "cask"
RSpec.describe Homebrew::Bundle::CaskDumper do
subject(:dumper) { described_class }
context "when brew-cask is not installed" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(false)
end
it "returns empty list" do
expect(dumper.cask_names).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
end
context "when there is no cask" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
allow(described_class).to receive(:`).and_return("")
end
it "returns empty list" do
expect(dumper.cask_names).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
it "doesnt want to greedily update a non-installed cask" do
expect(dumper.cask_is_outdated_using_greedy?("foo")).to be(false)
end
end
context "when casks `foo`, `bar` and `baz` are installed, with `baz` being a formula requirement" do
let(:foo) { instance_double(Cask::Cask, to_s: "foo", desc: nil, config: nil) }
let(:baz) { instance_double(Cask::Cask, to_s: "baz", desc: "Software", config: nil) }
let(:bar) do
instance_double(
Cask::Cask, to_s: "bar",
desc: nil,
config: instance_double(
Cask::Config,
explicit: {
fontdir: "/Library/Fonts",
languages: ["zh-TW"],
},
)
)
end
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
allow(Cask::Caskroom).to receive(:casks).and_return([foo, bar, baz])
end
it "returns list %w[foo bar baz]" do
expect(dumper.cask_names).to eql(%w[foo bar baz])
end
it "dumps as `cask 'baz'` and `cask 'foo' cask 'bar'` plus descriptions and config values" do
expected = <<~EOS
cask "foo"
cask "bar", args: { fontdir: "/Library/Fonts", language: "zh-TW" }
# Software
cask "baz"
EOS
expect(dumper.dump(describe: true)).to eql(expected.chomp)
end
it "doesnt want to greedily update a non-installed cask" do
expect(dumper.cask_is_outdated_using_greedy?("qux")).to be(false)
end
it "wants to greedily update foo if there is an update available" do
expect(foo).to receive(:outdated?).with(greedy: true).and_return(true)
expect(dumper.cask_is_outdated_using_greedy?("foo")).to be(true)
end
it "does not want to greedily update bar if there is no update available" do
expect(bar).to receive(:outdated?).with(greedy: true).and_return(false)
expect(dumper.cask_is_outdated_using_greedy?("bar")).to be(false)
end
end
describe "#formula_dependencies" do
context "when the given casks don't have formula dependencies" do
before do
described_class.reset!
end
it "returns an empty array" do
expect(dumper.formula_dependencies(["foo"])).to eql([])
end
end
context "when multiple casks have the same dependency" do
before do
described_class.reset!
foo = instance_double(Cask::Cask, to_s: "foo", depends_on: { formula: ["baz", "qux"] })
bar = instance_double(Cask::Cask, to_s: "bar", depends_on: {})
allow(Cask::Caskroom).to receive(:casks).and_return([foo, bar])
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
end
it "returns an array of unique formula dependencies" do
expect(dumper.formula_dependencies(["foo", "bar"])).to eql(["baz", "qux"])
end
end
end
end

View File

@ -0,0 +1,162 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::CaskInstaller do
describe ".installed_casks" do
before do
Homebrew::Bundle::CaskDumper.reset!
end
it "shells out" do
expect { described_class.installed_casks }.not_to raise_error
end
end
describe ".cask_installed_and_up_to_date?" do
it "returns result" do
described_class.reset!
allow(described_class).to receive_messages(installed_casks: ["foo", "baz"],
outdated_casks: ["baz"])
expect(described_class.cask_installed_and_up_to_date?("foo")).to be(true)
expect(described_class.cask_installed_and_up_to_date?("baz")).to be(false)
end
end
context "when brew-cask is not installed" do
describe ".outdated_casks" do
it "returns empty array" do
described_class.reset!
expect(described_class.outdated_casks).to eql([])
end
end
end
context "when brew-cask is installed" do
before do
Homebrew::Bundle::CaskDumper.reset!
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
end
describe ".outdated_casks" do
it "returns empty array" do
described_class.reset!
expect(described_class.outdated_casks).to eql([])
end
end
context "when cask is installed" do
before do
Homebrew::Bundle::CaskDumper.reset!
allow(described_class).to receive(:installed_casks).and_return(["google-chrome"])
end
it "skips" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(described_class.preinstall("google-chrome")).to be(false)
end
end
context "when cask is outdated" do
before do
allow(described_class).to receive_messages(installed_casks: ["google-chrome"],
outdated_casks: ["google-chrome"])
end
it "upgrades" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "upgrade", "--cask", "google-chrome", verbose: false)
.and_return(true)
expect(described_class.preinstall("google-chrome")).to be(true)
expect(described_class.install("google-chrome")).to be(true)
end
end
context "when cask is outdated and uses auto-update" do
before do
described_class.reset!
allow(Homebrew::Bundle::CaskDumper).to receive_messages(cask_names: ["opera"], outdated_cask_names: [])
allow(Homebrew::Bundle::CaskDumper).to receive(:cask_is_outdated_using_greedy?).with("opera").and_return(true)
end
it "upgrades" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "upgrade", "--cask", "opera", verbose: false)
.and_return(true)
expect(described_class.preinstall("opera", greedy: true)).to be(true)
expect(described_class.install("opera", greedy: true)).to be(true)
end
end
context "when cask is not installed" do
before do
allow(described_class).to receive(:installed_casks).and_return([])
end
it "installs cask" do
expect(Homebrew::Bundle).to receive(:brew).with("install", "--cask", "google-chrome", "--adopt",
verbose: false)
.and_return(true)
expect(described_class.preinstall("google-chrome")).to be(true)
expect(described_class.install("google-chrome")).to be(true)
end
it "installs cask with arguments" do
expect(Homebrew::Bundle).to(
receive(:brew).with("install", "--cask", "firefox", "--appdir=/Applications", "--adopt",
verbose: false)
.and_return(true),
)
expect(described_class.preinstall("firefox", args: { appdir: "/Applications" })).to be(true)
expect(described_class.install("firefox", args: { appdir: "/Applications" })).to be(true)
end
it "reports a failure" do
expect(Homebrew::Bundle).to receive(:brew).with("install", "--cask", "google-chrome", "--adopt",
verbose: false)
.and_return(false)
expect(described_class.preinstall("google-chrome")).to be(true)
expect(described_class.install("google-chrome")).to be(false)
end
context "with boolean arguments" do
it "includes a flag if true" do
expect(Homebrew::Bundle).to receive(:brew).with("install", "--cask", "iterm", "--force",
verbose: false)
.and_return(true)
expect(described_class.preinstall("iterm", args: { force: true })).to be(true)
expect(described_class.install("iterm", args: { force: true })).to be(true)
end
it "does not include a flag if false" do
expect(Homebrew::Bundle).to receive(:brew).with("install", "--cask", "iterm", "--adopt", verbose: false)
.and_return(true)
expect(described_class.preinstall("iterm", args: { force: false })).to be(true)
expect(described_class.install("iterm", args: { force: false })).to be(true)
end
end
end
context "when the postinstall option is provided" do
before do
Homebrew::Bundle::CaskDumper.reset!
allow(Homebrew::Bundle::CaskDumper).to receive_messages(cask_names: ["google-chrome"],
outdated_cask_names: ["google-chrome"])
allow(Homebrew::Bundle).to receive(:brew).and_return(true)
allow(described_class).to receive(:upgrading?).and_return(true)
end
it "runs the postinstall command" do
expect(Kernel).to receive(:system).with("custom command").and_return(true)
expect(described_class.preinstall("google-chrome", postinstall: "custom command")).to be(true)
expect(described_class.install("google-chrome", postinstall: "custom command")).to be(true)
end
it "reports a failure when postinstall fails" do
expect(Kernel).to receive(:system).with("custom command").and_return(false)
expect(described_class.preinstall("google-chrome", postinstall: "custom command")).to be(true)
expect(described_class.install("google-chrome", postinstall: "custom command")).to be(false)
end
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require "bundle"
require "cask/cask_loader"
RSpec.describe Homebrew::Bundle::Commands::Add do
subject(:add) do
described_class.run(*args, type:, global:, file:)
end
before { FileUtils.touch file }
after { FileUtils.rm_f file }
let(:global) { false }
context "when called with a valid formula" do
let(:args) { ["hello"] }
let(:type) { :brew }
let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
before do
stub_formula_loader formula("hello") { url "hello-1.0" }
end
it "adds entries to the given Brewfile" do
expect { add }.not_to raise_error
expect(File.read(file)).to include("#{type} \"#{args.first}\"")
end
end
context "when called with a valid cask" do
let(:args) { ["alacritty"] }
let(:type) { :cask }
let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
before do
stub_cask_loader Cask::CaskLoader::FromContentLoader.new(+<<~RUBY).load(config: nil)
cask "alacritty" do
version "1.0"
end
RUBY
end
it "adds entries to the given Brewfile" do
expect { add }.not_to raise_error
expect(File.read(file)).to include("#{type} \"#{args.first}\"")
end
end
end

View File

@ -0,0 +1,286 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::Check do
let(:do_check) do
described_class.run(no_upgrade:, verbose:)
end
let(:no_upgrade) { false }
let(:verbose) { false }
before do
Homebrew::Bundle::Checker.reset!
allow_any_instance_of(IO).to receive(:puts)
stub_formula_loader formula("mas") { url "mas-1.0" }
end
context "when dependencies are satisfied" do
it "does not raise an error" do
allow_any_instance_of(Pathname).to receive(:read).and_return("")
nothing = []
allow(Homebrew::Bundle::Checker).to receive_messages(casks_to_install: nothing,
formulae_to_install: nothing,
apps_to_install: nothing,
taps_to_tap: nothing,
extensions_to_install: nothing)
expect { do_check }.not_to raise_error
end
end
context "when no dependencies are specified" do
it "does not raise an error" do
allow_any_instance_of(Pathname).to receive(:read).and_return("")
allow_any_instance_of(Homebrew::Bundle::Dsl).to receive(:entries).and_return([])
expect { do_check }.not_to raise_error
end
end
context "when casks are not installed", :needs_macos do
it "raises an error" do
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow_any_instance_of(Pathname).to receive(:read).and_return("cask 'abc'")
expect { do_check }.to raise_error(SystemExit)
end
end
context "when formulae are not installed" do
it "raises an error" do
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow_any_instance_of(Pathname).to receive(:read).and_return("brew 'abc'")
expect { do_check }.to raise_error(SystemExit)
end
it "does not raise error on skippable formula" do
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow(Homebrew::Bundle::Skipper).to receive(:skip?).and_return(true)
allow_any_instance_of(Pathname).to receive(:read).and_return("brew 'abc'")
expect { do_check }.not_to raise_error
end
end
context "when taps are not tapped" do
it "raises an error" do
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow_any_instance_of(Pathname).to receive(:read).and_return("tap 'abc/def'")
expect { do_check }.to raise_error(SystemExit)
end
end
context "when apps are not installed", :needs_macos do
it "raises an error" do
allow_any_instance_of(Homebrew::Bundle::MacAppStoreDumper).to receive(:app_ids).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow_any_instance_of(Pathname).to receive(:read).and_return("mas 'foo', id: 123")
expect { do_check }.to raise_error(SystemExit)
end
end
context "when service is not started and app not installed" do
let(:verbose) { true }
let(:expected_output) do
<<~MSG
brew bundle can't satisfy your Brewfile's dependencies.
App foo needs to be installed or updated.
Service def needs to be started.
Satisfy missing dependencies with `brew bundle install`.
MSG
end
before do
Homebrew::Bundle::Checker.reset!
allow_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker).to \
receive(:installed_and_up_to_date?).and_return(false)
allow(Homebrew::Bundle::BrewInstaller).to receive_messages(installed_formulae: ["abc", "def"],
upgradable_formulae: [])
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with("abc").and_return(true)
allow(Homebrew::Bundle::BrewServices).to receive(:started?).with("def").and_return(false)
end
it "does not raise error when no service needs to be started" do
Homebrew::Bundle::Checker.reset!
allow_any_instance_of(Pathname).to receive(:read).and_return("brew 'abc'")
expect(Homebrew::Bundle::BrewInstaller.installed_formulae).to include("abc")
expect(Homebrew::Bundle::CaskInstaller.installed_casks).not_to include("abc")
expect(Homebrew::Bundle::BrewServices.started?("abc")).to be(true)
expect { do_check }.not_to raise_error
end
context "when restart_service is true" do
it "raises an error" do
allow_any_instance_of(Pathname)
.to receive(:read).and_return("brew 'abc', restart_service: true\nbrew 'def', restart_service: true")
allow_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker)
.to receive(:format_checkable).and_return(1 => "foo")
expect { do_check }.to raise_error(SystemExit).and output(expected_output).to_stdout
end
end
context "when start_service is true" do
it "raises an error" do
allow_any_instance_of(Pathname)
.to receive(:read).and_return("brew 'abc', start_service: true\nbrew 'def', start_service: true")
allow_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker)
.to receive(:format_checkable).and_return(1 => "foo")
expect { do_check }.to raise_error(SystemExit).and output(expected_output).to_stdout
end
end
end
context "when app not installed and `no_upgrade` is true" do
let(:expected_output) do
<<~MSG
brew bundle can't satisfy your Brewfile's dependencies.
App foo needs to be installed.
Satisfy missing dependencies with `brew bundle install`.
MSG
end
let(:no_upgrade) { true }
let(:verbose) { true }
before do
Homebrew::Bundle::Checker.reset!
allow_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker).to \
receive(:installed_and_up_to_date?).and_return(false)
allow(Homebrew::Bundle::BrewInstaller).to receive(:installed_formulae).and_return(["abc", "def"])
end
it "raises an error that doesn't mention upgrade" do
allow_any_instance_of(Pathname).to receive(:read).and_return("brew 'abc'")
allow_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker).to \
receive(:format_checkable).and_return(1 => "foo")
expect { do_check }.to raise_error(SystemExit).and output(expected_output).to_stdout
end
end
context "when extension not installed" do
let(:expected_output) do
<<~MSG
brew bundle can't satisfy your Brewfile's dependencies.
VSCode Extension foo needs to be installed.
Satisfy missing dependencies with `brew bundle install`.
MSG
end
let(:verbose) { true }
before do
Homebrew::Bundle::Checker.reset!
allow_any_instance_of(Homebrew::Bundle::Checker::VscodeExtensionChecker).to \
receive(:installed_and_up_to_date?).and_return(false)
end
it "raises an error that doesn't mention upgrade" do
allow_any_instance_of(Pathname).to receive(:read).and_return("vscode 'foo'")
expect { do_check }.to raise_error(SystemExit).and output(expected_output).to_stdout
end
end
context "when there are taps to install" do
before do
allow_any_instance_of(Pathname).to receive(:read).and_return("")
allow(Homebrew::Bundle::Checker).to receive(:taps_to_tap).and_return(["asdf"])
end
it "does not check for casks" do
expect(Homebrew::Bundle::Checker).not_to receive(:casks_to_install)
expect { do_check }.to raise_error(SystemExit)
end
it "does not check for formulae" do
expect(Homebrew::Bundle::Checker).not_to receive(:formulae_to_install)
expect { do_check }.to raise_error(SystemExit)
end
it "does not check for apps" do
expect(Homebrew::Bundle::Checker).not_to receive(:apps_to_install)
expect { do_check }.to raise_error(SystemExit)
end
end
context "when there are VSCode extensions to install" do
before do
allow_any_instance_of(Pathname).to receive(:read).and_return("")
allow(Homebrew::Bundle::Checker).to receive(:extensions_to_install).and_return(["asdf"])
end
it "does not check for formulae" do
expect(Homebrew::Bundle::Checker).not_to receive(:formulae_to_install)
expect { do_check }.to raise_error(SystemExit)
end
it "does not check for apps" do
expect(Homebrew::Bundle::Checker).not_to receive(:apps_to_install)
expect { do_check }.to raise_error(SystemExit)
end
end
context "when there are formulae to install" do
before do
allow_any_instance_of(Pathname).to receive(:read).and_return("")
allow(Homebrew::Bundle::Checker).to \
receive_messages(taps_to_tap: [],
casks_to_install: [],
apps_to_install: [],
formulae_to_install: ["one"])
end
it "does not start formulae" do
expect(Homebrew::Bundle::Checker).not_to receive(:formulae_to_start)
expect { do_check }.to raise_error(SystemExit)
end
end
context "when verbose mode is not enabled" do
it "stops checking after the first missing formula" do
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([])
allow(Homebrew::Bundle::BrewInstaller).to receive(:upgradable_formulae).and_return([])
allow_any_instance_of(Pathname).to receive(:read).and_return("brew 'abc'\nbrew 'def'")
expect_any_instance_of(Homebrew::Bundle::Checker::BrewChecker).to \
receive(:exit_early_check).once.and_call_original
expect { do_check }.to raise_error(SystemExit)
end
it "stops checking after the first missing cask", :needs_macos do
allow_any_instance_of(Pathname).to receive(:read).and_return("cask 'abc'\ncask 'def'")
expect_any_instance_of(Homebrew::Bundle::Checker::CaskChecker).to \
receive(:exit_early_check).once.and_call_original
expect { do_check }.to raise_error(SystemExit)
end
it "stops checking after the first missing mac app", :needs_macos do
allow_any_instance_of(Pathname).to receive(:read).and_return("mas 'foo', id: 123\nmas 'bar', id: 456")
expect_any_instance_of(Homebrew::Bundle::Checker::MacAppStoreChecker).to \
receive(:exit_early_check).once.and_call_original
expect { do_check }.to raise_error(SystemExit)
end
it "stops checking after the first VSCode extension" do
allow_any_instance_of(Pathname).to receive(:read).and_return("vscode 'abc'\nvscode 'def'")
expect_any_instance_of(Homebrew::Bundle::Checker::VscodeExtensionChecker).to \
receive(:exit_early_check).once.and_call_original
expect { do_check }.to raise_error(SystemExit)
end
end
context "when a new checker fails to implement installed_and_up_to_date" do
it "raises an exception" do
stub_const("TestChecker", Class.new(Homebrew::Bundle::Checker::Base) do
class_eval("PACKAGE_TYPE = :test", __FILE__, __LINE__)
end.freeze)
test_entry = Homebrew::Bundle::Dsl::Entry.new(:test, "test")
expect { TestChecker.new.find_actionable([test_entry]) }.to raise_error(NotImplementedError)
end
end
end

View File

@ -0,0 +1,4 @@
# typed: strict
class TestChecker < Homebrew::Bundle::Checker::Base
end

View File

@ -0,0 +1,256 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::Cleanup do
describe "read Brewfile and current installation" do
before do
described_class.reset!
# don't try to load gcc/glibc
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
allow_any_instance_of(Pathname).to receive(:read).and_return <<~EOS
tap 'x'
tap 'y'
cask '123'
brew 'a'
brew 'b'
brew 'd2'
brew 'homebrew/tap/f'
brew 'homebrew/tap/g'
brew 'homebrew/tap/h'
brew 'homebrew/tap/i2'
brew 'homebrew/tap/hasdependency'
brew 'hasbuilddependency1'
brew 'hasbuilddependency2'
mas 'appstoreapp1', id: 1
vscode 'VsCodeExtension1'
EOS
%w[a b d2 homebrew/tap/f homebrew/tap/g homebrew/tap/h homebrew/tap/i2
homebrew/tap/hasdependency hasbuilddependency1 hasbuilddependency2].each do |full_name|
tap_name, _, name = full_name.rpartition("/")
tap = tap_name.present? ? Tap.fetch(tap_name) : nil
f = formula(name, tap:) { url "#{name}-1.0" }
stub_formula_loader f, full_name
end
end
it "computes which casks to uninstall" do
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return(%w[123 456])
expect(described_class.casks_to_uninstall).to eql(%w[456])
end
it "computes which formulae to uninstall" do
dependencies_arrays_hash = { dependencies: [], build_dependencies: [] }
allow(Homebrew::Bundle::BrewDumper).to receive(:formulae).and_return [
{ name: "a2", full_name: "a2", aliases: ["a"], dependencies: ["d"] },
{ name: "c", full_name: "c" },
{ name: "d", full_name: "homebrew/tap/d", aliases: ["d2"] },
{ name: "e", full_name: "homebrew/tap/e" },
{ name: "f", full_name: "homebrew/tap/f" },
{ name: "h", full_name: "other/tap/h" },
{ name: "i", full_name: "homebrew/tap/i", aliases: ["i2"] },
{ name: "hasdependency", full_name: "homebrew/tap/hasdependency", dependencies: ["isdependency"] },
{ name: "isdependency", full_name: "homebrew/tap/isdependency" },
{
name: "hasbuilddependency1",
full_name: "hasbuilddependency1",
poured_from_bottle?: true,
build_dependencies: ["builddependency1"],
},
{
name: "hasbuilddependency2",
full_name: "hasbuilddependency2",
poured_from_bottle?: false,
build_dependencies: ["builddependency2"],
},
{ name: "builddependency1", full_name: "builddependency1" },
{ name: "builddependency2", full_name: "builddependency2" },
{ name: "caskdependency", full_name: "homebrew/tap/caskdependency" },
].map { |formula| dependencies_arrays_hash.merge(formula) }
allow(Homebrew::Bundle::CaskDumper).to receive(:formula_dependencies).and_return(%w[caskdependency])
expect(described_class.formulae_to_uninstall).to eql %w[
c
homebrew/tap/e
other/tap/h
builddependency1
]
end
it "computes which tap to untap" do
allow(Homebrew::Bundle::TapDumper).to \
receive(:tap_names).and_return(%w[z homebrew/bundle homebrew/core homebrew/tap])
expect(described_class.taps_to_untap).to eql(%w[z])
end
it "ignores unavailable formulae when computing which taps to keep" do
allow(Formulary).to \
receive(:factory).and_raise(TapFormulaUnavailableError.new(Tap.fetch("homebrew/tap"), "foo"))
allow(Homebrew::Bundle::TapDumper).to \
receive(:tap_names).and_return(%w[z homebrew/bundle homebrew/core homebrew/tap])
expect(described_class.taps_to_untap).to eql(%w[z homebrew/tap])
end
it "computes which VSCode extensions to uninstall" do
allow(Homebrew::Bundle::VscodeExtensionDumper).to receive(:extensions).and_return(%w[z])
expect(described_class.vscode_extensions_to_uninstall).to eql(%w[z])
end
it "computes which VSCode extensions to uninstall irrespective of case of the extension name" do
allow(Homebrew::Bundle::VscodeExtensionDumper).to receive(:extensions).and_return(%w[z vscodeextension1])
expect(described_class.vscode_extensions_to_uninstall).to eql(%w[z])
end
end
context "when there are no formulae to uninstall and no taps to untap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "does nothing" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
end
context "when there are casks to uninstall" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls casks" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true) }.to output(/Uninstalled 2 casks/).to_stdout
end
end
context "when there are casks to zap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls casks" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--zap", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true, zap: true) }.to output(/Uninstalled 2 casks/).to_stdout
end
end
context "when there are formulae to uninstall" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: %w[a b],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls formulae" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true) }.to output(/Uninstalled 2 formulae/).to_stdout
end
end
context "when there are taps to untap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: %w[a b],
vscode_extensions_to_uninstall: [])
end
it "untaps taps" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
end
context "when there are VSCode extensions to uninstall" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: %w[GitHub.codespaces])
end
it "uninstalls extensions" do
expect(Kernel).to receive(:system).with("code", "--uninstall-extension", "GitHub.codespaces")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
end
context "when there are casks and formulae to uninstall and taps to untap but without passing `--force`" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: %w[a b],
taps_to_untap: %w[a b],
vscode_extensions_to_uninstall: %w[a b])
end
it "lists casks, formulae and taps" do
expect(Formatter).to receive(:columns).with(%w[a b]).exactly(4).times
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect do
described_class.run
end.to raise_error(SystemExit)
.and output(/Would uninstall formulae:.*Would untap:.*Would uninstall VSCode extensions:/m).to_stdout
end
end
context "when there is brew cleanup output" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
def sane?
expect(described_class).to receive(:system_output_no_stderr).and_return("cleaned")
end
context "with --force" do
it "prints output" do
sane?
expect { described_class.run(force: true) }.to output(/cleaned/).to_stdout
end
end
context "without --force" do
it "prints output" do
sane?
expect { described_class.run }.to output(/cleaned/).to_stdout
end
end
end
describe "#system_output_no_stderr" do
it "shells out" do
expect(IO).to receive(:popen).and_return(StringIO.new("true"))
described_class.system_output_no_stderr("true")
end
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::Dump do
subject(:dump) do
described_class.run(global:, file: nil, describe: false, force:, no_restart: false, taps: true, brews: true,
casks: true, mas: true, whalebrew: true, vscode: true)
end
let(:force) { false }
let(:global) { false }
before do
Homebrew::Bundle::CaskDumper.reset!
Homebrew::Bundle::BrewDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::WhalebrewDumper.reset!
Homebrew::Bundle::VscodeExtensionDumper.reset!
end
context "when files existed" do
before do
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
end
it "raises error" do
expect do
dump
end.to raise_error(RuntimeError)
end
it "exits before doing any work" do
expect(Homebrew::Bundle::TapDumper).not_to receive(:dump)
expect(Homebrew::Bundle::BrewDumper).not_to receive(:dump)
expect(Homebrew::Bundle::CaskDumper).not_to receive(:dump)
expect(Homebrew::Bundle::WhalebrewDumper).not_to receive(:dump)
expect do
dump
end.to raise_error(RuntimeError)
end
end
context "when files existed and `--force` and `--global` are passed" do
let(:force) { true }
let(:global) { true }
before do
ENV["HOMEBREW_BUNDLE_FILE"] = ""
allow_any_instance_of(Pathname).to receive(:exist?).and_return(true)
allow(Homebrew::Bundle).to receive(:cask_installed?).and_return(true)
allow(Cask::Caskroom).to receive(:casks).and_return([])
# don't try to load gcc/glibc
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
stub_formula_loader formula("mas") { url "mas-1.0" }
stub_formula_loader formula("whalebrew") { url "whalebrew-1.0" }
end
it "doesn't raise error" do
io = instance_double(File, write: true)
expect_any_instance_of(Pathname).to receive(:open).with("w").and_yield(io)
expect(io).to receive(:write)
expect { dump }.not_to raise_error
end
end
end

View File

@ -0,0 +1,126 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::Exec do
context "when a Brewfile is not found" do
it "raises an error" do
expect { described_class.run }.to raise_error(RuntimeError)
end
end
context "when a Brewfile is found" do
let(:brewfile_contents) { "brew 'openssl'" }
before do
allow_any_instance_of(Pathname).to receive(:read)
.and_return(brewfile_contents)
# don't try to load gcc/glibc
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
stub_formula_loader formula("openssl") { url "openssl-1.0" }
stub_formula_loader formula("pkgconf") { url "pkgconf-1.0" }
ENV.extend(Superenv)
allow(ENV).to receive(:setup_build_environment)
end
context "with valid command setup" do
before do
allow(described_class).to receive(:exec).and_return(nil)
end
it "does not raise an error" do
expect { described_class.run("bundle", "install") }.not_to raise_error
end
it "does not raise an error when HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS is set" do
ENV["HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS"] = "1"
expect { described_class.run("bundle", "install") }.not_to raise_error
end
it "uses the formula version from the environment variable" do
openssl_version = "1.1.1"
ENV["PATH"] = "/opt/homebrew/opt/openssl/bin:/usr/bin:/bin"
ENV["MANPATH"] = "/opt/homebrew/opt/openssl/man"
ENV["HOMEBREW_BUNDLE_EXEC_FORMULA_VERSION_OPENSSL"] = openssl_version
allow(described_class).to receive(:which).and_return(Pathname("/usr/bin/bundle"))
described_class.run("bundle", "install")
expect(ENV.fetch("PATH")).to include("/Cellar/openssl/1.1.1/bin")
end
it "is able to run without bundle arguments" do
allow(described_class).to receive(:exec).with("bundle", "install").and_return(nil)
expect { described_class.run("bundle", "install") }.not_to raise_error
end
it "raises an exception if called without a command" do
expect { described_class.run }.to raise_error(RuntimeError)
end
end
context "with env command" do
it "outputs the environment variables" do
ENV["HOMEBREW_PREFIX"] = "/opt/homebrew"
ENV["HOMEBREW_PATH"] = "/usr/bin"
allow(OS).to receive(:linux?).and_return(true)
expect { described_class.run("env", subcommand: "env") }.to \
output(/HOMEBREW_PREFIX="#{ENV.fetch("HOMEBREW_PREFIX")}"/).to_stdout
end
end
it "raises if called with a command that's not on the PATH" do
allow(described_class).to receive_messages(exec: nil, which: nil)
expect { described_class.run("bundle", "install") }.to raise_error(RuntimeError)
end
it "prepends the path of the requested command to PATH before running" do
expect(described_class).to receive(:exec).with("bundle", "install").and_return(nil)
expect(described_class).to receive(:which).and_return(Pathname("/usr/local/bin/bundle"))
allow(ENV).to receive(:prepend_path).with(any_args).and_call_original
expect(ENV).to receive(:prepend_path).with("PATH", "/usr/local/bin").once.and_call_original
described_class.run("bundle", "install")
end
describe "when running a command which exists but is not on the PATH" do
let(:brewfile_contents) { "brew 'zlib'" }
before do
stub_formula_loader formula("zlib") { url "zlib-1.0" }
end
shared_examples "allows command execution" do |command|
it "does not raise" do
allow(described_class).to receive(:exec).with(command).and_return(nil)
expect(described_class).not_to receive(:which)
expect { described_class.run(command) }.not_to raise_error
end
end
it_behaves_like "allows command execution", "./configure"
it_behaves_like "allows command execution", "bin/install"
it_behaves_like "allows command execution", "/Users/admin/Downloads/command"
end
describe "when the Brewfile contains rbenv" do
let(:rbenv_root) { Pathname.new("/tmp/.rbenv") }
let(:brewfile_contents) { "brew 'rbenv'" }
before do
stub_formula_loader formula("rbenv") { url "rbenv-1.0" }
ENV["HOMEBREW_RBENV_ROOT"] = rbenv_root.to_s
end
it "prepends the path of the rbenv shims to PATH before running" do
allow(described_class).to receive(:exec).with("/usr/bin/true").and_return(0)
allow(ENV).to receive(:fetch).with(any_args).and_call_original
allow(ENV).to receive(:prepend_path).with(any_args).once.and_call_original
expect(ENV).to receive(:fetch).with("HOMEBREW_RBENV_ROOT", "#{Dir.home}/.rbenv").once.and_call_original
expect(ENV).to receive(:prepend_path).with("PATH", rbenv_root/"shims").once.and_call_original
described_class.run("/usr/bin/true")
end
end
end
end

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::Install do
before do
allow_any_instance_of(IO).to receive(:puts)
end
context "when a Brewfile is not found" do
it "raises an error" do
allow_any_instance_of(Pathname).to receive(:read).and_raise(Errno::ENOENT)
expect { described_class.run }.to raise_error(RuntimeError)
end
end
context "when a Brewfile is found" do
let(:brewfile_contents) do
<<~EOS
tap 'phinze/cask'
brew 'mysql', conflicts_with: ['mysql56']
cask 'phinze/cask/google-chrome', greedy: true
mas '1Password', id: 443987910
whalebrew 'whalebrew/wget'
vscode 'GitHub.codespaces'
EOS
end
it "does not raise an error" do
allow(Homebrew::Bundle::TapInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::WhalebrewInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::VscodeExtensionInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::BrewInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::CaskInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::MacAppStoreInstaller).to receive_messages(preinstall: true, install: true)
allow_any_instance_of(Pathname).to receive(:read).and_return(brewfile_contents)
expect { described_class.run }.not_to raise_error
end
it "#dsl returns a valid DSL" do
allow(Homebrew::Bundle::TapInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::WhalebrewInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::VscodeExtensionInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::BrewInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::CaskInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::MacAppStoreInstaller).to receive_messages(preinstall: true, install: true)
allow_any_instance_of(Pathname).to receive(:read).and_return(brewfile_contents)
described_class.run
expect(described_class.dsl.entries.first.name).to eql("phinze/cask")
end
it "does not raise an error when skippable" do
expect(Homebrew::Bundle::BrewInstaller).not_to receive(:install)
allow(Homebrew::Bundle::Skipper).to receive(:skip?).and_return(true)
allow_any_instance_of(Pathname).to receive(:read)
.and_return("brew 'mysql'")
expect { described_class.run }.not_to raise_error
end
it "exits on failures" do
allow(Homebrew::Bundle::BrewInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::CaskInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::MacAppStoreInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::TapInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::WhalebrewInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::VscodeExtensionInstaller).to receive_messages(preinstall: true, install: false)
allow_any_instance_of(Pathname).to receive(:read).and_return(brewfile_contents)
expect { described_class.run }.to raise_error(SystemExit)
end
it "skips installs from failed taps" do
allow(Homebrew::Bundle::CaskInstaller).to receive(:preinstall).and_return(false)
allow(Homebrew::Bundle::TapInstaller).to receive_messages(preinstall: true, install: false)
allow(Homebrew::Bundle::BrewInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::MacAppStoreInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::WhalebrewInstaller).to receive_messages(preinstall: true, install: true)
allow(Homebrew::Bundle::VscodeExtensionInstaller).to receive_messages(preinstall: true, install: true)
allow_any_instance_of(Pathname).to receive(:read).and_return(brewfile_contents)
expect(Homebrew::Bundle).not_to receive(:system)
expect { described_class.run }.to raise_error(SystemExit)
end
end
end

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Commands::List do
subject(:list) { described_class.run(global: false, file: nil, brews:, casks:, taps:, mas:, whalebrew:, vscode:) }
let(:brews) { true }
let(:casks) { false }
let(:taps) { false }
let(:mas) { false }
let(:whalebrew) { false }
let(:vscode) { false }
before do
allow_any_instance_of(IO).to receive(:puts)
end
describe "outputs dependencies to stdout" do
before do
allow_any_instance_of(Pathname).to receive(:read).and_return(
<<~EOS,
tap 'phinze/cask'
brew 'mysql', conflicts_with: ['mysql56']
cask 'google-chrome'
mas '1Password', id: 443987910
whalebrew 'whalebrew/imagemagick'
vscode 'shopify.ruby-lsp'
EOS
)
end
it "only shows brew deps when no options are passed" do
expect { list }.to output("mysql\n").to_stdout
end
describe "limiting when certain options are passed" do
types_and_deps = {
taps: "phinze/cask",
brews: "mysql",
casks: "google-chrome",
mas: "1Password",
whalebrew: "whalebrew/imagemagick",
vscode: "shopify.ruby-lsp",
}
combinations = 1.upto(types_and_deps.length).flat_map do |i|
types_and_deps.keys.combination(i).take((1..types_and_deps.length).reduce(:*) || 1)
end.sort
combinations.each do |options_list|
args_hash = options_list.to_h { |arg| [arg, true] }
words = options_list.join(" and ")
opts = options_list.map { |o| "`#{o}`" }.join(" and ")
verb = (options_list.length == 1 && "is") || "are"
context "when #{opts} #{verb} passed" do
let(:brews) { args_hash[:brews] }
let(:casks) { args_hash[:casks] }
let(:taps) { args_hash[:taps] }
let(:mas) { args_hash[:mas] }
let(:whalebrew) { args_hash[:whalebrew] }
let(:vscode) { args_hash[:vscode] }
it "shows only #{words}" do
expected = options_list.map { |opt| types_and_deps[opt] }.join("\n")
expect { list }.to output("#{expected}\n").to_stdout
end
end
end
end
end
end

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
require "bundle"
require "cask/cask_loader"
RSpec.describe Homebrew::Bundle::Commands::Remove do
subject(:remove) do
described_class.run(*args, type:, global:, file:)
end
before { File.write(file, content) }
after { FileUtils.rm_f file }
let(:global) { false }
context "when called with a valid formula" do
let(:args) { ["hello"] }
let(:type) { :brew }
let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
let(:content) do
<<~BREWFILE
brew "hello"
BREWFILE
end
before do
stub_formula_loader formula("hello") { url "hello-1.0" }
end
it "removes entries from the given Brewfile" do
expect { remove }.not_to raise_error
expect(File.read(file)).not_to include("#{type} \"#{args.first}\"")
end
end
context "when called with no type" do
let(:args) { ["foo"] }
let(:type) { :none }
let(:file) { "/tmp/some_random_brewfile#{Random.rand(2 ** 16)}" }
let(:content) do
<<~BREWFILE
tap "someone/tap"
brew "foo"
cask "foo"
BREWFILE
end
it "removes all matching entries from the given Brewfile" do
expect { remove }.not_to raise_error
expect(File.read(file)).not_to include(args.first)
end
context "with arguments that match entries only when considering formula aliases" do
let(:foo) do
instance_double(
Formula,
name: "foo",
full_name: "qux/quuz/foo",
oldnames: ["oldfoo"],
aliases: ["foobar"],
)
end
let(:args) { ["foobar"] }
it "suggests using `--formula` to match against formula aliases" do
expect(Formulary).to receive(:factory).with("foobar").and_return(foo)
expect { remove }.not_to raise_error
expect(File.read(file)).to eq(content)
# FIXME: Why doesn't this work?
# expect { remove }.to output("--formula").to_stderr
end
end
end
end

View File

@ -0,0 +1,104 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Dsl do
def dsl_from_string(string)
described_class.new(StringIO.new(string))
end
context "with a DSL example" do
subject(:dsl) do
dsl_from_string <<~EOS
# frozen_string_literal: true
cask_args appdir: '/Applications'
tap 'homebrew/cask'
tap 'telemachus/brew', 'https://telemachus@bitbucket.org/telemachus/brew.git'
tap 'auto/update', 'https://bitbucket.org/auto/update.git', force_auto_update: true
brew 'imagemagick'
brew 'mysql@5.6', restart_service: true, link: true, conflicts_with: ['mysql']
brew 'emacs', args: ['with-cocoa', 'with-gnutls'], link: :overwrite
cask 'google-chrome'
cask 'java' unless system '/usr/libexec/java_home --failfast'
cask 'firefox', args: { appdir: '~/my-apps/Applications' }
mas '1Password', id: 443987910
whalebrew 'whalebrew/wget'
vscode 'GitHub.codespaces'
EOS
end
before do
allow_any_instance_of(described_class).to receive(:system)
.with("/usr/libexec/java_home --failfast")
.and_return(false)
end
it "processes input" do
# Keep in sync with the README
expect(dsl.cask_arguments).to eql(appdir: "/Applications")
expect(dsl.entries[0].name).to eql("homebrew/cask")
expect(dsl.entries[1].name).to eql("telemachus/brew")
expect(dsl.entries[1].options).to eql(clone_target: "https://telemachus@bitbucket.org/telemachus/brew.git")
expect(dsl.entries[2].options).to eql(
clone_target: "https://bitbucket.org/auto/update.git",
force_auto_update: true,
)
expect(dsl.entries[3].name).to eql("imagemagick")
expect(dsl.entries[4].name).to eql("mysql@5.6")
expect(dsl.entries[4].options).to eql(restart_service: true, link: true, conflicts_with: ["mysql"])
expect(dsl.entries[5].name).to eql("emacs")
expect(dsl.entries[5].options).to eql(args: ["with-cocoa", "with-gnutls"], link: :overwrite)
expect(dsl.entries[6].name).to eql("google-chrome")
expect(dsl.entries[7].name).to eql("java")
expect(dsl.entries[8].name).to eql("firefox")
expect(dsl.entries[8].options).to eql(args: { appdir: "~/my-apps/Applications" }, full_name: "firefox")
expect(dsl.entries[9].name).to eql("1Password")
expect(dsl.entries[9].options).to eql(id: 443_987_910)
expect(dsl.entries[10].name).to eql("whalebrew/wget")
expect(dsl.entries[11].name).to eql("GitHub.codespaces")
end
end
context "with invalid input" do
it "handles completely invalid code" do
expect { dsl_from_string "abcdef" }.to raise_error(RuntimeError)
end
it "handles valid commands but with invalid options" do
expect { dsl_from_string "brew 1" }.to raise_error(RuntimeError)
expect { dsl_from_string "cask 1" }.to raise_error(RuntimeError)
expect { dsl_from_string "tap 1" }.to raise_error(RuntimeError)
expect { dsl_from_string "cask_args ''" }.to raise_error(RuntimeError)
end
it "errors on bad options" do
expect { dsl_from_string "brew 'foo', ['bad_option']" }.to raise_error(RuntimeError)
expect { dsl_from_string "cask 'foo', ['bad_option']" }.to raise_error(RuntimeError)
expect { dsl_from_string "tap 'foo', ['bad_clone_target']" }.to raise_error(RuntimeError)
end
end
it ".sanitize_brew_name" do
expect(described_class.send(:sanitize_brew_name, "homebrew/homebrew/foo")).to eql("foo")
expect(described_class.send(:sanitize_brew_name, "homebrew/homebrew-bar/foo")).to eql("homebrew/bar/foo")
expect(described_class.send(:sanitize_brew_name, "homebrew/bar/foo")).to eql("homebrew/bar/foo")
expect(described_class.send(:sanitize_brew_name, "foo")).to eql("foo")
end
it ".sanitize_tap_name" do
expect(described_class.send(:sanitize_tap_name, "homebrew/homebrew-foo")).to eql("homebrew/foo")
expect(described_class.send(:sanitize_tap_name, "homebrew/foo")).to eql("homebrew/foo")
end
it ".sanitize_cask_name" do
allow_any_instance_of(Object).to receive(:opoo)
expect(described_class.send(:sanitize_cask_name, "homebrew/cask-versions/adoptopenjdk8")).to eql("adoptopenjdk8")
expect(described_class.send(:sanitize_cask_name, "adoptopenjdk8")).to eql("adoptopenjdk8")
end
it ".pluralize_dependency" do
expect(described_class.send(:pluralize_dependency, 0)).to eql("dependencies")
expect(described_class.send(:pluralize_dependency, 1)).to eql("dependency")
expect(described_class.send(:pluralize_dependency, 5)).to eql("dependencies")
end
end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
require "bundle"
require "cask"
RSpec.describe Homebrew::Bundle::Dumper do
subject(:dumper) { described_class }
before do
ENV["HOMEBREW_BUNDLE_FILE"] = ""
allow(Homebrew::Bundle).to \
receive_messages(
cask_installed?: true, mas_installed?: false, whalebrew_installed?: false,
vscode_installed?: false
)
Homebrew::Bundle::BrewDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::CaskDumper.reset!
Homebrew::Bundle::MacAppStoreDumper.reset!
Homebrew::Bundle::WhalebrewDumper.reset!
Homebrew::Bundle::VscodeExtensionDumper.reset!
Homebrew::Bundle::BrewServices.reset!
chrome = instance_double(Cask::Cask,
full_name: "google-chrome",
to_s: "google-chrome",
config: nil)
java = instance_double(Cask::Cask,
full_name: "java",
to_s: "java",
config: nil)
iterm2beta = instance_double(Cask::Cask,
full_name: "homebrew/cask-versions/iterm2-beta",
to_s: "iterm2-beta",
config: nil)
allow(Cask::Caskroom).to receive(:casks).and_return([chrome, java, iterm2beta])
allow(Tap).to receive(:select).and_return([])
end
it "generates output" do
expect(dumper.build_brewfile(
describe: false, no_restart: false, brews: true, taps: true, casks: true, mas: true,
whalebrew: true, vscode: true
)).to eql("cask \"google-chrome\"\ncask \"java\"\ncask \"iterm2-beta\"\n")
end
it "determines the brewfile correctly" do
expect(dumper.brewfile_path).to eql(Pathname.new(Dir.pwd).join("Brewfile"))
end
end

View File

@ -0,0 +1,167 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::MacAppStoreDumper do
subject(:dumper) { described_class }
context "when mas is not installed" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(false)
end
it "returns empty list" do
expect(dumper.apps).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
end
context "when there is no apps" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(true)
allow(described_class).to receive(:`).and_return("")
end
it "returns empty list" do
expect(dumper.apps).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
end
context "when apps `foo`, `bar` and `baz` are installed" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(true)
allow(described_class).to receive(:`).and_return("123 foo (1.0)\n456 bar (2.0)\n789 baz (3.0)")
end
it "returns list %w[foo bar baz]" do
expect(dumper.apps).to eql([["123", "foo"], ["456", "bar"], ["789", "baz"]])
end
end
context "with invalid app details" do
let(:invalid_mas_output) do
<<~HEREDOC
497799835 Xcode (9.2)
425424353 The Unarchiver (4.0.0)
08981434 iMovie (10.1.8)
Install macOS High Sierra (13105)
409201541 Pages (7.1)
123456789 123AppNameWithNumbers (1.0)
409203825 Numbers (5.1)
944924917 Pastebin It! (1.0)
123456789 My (cool) app (1.0)
987654321 an-app-i-use (2.1)
123457867 App name with many spaces (1.0)
893489734 my,comma,app (2.2)
832423434 another_app_name (1.0)
543213432 My App? (1.0)
688963445 app;with;semicolons (1.0)
123345384 my 😊 app (2.0)
896732467 (1.1)
634324555 مرحبا (1.0)
234324325 áéíóú (1.0)
310633997 non><printing><characters (1.0)
HEREDOC
end
let(:expected_app_details_array) do
[
["497799835", "Xcode"],
["425424353", "The Unarchiver"],
["08981434", "iMovie"],
["409201541", "Pages"],
["123456789", "123AppNameWithNumbers"],
["409203825", "Numbers"],
["944924917", "Pastebin It!"],
["123456789", "My (cool) app"],
["987654321", "an-app-i-use"],
["123457867", "App name with many spaces"],
["893489734", "my,comma,app"],
["832423434", "another_app_name"],
["543213432", "My App?"],
["688963445", "app;with;semicolons"],
["123345384", "my 😊 app"],
["896732467", "你好"],
["634324555", "مرحبا"],
["234324325", "áéíóú"],
["310633997", "non><printing><characters"],
]
end
let(:expected_mas_dumped_output) do
<<~HEREDOC
mas "123AppNameWithNumbers", id: 123456789
mas "an-app-i-use", id: 987654321
mas "another_app_name", id: 832423434
mas "App name with many spaces", id: 123457867
mas "app;with;semicolons", id: 688963445
mas "iMovie", id: 08981434
mas "My (cool) app", id: 123456789
mas "My App?", id: 543213432
mas "my 😊 app", id: 123345384
mas "my,comma,app", id: 893489734
mas "non><printing><characters", id: 310633997
mas "Numbers", id: 409203825
mas "Pages", id: 409201541
mas "Pastebin It!", id: 944924917
mas "The Unarchiver", id: 425424353
mas "Xcode", id: 497799835
mas "áéíóú", id: 234324325
mas "مرحبا", id: 634324555
mas "你好", id: 896732467
HEREDOC
end
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(true)
allow(described_class).to receive(:`).and_return(invalid_mas_output)
end
it "returns only valid apps" do
expect(dumper.apps).to eql(expected_app_details_array)
end
it "dumps excluding invalid apps" do
expect(dumper.dump).to eq(expected_mas_dumped_output.strip)
end
end
context "with the new format after mas-cli/mas#339" do
let(:new_mas_output) do
<<~HEREDOC
1440147259 AdGuard for Safari (1.9.13)
497799835 Xcode (12.5)
425424353 The Unarchiver (4.3.1)
HEREDOC
end
let(:expected_app_details_array) do
[
["1440147259", "AdGuard for Safari"],
["497799835", "Xcode"],
["425424353", "The Unarchiver"],
]
end
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(true)
allow(described_class).to receive(:`).and_return(new_mas_output)
end
it "parses the app names without trailing whitespace" do
expect(dumper.apps).to eql(expected_app_details_array)
end
end
end

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::MacAppStoreInstaller do
before do
stub_formula_loader formula("mas") { url "mas-1.0" }
end
describe ".installed_app_ids" do
it "shells out" do
expect { described_class.installed_app_ids }.not_to raise_error
end
end
describe ".app_id_installed_and_up_to_date?" do
it "returns result" do
allow(described_class).to receive_messages(installed_app_ids: [123, 456], outdated_app_ids: [456])
expect(described_class.app_id_installed_and_up_to_date?(123)).to be(true)
expect(described_class.app_id_installed_and_up_to_date?(456)).to be(false)
end
end
context "when mas is not installed" do
before do
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(false)
end
it "tries to install mas" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "install", "mas",
verbose: false).and_return(true)
expect { described_class.preinstall("foo", 123) }.to raise_error(RuntimeError)
end
describe ".outdated_app_ids" do
it "does not shell out" do
expect(described_class).not_to receive(:`)
described_class.reset!
described_class.outdated_app_ids
end
end
end
context "when mas is installed" do
before do
allow(Homebrew::Bundle).to receive(:mas_installed?).and_return(true)
end
describe ".outdated_app_ids" do
it "returns app ids" do
expect(described_class).to receive(:`).and_return("foo 123")
described_class.reset!
described_class.outdated_app_ids
end
end
context "when app is installed" do
before do
allow(described_class).to receive(:installed_app_ids).and_return([123])
end
it "skips" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(described_class.preinstall("foo", 123)).to be(false)
end
end
context "when app is outdated" do
before do
allow(described_class).to receive_messages(installed_app_ids: [123], outdated_app_ids: [123])
end
it "upgrades" do
expect(Homebrew::Bundle).to receive(:system).with("mas", "upgrade", "123", verbose: false).and_return(true)
expect(described_class.preinstall("foo", 123)).to be(true)
expect(described_class.install("foo", 123)).to be(true)
end
end
context "when app is not installed" do
before do
allow(described_class).to receive(:installed_app_ids).and_return([])
end
it "installs app" do
expect(Homebrew::Bundle).to receive(:system).with("mas", "install", "123", verbose: false).and_return(true)
expect(described_class.preinstall("foo", 123)).to be(true)
expect(described_class.install("foo", 123)).to be(true)
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::Remover do
subject(:remover) { described_class }
let(:name) { "foo" }
before { allow(Formulary).to receive(:factory).with(name).and_raise(FormulaUnavailableError.new(name)) }
it "raises no errors when requested" do
expect { remover.possible_names(name, raise_error: false) }.not_to raise_error
end
end

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
require "ostruct"
require "bundle"
RSpec.describe Homebrew::Bundle::Skipper do
subject(:skipper) { described_class }
before do
allow(ENV).to receive(:[]).and_return(nil)
allow(ENV).to receive(:[]).with("HOMEBREW_BUNDLE_BREW_SKIP").and_return("mysql")
allow(ENV).to receive(:[]).with("HOMEBREW_BUNDLE_WHALEBREW_SKIP").and_return("whalebrew/imagemagick")
allow(ENV).to receive(:[]).with("HOMEBREW_BUNDLE_TAP_SKIP").and_return("org/repo")
allow(Formatter).to receive(:warning)
skipper.instance_variable_set(:@skipped_entries, nil)
skipper.instance_variable_set(:@failed_taps, nil)
end
describe ".skip?" do
context "with a listed formula" do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:brew, "mysql") }
it "returns true" do
expect(skipper.skip?(entry)).to be true
end
end
context "with an unbottled formula on ARM", :needs_macos do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:brew, "mysql") }
# TODO: remove OpenStruct usage
# rubocop:todo Style/OpenStructUse
it "returns true" do
allow(Hardware::CPU).to receive(:arm?).and_return(true)
allow_any_instance_of(Formula).to receive(:stable).and_return(OpenStruct.new(bottled?: false,
bottle_defined?: true))
expect(skipper.skip?(entry)).to be true
end
# rubocop:enable Style/OpenStructUse
end
context "with an unlisted cask", :needs_macos do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:cask, "java") }
it "returns false" do
expect(skipper.skip?(entry)).to be false
end
end
context "with a listed whalebrew image" do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:whalebrew, "whalebrew/imagemagick") }
it "returns true" do
expect(skipper.skip?(entry)).to be true
end
end
context "with a listed formula in a failed tap" do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:brew, "org/repo/formula") }
it "returns true" do
skipper.tap_failed!("org/repo")
expect(skipper.skip?(entry)).to be true
end
end
end
describe ".failed_tap!" do
context "with a tap" do
let(:tap) { Homebrew::Bundle::Dsl::Entry.new(:tap, "org/repo-b") }
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:brew, "org/repo-b/formula") }
it "returns false" do
expect(skipper.skip?(entry)).to be false
skipper.tap_failed! tap.name
expect(skipper.skip?(entry)).to be true
end
end
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::TapDumper do
subject(:dumper) { described_class }
context "when there is no tap" do
before do
described_class.reset!
allow(Tap).to receive(:select).and_return []
end
it "returns empty list" do
expect(dumper.tap_names).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
end
context "with taps" do
before do
described_class.reset!
bar = instance_double(Tap, name: "bitbucket/bar", custom_remote?: true,
remote: "https://bitbucket.org/bitbucket/bar.git")
baz = instance_double(Tap, name: "homebrew/baz", custom_remote?: false)
foo = instance_double(Tap, name: "homebrew/foo", custom_remote?: false)
ENV["HOMEBREW_GITHUB_API_TOKEN_BEFORE"] = ENV.fetch("HOMEBREW_GITHUB_API_TOKEN", nil)
ENV["HOMEBREW_GITHUB_API_TOKEN"] = "some-token"
private_tap = instance_double(Tap, name: "privatebrew/private", custom_remote?: true,
remote: "https://#{ENV.fetch("HOMEBREW_GITHUB_API_TOKEN")}@github.com/privatebrew/homebrew-private")
allow(Tap).to receive(:select).and_return [bar, baz, foo, private_tap]
end
after do
ENV["HOMEBREW_GITHUB_API_TOKEN"] = ENV.fetch("HOMEBREW_GITHUB_API_TOKEN_BEFORE", nil)
ENV.delete("HOMEBREW_GITHUB_API_TOKEN_BEFORE")
end
it "returns list of information" do
expect(dumper.tap_names).not_to be_empty
end
it "dumps output" do
expected_output = <<~EOS
tap "bitbucket/bar", "https://bitbucket.org/bitbucket/bar.git"
tap "homebrew/baz"
tap "homebrew/foo"
tap "privatebrew/private", "https://\#{ENV.fetch("HOMEBREW_GITHUB_API_TOKEN")}@github.com/privatebrew/homebrew-private"
EOS
expect(dumper.dump).to eql(expected_output.chomp)
end
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::TapInstaller do
describe ".installed_taps" do
before do
Homebrew::Bundle::TapDumper.reset!
end
it "calls Homebrew" do
expect { described_class.installed_taps }.not_to raise_error
end
end
context "when tap is installed" do
before do
allow(described_class).to receive(:installed_taps).and_return(["homebrew/cask"])
end
it "skips" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(described_class.preinstall("homebrew/cask")).to be(false)
end
end
context "when tap is not installed" do
before do
allow(described_class).to receive(:installed_taps).and_return([])
end
it "taps" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "tap", "homebrew/cask",
verbose: false).and_return(true)
expect(described_class.preinstall("homebrew/cask")).to be(true)
expect(described_class.install("homebrew/cask")).to be(true)
end
context "with clone target" do
it "taps" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "tap", "homebrew/cask", "clone_target_path",
verbose: false).and_return(true)
expect(described_class.preinstall("homebrew/cask", clone_target: "clone_target_path")).to be(true)
expect(described_class.install("homebrew/cask", clone_target: "clone_target_path")).to be(true)
end
it "fails" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "tap", "homebrew/cask", "clone_target_path",
verbose: false).and_return(false)
expect(described_class.preinstall("homebrew/cask", clone_target: "clone_target_path")).to be(true)
expect(described_class.install("homebrew/cask", clone_target: "clone_target_path")).to be(false)
end
end
context "with force_auto_update" do
it "taps" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "tap", "homebrew/cask",
"--force-auto-update",
verbose: false)
.and_return(true)
expect(described_class.preinstall("homebrew/cask", force_auto_update: true)).to be(true)
expect(described_class.install("homebrew/cask", force_auto_update: true)).to be(true)
end
it "fails" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "tap", "homebrew/cask",
"--force-auto-update",
verbose: false)
.and_return(false)
expect(described_class.preinstall("homebrew/cask", force_auto_update: true)).to be(true)
expect(described_class.install("homebrew/cask", force_auto_update: true)).to be(false)
end
end
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
require "bundle"
require "extend/kernel"
RSpec.describe Homebrew::Bundle::VscodeExtensionInstaller do
context "when VSCode is not installed" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive_messages(vscode_installed?: false, cask_installed?: true)
end
it "tries to install vscode" do
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "install", "--cask", "visual-studio-code", verbose: false)
.and_return(true)
expect { described_class.preinstall("foo") }.to raise_error(RuntimeError)
end
end
context "when VSCode is installed" do
before do
allow(Homebrew::Bundle).to receive(:vscode_installed?).and_return(true)
end
context "when extension is installed" do
before do
allow(described_class).to receive(:installed_extensions).and_return(["foo"])
end
it "skips" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(described_class.preinstall("foo")).to be(false)
end
it "skips ignoring case" do
expect(Homebrew::Bundle).not_to receive(:system)
expect(described_class.preinstall("Foo")).to be(false)
end
end
context "when extension is not installed" do
before do
allow(described_class).to receive(:installed_extensions).and_return([])
end
it "installs extension" do
expect(Homebrew::Bundle).to receive(:system).with("code", "--install-extension", "foo",
verbose: false).and_return(true)
expect(described_class.preinstall("foo")).to be(true)
expect(described_class.install("foo")).to be(true)
end
it "installs extension when euid != uid and Process::UID.re_exchangeable? returns true" do
expect(Process).to receive(:euid).and_return(1).once
expect(Process::UID).to receive(:re_exchangeable?).and_return(true).once
expect(Process::UID).to receive(:re_exchange).twice
expect(Homebrew::Bundle).to receive(:system).with("code", "--install-extension", "foo",
verbose: false).and_return(true)
expect(described_class.preinstall("foo")).to be(true)
expect(described_class.install("foo")).to be(true)
end
it "installs extension when euid != uid and Process::UID.re_exchangeable? returns false" do
expect(Process).to receive(:euid).and_return(1).once
expect(Process::UID).to receive(:re_exchangeable?).and_return(false).once
expect(Process::Sys).to receive(:seteuid).twice
expect(Homebrew::Bundle).to receive(:system).with("code", "--install-extension", "foo",
verbose: false).and_return(true)
expect(described_class.preinstall("foo")).to be(true)
expect(described_class.install("foo")).to be(true)
end
end
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::WhalebrewDumper do
subject(:dumper) { described_class }
describe ".images" do
before do
dumper.reset!
allow(Homebrew::Bundle).to receive(:whalebrew_installed?).and_return(true)
end
let(:whalebrew_list_single_output) do
"COMMAND IMAGE\nwget whalebrew/wget"
end
let(:whalebrew_list_duplicate_output) do
"COMMAND IMAGE\nwget whalebrew/wget\nwget whalebrew/wget"
end
it "removes the header" do
allow(dumper).to receive(:`).with("whalebrew list 2>/dev/null")
.and_return(whalebrew_list_single_output)
expect(dumper.images).not_to include("COMMAND")
expect(dumper.images).not_to include("IMAGE")
end
it "dedupes items" do
allow(dumper).to receive(:`).with("whalebrew list 2>/dev/null")
.and_return(whalebrew_list_duplicate_output)
expect(dumper.images).to eq(["whalebrew/wget"])
end
end
context "when whalebrew is not installed" do
before do
dumper.reset!
allow(Homebrew::Bundle).to receive(:whalebrew_installed?).and_return(false)
end
it "returns empty list" do
expect(dumper.images).to be_empty
end
it "dumps as empty string" do
expect(dumper.dump).to eql("")
end
end
context "when whalebrew is installed" do
before do
allow(Homebrew::Bundle).to receive(:whalebrew_installed?).and_return(true)
allow(dumper).to receive(:images).and_return(["whalebrew/wget", "whalebrew/dig"])
end
context "when images are installed" do
let(:expected_whalebrew_dump) do
%Q(whalebrew "whalebrew/wget"\nwhalebrew "whalebrew/dig")
end
it "returns correct listing" do
expect(dumper.images).to eq(["whalebrew/wget", "whalebrew/dig"])
end
it "dumps usable output for Brewfile" do
expect(dumper.dump).to eql(expected_whalebrew_dump)
end
end
end
end

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
require "bundle"
RSpec.describe Homebrew::Bundle::WhalebrewInstaller do
before do
stub_formula_loader formula("whalebrew") { url "whalebrew-1.0" }
end
describe ".installed_images" do
it "shells out" do
expect { described_class.installed_images }.not_to raise_error
end
end
describe ".image_installed?" do
context "when an image is already installed" do
before do
described_class.reset!
end
it "returns true" do
allow(Homebrew::Bundle::WhalebrewDumper).to receive(:images).and_return(["whalebrew/wget"])
expect(described_class.image_installed?("whalebrew/wget")).to be(true)
end
end
context "when an image isn't installed" do
before do
described_class.reset!
end
it "returns false" do
allow(Homebrew::Bundle::WhalebrewDumper).to receive(:images).and_return([])
expect(described_class.image_installed?("test/doesnotexist")).to be(false)
end
end
end
context "when whalebrew isn't installed" do
before do
allow(Homebrew::Bundle).to receive(:whalebrew_installed?).and_return(false)
end
it "successfully installs whalebrew" do
expect(Homebrew::Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "install", "--formula", "whalebrew",
verbose: false)
.and_return(true)
expect { described_class.preinstall("whalebrew/wget") }.to raise_error(RuntimeError)
end
end
context "when whalebrew is installed" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:whalebrew_installed?).and_return(true)
allow(Homebrew::Bundle).to receive(:system).with("whalebrew", "install", "whalebrew/wget", verbose: false)
.and_return(true)
end
context "when the requested image is already installed" do
before do
allow(described_class).to receive(:image_installed?).with("whalebrew/wget").and_return(true)
end
it "skips" do
expect(described_class.preinstall("whalebrew/wget")).to be(false)
end
end
it "successfully installs an image" do
expect(described_class.preinstall("whalebrew/wget")).to be(true)
expect { described_class.install("whalebrew/wget") }.not_to raise_error
end
end
end

View File

@ -1,13 +1,12 @@
# frozen_string_literal: true
require "cmd/bundle"
require "cmd/shared_examples/args_parse"
RSpec.describe "Homebrew::Cmd::BundleCmd", :integration_test, :needs_network do
before { setup_remote_tap "homebrew/bundle" }
RSpec.describe Homebrew::Cmd::Bundle do
it_behaves_like "parseable arguments"
it_behaves_like "parseable arguments", command_name: "bundle"
it "checks if a Brewfile's dependencies are satisfied" do
it "checks if a Brewfile's dependencies are satisfied", :integration_test do
HOMEBREW_REPOSITORY.cd do
system "git", "init"
system "git", "commit", "--allow-empty", "-m", "This is a test commit"

View File

@ -26,7 +26,7 @@ A *formula* is a package definition written in Ruby. It can be created with `bre
| **tap** | directory (and usually Git repository) of **formulae**, **casks** and/or **external commands** | `/opt/homebrew/Library/Taps/homebrew/homebrew-core` |
| **bottle** | pre-built **keg** poured into a **rack** of the **Cellar** instead of building from upstream sources | `qt--6.5.1.ventura.bottle.tar.gz` |
| **tab** | information about a **keg**, e.g. whether it was poured from a **bottle** or built from source | `/opt/homebrew/Cellar/foo/0.1/INSTALL_RECEIPT.json` |
| **Brew Bundle** | an [extension of Homebrew](https://github.com/Homebrew/homebrew-bundle) to describe dependencies | `brew 'myservice', restart_service: true` |
| **Brew Bundle** | a declarative interface to Homebrew | `brew 'myservice', restart_service: true` |
| **Brew Services** | the Homebrew command to manage background services | `brew services start myservice` |
## An introduction