342 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			342 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # typed: true
 | |
| # frozen_string_literal: true
 | |
| 
 | |
| # Never `require` anything in this file (except English). It needs to be able to
 | |
| # work as the first item in `brew.rb` so we can load gems with Bundler when
 | |
| # needed before anything else is loaded (e.g. `json`).
 | |
| 
 | |
| require "English"
 | |
| 
 | |
| module Homebrew
 | |
|   # Keep in sync with the `Gemfile.lock`'s BUNDLED WITH.
 | |
|   # After updating this, run `brew vendor-gems --update=--bundler`.
 | |
|   HOMEBREW_BUNDLER_VERSION = "2.4.18"
 | |
| 
 | |
|   # Bump this whenever a committed vendored gem is later added to or exclusion removed from gitignore.
 | |
|   # This will trigger it to reinstall properly if `brew install-bundler-gems` needs it.
 | |
|   VENDOR_VERSION = 6
 | |
|   private_constant :VENDOR_VERSION
 | |
| 
 | |
|   RUBY_BUNDLE_VENDOR_DIRECTORY = (HOMEBREW_LIBRARY_PATH/"vendor/bundle/ruby").freeze
 | |
|   private_constant :RUBY_BUNDLE_VENDOR_DIRECTORY
 | |
| 
 | |
|   # This is tracked across Ruby versions.
 | |
|   GEM_GROUPS_FILE = (RUBY_BUNDLE_VENDOR_DIRECTORY/".homebrew_gem_groups").freeze
 | |
|   private_constant :GEM_GROUPS_FILE
 | |
| 
 | |
|   # This is tracked per Ruby version.
 | |
|   VENDOR_VERSION_FILE = (
 | |
|     RUBY_BUNDLE_VENDOR_DIRECTORY/"#{RbConfig::CONFIG["ruby_version"]}/.homebrew_vendor_version"
 | |
|   ).freeze
 | |
|   private_constant :VENDOR_VERSION_FILE
 | |
| 
 | |
|   module_function
 | |
| 
 | |
|   # @api private
 | |
|   def gemfile
 | |
|     File.join(ENV.fetch("HOMEBREW_LIBRARY"), "Homebrew", "Gemfile")
 | |
|   end
 | |
| 
 | |
|   # @api private
 | |
|   def bundler_definition
 | |
|     @bundler_definition ||= Bundler::Definition.build(Bundler.default_gemfile, Bundler.default_lockfile, false)
 | |
|   end
 | |
| 
 | |
|   # @api private
 | |
|   def valid_gem_groups
 | |
|     install_bundler!
 | |
|     require "bundler"
 | |
| 
 | |
|     Bundler.with_unbundled_env do
 | |
|       ENV["BUNDLE_GEMFILE"] = gemfile
 | |
|       groups = bundler_definition.groups
 | |
|       groups.delete(:default)
 | |
|       groups.map(&:to_s)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def ruby_bindir
 | |
|     "#{RbConfig::CONFIG["prefix"]}/bin"
 | |
|   end
 | |
| 
 | |
|   def ohai_if_defined(message)
 | |
|     if defined?(ohai)
 | |
|       $stderr.ohai message
 | |
|     else
 | |
|       $stderr.puts "==> #{message}"
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def opoo_if_defined(message)
 | |
|     if defined?(opoo)
 | |
|       $stderr.opoo message
 | |
|     else
 | |
|       $stderr.puts "Warning: #{message}"
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def odie_if_defined(message)
 | |
|     if defined?(odie)
 | |
|       odie message
 | |
|     else
 | |
|       $stderr.puts "Error: #{message}"
 | |
|       exit 1
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def setup_gem_environment!(setup_path: true)
 | |
|     require "rubygems"
 | |
|     raise "RubyGems too old!" if Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.2.0")
 | |
| 
 | |
|     ENV["BUNDLER_NO_OLD_RUBYGEMS_WARNING"] = "1"
 | |
| 
 | |
|     # Match where our bundler gems are.
 | |
|     gem_home = "#{RUBY_BUNDLE_VENDOR_DIRECTORY}/#{RbConfig::CONFIG["ruby_version"]}"
 | |
|     Gem.paths = {
 | |
|       "GEM_HOME" => gem_home,
 | |
|       "GEM_PATH" => gem_home,
 | |
|     }
 | |
| 
 | |
|     # Set TMPDIR so Xcode's `make` doesn't fall back to `/var/tmp/`,
 | |
|     # which may be not user-writable.
 | |
|     ENV["TMPDIR"] = ENV.fetch("HOMEBREW_TEMP", nil)
 | |
| 
 | |
|     return unless setup_path
 | |
| 
 | |
|     # Add necessary Ruby and Gem binary directories to `PATH`.
 | |
|     paths = ENV.fetch("PATH").split(":")
 | |
|     paths.unshift(ruby_bindir) unless paths.include?(ruby_bindir)
 | |
|     paths.unshift(Gem.bindir) unless paths.include?(Gem.bindir)
 | |
|     ENV["PATH"] = paths.compact.join(":")
 | |
| 
 | |
|     # Set envs so the above binaries can be invoked.
 | |
|     # We don't do this unless requested as some formulae may invoke system Ruby instead of ours.
 | |
|     ENV["GEM_HOME"] = gem_home
 | |
|     ENV["GEM_PATH"] = gem_home
 | |
|   end
 | |
| 
 | |
|   def install_gem!(name, version: nil, setup_gem_environment: true)
 | |
|     setup_gem_environment! if setup_gem_environment
 | |
| 
 | |
|     specs = Gem::Specification.find_all_by_name(name, version)
 | |
| 
 | |
|     if specs.empty?
 | |
|       ohai_if_defined "Installing '#{name}' gem"
 | |
|       # `document: []` is equivalent to --no-document
 | |
|       # `build_args: []` stops ARGV being used as a default
 | |
|       # `env_shebang: true` makes shebangs generic to allow switching between system and Portable Ruby
 | |
|       specs = Gem.install name, version, document: [], build_args: [], env_shebang: true
 | |
|     end
 | |
| 
 | |
|     specs += specs.flat_map(&:runtime_dependencies)
 | |
|                   .flat_map(&:to_specs)
 | |
| 
 | |
|     # Add the specs to the $LOAD_PATH.
 | |
|     specs.each do |spec|
 | |
|       spec.require_paths.each do |path|
 | |
|         full_path = File.join(spec.full_gem_path, path)
 | |
|         $LOAD_PATH.unshift full_path unless $LOAD_PATH.include?(full_path)
 | |
|       end
 | |
|     end
 | |
|   rescue Gem::UnsatisfiableDependencyError
 | |
|     odie_if_defined "failed to install the '#{name}' gem."
 | |
|   end
 | |
| 
 | |
|   def install_gem_setup_path!(name, version: nil, executable: name, setup_gem_environment: true)
 | |
|     install_gem!(name, version: version, setup_gem_environment: setup_gem_environment)
 | |
|     return if find_in_path(executable)
 | |
| 
 | |
|     odie_if_defined <<~EOS
 | |
|       the '#{name}' gem is installed but couldn't find '#{executable}' in the PATH:
 | |
|         #{ENV.fetch("PATH")}
 | |
|     EOS
 | |
|   end
 | |
| 
 | |
|   def find_in_path(executable)
 | |
|     ENV.fetch("PATH").split(":").find do |path|
 | |
|       File.executable?(File.join(path, executable))
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def install_bundler!
 | |
|     old_bundler_version = ENV.fetch("BUNDLER_VERSION", nil)
 | |
| 
 | |
|     setup_gem_environment!
 | |
| 
 | |
|     ENV["BUNDLER_VERSION"] = HOMEBREW_BUNDLER_VERSION # Set so it correctly finds existing installs
 | |
|     install_gem_setup_path!(
 | |
|       "bundler",
 | |
|       version:               HOMEBREW_BUNDLER_VERSION,
 | |
|       executable:            "bundle",
 | |
|       setup_gem_environment: false,
 | |
|     )
 | |
|   ensure
 | |
|     ENV["BUNDLER_VERSION"] = old_bundler_version
 | |
|   end
 | |
| 
 | |
|   def user_gem_groups
 | |
|     @user_gem_groups ||= if GEM_GROUPS_FILE.exist?
 | |
|       GEM_GROUPS_FILE.readlines(chomp: true)
 | |
|     else
 | |
|       []
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def write_user_gem_groups(groups)
 | |
|     return if @user_gem_groups == groups && GEM_GROUPS_FILE.exist?
 | |
| 
 | |
|     # Write the file atomically, in case we're working parallel
 | |
|     require "tempfile"
 | |
|     tmpfile = Tempfile.new([GEM_GROUPS_FILE.basename.to_s, "~"], GEM_GROUPS_FILE.dirname)
 | |
|     path = tmpfile.path
 | |
|     return if path.nil?
 | |
| 
 | |
|     require "fileutils"
 | |
|     begin
 | |
|       FileUtils.chmod("+r", path)
 | |
|       tmpfile.write(groups.join("\n"))
 | |
|       tmpfile.close
 | |
|       File.rename(path, GEM_GROUPS_FILE)
 | |
|     ensure
 | |
|       tmpfile.unlink
 | |
|     end
 | |
| 
 | |
|     @user_gem_groups = groups
 | |
|   end
 | |
| 
 | |
|   def forget_user_gem_groups!
 | |
|     GEM_GROUPS_FILE.truncate(0) if GEM_GROUPS_FILE.exist?
 | |
|     @user_gem_groups = []
 | |
|   end
 | |
| 
 | |
|   def user_vendor_version
 | |
|     @user_vendor_version ||= if VENDOR_VERSION_FILE.exist?
 | |
|       VENDOR_VERSION_FILE.read.to_i
 | |
|     else
 | |
|       0
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def install_bundler_gems!(only_warn_on_failure: false, setup_path: true, groups: [])
 | |
|     old_path = ENV.fetch("PATH", nil)
 | |
|     old_gem_path = ENV.fetch("GEM_PATH", nil)
 | |
|     old_gem_home = ENV.fetch("GEM_HOME", nil)
 | |
|     old_bundle_gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
 | |
|     old_bundle_with = ENV.fetch("BUNDLE_WITH", nil)
 | |
|     old_bundle_frozen = ENV.fetch("BUNDLE_FROZEN", nil)
 | |
| 
 | |
|     invalid_groups = groups - valid_gem_groups
 | |
|     raise ArgumentError, "Invalid gem groups: #{invalid_groups.join(", ")}" unless invalid_groups.empty?
 | |
| 
 | |
|     # tests should not modify the state of the repo
 | |
|     if ENV["HOMEBREW_TESTS"]
 | |
|       setup_gem_environment!
 | |
|       return
 | |
|     end
 | |
| 
 | |
|     install_bundler!
 | |
| 
 | |
|     # Combine the passed groups with the ones stored in settings
 | |
|     groups |= (user_gem_groups & valid_gem_groups)
 | |
|     groups.sort!
 | |
| 
 | |
|     ENV["BUNDLE_GEMFILE"] = gemfile
 | |
|     ENV["BUNDLE_WITH"] = groups.join(" ")
 | |
|     ENV["BUNDLE_FROZEN"] = "true"
 | |
| 
 | |
|     if @bundle_installed_groups != groups
 | |
|       bundle = File.join(find_in_path("bundle"), "bundle")
 | |
|       bundle_check_output = `#{bundle} check 2>&1`
 | |
|       bundle_check_failed = !$CHILD_STATUS.success?
 | |
| 
 | |
|       # for some reason sometimes the exit code lies so check the output too.
 | |
|       bundle_install_required = bundle_check_failed || bundle_check_output.include?("Install missing gems")
 | |
| 
 | |
|       if user_vendor_version != VENDOR_VERSION
 | |
|         # Check if the install is intact. This is useful if any gems are added to gitignore.
 | |
|         # We intentionally map over everything and then call `any?` so that we remove the spec of each bad gem.
 | |
|         specs = bundler_definition.resolve.materialize(bundler_definition.locked_dependencies)
 | |
|         vendor_reinstall_required = specs.map do |spec|
 | |
|           spec_file = "#{Gem.dir}/specifications/#{spec.full_name}.gemspec"
 | |
|           next false unless File.exist?(spec_file)
 | |
| 
 | |
|           cache_file = "#{Gem.dir}/cache/#{spec.full_name}.gem"
 | |
|           if File.exist?(cache_file)
 | |
|             require "rubygems/package"
 | |
|             package = Gem::Package.new(cache_file)
 | |
| 
 | |
|             package_install_intact = begin
 | |
|               contents = package.contents
 | |
| 
 | |
|               # If the gem has contents, ensure we have every file installed it contains.
 | |
|               contents&.all? do |gem_file|
 | |
|                 File.exist?("#{Gem.dir}/gems/#{spec.full_name}/#{gem_file}")
 | |
|               end
 | |
|             rescue Gem::Package::Error, Gem::Security::Exception
 | |
|               # Malformed, assume broken
 | |
|               File.unlink(cache_file)
 | |
|               false
 | |
|             end
 | |
| 
 | |
|             next false if package_install_intact
 | |
|           end
 | |
| 
 | |
|           # Mark gem for reinstallation
 | |
|           File.unlink(spec_file)
 | |
|           true
 | |
|         end.any?
 | |
| 
 | |
|         VENDOR_VERSION_FILE.dirname.mkpath
 | |
|         VENDOR_VERSION_FILE.write(VENDOR_VERSION.to_s)
 | |
| 
 | |
|         bundle_install_required ||= vendor_reinstall_required
 | |
|       end
 | |
| 
 | |
|       bundle_installed = if bundle_install_required
 | |
|         if system bundle, "install", out: :err
 | |
|           true
 | |
|         else
 | |
|           message = <<~EOS
 | |
|             failed to run `#{bundle} install`!
 | |
|           EOS
 | |
|           if only_warn_on_failure
 | |
|             opoo_if_defined message
 | |
|           else
 | |
|             odie_if_defined message
 | |
|           end
 | |
|           false
 | |
|         end
 | |
|       elsif system bundle, "clean", out: :err # even if we have nothing to install, we may have removed gems
 | |
|         true
 | |
|       else
 | |
|         message = <<~EOS
 | |
|           failed to run `#{bundle} clean`!
 | |
|         EOS
 | |
|         if only_warn_on_failure
 | |
|           opoo_if_defined message
 | |
|         else
 | |
|           odie_if_defined message
 | |
|         end
 | |
|         false
 | |
|       end
 | |
| 
 | |
|       if bundle_installed
 | |
|         write_user_gem_groups(groups)
 | |
|         @bundle_installed_groups = groups
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     setup_gem_environment!
 | |
|   ensure
 | |
|     unless setup_path
 | |
|       # Reset the paths. We need to have at least temporarily changed them while invoking `bundle`.
 | |
|       ENV["PATH"] = old_path
 | |
|       ENV["GEM_PATH"] = old_gem_path
 | |
|       ENV["GEM_HOME"] = old_gem_home
 | |
|       ENV["BUNDLE_GEMFILE"] = old_bundle_gemfile
 | |
|       ENV["BUNDLE_WITH"] = old_bundle_with
 | |
|       ENV["BUNDLE_FROZEN"] = old_bundle_frozen
 | |
|     end
 | |
|   end
 | |
| end
 | 
