From bdeca530ff6dc5a26d493501ed50ceab81faea00 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 18 Mar 2025 17:38:37 +0000 Subject: [PATCH] Migrate Homebrew/bundle to Homebrew/brew Co-authored-by: Bo Anderson --- .devcontainer/devcontainer.json | 9 - .devcontainer/on-create-command.sh | 2 - .github/workflows/tests.yml | 4 +- Library/.rubocop.yml | 1 + Library/Homebrew/bundle.rb | 40 ++ Library/Homebrew/bundle/adder.rb | 31 + Library/Homebrew/bundle/brew_checker.rb | 17 + Library/Homebrew/bundle/brew_dumper.rb | 240 ++++++++ Library/Homebrew/bundle/brew_installer.rb | 289 +++++++++ .../Homebrew/bundle/brew_service_checker.rb | 51 ++ Library/Homebrew/bundle/brew_services.rb | 55 ++ Library/Homebrew/bundle/brewfile.rb | 56 ++ Library/Homebrew/bundle/brewfile.rbi | 7 + Library/Homebrew/bundle/bundle.rb | 86 +++ Library/Homebrew/bundle/cask_checker.rb | 17 + Library/Homebrew/bundle/cask_dumper.rb | 74 +++ Library/Homebrew/bundle/cask_dumper.rbi | 7 + Library/Homebrew/bundle/cask_installer.rb | 110 ++++ Library/Homebrew/bundle/cask_installer.rbi | 7 + Library/Homebrew/bundle/checker.rb | 144 +++++ Library/Homebrew/bundle/commands/add.rb | 16 + Library/Homebrew/bundle/commands/check.rb | 34 ++ Library/Homebrew/bundle/commands/cleanup.rb | 191 ++++++ Library/Homebrew/bundle/commands/commands.rbi | 29 + Library/Homebrew/bundle/commands/dump.rb | 18 + Library/Homebrew/bundle/commands/exec.rb | 170 ++++++ Library/Homebrew/bundle/commands/install.rb | 25 + Library/Homebrew/bundle/commands/list.rb | 20 + Library/Homebrew/bundle/commands/remove.rb | 16 + Library/Homebrew/bundle/dsl.rb | 134 +++++ Library/Homebrew/bundle/dumper.rb | 52 ++ Library/Homebrew/bundle/dumper.rbi | 7 + Library/Homebrew/bundle/installer.rb | 77 +++ Library/Homebrew/bundle/installer.rbi | 7 + Library/Homebrew/bundle/lister.rb | 27 + Library/Homebrew/bundle/lister.rbi | 7 + .../Homebrew/bundle/mac_app_store_checker.rb | 34 ++ .../Homebrew/bundle/mac_app_store_dumper.rb | 41 ++ .../Homebrew/bundle/mac_app_store_dumper.rbi | 7 + .../bundle/mac_app_store_installer.rb | 80 +++ .../bundle/mac_app_store_installer.rbi | 7 + Library/Homebrew/bundle/remover.rb | 47 ++ Library/Homebrew/bundle/remover.rbi | 7 + Library/Homebrew/bundle/skipper.rb | 64 ++ Library/Homebrew/bundle/tap_checker.rb | 21 + Library/Homebrew/bundle/tap_dumper.rb | 45 ++ Library/Homebrew/bundle/tap_dumper.rbi | 7 + Library/Homebrew/bundle/tap_installer.rb | 46 ++ Library/Homebrew/bundle/tap_installer.rbi | 7 + .../bundle/vscode_extension_checker.rb | 21 + .../bundle/vscode_extension_dumper.rb | 28 + .../bundle/vscode_extension_dumper.rbi | 7 + .../bundle/vscode_extension_installer.rb | 53 ++ .../bundle/vscode_extension_installer.rbi | 7 + Library/Homebrew/bundle/whalebrew_dumper.rb | 27 + Library/Homebrew/bundle/whalebrew_dumper.rbi | 7 + .../Homebrew/bundle/whalebrew_installer.rb | 48 ++ .../Homebrew/bundle/whalebrew_installer.rbi | 7 + Library/Homebrew/cmd/bundle.rb | 272 +++++++++ Library/Homebrew/diagnostic.rb | 4 + Library/Homebrew/extend/os/bundle/bundle.rb | 4 + Library/Homebrew/extend/os/bundle/skipper.rb | 4 + .../Homebrew/extend/os/linux/bundle/bundle.rb | 17 + .../extend/os/linux/bundle/skipper.rb | 31 + Library/Homebrew/official_taps.rb | 2 +- .../sorbet/rbi/dsl/homebrew/cmd/bundle.rbi | 79 +++ .../rbi/dsl/rubo_cop/cask/ast/stanza.rbi | 3 + .../Homebrew/test/bundle/brew_dumper_spec.rb | 267 +++++++++ .../test/bundle/brew_installer_spec.rb | 555 ++++++++++++++++++ .../test/bundle/brew_services_spec.rb | 62 ++ Library/Homebrew/test/bundle/brewfile_spec.rb | 199 +++++++ Library/Homebrew/test/bundle/bundle_spec.rb | 56 ++ .../Homebrew/test/bundle/cask_dumper_spec.rb | 122 ++++ .../test/bundle/cask_installer_spec.rb | 162 +++++ .../Homebrew/test/bundle/commands/add_spec.rb | 49 ++ .../test/bundle/commands/check_spec.rb | 286 +++++++++ .../test/bundle/commands/check_spec.rbi | 4 + .../test/bundle/commands/cleanup_spec.rb | 256 ++++++++ .../test/bundle/commands/dump_spec.rb | 69 +++ .../test/bundle/commands/exec_spec.rb | 126 ++++ .../test/bundle/commands/install_spec.rb | 86 +++ .../test/bundle/commands/list_spec.rb | 73 +++ .../test/bundle/commands/remove_spec.rb | 74 +++ Library/Homebrew/test/bundle/dsl_spec.rb | 104 ++++ Library/Homebrew/test/bundle/dumper_spec.rb | 52 ++ .../test/bundle/mac_app_store_dumper_spec.rb | 167 ++++++ .../bundle/mac_app_store_installer_spec.rb | 92 +++ Library/Homebrew/test/bundle/remover_spec.rb | 15 + Library/Homebrew/test/bundle/skipper_spec.rb | 84 +++ .../Homebrew/test/bundle/tap_dumper_spec.rb | 59 ++ .../test/bundle/tap_installer_spec.rb | 77 +++ .../bundle/vscode_extension_installer_spec.rb | 77 +++ .../test/bundle/whalebrew_dumper_spec.rb | 71 +++ .../test/bundle/whalebrew_installer_spec.rb | 76 +++ Library/Homebrew/test/cmd/bundle_spec.rb | 9 +- docs/Formula-Cookbook.md | 32 +- 96 files changed, 6436 insertions(+), 36 deletions(-) create mode 100644 Library/Homebrew/bundle.rb create mode 100644 Library/Homebrew/bundle/adder.rb create mode 100644 Library/Homebrew/bundle/brew_checker.rb create mode 100644 Library/Homebrew/bundle/brew_dumper.rb create mode 100644 Library/Homebrew/bundle/brew_installer.rb create mode 100644 Library/Homebrew/bundle/brew_service_checker.rb create mode 100644 Library/Homebrew/bundle/brew_services.rb create mode 100644 Library/Homebrew/bundle/brewfile.rb create mode 100644 Library/Homebrew/bundle/brewfile.rbi create mode 100644 Library/Homebrew/bundle/bundle.rb create mode 100644 Library/Homebrew/bundle/cask_checker.rb create mode 100644 Library/Homebrew/bundle/cask_dumper.rb create mode 100644 Library/Homebrew/bundle/cask_dumper.rbi create mode 100644 Library/Homebrew/bundle/cask_installer.rb create mode 100644 Library/Homebrew/bundle/cask_installer.rbi create mode 100644 Library/Homebrew/bundle/checker.rb create mode 100644 Library/Homebrew/bundle/commands/add.rb create mode 100644 Library/Homebrew/bundle/commands/check.rb create mode 100644 Library/Homebrew/bundle/commands/cleanup.rb create mode 100644 Library/Homebrew/bundle/commands/commands.rbi create mode 100644 Library/Homebrew/bundle/commands/dump.rb create mode 100644 Library/Homebrew/bundle/commands/exec.rb create mode 100644 Library/Homebrew/bundle/commands/install.rb create mode 100644 Library/Homebrew/bundle/commands/list.rb create mode 100644 Library/Homebrew/bundle/commands/remove.rb create mode 100644 Library/Homebrew/bundle/dsl.rb create mode 100644 Library/Homebrew/bundle/dumper.rb create mode 100644 Library/Homebrew/bundle/dumper.rbi create mode 100644 Library/Homebrew/bundle/installer.rb create mode 100644 Library/Homebrew/bundle/installer.rbi create mode 100644 Library/Homebrew/bundle/lister.rb create mode 100644 Library/Homebrew/bundle/lister.rbi create mode 100644 Library/Homebrew/bundle/mac_app_store_checker.rb create mode 100644 Library/Homebrew/bundle/mac_app_store_dumper.rb create mode 100644 Library/Homebrew/bundle/mac_app_store_dumper.rbi create mode 100644 Library/Homebrew/bundle/mac_app_store_installer.rb create mode 100644 Library/Homebrew/bundle/mac_app_store_installer.rbi create mode 100644 Library/Homebrew/bundle/remover.rb create mode 100644 Library/Homebrew/bundle/remover.rbi create mode 100644 Library/Homebrew/bundle/skipper.rb create mode 100644 Library/Homebrew/bundle/tap_checker.rb create mode 100644 Library/Homebrew/bundle/tap_dumper.rb create mode 100644 Library/Homebrew/bundle/tap_dumper.rbi create mode 100644 Library/Homebrew/bundle/tap_installer.rb create mode 100644 Library/Homebrew/bundle/tap_installer.rbi create mode 100644 Library/Homebrew/bundle/vscode_extension_checker.rb create mode 100644 Library/Homebrew/bundle/vscode_extension_dumper.rb create mode 100644 Library/Homebrew/bundle/vscode_extension_dumper.rbi create mode 100644 Library/Homebrew/bundle/vscode_extension_installer.rb create mode 100644 Library/Homebrew/bundle/vscode_extension_installer.rbi create mode 100644 Library/Homebrew/bundle/whalebrew_dumper.rb create mode 100644 Library/Homebrew/bundle/whalebrew_dumper.rbi create mode 100644 Library/Homebrew/bundle/whalebrew_installer.rb create mode 100644 Library/Homebrew/bundle/whalebrew_installer.rbi create mode 100755 Library/Homebrew/cmd/bundle.rb create mode 100644 Library/Homebrew/extend/os/bundle/bundle.rb create mode 100644 Library/Homebrew/extend/os/bundle/skipper.rb create mode 100644 Library/Homebrew/extend/os/linux/bundle/bundle.rb create mode 100644 Library/Homebrew/extend/os/linux/bundle/skipper.rb create mode 100644 Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/bundle.rbi create mode 100644 Library/Homebrew/test/bundle/brew_dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/brew_installer_spec.rb create mode 100644 Library/Homebrew/test/bundle/brew_services_spec.rb create mode 100644 Library/Homebrew/test/bundle/brewfile_spec.rb create mode 100644 Library/Homebrew/test/bundle/bundle_spec.rb create mode 100644 Library/Homebrew/test/bundle/cask_dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/cask_installer_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/add_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/check_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/check_spec.rbi create mode 100644 Library/Homebrew/test/bundle/commands/cleanup_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/dump_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/exec_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/install_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/list_spec.rb create mode 100644 Library/Homebrew/test/bundle/commands/remove_spec.rb create mode 100644 Library/Homebrew/test/bundle/dsl_spec.rb create mode 100644 Library/Homebrew/test/bundle/dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/mac_app_store_dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/mac_app_store_installer_spec.rb create mode 100644 Library/Homebrew/test/bundle/remover_spec.rb create mode 100644 Library/Homebrew/test/bundle/skipper_spec.rb create mode 100644 Library/Homebrew/test/bundle/tap_dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/tap_installer_spec.rb create mode 100644 Library/Homebrew/test/bundle/vscode_extension_installer_spec.rb create mode 100644 Library/Homebrew/test/bundle/whalebrew_dumper_spec.rb create mode 100644 Library/Homebrew/test/bundle/whalebrew_installer_spec.rb diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dd573af595..6524ff2817 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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 diff --git a/.devcontainer/on-create-command.sh b/.devcontainer/on-create-command.sh index db9856d3d3..038f71c80e 100755 --- a/.devcontainer/on-create-command.sh +++ b/.devcontainer/on-create-command.sh @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0de905a825..de788ce9b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/Library/.rubocop.yml b/Library/.rubocop.yml index 7259c42a9b..21fc551875 100644 --- a/Library/.rubocop.yml +++ b/Library/.rubocop.yml @@ -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 diff --git a/Library/Homebrew/bundle.rb b/Library/Homebrew/bundle.rb new file mode 100644 index 0000000000..0b5d9f20b1 --- /dev/null +++ b/Library/Homebrew/bundle.rb @@ -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" diff --git a/Library/Homebrew/bundle/adder.rb b/Library/Homebrew/bundle/adder.rb new file mode 100644 index 0000000000..6b86f37128 --- /dev/null +++ b/Library/Homebrew/bundle/adder.rb @@ -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 diff --git a/Library/Homebrew/bundle/brew_checker.rb b/Library/Homebrew/bundle/brew_checker.rb new file mode 100644 index 0000000000..8212361be2 --- /dev/null +++ b/Library/Homebrew/bundle/brew_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/brew_dumper.rb b/Library/Homebrew/bundle/brew_dumper.rb new file mode 100644 index 0000000000..34c34f62fa --- /dev/null +++ b/Library/Homebrew/bundle/brew_dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/brew_installer.rb b/Library/Homebrew/bundle/brew_installer.rb new file mode 100644 index 0000000000..641adebaf3 --- /dev/null +++ b/Library/Homebrew/bundle/brew_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/brew_service_checker.rb b/Library/Homebrew/bundle/brew_service_checker.rb new file mode 100644 index 0000000000..a55ff5f468 --- /dev/null +++ b/Library/Homebrew/bundle/brew_service_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/brew_services.rb b/Library/Homebrew/bundle/brew_services.rb new file mode 100644 index 0000000000..0583418339 --- /dev/null +++ b/Library/Homebrew/bundle/brew_services.rb @@ -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 diff --git a/Library/Homebrew/bundle/brewfile.rb b/Library/Homebrew/bundle/brewfile.rb new file mode 100644 index 0000000000..bf13beb1f5 --- /dev/null +++ b/Library/Homebrew/bundle/brewfile.rb @@ -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 diff --git a/Library/Homebrew/bundle/brewfile.rbi b/Library/Homebrew/bundle/brewfile.rbi new file mode 100644 index 0000000000..a3c87db1e3 --- /dev/null +++ b/Library/Homebrew/bundle/brewfile.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module Brewfile + include Kernel + end +end diff --git a/Library/Homebrew/bundle/bundle.rb b/Library/Homebrew/bundle/bundle.rb new file mode 100644 index 0000000000..c54f75a716 --- /dev/null +++ b/Library/Homebrew/bundle/bundle.rb @@ -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" diff --git a/Library/Homebrew/bundle/cask_checker.rb b/Library/Homebrew/bundle/cask_checker.rb new file mode 100644 index 0000000000..62bd4ba72c --- /dev/null +++ b/Library/Homebrew/bundle/cask_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/cask_dumper.rb b/Library/Homebrew/bundle/cask_dumper.rb new file mode 100644 index 0000000000..1a841ac1b1 --- /dev/null +++ b/Library/Homebrew/bundle/cask_dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/cask_dumper.rbi b/Library/Homebrew/bundle/cask_dumper.rbi new file mode 100644 index 0000000000..b654a05000 --- /dev/null +++ b/Library/Homebrew/bundle/cask_dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module CaskDumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/cask_installer.rb b/Library/Homebrew/bundle/cask_installer.rb new file mode 100644 index 0000000000..2c81a195fb --- /dev/null +++ b/Library/Homebrew/bundle/cask_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/cask_installer.rbi b/Library/Homebrew/bundle/cask_installer.rbi new file mode 100644 index 0000000000..d8951a01b5 --- /dev/null +++ b/Library/Homebrew/bundle/cask_installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module CaskInstaller + include Kernel + end +end diff --git a/Library/Homebrew/bundle/checker.rb b/Library/Homebrew/bundle/checker.rb new file mode 100644 index 0000000000..25ad007ba7 --- /dev/null +++ b/Library/Homebrew/bundle/checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/add.rb b/Library/Homebrew/bundle/commands/add.rb new file mode 100644 index 0000000000..6dffd7188c --- /dev/null +++ b/Library/Homebrew/bundle/commands/add.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/check.rb b/Library/Homebrew/bundle/commands/check.rb new file mode 100644 index 0000000000..722ec96241 --- /dev/null +++ b/Library/Homebrew/bundle/commands/check.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/cleanup.rb b/Library/Homebrew/bundle/commands/cleanup.rb new file mode 100644 index 0000000000..63f238e495 --- /dev/null +++ b/Library/Homebrew/bundle/commands/cleanup.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/commands.rbi b/Library/Homebrew/bundle/commands/commands.rbi new file mode 100644 index 0000000000..c81f637643 --- /dev/null +++ b/Library/Homebrew/bundle/commands/commands.rbi @@ -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 diff --git a/Library/Homebrew/bundle/commands/dump.rb b/Library/Homebrew/bundle/commands/dump.rb new file mode 100644 index 0000000000..41c699988f --- /dev/null +++ b/Library/Homebrew/bundle/commands/dump.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/exec.rb b/Library/Homebrew/bundle/commands/exec.rb new file mode 100644 index 0000000000..2babca0d67 --- /dev/null +++ b/Library/Homebrew/bundle/commands/exec.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/install.rb b/Library/Homebrew/bundle/commands/install.rb new file mode 100644 index 0000000000..e27fc976dd --- /dev/null +++ b/Library/Homebrew/bundle/commands/install.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/list.rb b/Library/Homebrew/bundle/commands/list.rb new file mode 100644 index 0000000000..34e69c8d90 --- /dev/null +++ b/Library/Homebrew/bundle/commands/list.rb @@ -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 diff --git a/Library/Homebrew/bundle/commands/remove.rb b/Library/Homebrew/bundle/commands/remove.rb new file mode 100644 index 0000000000..de555c04b8 --- /dev/null +++ b/Library/Homebrew/bundle/commands/remove.rb @@ -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 diff --git a/Library/Homebrew/bundle/dsl.rb b/Library/Homebrew/bundle/dsl.rb new file mode 100644 index 0000000000..5196c17499 --- /dev/null +++ b/Library/Homebrew/bundle/dsl.rb @@ -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 diff --git a/Library/Homebrew/bundle/dumper.rb b/Library/Homebrew/bundle/dumper.rb new file mode 100644 index 0000000000..baa3f6a51b --- /dev/null +++ b/Library/Homebrew/bundle/dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/dumper.rbi b/Library/Homebrew/bundle/dumper.rbi new file mode 100644 index 0000000000..7fff002c29 --- /dev/null +++ b/Library/Homebrew/bundle/dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module Dumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb new file mode 100644 index 0000000000..5a27992eee --- /dev/null +++ b/Library/Homebrew/bundle/installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/installer.rbi b/Library/Homebrew/bundle/installer.rbi new file mode 100644 index 0000000000..7de42fde6e --- /dev/null +++ b/Library/Homebrew/bundle/installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module Installer + include Kernel + end +end diff --git a/Library/Homebrew/bundle/lister.rb b/Library/Homebrew/bundle/lister.rb new file mode 100644 index 0000000000..23882715fc --- /dev/null +++ b/Library/Homebrew/bundle/lister.rb @@ -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 diff --git a/Library/Homebrew/bundle/lister.rbi b/Library/Homebrew/bundle/lister.rbi new file mode 100644 index 0000000000..01f928e8aa --- /dev/null +++ b/Library/Homebrew/bundle/lister.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module Lister + include Kernel + end +end diff --git a/Library/Homebrew/bundle/mac_app_store_checker.rb b/Library/Homebrew/bundle/mac_app_store_checker.rb new file mode 100644 index 0000000000..ba348c3220 --- /dev/null +++ b/Library/Homebrew/bundle/mac_app_store_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/mac_app_store_dumper.rb b/Library/Homebrew/bundle/mac_app_store_dumper.rb new file mode 100644 index 0000000000..88cc23d4fe --- /dev/null +++ b/Library/Homebrew/bundle/mac_app_store_dumper.rb @@ -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(?\d+)\s+(?.*?)\s+\((?[\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 diff --git a/Library/Homebrew/bundle/mac_app_store_dumper.rbi b/Library/Homebrew/bundle/mac_app_store_dumper.rbi new file mode 100644 index 0000000000..028b8c98de --- /dev/null +++ b/Library/Homebrew/bundle/mac_app_store_dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module MacAppStoreDumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/mac_app_store_installer.rb b/Library/Homebrew/bundle/mac_app_store_installer.rb new file mode 100644 index 0000000000..af094fafc4 --- /dev/null +++ b/Library/Homebrew/bundle/mac_app_store_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/mac_app_store_installer.rbi b/Library/Homebrew/bundle/mac_app_store_installer.rbi new file mode 100644 index 0000000000..63fb4f6570 --- /dev/null +++ b/Library/Homebrew/bundle/mac_app_store_installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module MacAppStoreInstaller + include Kernel + end +end diff --git a/Library/Homebrew/bundle/remover.rb b/Library/Homebrew/bundle/remover.rb new file mode 100644 index 0000000000..38851c3d27 --- /dev/null +++ b/Library/Homebrew/bundle/remover.rb @@ -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 diff --git a/Library/Homebrew/bundle/remover.rbi b/Library/Homebrew/bundle/remover.rbi new file mode 100644 index 0000000000..853585190f --- /dev/null +++ b/Library/Homebrew/bundle/remover.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module Remover + include Kernel + end +end diff --git a/Library/Homebrew/bundle/skipper.rb b/Library/Homebrew/bundle/skipper.rb new file mode 100644 index 0000000000..cbebe21519 --- /dev/null +++ b/Library/Homebrew/bundle/skipper.rb @@ -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" diff --git a/Library/Homebrew/bundle/tap_checker.rb b/Library/Homebrew/bundle/tap_checker.rb new file mode 100644 index 0000000000..81f0a59550 --- /dev/null +++ b/Library/Homebrew/bundle/tap_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/tap_dumper.rb b/Library/Homebrew/bundle/tap_dumper.rb new file mode 100644 index 0000000000..5c70c68edd --- /dev/null +++ b/Library/Homebrew/bundle/tap_dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/tap_dumper.rbi b/Library/Homebrew/bundle/tap_dumper.rbi new file mode 100644 index 0000000000..6a6b2e0801 --- /dev/null +++ b/Library/Homebrew/bundle/tap_dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module TapDumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/tap_installer.rb b/Library/Homebrew/bundle/tap_installer.rb new file mode 100644 index 0000000000..567e1fd0f6 --- /dev/null +++ b/Library/Homebrew/bundle/tap_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/tap_installer.rbi b/Library/Homebrew/bundle/tap_installer.rbi new file mode 100644 index 0000000000..964d0615ff --- /dev/null +++ b/Library/Homebrew/bundle/tap_installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module TapInstaller + include Kernel + end +end diff --git a/Library/Homebrew/bundle/vscode_extension_checker.rb b/Library/Homebrew/bundle/vscode_extension_checker.rb new file mode 100644 index 0000000000..9b78a3e4a0 --- /dev/null +++ b/Library/Homebrew/bundle/vscode_extension_checker.rb @@ -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 diff --git a/Library/Homebrew/bundle/vscode_extension_dumper.rb b/Library/Homebrew/bundle/vscode_extension_dumper.rb new file mode 100644 index 0000000000..5b1ee3c42e --- /dev/null +++ b/Library/Homebrew/bundle/vscode_extension_dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/vscode_extension_dumper.rbi b/Library/Homebrew/bundle/vscode_extension_dumper.rbi new file mode 100644 index 0000000000..cdfcd44483 --- /dev/null +++ b/Library/Homebrew/bundle/vscode_extension_dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module VscodeExtensionDumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/vscode_extension_installer.rb b/Library/Homebrew/bundle/vscode_extension_installer.rb new file mode 100644 index 0000000000..b27c3b04de --- /dev/null +++ b/Library/Homebrew/bundle/vscode_extension_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/vscode_extension_installer.rbi b/Library/Homebrew/bundle/vscode_extension_installer.rbi new file mode 100644 index 0000000000..946ce4e445 --- /dev/null +++ b/Library/Homebrew/bundle/vscode_extension_installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module VscodeExtensionInstaller + include Kernel + end +end diff --git a/Library/Homebrew/bundle/whalebrew_dumper.rb b/Library/Homebrew/bundle/whalebrew_dumper.rb new file mode 100644 index 0000000000..d8935ac261 --- /dev/null +++ b/Library/Homebrew/bundle/whalebrew_dumper.rb @@ -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 diff --git a/Library/Homebrew/bundle/whalebrew_dumper.rbi b/Library/Homebrew/bundle/whalebrew_dumper.rbi new file mode 100644 index 0000000000..d23c516811 --- /dev/null +++ b/Library/Homebrew/bundle/whalebrew_dumper.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module WhalebrewDumper + include Kernel + end +end diff --git a/Library/Homebrew/bundle/whalebrew_installer.rb b/Library/Homebrew/bundle/whalebrew_installer.rb new file mode 100644 index 0000000000..d6425ee10d --- /dev/null +++ b/Library/Homebrew/bundle/whalebrew_installer.rb @@ -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 diff --git a/Library/Homebrew/bundle/whalebrew_installer.rbi b/Library/Homebrew/bundle/whalebrew_installer.rbi new file mode 100644 index 0000000000..f029628240 --- /dev/null +++ b/Library/Homebrew/bundle/whalebrew_installer.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew::Bundle + module WhalebrewInstaller + include Kernel + end +end diff --git a/Library/Homebrew/cmd/bundle.rb b/Library/Homebrew/cmd/bundle.rb new file mode 100755 index 0000000000..fdb8fb2613 --- /dev/null +++ b/Library/Homebrew/cmd/bundle.rb @@ -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` [] + + 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` [...]: + Add entries to your `Brewfile`. Adds formulae by default. Use `--cask`, `--tap`, `--whalebrew` or `--vscode` to add the corresponding entry instead. + + `brew bundle remove` [...]: + 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` : + 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 diff --git a/Library/Homebrew/diagnostic.rb b/Library/Homebrew/diagnostic.rb index bb00169fce..bc42e3e795 100644 --- a/Library/Homebrew/diagnostic.rb +++ b/Library/Homebrew/diagnostic.rb @@ -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 diff --git a/Library/Homebrew/extend/os/bundle/bundle.rb b/Library/Homebrew/extend/os/bundle/bundle.rb new file mode 100644 index 0000000000..4ce728818e --- /dev/null +++ b/Library/Homebrew/extend/os/bundle/bundle.rb @@ -0,0 +1,4 @@ +# typed: strict +# frozen_string_literal: true + +require "extend/os/linux/bundle/bundle" if OS.linux? diff --git a/Library/Homebrew/extend/os/bundle/skipper.rb b/Library/Homebrew/extend/os/bundle/skipper.rb new file mode 100644 index 0000000000..7439283480 --- /dev/null +++ b/Library/Homebrew/extend/os/bundle/skipper.rb @@ -0,0 +1,4 @@ +# typed: strict +# frozen_string_literal: true + +require "extend/os/linux/bundle/skipper" if OS.linux? diff --git a/Library/Homebrew/extend/os/linux/bundle/bundle.rb b/Library/Homebrew/extend/os/linux/bundle/bundle.rb new file mode 100644 index 0000000000..71c2e41625 --- /dev/null +++ b/Library/Homebrew/extend/os/linux/bundle/bundle.rb @@ -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) diff --git a/Library/Homebrew/extend/os/linux/bundle/skipper.rb b/Library/Homebrew/extend/os/linux/bundle/skipper.rb new file mode 100644 index 0000000000..6e1b266c66 --- /dev/null +++ b/Library/Homebrew/extend/os/linux/bundle/skipper.rb @@ -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) diff --git a/Library/Homebrew/official_taps.rb b/Library/Homebrew/official_taps.rb index 41b55def45..5c4d081030 100644 --- a/Library/Homebrew/official_taps.rb +++ b/Library/Homebrew/official_taps.rb @@ -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 diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/bundle.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/bundle.rbi new file mode 100644 index 0000000000..99b7673e46 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/bundle.rbi @@ -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 diff --git a/Library/Homebrew/sorbet/rbi/dsl/rubo_cop/cask/ast/stanza.rbi b/Library/Homebrew/sorbet/rbi/dsl/rubo_cop/cask/ast/stanza.rbi index 99bb396d90..80511ce997 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/rubo_cop/cask/ast/stanza.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/rubo_cop/cask/ast/stanza.rbi @@ -126,6 +126,9 @@ class RuboCop::Cask::AST::Stanza sig { returns(T::Boolean) } def on_ventura?; end + sig { returns(T::Boolean) } + def os?; end + sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } def parent_node(*args, &block); end diff --git a/Library/Homebrew/test/bundle/brew_dumper_spec.rb b/Library/Homebrew/test/bundle/brew_dumper_spec.rb new file mode 100644 index 0000000000..f86af50f93 --- /dev/null +++ b/Library/Homebrew/test/bundle/brew_dumper_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/brew_installer_spec.rb b/Library/Homebrew/test/bundle/brew_installer_spec.rb new file mode 100644 index 0000000000..6e9b5c945c --- /dev/null +++ b/Library/Homebrew/test/bundle/brew_installer_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/brew_services_spec.rb b/Library/Homebrew/test/bundle/brew_services_spec.rb new file mode 100644 index 0000000000..86b6d15c34 --- /dev/null +++ b/Library/Homebrew/test/bundle/brew_services_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/brewfile_spec.rb b/Library/Homebrew/test/bundle/brewfile_spec.rb new file mode 100644 index 0000000000..1aae4ebfb2 --- /dev/null +++ b/Library/Homebrew/test/bundle/brewfile_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/bundle_spec.rb b/Library/Homebrew/test/bundle/bundle_spec.rb new file mode 100644 index 0000000000..fa03a48ce7 --- /dev/null +++ b/Library/Homebrew/test/bundle/bundle_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/cask_dumper_spec.rb b/Library/Homebrew/test/bundle/cask_dumper_spec.rb new file mode 100644 index 0000000000..cc8fd5663e --- /dev/null +++ b/Library/Homebrew/test/bundle/cask_dumper_spec.rb @@ -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 "doesn’t 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 "doesn’t 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 diff --git a/Library/Homebrew/test/bundle/cask_installer_spec.rb b/Library/Homebrew/test/bundle/cask_installer_spec.rb new file mode 100644 index 0000000000..ed35c3bd93 --- /dev/null +++ b/Library/Homebrew/test/bundle/cask_installer_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/add_spec.rb b/Library/Homebrew/test/bundle/commands/add_spec.rb new file mode 100644 index 0000000000..c011a7f350 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/add_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/check_spec.rb b/Library/Homebrew/test/bundle/commands/check_spec.rb new file mode 100644 index 0000000000..ee98b95cf9 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/check_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/check_spec.rbi b/Library/Homebrew/test/bundle/commands/check_spec.rbi new file mode 100644 index 0000000000..760c48b902 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/check_spec.rbi @@ -0,0 +1,4 @@ +# typed: strict + +class TestChecker < Homebrew::Bundle::Checker::Base +end diff --git a/Library/Homebrew/test/bundle/commands/cleanup_spec.rb b/Library/Homebrew/test/bundle/commands/cleanup_spec.rb new file mode 100644 index 0000000000..95f875cfcb --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/cleanup_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/dump_spec.rb b/Library/Homebrew/test/bundle/commands/dump_spec.rb new file mode 100644 index 0000000000..9555c40ff3 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/dump_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/exec_spec.rb b/Library/Homebrew/test/bundle/commands/exec_spec.rb new file mode 100644 index 0000000000..f7ae3e9d2b --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/exec_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/install_spec.rb b/Library/Homebrew/test/bundle/commands/install_spec.rb new file mode 100644 index 0000000000..7ad0a070ca --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/install_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/list_spec.rb b/Library/Homebrew/test/bundle/commands/list_spec.rb new file mode 100644 index 0000000000..c4ed592453 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/list_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/commands/remove_spec.rb b/Library/Homebrew/test/bundle/commands/remove_spec.rb new file mode 100644 index 0000000000..69ff15f278 --- /dev/null +++ b/Library/Homebrew/test/bundle/commands/remove_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/dsl_spec.rb b/Library/Homebrew/test/bundle/dsl_spec.rb new file mode 100644 index 0000000000..18a08806af --- /dev/null +++ b/Library/Homebrew/test/bundle/dsl_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/dumper_spec.rb b/Library/Homebrew/test/bundle/dumper_spec.rb new file mode 100644 index 0000000000..31c8d1fcdb --- /dev/null +++ b/Library/Homebrew/test/bundle/dumper_spec.rb @@ -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 diff --git a/Library/Homebrew/test/bundle/mac_app_store_dumper_spec.rb b/Library/Homebrew/test/bundle/mac_app_store_dumper_spec.rb new file mode 100644 index 0000000000..52a53e64a0 --- /dev/null +++ b/Library/Homebrew/test/bundle/mac_app_store_dumper_spec.rb @@ -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>‎/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 diff --git a/Library/Homebrew/test/bundle/whalebrew_installer_spec.rb b/Library/Homebrew/test/bundle/whalebrew_installer_spec.rb new file mode 100644 index 0000000000..4e9528c14c --- /dev/null +++ b/Library/Homebrew/test/bundle/whalebrew_installer_spec.rb @@ -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 diff --git a/Library/Homebrew/test/cmd/bundle_spec.rb b/Library/Homebrew/test/cmd/bundle_spec.rb index 90f040273d..1c6d80a76c 100644 --- a/Library/Homebrew/test/cmd/bundle_spec.rb +++ b/Library/Homebrew/test/cmd/bundle_spec.rb @@ -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" diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 4e0a49410c..1e92a49938 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -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 @@ -152,10 +152,10 @@ A `Hash` (e.g. `=>`) adds information to a dependency. Given a string or symbol, * `:optional` (not allowed in `Homebrew/homebrew-core`) generates an implicit `with-foo` option for the formula. This means that, given `depends_on "foo" => :optional`, the user must pass `--with-foo` to use the dependency. * `:recommended` (not allowed in `Homebrew/homebrew-core`) generates an implicit `without-foo` option, meaning that the dependency is enabled by default and the user must pass `--without-foo` to disable this dependency. The default description can be overridden using the [`option`](https://rubydoc.brew.sh/Formula#option-class_method) syntax (in this case, the [`option` declaration](#adding-optional-steps) must precede the dependency): - ```ruby +```ruby option "with-foo", "Compile with foo bindings" # This overrides the generated description if you want to depends_on "foo" => :optional # Generated description would otherwise be "Build with foo support" - ``` +``` * `""` (not allowed in `Homebrew/homebrew-core`) requires a dependency to have been built with the specified option. @@ -950,27 +950,27 @@ Several other utilities for Ruby's [`Pathname`](https://rubydoc.brew.sh/Pathname * To perform several operations within a directory, enclose them within a [`cd do`](https://rubydoc.brew.sh/Pathname#cd-instance_method) block: - ```ruby +```ruby cd "src" do system "./configure", "--disable-debug", "--prefix=#{prefix}" system "make", "install" end - ``` +``` * To surface one or more binaries buried in `libexec` or a macOS `.app` package, use [`write_exec_script`](https://rubydoc.brew.sh/Pathname#write_exec_script-instance_method) or [`write_jar_script`](https://rubydoc.brew.sh/Pathname#write_jar_script-instance_method): - ```ruby +```ruby bin.write_exec_script (libexec/"bin").children bin.write_exec_script prefix/"Package.app/Contents/MacOS/package" bin.write_jar_script libexec/jar_file, "jarfile", java_version: "11" - ``` +``` * For binaries that require setting one or more environment variables to function properly, use [`write_env_script`](https://rubydoc.brew.sh/Pathname#write_env_script-instance_method) or [`env_script_all_files`](https://rubydoc.brew.sh/Pathname#env_script_all_files-instance_method): - ```ruby +```ruby (bin/"package").write_env_script libexec/"package", PACKAGE_ROOT: libexec bin.env_script_all_files(libexec/"bin", PERL5LIB: ENV.fetch("PERL5LIB", nil)) - ``` +``` ### Rewriting a script shebang @@ -1046,34 +1046,34 @@ There are two ways to add `launchd` plists and `systemd` services to a formula, 1. If the package already provides a service file the formula can reference it by name: - ```ruby +```ruby service do name macos: "custom.launchd.name", linux: "custom.systemd.name" end - ``` +``` To find the file we append `.plist` to the `launchd` service name and `.service` to the `systemd` service name internally. 2. If the formula does not provide a service file you can generate one using the following stanza: - ```ruby - # 1. An individual command +```ruby +# 1. An individual command service do run opt_bin/"script" end - # 2. A command with arguments +# 2. A command with arguments service do run [opt_bin/"script", "--config", etc/"dir/config.yml"] end - # 3. OS specific commands (If you omit one, the service file won't get generated for that OS.) +# 3. OS specific commands (If you omit one, the service file won't get generated for that OS.) service do run macos: [opt_bin/"macos_script", "standalone"], linux: var/"special_linux_script" end - ``` +``` #### Service block methods