diff --git a/bundler/helpers/v2/lib/functions.rb b/bundler/helpers/v2/lib/functions.rb index ed82cdc8563..4e380076e99 100644 --- a/bundler/helpers/v2/lib/functions.rb +++ b/bundler/helpers/v2/lib/functions.rb @@ -3,8 +3,9 @@ require "functions/conflicting_dependency_resolver" require "functions/dependency_source" require "functions/file_parser" -require "functions/version_resolver" +require "functions/force_updater" require "functions/lockfile_updater" +require "functions/version_resolver" module Functions class NotImplementedError < StandardError; end @@ -43,7 +44,15 @@ def self.update_lockfile(dir:, gemfile_name:, lockfile_name:, using_bundler2:, def self.force_update(dir:, dependency_name:, target_version:, gemfile_name:, lockfile_name:, using_bundler2:, credentials:, update_multiple_dependencies:) - raise NotImplementedError, "Bundler 2 adapter does not yet implement #{__method__}" + set_bundler_flags_and_credentials(dir: dir, credentials: credentials, + using_bundler2: using_bundler2) + ForceUpdater.new( + dependency_name: dependency_name, + target_version: target_version, + gemfile_name: gemfile_name, + lockfile_name: lockfile_name, + update_multiple_dependencies: update_multiple_dependencies + ).run end def self.dependency_source_type(gemfile_name:, dependency_name:, dir:, diff --git a/bundler/helpers/v2/lib/functions/force_updater.rb b/bundler/helpers/v2/lib/functions/force_updater.rb new file mode 100644 index 00000000000..0d44dee11fa --- /dev/null +++ b/bundler/helpers/v2/lib/functions/force_updater.rb @@ -0,0 +1,167 @@ +module Functions + class ForceUpdater + class TransitiveDependencyError < StandardError; end + + def initialize(dependency_name:, target_version:, gemfile_name:, + lockfile_name:, update_multiple_dependencies:) + @dependency_name = dependency_name + @target_version = target_version + @gemfile_name = gemfile_name + @lockfile_name = lockfile_name + @update_multiple_dependencies = update_multiple_dependencies + end + + def run + # Only allow upgrades. Otherwise it's unlikely that this + # resolution will be found by the FileUpdater + Bundler.settings.set_command_option( + "only_update_to_newer_versions", + true + ) + + dependencies_to_unlock = [] + + begin + definition = build_definition(dependencies_to_unlock: dependencies_to_unlock) + definition.resolve_remotely! + specs = definition.resolve + updates = [{ name: dependency_name }] + + dependencies_to_unlock.map { |dep| { name: dep.name } } + specs = specs.map do |dep| + { + name: dep.name, + version: dep.version + } + end + [updates, specs] + rescue Bundler::VersionConflict => e + raise unless update_multiple_dependencies? + + # TODO: Not sure this won't unlock way too many things... + new_dependencies_to_unlock = + new_dependencies_to_unlock_from( + error: e, + already_unlocked: dependencies_to_unlock + ) + + raise if new_dependencies_to_unlock.none? + + dependencies_to_unlock += new_dependencies_to_unlock + retry + end + end + + private + + attr_reader :dependency_name, :target_version, :gemfile_name, + :lockfile_name, :credentials, + :update_multiple_dependencies + alias update_multiple_dependencies? update_multiple_dependencies + + def new_dependencies_to_unlock_from(error:, already_unlocked:) + potentials_deps = + relevant_conflicts(error, already_unlocked). + flat_map(&:requirement_trees). + reject do |tree| + # If the final requirement wasn't specific, it can't be binding + next true if tree.last.requirement == Gem::Requirement.new(">= 0") + + # If the conflict wasn't for the dependency we're updating then + # we don't have enough info to reject it + next false unless tree.last.name == dependency_name + + # If the final requirement *was* for the dependency we're updating + # then we can ignore the tree if it permits the target version + tree.last.requirement.satisfied_by?( + Gem::Version.new(target_version) + ) + end.map(&:first) + + potentials_deps. + reject { |dep| already_unlocked.map(&:name).include?(dep.name) }. + reject { |dep| [dependency_name, "ruby\0"].include?(dep.name) }. + uniq + end + + def relevant_conflicts(error, dependencies_being_unlocked) + names = [*dependencies_being_unlocked.map(&:name), dependency_name] + + # For a conflict to be relevant to the updates we're making it must be + # 1) caused by a new requirement introduced by our unlocking, or + # 2) caused by an old requirement that prohibits the update. + # Hence, we look at the beginning and end of the requirement trees + error.cause.conflicts.values. + select do |conflict| + conflict.requirement_trees.any? do |t| + names.include?(t.last.name) || names.include?(t.first.name) + end + end + end + + def build_definition(dependencies_to_unlock:) + gems_to_unlock = dependencies_to_unlock.map(&:name) + [dependency_name] + definition = Bundler::Definition.build( + gemfile_name, + lockfile_name, + gems: gems_to_unlock + subdependencies, + lock_shared_dependencies: true + ) + + # Remove the Gemfile / gemspec requirements on the gems we're + # unlocking (i.e., completely unlock them) + gems_to_unlock.each do |gem_name| + unlock_gem(definition: definition, gem_name: gem_name) + end + + dep = definition.dependencies. + find { |d| d.name == dependency_name } + + # If the dependency is not found in the Gemfile it means this is a + # transitive dependency that we can't force update. + raise TransitiveDependencyError unless dep + + # Set the requirement for the gem we're forcing an update of + new_req = Gem::Requirement.create("= #{target_version}") + dep.instance_variable_set(:@requirement, new_req) + dep.source = nil if dep.source.is_a?(Bundler::Source::Git) + + definition + end + + def lockfile + return @lockfile if defined?(@lockfile) + + @lockfile = + begin + return unless lockfile_name && File.exist?(lockfile_name) + + File.read(lockfile_name) + end + end + + def subdependencies + # If there's no lockfile we don't need to worry about + # subdependencies + return [] unless lockfile + + all_deps = Bundler::LockfileParser.new(lockfile). + specs.map(&:name).map(&:to_s) + top_level = Bundler::Definition. + build(gemfile_name, lockfile_name, {}). + dependencies.map(&:name).map(&:to_s) + + all_deps - top_level + end + + def unlock_gem(definition:, gem_name:) + dep = definition.dependencies.find { |d| d.name == gem_name } + version = definition.locked_gems.specs. + find { |d| d.name == gem_name }.version + + dep&.instance_variable_set( + :@requirement, + Gem::Requirement.create(">= #{version}") + ) + end + end +end diff --git a/bundler/helpers/v2/spec/functions_spec.rb b/bundler/helpers/v2/spec/functions_spec.rb index 46490d5016c..e5e114bb75a 100644 --- a/bundler/helpers/v2/spec/functions_spec.rb +++ b/bundler/helpers/v2/spec/functions_spec.rb @@ -5,8 +5,6 @@ RSpec.describe Functions do # Verify v1 method signatures are exist, but raise as NYI { - force_update: [ :dir, :dependency_name, :target_version, :gemfile_name, :lockfile_name, :using_bundler2, - :credentials, :update_multiple_dependencies ], private_registry_versions: [:gemfile_name, :dependency_name, :dir, :credentials ], jfrog_source: [:dir, :gemfile_name, :credentials, :using_bundler2], git_specs: [:dir, :gemfile_name, :credentials, :using_bundler2], diff --git a/bundler/spec/dependabot/bundler/update_checker/force_updater_spec.rb b/bundler/spec/dependabot/bundler/update_checker/force_updater_spec.rb index 3a77789618f..ee1b07d2ddf 100644 --- a/bundler/spec/dependabot/bundler/update_checker/force_updater_spec.rb +++ b/bundler/spec/dependabot/bundler/update_checker/force_updater_spec.rb @@ -21,7 +21,7 @@ "username" => "x-access-token", "password" => "token" }], - options: {} + options: { bundler_2_available: bundler_2_available? } ) end let(:dependency_files) { [gemfile, lockfile] } @@ -66,9 +66,9 @@ subject(:updated_dependencies) { updater.updated_dependencies } context "when updating the dependency that requires the other" do - let(:gemfile_body) { fixture("ruby", "gemfiles", "version_conflict") } + let(:gemfile_body) { fixture("projects", "bundler1", "version_conflict", "Gemfile") } let(:lockfile_body) do - fixture("ruby", "lockfiles", "version_conflict.lock") + fixture("projects", "bundler1", "version_conflict", "Gemfile.lock") end let(:target_version) { "3.6.0" } let(:dependency_name) { "rspec-mocks" } @@ -114,9 +114,9 @@ end context "when updating the dependency that is required by the other" do - let(:gemfile_body) { fixture("ruby", "gemfiles", "version_conflict") } + let(:gemfile_body) { fixture("projects", "bundler1", "version_conflict", "Gemfile") } let(:lockfile_body) do - fixture("ruby", "lockfiles", "version_conflict.lock") + fixture("projects", "bundler1", "version_conflict", "Gemfile.lock") end let(:target_version) { "3.6.0" } let(:dependency_name) { "rspec-support" } @@ -162,11 +162,9 @@ end context "when two dependencies require the same subdependency" do - let(:gemfile_body) do - fixture("ruby", "gemfiles", "version_conflict_mutual_sub") - end + let(:gemfile_body) { fixture("projects", "bundler1", "version_conflict_mutual_sub", "Gemfile") } let(:lockfile_body) do - fixture("ruby", "lockfiles", "version_conflict_mutual_sub.lock") + fixture("projects", "bundler1", "version_conflict_mutual_sub", "Gemfile.lock") end let(:dependency_name) { "rspec-mocks" } @@ -213,11 +211,9 @@ end context "when another dependency would need to be downgraded" do - let(:gemfile_body) do - fixture("ruby", "gemfiles", "subdep_blocked_by_subdep") - end + let(:gemfile_body) { fixture("projects", "bundler1", "subdep_blocked_by_subdep", "Gemfile") } let(:lockfile_body) do - fixture("ruby", "lockfiles", "subdep_blocked_by_subdep.lock") + fixture("projects", "bundler1", "subdep_blocked_by_subdep", "Gemfile.lock") end let(:target_version) { "2.0.0" } let(:dependency_name) { "dummy-pkg-a" } @@ -229,11 +225,9 @@ end context "when the ruby version would need to change" do - let(:gemfile_body) do - fixture("ruby", "gemfiles", "legacy_ruby") - end + let(:gemfile_body) { fixture("projects", "bundler1", "legacy_ruby", "Gemfile") } let(:lockfile_body) do - fixture("ruby", "lockfiles", "legacy_ruby.lock") + fixture("projects", "bundler1", "legacy_ruby", "Gemfile.lock") end let(:target_version) { "2.0.5" } let(:dependency_name) { "public_suffix" } diff --git a/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile b/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile new file mode 100644 index 00000000000..99b4eeedc6d --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "rspec-mocks", "3.5.0" +gem "rspec-support", "3.5.0" + +gem "diff-lcs", "1.2.0" diff --git a/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile.lock b/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile.lock new file mode 100644 index 00000000000..d8a66715c85 --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler1/version_conflict/Gemfile.lock @@ -0,0 +1,19 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.2.0) + rspec-mocks (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-support (3.5.0) + +PLATFORMS + ruby + +DEPENDENCIES + diff-lcs (= 1.2.0) + rspec-mocks (= 3.5.0) + rspec-support (= 3.5.0) + +BUNDLED WITH + 2.0.0.dev diff --git a/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile b/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile new file mode 100644 index 00000000000..7d275fdc37f --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +gem 'rspec-expectations', '~> 3.5.0' +gem 'rspec-mocks', '~> 3.5.0' diff --git a/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile.lock b/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile.lock new file mode 100644 index 00000000000..cc22499af06 --- /dev/null +++ b/bundler/spec/fixtures/projects/bundler1/version_conflict_mutual_sub/Gemfile.lock @@ -0,0 +1,21 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.3) + rspec-expectations (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-mocks (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-support (3.5.0) + +PLATFORMS + ruby + +DEPENDENCIES + rspec-expectations (~> 3.5.0) + rspec-mocks (~> 3.5.0) + +BUNDLED WITH + 1.16.0