Skip to content

Commit

Permalink
Bundler [Prerelease]: Add force updater helpers
Browse files Browse the repository at this point in the history
Adding Bundler v2 native helpers for the ForceUpdater.
  • Loading branch information
feelepxyz committed Mar 24, 2021
1 parent 495b7ab commit 6a78cb0
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 21 deletions.
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

0 comments on commit 6a78cb0

Please sign in to comment.