dependabot-bundler 0.380.0 → 0.381.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/v2/build +2 -2
  3. data/helpers/v2/lib/bundler_version_constraint.rb +1 -1
  4. data/helpers/v2/monkey_patches/definition_ruby_version_patch.rb +1 -1
  5. data/helpers/v2/run.rb +5 -4
  6. data/helpers/v2/spec/bundler_version_constraint_spec.rb +6 -8
  7. data/helpers/v4/.gitignore +8 -0
  8. data/helpers/v4/Gemfile +7 -0
  9. data/helpers/v4/build +45 -0
  10. data/helpers/v4/lib/bundler_version_constraint.rb +25 -0
  11. data/helpers/v4/lib/functions/conflicting_dependency_resolver.rb +87 -0
  12. data/helpers/v4/lib/functions/dependency_source.rb +90 -0
  13. data/helpers/v4/lib/functions/file_parser.rb +119 -0
  14. data/helpers/v4/lib/functions/force_updater.rb +189 -0
  15. data/helpers/v4/lib/functions/lockfile_updater.rb +234 -0
  16. data/helpers/v4/lib/functions/version_resolver.rb +145 -0
  17. data/helpers/v4/lib/functions.rb +187 -0
  18. data/helpers/v4/monkey_patches/definition_bundler_version_patch.rb +14 -0
  19. data/helpers/v4/monkey_patches/definition_ruby_version_patch.rb +53 -0
  20. data/helpers/v4/monkey_patches/git_source_patch.rb +67 -0
  21. data/helpers/v4/run.rb +46 -0
  22. data/helpers/v4/spec/bundler_version_constraint_spec.rb +63 -0
  23. data/helpers/v4/spec/functions/conflicting_dependency_resolver_spec.rb +130 -0
  24. data/helpers/v4/spec/functions/dependency_source_spec.rb +193 -0
  25. data/helpers/v4/spec/functions/file_parser_spec.rb +140 -0
  26. data/helpers/v4/spec/functions/force_updater_spec.rb +59 -0
  27. data/helpers/v4/spec/functions/version_resolver_spec.rb +118 -0
  28. data/helpers/v4/spec/functions_spec.rb +59 -0
  29. data/helpers/v4/spec/native_spec_helper.rb +57 -0
  30. data/helpers/v4/spec/ruby_version_spec.rb +42 -0
  31. data/helpers/v4/spec/shared_contexts.rb +51 -0
  32. data/lib/dependabot/bundler/file_parser.rb +5 -3
  33. data/lib/dependabot/bundler/helpers.rb +12 -1
  34. metadata +29 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c28d7f789c7e18806f0c0141599adc1c277f932587d2331f77bf54200333df9
4
- data.tar.gz: bb053e4ca2a32b6cd63abd04443327f74a12df2fd6dc472a6646cde5a57e2411
3
+ metadata.gz: d6dace53b7c6c93f276ae838f5c2c44f941388b5e129380ba19350ebb7f13eff
4
+ data.tar.gz: ce2a77ec63b75dadeb2eda06b97c443b92130d968b70f907de766b7749351901
5
5
  SHA512:
6
- metadata.gz: c4d4fd80b03f35f2110e390984ec3b6d7111860e996f005f05abb5fdf90a6c3c4e4babc0891e15a8bda3a1b1bc563483c6389df6ca82f6eb9e9f83941a31e6c0
7
- data.tar.gz: 833760e2485382ce9e0ebda893341d098bdced4c4de9c682637063c225564b528f7afb3e91688528f30ceace54fa8f6caa71ea0d9ca6364b84aa62e954c5509c
6
+ metadata.gz: fa101fe9112a94de4a65f83570bbd57343e0a90ca0f2a66e178752c9b05ec604d352f4a11712e3832afb5d2ab699147c100894bfb971da9bed6a397f7c4417cb
7
+ data.tar.gz: f0ee43f8880fc22c0b8521c5290dfd46575c663c133bedde28b41e51f5ebb7ad81b519e96a28ce621b88395822df6a57ccf465cd73cd7b726205f612f1b31ed3
data/helpers/v2/build CHANGED
@@ -19,8 +19,8 @@ fi
19
19
 
20
20
  cd "$install_dir"
21
21
 
22
- # Default to Bundler 4, with an override for controlled testing/rollouts.
23
- bundler_constraint="${DEPENDABOT_BUNDLER_VERSION_CONSTRAINT:-${BUNDLER_VERSION_CONSTRAINT:-~> 4.0}}"
22
+ # Default to Bundler 2, with an override for controlled testing/rollouts.
23
+ bundler_constraint="${DEPENDABOT_BUNDLER_VERSION_CONSTRAINT:-${BUNDLER_VERSION_CONSTRAINT:-~> 2}}"
24
24
 
25
25
  export GEM_HOME=$install_dir/.bundle
26
26
 
@@ -8,7 +8,7 @@
8
8
  # Used by both `run.rb` (for activation via `gem`) and the helper specs so
9
9
  # the rollback/staged-rollout behavior is exercised by real code.
10
10
  module BundlerVersionConstraint
11
- DEFAULT_ACTIVATION_CONSTRAINT = ">= 2.4, < 5"
11
+ DEFAULT_ACTIVATION_CONSTRAINT = ">= 2.4, < 3"
12
12
 
13
13
  def self.resolve(env: ENV, default: DEFAULT_ACTIVATION_CONSTRAINT)
14
14
  env.fetch(
@@ -26,7 +26,7 @@ module BundlerDefinitionRubyVersionPatch
26
26
  Gem::Specification.new("Ruby\0", requested_version)
27
27
  end
28
28
 
29
- %w(2.5.3 2.6.10 2.7.8 3.0.7 3.1.6 3.2.8 3.3.8).each do |version|
29
+ %w(2.5.3 2.6.10 2.7.8 3.0.7 3.1.6 3.2.8 3.3.8 3.4.8).each do |version|
30
30
  sources.metadata_source.specs << Gem::Specification.new("Ruby\0", version)
31
31
  end
32
32
 
data/helpers/v2/run.rb CHANGED
@@ -3,10 +3,11 @@
3
3
 
4
4
  require_relative "lib/bundler_version_constraint"
5
5
 
6
- # Allow Bundler 4 by default with an upper bound to prevent unintended future
7
- # major versions. Honor DEPENDABOT_BUNDLER_VERSION_CONSTRAINT (or its
8
- # BUNDLER_VERSION_CONSTRAINT fallback) so staged rollouts and emergency
9
- # rollbacks performed by the build script are respected at activation time.
6
+ # Activate Bundler 2 by default with an upper bound to prevent unintended
7
+ # future major versions (Bundler 4 lives in the v4 helper tree). Honor
8
+ # DEPENDABOT_BUNDLER_VERSION_CONSTRAINT (or its BUNDLER_VERSION_CONSTRAINT
9
+ # fallback) so staged rollouts and emergency rollbacks performed by the build
10
+ # script are respected at activation time.
10
11
  bundler_constraint = BundlerVersionConstraint.resolve
11
12
  gem "bundler", *BundlerVersionConstraint.activation_clauses(bundler_constraint)
12
13
  require "bundler"
@@ -6,12 +6,10 @@ require_relative "../lib/bundler_version_constraint"
6
6
 
7
7
  RSpec.describe BundlerVersionConstraint do
8
8
  describe "helper runtime activation" do
9
- it "is running a supported Bundler major version" do
10
- # Bundler 3 was intentionally skipped upstream (Bundler jumped from 2.7
11
- # straight to 4.0 to align with RubyGems) so the supported window is
12
- # 2.x or 4.x.
9
+ it "is running Bundler 2" do
10
+ # The v2 helper tree pins Bundler 2.x; Bundler 4 lives in the v4 tree.
13
11
  bundler_major = Bundler::VERSION.split(".").first.to_i
14
- expect(bundler_major).to be_between(2, 4)
12
+ expect(bundler_major).to eq(2)
15
13
  end
16
14
  end
17
15
 
@@ -35,7 +33,7 @@ RSpec.describe BundlerVersionConstraint do
35
33
  end
36
34
 
37
35
  it "uses the default activation constraint when no env var is set" do
38
- expect(described_class.resolve(env: {})).to eq(">= 2.4, < 5")
36
+ expect(described_class.resolve(env: {})).to eq(">= 2.4, < 3")
39
37
  end
40
38
 
41
39
  it "honours an explicit default override" do
@@ -45,11 +43,11 @@ RSpec.describe BundlerVersionConstraint do
45
43
 
46
44
  describe ".activation_clauses" do
47
45
  it "splits comma-separated requirement strings into trimmed clauses" do
48
- expect(described_class.activation_clauses(">= 2.4, < 5")).to eq([">= 2.4", "< 5"])
46
+ expect(described_class.activation_clauses(">= 2.4, < 3")).to eq([">= 2.4", "< 3"])
49
47
  end
50
48
 
51
49
  it "returns a single clause for a single requirement" do
52
- expect(described_class.activation_clauses("~> 4.0")).to eq(["~> 4.0"])
50
+ expect(described_class.activation_clauses("~> 2")).to eq(["~> 2"])
53
51
  end
54
52
  end
55
53
 
@@ -0,0 +1,8 @@
1
+ /.bundle
2
+ /.env
3
+ /tmp
4
+ /dependabot-*.gem
5
+ Gemfile.lock
6
+ spec/fixtures/projects/*/.bundle/
7
+ !spec/fixtures/projects/**/Gemfile.lock
8
+ !spec/fixtures/projects/**/vendor
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "dependabot-common", path: "../../../common"
6
+
7
+ gemspec path: "../.."
data/helpers/v4/build ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -e
4
+
5
+ helpers_dir=$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)
6
+
7
+ if [ -z "$DEPENDABOT_NATIVE_HELPERS_PATH" ]; then
8
+ install_dir="$helpers_dir"
9
+ else
10
+ install_dir="$DEPENDABOT_NATIVE_HELPERS_PATH/bundler/v4"
11
+ mkdir -p "$install_dir"
12
+
13
+ cp -r \
14
+ "$helpers_dir/lib" \
15
+ "$helpers_dir/monkey_patches" \
16
+ "$helpers_dir/run.rb" \
17
+ "$install_dir"
18
+ fi
19
+
20
+ cd "$install_dir"
21
+
22
+ # Default to Bundler 4, with an override for controlled testing/rollouts.
23
+ bundler_constraint="${DEPENDABOT_BUNDLER_VERSION_CONSTRAINT:-${BUNDLER_VERSION_CONSTRAINT:-~> 4.0}}"
24
+
25
+ export GEM_HOME=$install_dir/.bundle
26
+
27
+ gem install bundler -v "$bundler_constraint" --no-document
28
+
29
+ # Resolve the Bundler version that was actually installed in GEM_HOME to ensure
30
+ # consistency with what was requested and to avoid picking up system gems.
31
+ default_version=$(ruby -e '
32
+ gemspecs = Dir.glob("#{ENV["GEM_HOME"]}/specifications/bundler-*.gemspec")
33
+ latest = gemspecs.max_by { |f| Gem::Version.new(File.basename(f).match(/bundler-(.*)\.gemspec/)[1]) }
34
+ abort("No bundler gemspec found in #{ENV["GEM_HOME"]}/specifications") unless latest
35
+ print File.basename(latest).match(/bundler-(.*)\.gemspec/)[1]
36
+ ')
37
+
38
+ if [ -z "$default_version" ]; then
39
+ echo "error: failed to resolve installed Bundler version in $GEM_HOME" >&2
40
+ exit 1
41
+ fi
42
+
43
+ if [ -z "$DEPENDABOT_NATIVE_HELPERS_PATH" ]; then
44
+ bundle _"$default_version"_ install
45
+ fi
@@ -0,0 +1,25 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Resolves the Bundler version constraint that the native helper should use
5
+ # at activation time. Honors DEPENDABOT_BUNDLER_VERSION_CONSTRAINT, falling
6
+ # back to BUNDLER_VERSION_CONSTRAINT, and finally to the supplied default.
7
+ #
8
+ # Used by both `run.rb` (for activation via `gem`) and the helper specs so
9
+ # the rollback/staged-rollout behavior is exercised by real code.
10
+ module BundlerVersionConstraint
11
+ DEFAULT_ACTIVATION_CONSTRAINT = ">= 4, < 5"
12
+
13
+ def self.resolve(env: ENV, default: DEFAULT_ACTIVATION_CONSTRAINT)
14
+ env.fetch(
15
+ "DEPENDABOT_BUNDLER_VERSION_CONSTRAINT",
16
+ env.fetch("BUNDLER_VERSION_CONSTRAINT", default)
17
+ )
18
+ end
19
+
20
+ # Splits a comma-separated requirement string into the individual clauses
21
+ # accepted by Kernel#gem (e.g. ">= 2.4, < 5" -> [">= 2.4", "< 5"]).
22
+ def self.activation_clauses(constraint)
23
+ constraint.split(",").map(&:strip)
24
+ end
25
+ end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Functions
5
+ class ConflictingDependencyResolver
6
+ def initialize(dependency_name:, target_version:, lockfile_name:)
7
+ @dependency_name = dependency_name
8
+ @target_version = target_version
9
+ @lockfile_name = lockfile_name
10
+ end
11
+
12
+ # Finds any dependencies in the lockfile that have a subdependency on the
13
+ # given dependency that does not satisfly the target_version.
14
+ # @return [Array<Hash{String => String}]
15
+ # * explanation [String] a sentence explaining the conflict
16
+ # * name [String] the blocking dependencies name
17
+ # * version [String] the version of the blocking dependency
18
+ # * requirement [String] the requirement on the target_dependency
19
+ def conflicting_dependencies
20
+ parent_specs.flat_map do |parent_spec|
21
+ top_level_specs_for(parent_spec).map do |top_level|
22
+ dependency = parent_spec.dependencies.find { |bd| bd.name == dependency_name }
23
+ {
24
+ "explanation" => explanation(parent_spec, dependency, top_level),
25
+ "name" => parent_spec.name,
26
+ "version" => parent_spec.version.to_s,
27
+ "requirement" => dependency.requirement.to_s
28
+ }
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :dependency_name
36
+ attr_reader :target_version
37
+ attr_reader :lockfile_name
38
+
39
+ def parent_specs
40
+ version = Gem::Version.new(target_version)
41
+ parsed_lockfile.specs.filter do |spec|
42
+ spec.dependencies.any? do |dep|
43
+ dep.name == dependency_name &&
44
+ !dep.requirement.satisfied_by?(version)
45
+ end
46
+ end
47
+ end
48
+
49
+ def top_level_specs_for(parent_spec)
50
+ return [parent_spec] if top_level?(parent_spec)
51
+
52
+ parsed_lockfile.specs.filter do |spec|
53
+ spec.dependencies.any? do |dep|
54
+ dep.name == parent_spec.name && top_level?(spec)
55
+ end
56
+ end
57
+ end
58
+
59
+ def top_level?(spec)
60
+ parsed_lockfile.dependencies.key?(spec.name)
61
+ end
62
+
63
+ def explanation(spec, dependency, top_level)
64
+ if spec.name == top_level.name
65
+ "#{spec.name} (#{spec.version}) requires #{dependency_name} (#{dependency.requirement})"
66
+ else
67
+ "#{top_level.name} (#{top_level.version}) requires #{dependency_name} " \
68
+ "(#{dependency.requirement}) via #{spec.name} (#{spec.version})"
69
+ end
70
+ end
71
+
72
+ def parsed_lockfile
73
+ @parsed_lockfile ||= Bundler::LockfileParser.new(lockfile)
74
+ end
75
+
76
+ def lockfile
77
+ return @lockfile if defined?(@lockfile)
78
+
79
+ @lockfile =
80
+ begin
81
+ return unless lockfile_name && File.exist?(lockfile_name)
82
+
83
+ File.read(lockfile_name)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,90 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Functions
5
+ class DependencySource
6
+ attr_reader :gemfile_name
7
+ attr_reader :dependency_name
8
+
9
+ RUBYGEMS = "rubygems"
10
+ PRIVATE_REGISTRY = "private"
11
+ GIT = "git"
12
+ OTHER = "other"
13
+
14
+ def initialize(gemfile_name:, dependency_name:)
15
+ @gemfile_name = gemfile_name
16
+ @dependency_name = dependency_name
17
+ end
18
+
19
+ def type
20
+ bundler_source = specified_source || default_source
21
+ type_of(bundler_source)
22
+ end
23
+
24
+ def latest_git_version(dependency_source_url:, dependency_source_branch:)
25
+ source = Bundler::Source::Git.new(
26
+ "uri" => dependency_source_url,
27
+ "branch" => dependency_source_branch,
28
+ "name" => dependency_name,
29
+ "submodules" => true
30
+ )
31
+
32
+ # Tell Bundler we're fine with fetching the source remotely
33
+ source.instance_variable_set(:@allow_remote, true)
34
+
35
+ spec = source.specs.first
36
+ { version: spec.version, commit_sha: spec.source.revision }
37
+ end
38
+
39
+ def private_registry_versions
40
+ bundler_source = specified_source || default_source
41
+
42
+ bundler_source
43
+ .fetchers.flat_map do |fetcher|
44
+ index = fetcher.specs([dependency_name], bundler_source)
45
+ # Bundler 4 removed Index#search_all; use #search which returns all matches
46
+ specs = index.respond_to?(:search_all) ? index.search_all(dependency_name) : index.search(dependency_name)
47
+ specs.map(&:version)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def type_of(bundler_source)
54
+ case bundler_source
55
+ when Bundler::Source::Rubygems
56
+ remote = bundler_source.remotes.first
57
+ if remote.nil? || remote.to_s == "https://rubygems.org/"
58
+ RUBYGEMS
59
+ else
60
+ PRIVATE_REGISTRY
61
+ end
62
+ when Bundler::Source::Git
63
+ GIT
64
+ else
65
+ OTHER
66
+ end
67
+ end
68
+
69
+ def specified_source
70
+ return @specified_source if defined? @specified_source
71
+
72
+ @specified_source = definition.dependencies
73
+ .find { |dep| dep.name == dependency_name }&.source
74
+ end
75
+
76
+ def default_source
77
+ definition.send(:sources).default_source
78
+ end
79
+
80
+ def definition
81
+ @definition ||= Bundler::Definition.build(gemfile_name, nil, {})
82
+ end
83
+
84
+ def serialize_bundler_source(source)
85
+ {
86
+ type: source.class.to_s
87
+ }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,119 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "uri"
5
+
6
+ module Functions
7
+ class FileParser
8
+ def initialize(lockfile_name:)
9
+ @lockfile_name = lockfile_name
10
+ end
11
+
12
+ attr_reader :lockfile_name
13
+
14
+ def parsed_gemfile(gemfile_name:)
15
+ Bundler::Definition.build(gemfile_name, nil, {})
16
+ .dependencies.select(&:current_platform?)
17
+ .reject { |dep| local_sources.include?(dep.source.class) }
18
+ .map { |dep| serialize_bundler_dependency(dep) }
19
+ end
20
+
21
+ def parsed_gemspec(gemspec_name:)
22
+ Bundler.load_gemspec_uncached(gemspec_name)
23
+ .dependencies
24
+ .map { |dep| serialize_bundler_dependency(dep) }
25
+ end
26
+
27
+ private
28
+
29
+ def lockfile
30
+ return @lockfile if defined?(@lockfile)
31
+
32
+ @lockfile =
33
+ begin
34
+ return unless lockfile_name && File.exist?(lockfile_name)
35
+
36
+ File.read(lockfile_name)
37
+ end
38
+ end
39
+
40
+ def parsed_lockfile
41
+ return unless lockfile
42
+
43
+ @parsed_lockfile ||= Bundler::LockfileParser.new(lockfile)
44
+ end
45
+
46
+ def source_from_lockfile(dependency_name)
47
+ parsed_lockfile&.specs&.find { |s| s.name == dependency_name }&.source
48
+ end
49
+
50
+ def source_for(dependency)
51
+ source = dependency.source
52
+ if lockfile && default_rubygems?(source)
53
+ # If there's a lockfile and the Gemfile doesn't have anything
54
+ # interesting to say about the source, check that.
55
+ source = source_from_lockfile(dependency.name)
56
+ end
57
+ raise "Bad source: #{source}" unless sources.include?(source.class)
58
+
59
+ return nil if default_rubygems?(source)
60
+
61
+ details = { type: source.class.name.split("::").last.downcase }
62
+ details.merge!(git_source_details(source)) if source.is_a?(Bundler::Source::Git)
63
+ details[:url] = source.remotes.first.to_s if source.is_a?(Bundler::Source::Rubygems)
64
+ details
65
+ end
66
+
67
+ def git_source_details(source)
68
+ {
69
+ url: source.uri,
70
+ branch: source.branch,
71
+ ref: source.ref
72
+ }
73
+ end
74
+
75
+ RUBYGEMS_HOSTS = [
76
+ "rubygems.org",
77
+ "www.rubygems.org"
78
+ ].freeze
79
+
80
+ def default_rubygems?(source)
81
+ return true if source.nil?
82
+ return false unless source.is_a?(Bundler::Source::Rubygems)
83
+
84
+ source.remotes.any? do |r|
85
+ RUBYGEMS_HOSTS.include?(URI(r.to_s).host)
86
+ end
87
+ end
88
+
89
+ def serialize_bundler_dependency(dependency)
90
+ {
91
+ name: dependency.name,
92
+ requirement: dependency.requirement,
93
+ groups: dependency.groups,
94
+ source: source_for(dependency),
95
+ type: dependency.type
96
+ }
97
+ end
98
+
99
+ # Can't be a constant because some of these don't exist in bundler
100
+ # 1.15, which used to cause issues on Heroku (causing exception on boot).
101
+ # TODO: Check if this will be an issue with multiple bundler versions
102
+ def sources
103
+ [
104
+ NilClass,
105
+ Bundler::Source::Rubygems,
106
+ Bundler::Source::Git,
107
+ *local_sources,
108
+ Bundler::Source::Metadata
109
+ ]
110
+ end
111
+
112
+ def local_sources
113
+ [
114
+ Bundler::Source::Path,
115
+ Bundler::Source::Gemspec
116
+ ]
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,189 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Functions
5
+ class ForceUpdater
6
+ class TopLevelDependencyDowngradedError < StandardError; end
7
+
8
+ def initialize(
9
+ dependency_name:,
10
+ target_version:,
11
+ gemfile_name:,
12
+ lockfile_name:,
13
+ update_multiple_dependencies:
14
+ )
15
+ @dependency_name = dependency_name
16
+ @target_version = target_version
17
+ @gemfile_name = gemfile_name
18
+ @lockfile_name = lockfile_name
19
+ @update_multiple_dependencies = update_multiple_dependencies
20
+ end
21
+
22
+ def run
23
+ dependencies_to_unlock = []
24
+
25
+ begin
26
+ definition = build_definition(dependencies_to_unlock: dependencies_to_unlock)
27
+ definition.resolve_remotely!
28
+ specs = definition.resolve
29
+ updates = ([dependency_name, *dependencies_to_unlock] - subdependencies + extra_top_level_deps(specs)).uniq
30
+
31
+ updates = updates.map do |name|
32
+ {
33
+ name: name
34
+ }
35
+ end
36
+
37
+ specs = specs.map do |dep|
38
+ {
39
+ name: dep.name,
40
+ version: dep.version
41
+ }
42
+ end
43
+
44
+ [updates, specs]
45
+ rescue Bundler::SolveFailure => e
46
+ raise unless update_multiple_dependencies?
47
+
48
+ # TODO: Not sure this won't unlock way too many things...
49
+ new_dependencies_to_unlock =
50
+ new_dependencies_to_unlock_from(
51
+ error: e,
52
+ already_unlocked: dependencies_to_unlock
53
+ )
54
+
55
+ raise if new_dependencies_to_unlock.none?
56
+
57
+ dependencies_to_unlock |= new_dependencies_to_unlock
58
+ retry
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :dependency_name
65
+ attr_reader :target_version
66
+ attr_reader :gemfile_name
67
+ attr_reader :lockfile_name
68
+ attr_reader :credentials
69
+ attr_reader :update_multiple_dependencies
70
+ alias update_multiple_dependencies? update_multiple_dependencies
71
+
72
+ def extra_top_level_deps(specs)
73
+ top_level_dep_names.reject do |name|
74
+ original_version = original_specs.find { |s| s.name == name }&.version
75
+ new_version = specs[name].first&.version
76
+
77
+ if original_version == new_version
78
+ true
79
+ else
80
+ original_version = Gem::Version.new(original_version)
81
+ new_version = Gem::Version.new(new_version)
82
+
83
+ raise TopLevelDependencyDowngradedError if new_version < original_version
84
+
85
+ false
86
+ end
87
+ end
88
+ end
89
+
90
+ def new_dependencies_to_unlock_from(error:, already_unlocked:)
91
+ names = [*already_unlocked, dependency_name]
92
+ extra_names_to_unlock = []
93
+
94
+ incompatibility = error.cause.incompatibility
95
+
96
+ while incompatibility.conflict?
97
+ cause = incompatibility.cause
98
+ incompatibility = cause.incompatibility
99
+
100
+ incompatibility.terms.each do |term|
101
+ name = term.package.name
102
+ extra_names_to_unlock << name unless names.include?(name)
103
+ end
104
+ end
105
+
106
+ extra_names_to_unlock
107
+ end
108
+
109
+ def build_definition(dependencies_to_unlock:)
110
+ gems_to_unlock = dependencies_to_unlock + [dependency_name]
111
+ definition = Bundler::Definition.build(
112
+ gemfile_name,
113
+ lockfile_name,
114
+ gems: gems_to_unlock + subdependencies,
115
+ conservative: true
116
+ )
117
+
118
+ # Remove the Gemfile / gemspec requirements on the gems we're
119
+ # unlocking (i.e., completely unlock them)
120
+ gems_to_unlock.each do |gem_name|
121
+ unlock_gem(definition: definition, gem_name: gem_name)
122
+ end
123
+
124
+ dep = definition.dependencies
125
+ .find { |d| d.name == dependency_name }
126
+
127
+ if dep
128
+ # Set the requirement for the gem we're forcing an update of
129
+ new_req = Gem::Requirement.create("= #{target_version}")
130
+ dep.instance_variable_set(:@requirement, new_req)
131
+ dep.source = nil if dep.source.is_a?(Bundler::Source::Git)
132
+
133
+ definition
134
+ else
135
+ # If the dependency is not found in the Gemfile it means this is a
136
+ # transitive dependency. To force update it, we recreate a definition
137
+ # from the Gemfile, but add an extra dependency to it that pins the
138
+ # dependency we want to update.
139
+ gemfile = Pathname.new(gemfile_name).expand_path
140
+ builder = Bundler::Dsl.new
141
+ builder.eval_gemfile(gemfile)
142
+ builder.gem dependency_name, "= #{target_version}"
143
+ builder.to_definition(
144
+ lockfile_name,
145
+ gems: gems_to_unlock + subdependencies,
146
+ conservative: true
147
+ )
148
+ end
149
+ end
150
+
151
+ def lockfile
152
+ return @lockfile if defined?(@lockfile)
153
+
154
+ @lockfile =
155
+ begin
156
+ return unless lockfile_name && File.exist?(lockfile_name)
157
+
158
+ File.read(lockfile_name)
159
+ end
160
+ end
161
+
162
+ def subdependencies
163
+ # If there's no lockfile we don't need to worry about
164
+ # subdependencies
165
+ return [] unless lockfile
166
+
167
+ original_specs.map(&:name) - top_level_dep_names
168
+ end
169
+
170
+ def top_level_dep_names
171
+ @top_level_dep_names ||= Bundler::Definition.build(gemfile_name, lockfile_name, {}).dependencies.map(&:name)
172
+ end
173
+
174
+ def original_specs
175
+ @original_specs ||= Bundler::LockfileParser.new(lockfile).specs
176
+ end
177
+
178
+ def unlock_gem(definition:, gem_name:)
179
+ dep = definition.dependencies.find { |d| d.name == gem_name }
180
+ version = definition.locked_gems.specs
181
+ .find { |d| d.name == gem_name }.version
182
+
183
+ dep&.instance_variable_set(
184
+ :@requirement,
185
+ Gem::Requirement.create(">= #{version}")
186
+ )
187
+ end
188
+ end
189
+ end