diff options
Diffstat (limited to 'lib/bundler/definition.rb')
| -rw-r--r-- | lib/bundler/definition.rb | 1329 |
1 files changed, 1329 insertions, 0 deletions
diff --git a/lib/bundler/definition.rb b/lib/bundler/definition.rb new file mode 100644 index 0000000000..7a95671471 --- /dev/null +++ b/lib/bundler/definition.rb @@ -0,0 +1,1329 @@ +# frozen_string_literal: true + +require_relative "lockfile_parser" +require_relative "worker" + +module Bundler + class Definition + class << self + # Do not create or modify a lockfile (Makes #lock a noop) + attr_accessor :no_lock + end + + attr_writer :lockfile, :overrides + + attr_reader( + :dependencies, + :locked_checksums, + :locked_deps, + :locked_gems, + :overrides, + :platforms, + :ruby_version, + :lockfile, + :gemfiles, + :sources + ) + + # Given a gemfile and lockfile creates a Bundler definition + # + # @param gemfile [Pathname] Path to Gemfile + # @param lockfile [Pathname,nil] Path to Gemfile.lock + # @param unlock [Hash, Boolean, nil] Gems that have been requested + # to be updated or true if all gems should be updated + # @return [Bundler::Definition] + def self.build(gemfile, lockfile, unlock) + unlock ||= {} + gemfile = Pathname.new(gemfile).expand_path + + raise GemfileNotFound, "#{gemfile} not found" unless gemfile.file? + + Plugin.hook(Plugin::Events::GEM_BEFORE_EVAL, gemfile, lockfile) + Dsl.evaluate(gemfile, lockfile, unlock).tap do |definition| + Plugin.hook(Plugin::Events::GEM_AFTER_EVAL, definition) + end + end + + # + # How does the new system work? + # + # * Load information from Gemfile and Lockfile + # * Invalidate stale locked specs + # * All specs from stale source are stale + # * All specs that are reachable only through a stale + # dependency are stale. + # * If all fresh dependencies are satisfied by the locked + # specs, then we can try to resolve locally. + # + # @param lockfile [Pathname] Path to Gemfile.lock + # @param dependencies [Array(Bundler::Dependency)] array of dependencies from Gemfile + # @param sources [Bundler::SourceList] + # @param unlock [Hash, Boolean, nil] Gems that have been requested + # to be updated or true if all gems should be updated + # @param ruby_version [Bundler::RubyVersion, nil] Requested Ruby Version + # @param optional_groups [Array(String)] A list of optional groups + def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, optional_groups = [], gemfiles = [], overrides = []) + unlock ||= {} + + if unlock == true + @unlocking_all = true + strict = false + @unlocking_bundler = false + @unlocking = unlock + @sources_to_unlock = [] + @unlocking_ruby = false + @explicit_unlocks = [] + conservative = false + else + @unlocking_all = false + strict = unlock.delete(:strict) + @unlocking_bundler = unlock.delete(:bundler) + @unlocking = unlock.any? {|_k, v| !Array(v).empty? } + @sources_to_unlock = unlock.delete(:sources) || [] + @unlocking_ruby = unlock.delete(:ruby) + @explicit_unlocks = unlock.delete(:gems) || [] + conservative = unlock.delete(:conservative) + end + + @dependencies = dependencies + @sources = sources + @optional_groups = optional_groups + @prefer_local = false + @specs = nil + @ruby_version = ruby_version + @gemfiles = gemfiles + @overrides = overrides + + @lockfile = lockfile + @lockfile_contents = String.new + + @locked_bundler_version = nil + @resolved_bundler_version = nil + + @locked_ruby_version = nil + @new_platforms = [] + @removed_platforms = [] + @originally_invalid_platforms = [] + + if lockfile_exists? + @lockfile_contents = Bundler.read_file(lockfile) + @locked_gems = LockfileParser.new(@lockfile_contents, strict: strict) + @locked_platforms = @locked_gems.platforms + @most_specific_locked_platform = @locked_gems.most_specific_locked_platform + @platforms = @locked_platforms.dup + @locked_bundler_version = @locked_gems.bundler_version + @locked_ruby_version = @locked_gems.ruby_version + @locked_deps = @locked_gems.dependencies + Override.attach(@locked_gems.specs, @overrides) + @originally_locked_specs = SpecSet.new(@locked_gems.specs) + @originally_locked_sources = @locked_gems.sources + @locked_checksums = @locked_gems.checksums + + if @unlocking_all + @locked_specs = SpecSet.new([]) + @locked_sources = [] + else + @locked_specs = @originally_locked_specs + @locked_sources = @originally_locked_sources + end + + locked_gem_sources = @originally_locked_sources.select {|s| s.is_a?(Source::Rubygems) } + multisource_lockfile = locked_gem_sources.size == 1 && locked_gem_sources.first.multiple_remotes? + + if multisource_lockfile + msg = "Your lockfile contains a single rubygems source section with multiple remotes, which is insecure. Make sure you run `bundle install` in non frozen mode and commit the result to make your lockfile secure." + + Bundler::SharedHelpers.feature_removed! msg + end + else + @locked_gems = nil + @locked_platforms = [] + @most_specific_locked_platform = nil + @platforms = [] + @locked_deps = {} + @locked_specs = SpecSet.new([]) + @locked_sources = [] + @originally_locked_specs = @locked_specs + @originally_locked_sources = @locked_sources + @locked_checksums = Bundler.settings[:lockfile_checksums] + end + + @unlocking_ruby ||= if @ruby_version && locked_ruby_version_object + @ruby_version.diff(locked_ruby_version_object) + end + @unlocking ||= @unlocking_ruby ||= (!@locked_ruby_version ^ !@ruby_version) + + @current_platform_missing = add_current_platform unless Bundler.frozen_bundle? + + @source_changes = converge_sources + @path_changes = converge_paths + + if conservative + @gems_to_unlock = @explicit_unlocks.any? ? @explicit_unlocks : @dependencies.map(&:name) + else + eager_unlock = @explicit_unlocks.map {|name| Dependency.new(name, ">= 0") } + @gems_to_unlock = @locked_specs.for(eager_unlock, platforms).map(&:name).uniq + end + + @dependency_changes = converge_dependencies + @local_changes = converge_locals + + check_lockfile + end + + def gem_version_promoter + @gem_version_promoter ||= GemVersionPromoter.new + end + + def check! + # If dependencies have changed, we need to resolve remotely. Otherwise, + # since we'll be resolving with a single local source, we may end up + # locking gems under the wrong source in the lockfile, and missing lockfile + # checksums + resolve_remotely! if @dependency_changes + + # Now do a local only resolve, to verify if any gems are missing locally + sources.local_only! + resolve + end + + # + # Setup sources according to the given options and the state of the + # definition. + # + # @return [Boolean] Whether fetching remote information will be necessary or not + # + def setup_domain!(options = {}) + prefer_local! if options[:"prefer-local"] + + sources.cached! + + if options[:add_checksums] || (!options[:local] && install_needed?) + sources.remote! + true + else + Bundler.settings.set_command_option(:jobs, 1) unless install_needed? # to avoid the overhead of Bundler::Worker + sources.local! + false + end + end + + def resolve_with_cache! + with_cache! + + resolve + end + + def with_cache! + sources.local! + sources.cached! + end + + def resolve_remotely! + remotely! + + resolve + end + + def remotely! + sources.cached! + sources.remote! + end + + def prefer_local! + @prefer_local = true + + sources.prefer_local! + end + + # For given dependency list returns a SpecSet with Gemspec of all the required + # dependencies. + # 1. The method first resolves the dependencies specified in Gemfile + # 2. After that it tries and fetches gemspec of resolved dependencies + # + # @return [Bundler::SpecSet] + def specs + @specs ||= materialize(requested_dependencies) + end + + def new_specs + specs - @locked_specs + end + + def removed_specs + @locked_specs - specs + end + + def missing_specs + preload_git_sources + resolve.missing_specs_for(requested_dependencies) + end + + def missing_specs? + missing = missing_specs + return false if missing.empty? + Bundler.ui.debug "The definition is missing #{missing.map(&:full_name)}" + true + rescue BundlerError => e + @resolve = nil + @resolver = nil + @resolution_base = nil + @source_requirements = nil + @specs = nil + + Bundler.ui.debug "The definition is missing dependencies, failed to resolve & materialize locally (#{e})" + true + end + + def requested_specs + specs_for(requested_groups) + end + + def requested_dependencies + dependencies_for(requested_groups) + end + + def current_dependencies + filter_relevant(dependencies) + end + + def current_locked_dependencies + filter_relevant(locked_dependencies) + end + + def filter_relevant(dependencies) + dependencies.select do |d| + relevant_deps?(d) + end + end + + def relevant_deps?(dep) + platforms_array = [Bundler.generic_local_platform].freeze + + dep.should_include? && !dep.gem_platforms(platforms_array).empty? + end + + def locked_dependencies + @locked_deps.values + end + + def new_deps + @new_deps ||= @dependencies - locked_dependencies + end + + def deleted_deps + @deleted_deps ||= locked_dependencies - @dependencies + end + + def specs_for(groups) + return specs if groups.empty? + deps = dependencies_for(groups) + materialize(deps) + end + + def dependencies_for(groups) + groups.map!(&:to_sym) + deps = current_dependencies # always returns a new array + deps.select! do |d| + d.groups.intersect?(groups) + end + deps + end + + # Resolve all the dependencies specified in Gemfile. It ensures that + # dependencies that have been already resolved via locked file and are fresh + # are reused when resolving dependencies + # + # @return [SpecSet] resolved dependencies + def resolve + @resolve ||= if Bundler.frozen_bundle? + Bundler.ui.debug "Frozen, using resolution from the lockfile" + @locked_specs + elsif no_resolve_needed? + if deleted_deps.any? + Bundler.ui.debug "Some dependencies were deleted, using a subset of the resolution from the lockfile" + SpecSet.new(filter_specs(@locked_specs, @dependencies - deleted_deps)) + else + Bundler.ui.debug "Found no changes, using resolution from the lockfile" + if @removed_platforms.any? || @locked_gems.may_include_redundant_platform_specific_gems? + SpecSet.new(filter_specs(@locked_specs, @dependencies)) + else + @locked_specs + end + end + else + Bundler.ui.debug resolve_needed_reason + + start_resolution + end + end + + def spec_git_paths + sources.git_sources.filter_map {|s| File.realpath(s.path) if File.exist?(s.path) } + end + + def groups + dependencies.flat_map(&:groups).uniq + end + + def lock(file_or_preserve_unknown_sections = false, preserve_unknown_sections_or_unused = false) + if [true, false, nil].include?(file_or_preserve_unknown_sections) + target_lockfile = lockfile + preserve_unknown_sections = file_or_preserve_unknown_sections + else + target_lockfile = file_or_preserve_unknown_sections + preserve_unknown_sections = preserve_unknown_sections_or_unused + + suggestion = if target_lockfile == lockfile + "To fix this warning, remove it from the `Definition#lock` call." + else + "Instead, instantiate a new definition passing `#{target_lockfile}`, and call `lock` without a file argument on that definition" + end + + msg = "`Definition#lock` was passed a target file argument. #{suggestion}" + + Bundler::SharedHelpers.feature_removed! msg + end + + write_lock(target_lockfile, preserve_unknown_sections) + end + + def write_lock(file, preserve_unknown_sections) + return if Definition.no_lock || !lockfile || file.nil? + + contents = to_lock + + # Convert to \r\n if the existing lock has them + # i.e., Windows with `git config core.autocrlf=true` + contents.gsub!(/\n/, "\r\n") if @lockfile_contents.match?("\r\n") + + if @locked_bundler_version + locked_major = @locked_bundler_version.segments.first + current_major = bundler_version_to_lock.segments.first + + updating_major = locked_major < current_major + end + + preserve_unknown_sections ||= !updating_major && (Bundler.frozen_bundle? || !(unlocking? || @unlocking_bundler)) + + if File.exist?(file) && lockfiles_equal?(@lockfile_contents, contents, preserve_unknown_sections) + return if Bundler.frozen_bundle? + SharedHelpers.filesystem_access(file) { FileUtils.touch(file) } + return + end + + if Bundler.frozen_bundle? + Bundler.ui.error "Cannot write a changed lockfile while frozen." + return + end + + begin + SharedHelpers.filesystem_access(file) do |p| + File.open(p, "wb") {|f| f.puts(contents) } + end + rescue ReadOnlyFileSystemError + raise ProductionError, lockfile_changes_summary("file system is read-only") + end + end + + def locked_ruby_version + return unless ruby_version + if @unlocking_ruby || !@locked_ruby_version + Bundler::RubyVersion.system + else + @locked_ruby_version + end + end + + def locked_ruby_version_object + return unless @locked_ruby_version + @locked_ruby_version_object ||= begin + unless version = RubyVersion.from_string(@locked_ruby_version) + raise LockfileError, "The Ruby version #{@locked_ruby_version} from " \ + "#{@lockfile} could not be parsed. " \ + "Try running bundle update --ruby to resolve this." + end + version + end + end + + def bundler_version_to_lock + @resolved_bundler_version || Bundler.gem_version + end + + def to_lock + require_relative "lockfile_generator" + LockfileGenerator.generate(self) + end + + def ensure_equivalent_gemfile_and_lockfile(explicit_flag = false) + return unless Bundler.frozen_bundle? + + raise ProductionError, "Frozen mode is set, but there's no lockfile" unless lockfile_exists? + + msg = lockfile_changes_summary("frozen mode is set") + return unless msg + + unless explicit_flag + suggested_command = unless Bundler.settings.locations("frozen").keys.include?(:env) + "bundle config set frozen false" + end + msg << "\n\nIf this is a development machine, remove the #{SharedHelpers.relative_lockfile_path} " \ + "freeze by running `#{suggested_command}`." if suggested_command + end + + raise ProductionError, msg + end + + def validate_runtime! + validate_ruby! + validate_platforms! + end + + def validate_ruby! + return unless ruby_version + + if diff = ruby_version.diff(Bundler::RubyVersion.system) + problem, expected, actual = diff + + msg = case problem + when :engine + "Your Ruby engine is #{actual}, but your Gemfile specified #{expected}" + when :version + "Your Ruby version is #{actual}, but your Gemfile specified #{expected}" + when :engine_version + "Your #{Bundler::RubyVersion.system.engine} version is #{actual}, but your Gemfile specified #{ruby_version.engine} #{expected}" + end + + raise RubyVersionMismatch, msg + end + end + + def validate_platforms! + return if current_platform_locked? || @platforms.include?(Gem::Platform::RUBY) + + raise ProductionError, "Your bundle only supports platforms #{@platforms.map(&:to_s)} " \ + "but your local platform is #{Bundler.local_platform}. " \ + "Add the current platform to the lockfile with\n`bundle lock --add-platform #{Bundler.local_platform}` and try again." + end + + def normalize_platforms + resolve.normalize_platforms!(current_dependencies, platforms) + + @resolve = SpecSet.new(resolve.for(current_dependencies, @platforms)) + end + + def add_platform(platform) + return if @platforms.include?(platform) + + @new_platforms << platform + @platforms << platform + end + + def remove_platform(platform) + raise InvalidOption, "Unable to remove the platform `#{platform}` since the only platforms are #{@platforms.join ", "}" unless @platforms.include?(platform) + + @removed_platforms << platform + @platforms.delete(platform) + end + + def nothing_changed? + !something_changed? + end + + def no_resolve_needed? + !resolve_needed? + end + + def unlocking? + @unlocking + end + + def add_checksums + require "rubygems/package" + + @locked_checksums = true + + setup_domain!(add_checksums: true) + + # force materialization to real specifications, so that checksums are fetched + specs.each do |spec| + next unless spec.source.is_a?(Bundler::Source::Rubygems) + # Checksum was fetched from the compact index API. + next if !spec.source.checksum_store.missing?(spec) && !spec.source.checksum_store.empty?(spec) + # The gem isn't installed, can't compute the checksum. + next unless spec.loaded_from + + package = Gem::Package.new(spec.source.cached_built_in_gem(spec)) + checksum = Checksum.from_gem_package(package) + spec.source.checksum_store.register(spec, checksum) + end + end + + private + + def lockfile_changes_summary(update_refused_reason) + added = [] + deleted = [] + changed = [] + + added.concat @new_platforms.map {|p| "* platform: #{p}" } + deleted.concat @removed_platforms.map {|p| "* platform: #{p}" } + + added.concat new_deps.map {|d| "* #{pretty_dep(d)}" } if new_deps.any? + deleted.concat deleted_deps.map {|d| "* #{pretty_dep(d)}" } if deleted_deps.any? + + both_sources = Hash.new {|h, k| h[k] = [] } + current_dependencies.each {|d| both_sources[d.name][0] = d } + current_locked_dependencies.each {|d| both_sources[d.name][1] = d } + + both_sources.each do |name, (dep, lock_dep)| + next if dep.nil? || lock_dep.nil? + + gemfile_source = dep.source || default_source + lock_source = lock_dep.source || default_source + next if lock_source.include?(gemfile_source) + + gemfile_source_name = dep.source ? gemfile_source.to_gemfile : "no specified source" + lockfile_source_name = lock_dep.source ? lock_source.to_gemfile : "no specified source" + changed << "* #{name} from `#{lockfile_source_name}` to `#{gemfile_source_name}`" + end + + return unless added.any? || deleted.any? || changed.any? || resolve_needed? + + msg = String.new("#{change_reason[0].upcase}#{change_reason[1..-1].strip}, but ") + msg << "the lockfile " unless msg.start_with?("Your lockfile") + msg << "can't be updated because #{update_refused_reason}" + msg << "\n\nYou have added to the Gemfile:\n" << added.join("\n") if added.any? + msg << "\n\nYou have deleted from the Gemfile:\n" << deleted.join("\n") if deleted.any? + msg << "\n\nYou have changed in the Gemfile:\n" << changed.join("\n") if changed.any? + msg << "\n\nRun `bundle install` elsewhere and add the updated #{SharedHelpers.relative_lockfile_path} to version control.\n" unless unlocking? + msg + end + + def install_needed? + resolve_needed? || missing_specs? + end + + def something_changed? + return true unless lockfile_exists? + + @source_changes || + @dependency_changes || + @current_platform_missing || + @new_platforms.any? || + @path_changes || + @local_changes || + @missing_lockfile_dep || + @unlocking_bundler || + @locked_spec_with_missing_checksums || + @locked_spec_with_empty_checksums || + @locked_spec_with_missing_deps || + @locked_spec_with_invalid_deps + end + + def resolve_needed? + unlocking? || something_changed? + end + + def should_add_extra_platforms? + !lockfile_exists? && Bundler::MatchPlatform.generic_local_platform_is_ruby? && !Bundler.settings[:force_ruby_platform] + end + + def lockfile_exists? + lockfile && File.exist?(lockfile) + end + + def resolver + @resolver ||= new_resolver(resolution_base) + end + + def expanded_dependencies + apply_overrides_to(dependencies_with_bundler) + metadata_dependencies + end + + def apply_overrides_to(deps) + return deps if @overrides.empty? + deps.map {|dep| apply_override_to(dep) } + end + + def apply_override_to(dep) + override = Override.find_for(@overrides, dep.name, :version) + return dep unless override + new_dep = dep.dup + new_dep.instance_variable_set(:@requirement, override.apply_to(dep.requirement)) + new_dep + end + + def dependencies_with_bundler + return dependencies unless @unlocking_bundler + return dependencies if dependencies.any? {|d| d.name == "bundler" } + + [Dependency.new("bundler", @unlocking_bundler)] + dependencies + end + + def resolution_base + @resolution_base ||= begin + last_resolve = converge_locked_specs + remove_invalid_platforms! + base = new_resolution_base(last_resolve: last_resolve, unlock: @unlocking_all || @gems_to_unlock) + base = additional_base_requirements_to_prevent_downgrades(base) + base = additional_base_requirements_to_force_updates(base) + base + end + end + + def filter_specs(specs, deps, skips: []) + SpecSet.new(specs).for(deps, platforms, skips: skips) + end + + def materialize(dependencies) + specs = begin + resolve.materialize(dependencies) + rescue IncorrectLockfileDependencies => e + raise if Bundler.frozen_bundle? + + reresolve_without([e.spec]) + retry + end + + missing_specs = resolve.missing_specs + + if missing_specs.any? + missing_specs.each do |s| + locked_gem = @locked_specs[s.name].last + next if locked_gem.nil? || locked_gem.version != s.version || sources.local_mode? + + message = if sources.implicit_global_source? + "Because your Gemfile specifies no global remote source, your bundle is locked to " \ + "#{locked_gem} from #{locked_gem.source}. However, #{locked_gem} is not installed. You'll " \ + "need to either add a global remote source to your Gemfile or make sure #{locked_gem} is " \ + "available locally before rerunning Bundler." + else + "Your bundle is locked to #{locked_gem} from #{locked_gem.source}, but that version can " \ + "no longer be found in that source. That means the author of #{locked_gem} has removed it. " \ + "You'll need to update your bundle to a version other than #{locked_gem} that hasn't been " \ + "removed in order to install." + end + + raise GemNotFound, message + end + + missing_specs_list = missing_specs.group_by(&:source).map do |source, missing_specs_for_source| + "#{missing_specs_for_source.map(&:full_name).join(", ")} in #{source}" + end + + raise GemNotFound, "Could not find #{missing_specs_list.join(" nor ")}" + end + + partially_missing_specs = resolve.partially_missing_specs + + if partially_missing_specs.any? && !sources.local_mode? + Bundler.ui.warn "Some locked specs have possibly been yanked (#{partially_missing_specs.map(&:full_name).join(", ")}). Ignoring them..." + + resolve.delete(partially_missing_specs) + end + + incomplete_specs = resolve.incomplete_specs + loop do + break if incomplete_specs.empty? + + Bundler.ui.debug("The lockfile does not have all gems needed for the current platform though, Bundler will still re-resolve dependencies") + sources.remote! + reresolve_without(incomplete_specs) + specs = resolve.materialize(dependencies) + + still_incomplete_specs = resolve.incomplete_specs + + if still_incomplete_specs == incomplete_specs + resolver.raise_incomplete! incomplete_specs + end + + incomplete_specs = still_incomplete_specs + end + + insecurely_materialized_specs = resolve.insecurely_materialized_specs + + if insecurely_materialized_specs.any? + Bundler.ui.warn "The following platform specific gems are getting installed, yet the lockfile includes only their generic ruby version:\n" \ + " * #{insecurely_materialized_specs.map(&:full_name).join("\n * ")}\n" \ + "Please run `bundle lock --normalize-platforms` and commit the resulting lockfile.\n" \ + "Alternatively, you may run `bundle lock --add-platform <list-of-platforms-that-you-want-to-support>`" + end + + bundler = sources.metadata_source.specs.search(["bundler", Bundler.gem_version]).last + specs["bundler"] = bundler + + specs + end + + def reresolve_without(incomplete_specs) + resolution_base.delete(incomplete_specs) + @resolve = start_resolution + end + + def start_resolution + local_platform_needed_for_resolvability = @most_specific_non_local_locked_platform && !@platforms.include?(Bundler.local_platform) + @platforms << Bundler.local_platform if local_platform_needed_for_resolvability + + result = SpecSet.new(resolver.start) + + @resolved_bundler_version = result.find {|spec| spec.name == "bundler" }&.version + + @new_platforms.each do |platform| + incomplete_specs = result.incomplete_specs_for_platform(current_dependencies, platform) + + if incomplete_specs.any? + resolver.raise_incomplete! incomplete_specs + end + end + + if @most_specific_non_local_locked_platform + if result.incomplete_for_platform?(current_dependencies, @most_specific_non_local_locked_platform) + @platforms.delete(@most_specific_non_local_locked_platform) + elsif local_platform_needed_for_resolvability + @platforms.delete(Bundler.local_platform) + end + end + + if should_add_extra_platforms? + result.add_extra_platforms!(platforms) + elsif @originally_invalid_platforms.any? + result.add_originally_invalid_platforms!(platforms, @originally_invalid_platforms) + end + + SpecSet.new(result.for(dependencies, @platforms | [Gem::Platform::RUBY])) + end + + def precompute_source_requirements_for_indirect_dependencies? + if sources.non_global_rubygems_sources.all?(&:dependency_api_available?) + true + else + non_dependency_api_warning + false + end + end + + def non_dependency_api_warning + non_api_sources = sources.non_global_rubygems_sources.reject(&:dependency_api_available?) + non_api_source_names = non_api_sources.map {|d| " * #{d}" }.join("\n") + + msg = String.new + msg << "Your Gemfile contains scoped sources that don't implement a dependency API, namely:\n\n" + msg << non_api_source_names + msg << "\n\nUsing the above gem servers may result in installing unexpected gems. " \ + "To resolve this warning, make sure you use gem servers that implement dependency APIs, " \ + "such as gemstash or geminabox gem servers." + Bundler.ui.warn msg + end + + def current_platform_locked? + @platforms.any? do |bundle_platform| + Bundler.generic_local_platform == bundle_platform || Bundler.local_platform === bundle_platform + end + end + + def add_current_platform + return if @platforms.include?(Bundler.local_platform) + + @most_specific_non_local_locked_platform = find_most_specific_locked_platform + return if @most_specific_non_local_locked_platform + + @platforms << Bundler.local_platform + true + end + + def find_most_specific_locked_platform + return unless current_platform_locked? + + @most_specific_locked_platform + end + + def resolve_needed_reason + if lockfile_exists? + if unlocking? + "Re-resolving dependencies because #{unlocking_reason}" + else + "Found changes from the lockfile, re-resolving dependencies because #{lockfile_changed_reason}" + end + else + "Resolving dependencies because there's no lockfile" + end + end + + def change_reason + if resolve_needed? + if unlocking? + unlocking_reason + else + lockfile_changed_reason + end + else + "some dependencies were deleted from your gemfile" + end + end + + def unlocking_reason + unlock_targets = if @gems_to_unlock.any? + ["gems", @gems_to_unlock] + elsif @sources_to_unlock.any? + ["sources", @sources_to_unlock] + end + + unlock_reason = if unlock_targets + "#{unlock_targets.first}: (#{unlock_targets.last.join(", ")})" + else + @unlocking_ruby ? "ruby" : "" + end + + "bundler is unlocking #{unlock_reason}" + end + + def lockfile_changed_reason + [ + [@source_changes, "the list of sources changed"], + [@dependency_changes, "the dependencies in your gemfile changed"], + [@current_platform_missing, "your lockfile is missing the current platform"], + [@new_platforms.any?, "you are adding a new platform to your lockfile"], + [@path_changes, "the gemspecs for path gems changed"], + [@local_changes, "the gemspecs for git local gems changed"], + [@missing_lockfile_dep, "your lockfile is missing \"#{@missing_lockfile_dep}\""], + [@unlocking_bundler, "an update to the version of Bundler itself was requested"], + [@locked_spec_with_missing_checksums, "your lockfile is missing a CHECKSUMS entry for \"#{@locked_spec_with_missing_checksums}\""], + [@locked_spec_with_empty_checksums, "your lockfile has an empty CHECKSUMS entry for \"#{@locked_spec_with_empty_checksums}\""], + [@locked_spec_with_missing_deps, "your lockfile includes \"#{@locked_spec_with_missing_deps}\" but not some of its dependencies"], + [@locked_spec_with_invalid_deps, "your lockfile does not satisfy dependencies of \"#{@locked_spec_with_invalid_deps}\""], + ].select(&:first).map(&:last).join(", ") + end + + def pretty_dep(dep) + SharedHelpers.pretty_dependency(dep) + end + + # Check if the specs of the given source changed + # according to the locked source. + def specs_changed?(source) + locked = @locked_sources.find {|s| s == source } + + !locked || dependencies_for_source_changed?(source, locked) || specs_for_source_changed?(source) + end + + def dependencies_for_source_changed?(source, locked_source) + deps_for_source = @dependencies.select {|dep| dep.source == source } + locked_deps_for_source = locked_dependencies.select {|dep| dep.source == locked_source } + + deps_for_source.uniq.sort != locked_deps_for_source.sort + end + + def specs_for_source_changed?(source) + locked_index = Index.new + locked_index.use(@locked_specs.select {|s| s.replace_source_with!(source) }) + + !locked_index.subset?(source.specs) + rescue PathError, GitError => e + Bundler.ui.debug "Assuming that #{source} has not changed since fetching its specs errored (#{e})" + false + end + + # Get all locals and override their matching sources. + # Return true if any of the locals changed (for example, + # they point to a new revision) or depend on new specs. + def converge_locals + locals = [] + + Bundler.settings.local_overrides.map do |k, v| + spec = @dependencies.find {|s| s.name == k } + source = spec&.source + if source&.respond_to?(:local_override!) + source.unlock! if @gems_to_unlock.include?(spec.name) + locals << [source, source.local_override!(v)] + end + end + + sources_with_changes = locals.select do |source, changed| + changed || specs_changed?(source) + end.map(&:first) + !sources_with_changes.each {|source| @sources_to_unlock << source.name }.empty? + end + + def check_lockfile + @locked_spec_with_invalid_deps = nil + @locked_spec_with_missing_deps = nil + @locked_spec_with_missing_checksums = nil + @locked_spec_with_empty_checksums = nil + + missing_deps = [] + missing_checksums = [] + empty_checksums = [] + invalid = [] + + @locked_specs.each do |s| + if @locked_checksums + checksum_store = s.source.checksum_store + + if checksum_store.missing?(s) + missing_checksums << s + elsif checksum_store.empty?(s) + empty_checksums << s + end + end + + validation = @locked_specs.validate_deps(s) + + missing_deps << s if validation == :missing + invalid << s if validation == :invalid + end + + @locked_spec_with_missing_checksums = missing_checksums.first.name if missing_checksums.any? + @locked_spec_with_empty_checksums = empty_checksums.first.name if empty_checksums.any? + + if missing_deps.any? + @locked_specs.delete(missing_deps) + + @locked_spec_with_missing_deps = missing_deps.first.name + end + + if invalid.any? + @locked_specs.delete(invalid) + + @locked_spec_with_invalid_deps = invalid.first.name + end + end + + def converge_paths + sources.path_sources.any? do |source| + specs_changed?(source) + end + end + + def converge_sources + # Replace the sources from the Gemfile with the sources from the Gemfile.lock, + # if they exist in the Gemfile.lock and are `==`. If you can't find an equivalent + # source in the Gemfile.lock, use the one from the Gemfile. + changes = sources.replace_sources!(@locked_sources) + + sources.all_sources.each do |source| + # has to be done separately, because we want to keep the locked checksum + # store for a source, even when doing a full update + if @locked_checksums && @locked_gems && locked_source = @originally_locked_sources.find {|s| s == source && !s.equal?(source) } + source.checksum_store.merge!(locked_source.checksum_store) + end + # If the source is unlockable and the current command allows an unlock of + # the source (for example, you are doing a `bundle update <foo>` of a git-pinned + # gem), unlock it. For git sources, this means to unlock the revision, which + # will cause the `ref` used to be the most recent for the branch (or master) if + # an explicit `ref` is not used. + if source.respond_to?(:unlock!) && @sources_to_unlock.include?(source.name) + source.unlock! + changes = true + end + end + + sources.metadata_source.checksum_store.merge!(@locked_gems.metadata_source.checksum_store) if @locked_gems + + changes + end + + def converge_dependencies + @missing_lockfile_dep = nil + @changed_dependencies = [] + + @dependencies.each do |dep| + if dep.source + dep.source = sources.get(dep.source) + end + next unless relevant_deps?(dep) + + name = dep.name + + dep_changed = @locked_deps[name].nil? + + unless name == "bundler" + locked_specs = @originally_locked_specs[name] + + if locked_specs.empty? + @missing_lockfile_dep = name if dep_changed == false + else + if locked_specs.map(&:source).uniq.size > 1 + @locked_specs.delete(locked_specs.select {|s| s.source != dep.source }) + end + + unless apply_override_to(dep).matches_spec?(locked_specs.first) + @gems_to_unlock << name + dep_changed = true + end + end + end + + @changed_dependencies << name if dep_changed + end + + converge_overrides_outside_dependencies + + @changed_dependencies.any? + end + + def converge_overrides_outside_dependencies + @overrides.each do |override| + # :all overrides are intentionally not pre-unlocked. They take effect on + # fresh resolution (no lockfile) or when the user runs `bundle update`. + # Forcing a full re-resolve from a single :all directive would surprise + # users with unrelated dependency churn. + next unless override.target.is_a?(String) + + name = override.target + next if @changed_dependencies.include?(name) + next if @originally_locked_specs[name].empty? + # version: overrides on direct deps are detected in the per-dep + # converge_dependencies loop via apply_override_to + matches_spec?. + # Other fields are not visible there, so they always reach here. + next if override.field == :version && @dependencies.any? {|d| d.name == name } + + @gems_to_unlock << name + @changed_dependencies << name + end + end + + # Remove elements from the locked specs that are expired. This will most + # commonly happen if the Gemfile has changed since the lockfile was last + # generated + def converge_locked_specs + converged = converge_specs(@locked_specs) + + resolve = SpecSet.new(converged) + + diff = nil + + # Now, we unlock any sources that do not have anymore gems pinned to it + sources.all_sources.each do |source| + next unless source.respond_to?(:unlock!) + + unless resolve.any? {|s| s.source == source } + diff ||= @locked_specs.to_a - resolve.to_a + source.unlock! if diff.any? {|s| s.source == source } + end + end + + resolve + end + + def converge_specs(specs) + converged = [] + deps = [] + + specs.each do |s| + name = s.name + next if @gems_to_unlock.include?(name) + + dep = @dependencies.find {|d| s.satisfies?(d) } + lockfile_source = s.source + + if dep + replacement_source = dep.source + + deps << dep if !replacement_source || lockfile_source.include?(replacement_source) || new_deps.include?(dep) + else + parent_dep = @dependencies.find do |d| + next unless d.source && d.source != lockfile_source + next if d.source.is_a?(Source::Gemspec) + + parent_locked_specs = @originally_locked_specs[d.name] + + parent_locked_specs.any? do |parent_spec| + parent_spec.runtime_dependencies.any? {|rd| rd.name == s.name } + end + end + + if parent_dep && parent_dep.source.is_a?(Source::Path) && parent_dep.source.specs[s]&.any? + replacement_source = parent_dep.source + else + replacement_source = sources.get(lockfile_source) + end + end + + # Replace the locked dependency's source with the equivalent source from the Gemfile + s.source = replacement_source || default_source + next if s.source_changed? + + source = s.source + next if @sources_to_unlock.include?(source.name) + + # Path sources have special logic + if source.is_a?(Source::Path) + new_spec = source.specs[s].first + if new_spec + s.runtime_dependencies.replace(new_spec.runtime_dependencies) + else + # If the spec is no longer in the path source, unlock it. This + # commonly happens if the version changed in the gemspec + @gems_to_unlock << name + end + end + + converged << s + end + + filter_specs(converged, deps, skips: @gems_to_unlock) + end + + def metadata_dependencies + @metadata_dependencies ||= [ + Dependency.new("Ruby\0", Bundler::RubyVersion.system.gem_version), + Dependency.new("RubyGems\0", Gem::VERSION), + ] + end + + def source_requirements + @source_requirements ||= find_source_requirements + end + + def preload_git_source_worker + workers = Bundler.settings.installation_parallelization + + @preload_git_source_worker ||= Bundler::Worker.new(workers, "Git source preloading", ->(source, _) { source.specs }) + end + + def preload_git_sources + if Gem.ruby_version < Gem::Version.new("3.3") + # Ruby 3.2 has a bug that incorrectly triggers a circular dependency warning. This version will continue to + # fetch git repositories one by one. + return + end + + begin + needed_git_sources.each {|source| preload_git_source_worker.enq(source) } + ensure + preload_git_source_worker.stop + end + end + + # Git sources needed for the requested groups (excludes sources only used by --without groups) + def needed_git_sources + needed_deps = dependencies_for(requested_groups) + sources.git_sources.select do |source| + needed_deps.any? {|d| d.source == source } + end + end + + # Git sources that should be excluded (only used by --without groups) + def excluded_git_sources + sources.git_sources - needed_git_sources + end + + def find_source_requirements + preload_git_sources + + # Only safe to exclude when locked_requirements (merged below) backfills the gap. + nothing_changed = nothing_changed? + excluded = nothing_changed ? excluded_git_sources : [] + + # Record the specs available in each gem's source, so that those + # specs will be available later when the resolver knows where to + # look for that gemspec (or its dependencies) + source_requirements = if precompute_source_requirements_for_indirect_dependencies? + all_requirements = source_map.all_requirements(excluded) + { default: default_source }.merge(all_requirements) + else + { default: Source::RubygemsAggregate.new(sources, source_map, excluded) }.merge(source_map.direct_requirements) + end + source_requirements.merge!(source_map.locked_requirements) if nothing_changed + metadata_dependencies.each do |dep| + source_requirements[dep.name] = sources.metadata_source + end + + default_bundler_source = source_requirements["bundler"] || default_source + + if @unlocking_bundler + default_bundler_source.add_dependency_names("bundler") + else + source_requirements[:default_bundler] = default_bundler_source + source_requirements["bundler"] = sources.metadata_source # needs to come last to override + end + + source_requirements + end + + def default_source + sources.default_source + end + + def requested_groups + values = groups - Bundler.settings[:without] - @optional_groups + Bundler.settings[:with] + values &= Bundler.settings[:only] unless Bundler.settings[:only].empty? + values + end + + def lockfiles_equal?(current, proposed, preserve_unknown_sections) + if preserve_unknown_sections + sections_to_ignore = LockfileParser.sections_to_ignore(@locked_bundler_version) + sections_to_ignore += LockfileParser.unknown_sections_in_lockfile(current) + sections_to_ignore << LockfileParser::RUBY + sections_to_ignore << LockfileParser::BUNDLED unless @unlocking_bundler + pattern = /#{Regexp.union(sections_to_ignore)}\n(\s{2,}.*\n)+/ + whitespace_cleanup = /\n{2,}/ + current = current.gsub(pattern, "\n").gsub(whitespace_cleanup, "\n\n").strip + proposed = proposed.gsub(pattern, "\n").gsub(whitespace_cleanup, "\n\n").strip + end + current == proposed + end + + def additional_base_requirements_to_prevent_downgrades(resolution_base) + return resolution_base unless @locked_gems + @originally_locked_specs.each do |locked_spec| + next if locked_spec.source.is_a?(Source::Path) || locked_spec.source_changed? + + name = locked_spec.name + next if @changed_dependencies.include?(name) + + resolution_base.base_requirements[name] = Gem::Requirement.new(">= #{locked_spec.version}") + end + resolution_base + end + + def additional_base_requirements_to_force_updates(resolution_base) + return resolution_base if @explicit_unlocks.empty? + full_update = SpecSet.new(new_resolver_for_full_update.start) + @explicit_unlocks.each do |name| + version = full_update.version_for(name) + resolution_base.base_requirements[name] = Gem::Requirement.new("= #{version}") if version + end + resolution_base + end + + def remove_invalid_platforms! + return if Bundler.frozen_bundle? + + skips = (@new_platforms + [Bundler.local_platform]).uniq + + # We should probably avoid removing non-ruby platforms, since that means + # lockfile will no longer install on those platforms, so a error to give + # heads up to the user may be better. However, we have tests expecting + # non ruby platform autoremoval to work, so leaving that in place for + # now. + skips |= platforms - [Gem::Platform::RUBY] if @dependency_changes + + @originally_invalid_platforms = @originally_locked_specs.remove_invalid_platforms!(current_dependencies, platforms, skips: skips) + end + + def source_map + @source_map ||= SourceMap.new(sources, dependencies, @locked_specs) + end + + def new_resolver_for_full_update + new_resolver(unlocked_resolution_base) + end + + def unlocked_resolution_base + new_resolution_base(last_resolve: SpecSet.new([]), unlock: true) + end + + def new_resolution_base(last_resolve:, unlock:) + new_resolution_platforms = @current_platform_missing ? @new_platforms + [Bundler.local_platform] : @new_platforms + Resolver::Base.new(source_requirements, expanded_dependencies, last_resolve, @platforms, locked_specs: @originally_locked_specs, unlock: unlock, prerelease: gem_version_promoter.pre?, prefer_local: @prefer_local, new_platforms: new_resolution_platforms, overrides: @overrides) + end + + def new_resolver(base) + Resolver.new(base, gem_version_promoter, @most_specific_locked_platform) + end + end +end |
