Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bundler [Prerelease]: Add force updater helpers #3329

Merged
merged 1 commit into from
Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions bundler/helpers/v2/lib/functions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:,
Expand Down
167 changes: 167 additions & 0 deletions bundler/helpers/v2/lib/functions/force_updater.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions bundler/helpers/v2/spec/functions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"username" => "x-access-token",
"password" => "token"
}],
options: {}
options: { bundler_2_available: bundler_2_available? }
)
end
let(:dependency_files) { [gemfile, lockfile] }
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
source 'https://rubygems.org'

gem 'rspec-expectations', '~> 3.5.0'
gem 'rspec-mocks', '~> 3.5.0'
Original file line number Diff line number Diff line change
@@ -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