bundler-multilock 2.0.0.beta4 → 2.0.0.beta6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45718b8a0410e25b206212808dd21eb5530561297393c3f6fca54a14acd26e40
4
- data.tar.gz: 35245c2fc0f936995c0cbc5be482bf20dc6767de70cc23b41fe1b89b63e179cd
3
+ metadata.gz: 6bdc44af5344eed4edb2782c151e3ab714450bcd81003130fba1385247c227da
4
+ data.tar.gz: 77391ab3ec993404d7363736accb18921c8d1c5190c2609a3bf45e4e950c6f3f
5
5
  SHA512:
6
- metadata.gz: 2e069c0c770748ebdfe957fc1c795553ecaa7afdd7655a180e5c6ced84f0e31840bd7f9233dcd6d07b70ac765fbde244624b1976a15f2d5dc31adb1e1accadaf
7
- data.tar.gz: c70adec856e83bb4520d3705e714bd2d1e99672d8c866faf1718928d65c1b3062b004c242e353037f7d7a0b35ea7bfb0675b2af20afd64416c1f8a14d03c7421
6
+ metadata.gz: 38436b8ee5d0096b5387da472d11a543158dcb884143cbbd9cf8acf9f9dd76e91a644ffb4b3fcdadeb7768b7b0f87f5597531060633fdc1dca92c8893adc31eb
7
+ data.tar.gz: aad7cd30a4f7ef60aff473d0ac763dab63f33f7dbfddceb2b18748563294d9d49f414f44f067ec2bfe482edee9925a4242f0a34f5dbc0de0d4053564a1661fa7
@@ -12,8 +12,10 @@ module Bundler
12
12
  @specs = {}
13
13
  @reverse_dependencies = {}
14
14
  @reverse_requirements = {}
15
+ @dependent_requirements = {}
15
16
  @base_checks = {}
16
17
  @deep_checks = {}
18
+ @dependency_options = {}
17
19
  @base_check_messages = {}
18
20
  @deep_check_messages = {}
19
21
  @missing_specs = Set.new
@@ -31,6 +33,7 @@ module Bundler
31
33
  @specs.delete(lockfile_name)
32
34
  @reverse_dependencies.delete(lockfile_name)
33
35
  @reverse_requirements.delete(lockfile_name)
36
+ @dependent_requirements.delete(lockfile_name)
34
37
  invalidate_checks(lockfile_name)
35
38
  end
36
39
 
@@ -42,6 +45,24 @@ module Bundler
42
45
  @deep_check_messages.clear
43
46
  end
44
47
 
48
+ # @param lockfile_name [Pathname]
49
+ # @param options [Hash<String, Array<Hash>>]
50
+ # how the lockfile's Gemfile limits the dependencies that have `platforms:` or `force_ruby_platform`, by
51
+ # gem name, with a hash for each time the gem is declared. `:force_ruby` is if only the source variant is
52
+ # used, and `:platforms` is the lockfile's platforms that it's for (or nil for all of them)
53
+ # @return [void]
54
+ def record_dependency_options(lockfile_name, options)
55
+ @dependency_options[lockfile_name] = options
56
+ end
57
+
58
+ # (known once the lockfile's Gemfile has been evaluated by the base check)
59
+ #
60
+ # @param lockfile_name [Pathname]
61
+ # @return [Hash<String, Array<Hash>>] see {#record_dependency_options}
62
+ def dependency_options(lockfile_name)
63
+ @dependency_options.fetch(lockfile_name) { {} }
64
+ end
65
+
45
66
  # @param lockfile_name [Pathname]
46
67
  # @return [String] the raw contents of the lockfile
47
68
  def contents(lockfile_name)
@@ -92,6 +113,46 @@ module Bundler
92
113
  @reverse_requirements[lockfile_name]
93
114
  end
94
115
 
116
+ # The gems in a lockfile whose requirements a version of a gem doesn't satisfy, other than
117
+ # the gems that are being ignored (like because they're going to change themselves).
118
+ #
119
+ # Requirements from the lockfile's own dependencies (the Gemfile) are never ignored.
120
+ #
121
+ # @param lockfile_name [Pathname]
122
+ # @param name [String] the gem's name
123
+ # @param version [Gem::Version]
124
+ # @param ignored_dependents [Set<String>] names of gems whose requirements shouldn't be considered
125
+ # @param retained [Set<LazySpecification>] variants of gems that are still considered, even if the gem is ignored
126
+ # @param gemfile_requirements [Hash<String, Gem::Requirement>, nil]
127
+ # the requirements of the lockfile's Gemfile, if they might be different from what's in the lockfile
128
+ # @return [Array<String, nil>] the names of the gems (or nil, for the Gemfile)
129
+ def blocking_dependents(lockfile_name,
130
+ name,
131
+ version,
132
+ ignored_dependents,
133
+ retained: nil,
134
+ gemfile_requirements: nil)
135
+ ensure_reverse_data(lockfile_name)
136
+ blocking = []
137
+ @dependent_requirements[lockfile_name][name].each do |dependent, requirement, spec|
138
+ next if ignored_dependents.include?(dependent) && !retained&.include?(spec)
139
+ # (the lockfile's version of the Gemfile's requirements is replaced below)
140
+ next if dependent.nil? && gemfile_requirements
141
+
142
+ blocking << dependent unless requirement.satisfied_by?(version)
143
+ end
144
+ blocking << nil if gemfile_requirements&.key?(name) && !gemfile_requirements[name].satisfied_by?(version)
145
+ blocking.uniq
146
+ end
147
+
148
+ # @param lockfile_name [Pathname]
149
+ # @param name [String] the gem's name
150
+ # @param version [Gem::Version]
151
+ # @return [true, false] if a version of a gem satisfies everything in a lockfile that requires it
152
+ def requirements_satisfied?(lockfile_name, name, version)
153
+ reverse_requirements(lockfile_name)[name].satisfied_by?(version)
154
+ end
155
+
95
156
  def conflicting_requirements?(lockfile1_name, lockfile2_name, spec1, spec2)
96
157
  reverse_requirements1 = reverse_requirements(lockfile1_name)[spec1.name]
97
158
  reverse_requirements2 = reverse_requirements(lockfile2_name)[spec1.name]
@@ -134,21 +195,26 @@ module Bundler
134
195
  # can use Gem::Requirement.default_prelease when Ruby 2.6 support is dropped
135
196
  reverse_requirements = Hash.new { |h, k| h[k] = Gem::Requirement.new(">= 0.a") }
136
197
  reverse_dependencies = Hash.new { |h, k| h[k] = Set.new }
198
+ # name => [[dependent name, Gem::Requirement, dependent spec]] (nil for the lockfile's own dependencies)
199
+ dependent_requirements = Hash.new { |h, k| h[k] = [] }
137
200
 
138
201
  lockfile = parser(lockfile_name)
139
202
 
140
203
  lockfile.dependencies.each_value do |dep|
141
204
  reverse_requirements[dep.name].requirements.concat(dep.requirement.requirements)
205
+ dependent_requirements[dep.name] << [nil, dep.requirement, nil]
142
206
  end
143
207
  lockfile.specs.each do |spec|
144
208
  spec.dependencies.each do |dep|
145
209
  reverse_requirements[dep.name].requirements.concat(dep.requirement.requirements)
146
210
  reverse_dependencies[dep.name] << spec.name
211
+ dependent_requirements[dep.name] << [spec.name, dep.requirement, spec]
147
212
  end
148
213
  end
149
214
 
150
215
  @reverse_requirements[lockfile_name] = reverse_requirements
151
216
  @reverse_dependencies[lockfile_name] = reverse_dependencies
217
+ @dependent_requirements[lockfile_name] = dependent_requirements
152
218
  end
153
219
  end
154
220
  end
@@ -63,6 +63,7 @@ module Bundler
63
63
  definition = Bundler.with_default_lockfile(lockfile_name) do
64
64
  Definition.build(lockfile_definition[:gemfile], lockfile_name, false)
65
65
  end
66
+ @cache.record_dependency_options(lockfile_name, dependency_options(definition))
66
67
  unless definition.send(:current_platform_locked?)
67
68
  next base_check_failed("#{lockfile_path} does not include the current platform " \
68
69
  "(#{Bundler.local_platform}).")
@@ -121,6 +122,28 @@ module Bundler
121
122
  false
122
123
  end
123
124
 
125
+ # @param definition [Definition]
126
+ # @return [Hash<String, Array<Hash>>] how the Gemfile limits the variants of the gems it has dependencies
127
+ # for, by gem name (see {Cache#record_dependency_options})
128
+ def dependency_options(definition)
129
+ # (a gem can be declared more than once, like in blocks for different platforms)
130
+ definition.dependencies.group_by(&:name).filter_map do |name, dependencies|
131
+ declarations = dependencies.map do |dependency|
132
+ platforms = if dependency.platforms.any?
133
+ definition.platforms.select do |platform|
134
+ dependency.expanded_platforms.include?(Gem::Platform.generic(platform))
135
+ end
136
+ end
137
+ { force_ruby: dependency.force_ruby_platform ? true : false, platforms: }
138
+ end
139
+
140
+ # (a declaration that isn't limited at all is for every platform, so that's what the gem is)
141
+ next if declarations.any? { |declaration| !declaration[:force_ruby] && declaration[:platforms].nil? }
142
+
143
+ [name, declarations]
144
+ end.to_h
145
+ end
146
+
124
147
  def source_changes_message(definition, lockfile_path, gemfile_path)
125
148
  locked = definition.locked_gems.sources
126
149
  current = definition.send(:sources).lock_sources
@@ -133,6 +156,36 @@ module Bundler
133
156
  message
134
157
  end
135
158
 
159
+ # @param unneeded_specs [Array<LazySpecification>]
160
+ # @param parser [LockfileParser]
161
+ # @return [Array<LazySpecification>] the specs that are only in the lockfile because of the given specs
162
+ # (which aren't included), because they're needed with them, and not without them
163
+ def specs_only_needed_by(unneeded_specs, parser)
164
+ specs_by_name = parser.specs.group_by(&:name)
165
+ reachable_without = lambda do |excluded|
166
+ reachable = Set.new
167
+ pending = parser.dependencies.keys
168
+ until pending.empty?
169
+ name = pending.shift
170
+ next unless reachable.add?(name)
171
+
172
+ specs_by_name[name]&.each do |spec|
173
+ next if excluded.any? { |unneeded| unneeded.equal?(spec) }
174
+
175
+ pending.concat(spec.dependencies.map(&:name))
176
+ end
177
+ end
178
+ reachable
179
+ end
180
+
181
+ needed = reachable_without.call([])
182
+ still_needed = reachable_without.call(unneeded_specs)
183
+ parser.specs.select do |spec|
184
+ needed.include?(spec.name) && !still_needed.include?(spec.name) &&
185
+ unneeded_specs.none? { |unneeded| unneeded.equal?(spec) }
186
+ end
187
+ end
188
+
136
189
  # checks that every gem has a checksum, and that gems in common (same version
137
190
  # and source) with the parent lockfile have the same checksums
138
191
  def checksums_valid?(parser, parent_parser, lockfile_path)
@@ -194,6 +247,56 @@ module Bundler
194
247
  success = false
195
248
  end
196
249
 
250
+ # the variants of gems don't depend on the platforms that are listed, so check them separately
251
+ same_source = lambda do |spec, parent_spec|
252
+ Multilock.same_source?(spec, parent_spec, lockfile_name, parent_lockfile_name)
253
+ end
254
+ stale_variants, missing_variants = Multilock.variant_drift(
255
+ parser.specs,
256
+ parent_parser.specs,
257
+ parser.platforms,
258
+ parent_parser.platforms,
259
+ dependency_names: parser.dependencies.keys,
260
+ dependency_options: @cache.dependency_options(lockfile_name),
261
+ same_source:
262
+ )
263
+ stale_variants += specs_only_needed_by(stale_variants, parser) unless stale_variants.empty?
264
+
265
+ # the source variants of gems that the parent only has other variants of don't have to be in the
266
+ # parent lockfile, and aren't additional dependencies (nor are the gems that only they need).
267
+ # (but the ones the parent does have are just like any other gem, with dependencies that are needed)
268
+ parent_versions = parent_parser.specs.group_by { |spec| [spec.name, spec.version] }
269
+ source_variants = parser.specs.select do |spec|
270
+ next false unless spec.platform == Gem::Platform::RUBY
271
+ next false unless (parent_variants = parent_versions[[spec.name, spec.version]])
272
+
273
+ parent_variants.none? { |parent_spec| parent_spec.platform == Gem::Platform::RUBY } &&
274
+ parent_variants.any? do |parent_spec|
275
+ Multilock.same_source?(spec, parent_spec, lockfile_name, parent_lockfile_name)
276
+ end
277
+ end
278
+ exempt_from_pins = stale_variants + source_variants
279
+ exempt_from_pins += specs_only_needed_by(exempt_from_pins, parser) unless source_variants.empty?
280
+ unless stale_variants.empty? && missing_variants.empty?
281
+ parent_path = parent_lockfile_name.relative_path_from(Dir.pwd)
282
+ if stale_variants.any?
283
+ Bundler.ui.error("#{lockfile_path} has gem variants that #{parent_path} doesn't " \
284
+ "(#{stale_variants.map { |spec| spec.lock_name.strip }.join(", ")}), " \
285
+ "so it is out of sync with it.")
286
+ end
287
+ if missing_variants.any?
288
+ Bundler.ui.error("#{lockfile_path} is missing gem variants that #{parent_path} has " \
289
+ "(#{missing_variants.map { |spec| spec.lock_name.strip }.join(", ")}), " \
290
+ "so it is out of sync with it.")
291
+ end
292
+ if Bundler.frozen_bundle?
293
+ Bundler.ui.error("That can't be fixed while the bundle is frozen. Run `bundle install` " \
294
+ "without frozen mode, and commit the updated #{lockfile_path}.")
295
+ end
296
+ success = false
297
+ end
298
+ exempt_from_pins = exempt_from_pins.to_set { |spec| [spec.name, spec.version] }
299
+
197
300
  # look through top-level explicit dependencies for pinned requirements
198
301
  if lockfile_definition[:enforce_pinned_additional_dependencies]
199
302
  find_pinned_dependencies(proven_pinned, parser.dependencies.each_value)
@@ -207,23 +310,14 @@ module Bundler
207
310
  # look through what this spec depends on, and keep track of all pinned requirements
208
311
  find_pinned_dependencies(proven_pinned, spec.dependencies)
209
312
 
210
- needs_pin_check << spec unless parent_spec
313
+ # (already reported, and not actually additional)
314
+ needs_pin_check << spec unless parent_spec || exempt_from_pins.include?([spec.name, spec.version])
211
315
  end
212
316
 
213
317
  next unless parent_spec
214
318
 
215
- # have to ensure Path sources are relative to their lockfile before comparing
216
- same_source = if [parent_spec.source, spec.source].grep(Source::Path).length == 2
217
- lockfile_name
218
- .dirname
219
- .join(spec.source.path)
220
- .ascend
221
- .any?(parent_lockfile_name.dirname.join(parent_spec.source.path))
222
- else
223
- parent_spec.source == spec.source
224
- end
225
-
226
- next if parent_spec.version == spec.version && same_source
319
+ next if parent_spec.version == spec.version &&
320
+ Multilock.same_source?(spec, parent_spec, lockfile_name, parent_lockfile_name)
227
321
 
228
322
  # the version in the parent lockfile cannot possibly satisfy the requirements
229
323
  # in this lockfile, and vice versa, so we assume it's intentional and allow it
@@ -99,10 +99,15 @@ module Bundler
99
99
  @allowed_duplicate_plugins = Set.new
100
100
  end
101
101
 
102
- def lockfile(*, **, &)
102
+ # Significant changes:
103
+ # * a lockfile with a block or options is managed by Multilock
104
+ # * a bare lockfile name is just Bundler's own `lockfile`, for
105
+ # Gemfiles that only want to name their lockfile
106
+ def lockfile(*args, **kwargs, &block)
103
107
  return true if Multilock.loaded?
108
+ return super(args.first) if args.length == 1 && args.first.is_a?(String) && kwargs.empty? && !block
104
109
 
105
- Multilock.add_lockfile(*, builder: self, **, &)
110
+ Multilock.add_lockfile(*args, builder: self, **kwargs, &block)
106
111
  end
107
112
  end
108
113
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Multilock
5
+ module Ext
6
+ # Makes `bundle cache` (and `bundle install` when vendor/cache exists) package
7
+ # the gems from every lockfile, and only prune gems that none of them use.
8
+ # Likewise, `bundle clean` only removes gems that none of the lockfiles use, and the
9
+ # `prune` setting covers the git gems from all of them.
10
+ module Runtime
11
+ ::Bundler::Runtime.prepend(self)
12
+
13
+ def cache(custom_path = nil, *)
14
+ cache_alternate_lockfiles(custom_path)
15
+ super
16
+ end
17
+
18
+ def prune_cache(cache_path)
19
+ return super if multilock_alternate_definitions.empty?
20
+
21
+ ensure_multilock_cache_path(cache_path)
22
+
23
+ resolve = [@definition, *multilock_alternate_definitions].flat_map { |definition| definition.resolve.to_a }
24
+ prune_gem_cache(resolve, cache_path)
25
+ prune_git_and_path_cache(resolve, cache_path)
26
+ end
27
+
28
+ # Bundler's `clean` only keeps what the one definition needs, so keep what all of them need.
29
+ # It also keys the specs it keeps by name, which would drop all but one version of a gem
30
+ # that the lockfiles disagree on, so that's replaced for the duration. (Keyed by where they
31
+ # are installed, since different lockfiles can lock different revisions of the same git gem.)
32
+ def clean(*)
33
+ alternate_definitions = multilock_alternate_definitions
34
+ return super if alternate_definitions.empty?
35
+
36
+ combined = [specs, *alternate_definitions.map(&:specs)].flat_map(&:to_a)
37
+ rubygems = Bundler.rubygems
38
+ original_add_default_gems_to = rubygems.method(:add_default_gems_to)
39
+ rubygems.define_singleton_method(:add_default_gems_to) do |_specs|
40
+ (original_add_default_gems_to.call([]).values + combined).to_h { |spec| [spec.full_gem_path, spec] }
41
+ end
42
+
43
+ begin
44
+ super
45
+ ensure
46
+ rubygems.singleton_class.send(:remove_method, :add_default_gems_to)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Same as Bundler's, except that it also covers the git gems that only other lockfiles use
53
+ def prune_git_metadata
54
+ return super if multilock_alternate_definitions.empty?
55
+
56
+ owned = "#{Bundler.install_path}#{File::SEPARATOR}"
57
+ git_sources = [@definition, *multilock_alternate_definitions].flat_map do |definition|
58
+ definition.sources.git_sources
59
+ end
60
+ git_dirs = git_sources.reject(&:local?).filter_map do |source|
61
+ install_path = source.install_path.to_s
62
+ next unless install_path.start_with?(owned)
63
+
64
+ git_dir = File.join(install_path, ".git")
65
+ git_dir if File.exist?(git_dir)
66
+ end.uniq
67
+ return if git_dirs.empty?
68
+
69
+ Bundler.ui.info "Removing git metadata from checked out git gems"
70
+ git_dirs.each do |git_dir|
71
+ ::Bundler::SharedHelpers.filesystem_access(git_dir) do |p|
72
+ FileUtils.rm_rf(p)
73
+ end
74
+ end
75
+ end
76
+
77
+ def ensure_multilock_cache_path(cache_path)
78
+ return if File.exist?(cache_path)
79
+
80
+ ::Bundler::SharedHelpers.filesystem_access(cache_path) do |p|
81
+ FileUtils.mkdir_p(p)
82
+ end
83
+ end
84
+
85
+ # @return [Array<Definition>] the definitions of the lockfiles other than the one being cached
86
+ def multilock_alternate_definitions
87
+ @multilock_alternate_definitions ||= Multilock.alternate_definitions
88
+ end
89
+
90
+ # Caches the gems that only the other lockfiles use. The ones in common are cached by the
91
+ # usual process, which also cleans up git sources and prunes afterwards.
92
+ def cache_alternate_lockfiles(custom_path)
93
+ return if multilock_alternate_definitions.empty?
94
+
95
+ cache_path = Bundler.app_cache(custom_path)
96
+ ensure_multilock_cache_path(cache_path)
97
+
98
+ cached = Set.new
99
+ multilock_alternate_definitions.each do |definition|
100
+ specs_to_cache = if Bundler.settings[:cache_all_platforms]
101
+ definition.resolve.materialized_for_all_platforms
102
+ else
103
+ definition.specs
104
+ end
105
+
106
+ specs_to_cache.each do |spec|
107
+ next if spec.name == "bundler"
108
+
109
+ source = spec.source
110
+ next if source.is_a?(::Bundler::Source::Gemspec)
111
+
112
+ # git sources are equal regardless of which revision is locked, but each one
113
+ # is cached separately
114
+ cache_key = source.respond_to?(:app_cache_dirname) ? source.app_cache_dirname : source
115
+ next unless cached.add?([spec.full_name, cache_key])
116
+
117
+ if source.respond_to?(:migrate_cache)
118
+ source.migrate_cache(custom_path, local: false)
119
+ elsif source.respond_to?(:cache)
120
+ source.cache(spec, custom_path)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bundler
4
4
  module Multilock
5
- VERSION = "2.0.0.beta4"
5
+ VERSION = "2.0.0.beta6"
6
6
  end
7
7
  end
@@ -4,6 +4,7 @@ require_relative "multilock/ext/bundler"
4
4
  require_relative "multilock/ext/definition"
5
5
  require_relative "multilock/ext/dsl"
6
6
  require_relative "multilock/ext/plugin/dsl"
7
+ require_relative "multilock/ext/runtime"
7
8
  require_relative "multilock/ext/shared_helpers"
8
9
  require_relative "multilock/ext/source"
9
10
  require_relative "multilock/ext/source_list"
@@ -40,11 +41,15 @@ module Bundler
40
41
  parent: nil,
41
42
  enforce_pinned_additional_dependencies: false,
42
43
  &block)
44
+ # remembered for gems that need to be kept for the primary lockfile, even if it was never
45
+ # declared and an alternate lockfile is active (which may use its own Gemfile)
46
+ @primary_gemfile ||= Bundler.default_gemfile
47
+ @primary_lockfile ||= Bundler.default_lockfile(force_original: true)
48
+
43
49
  active = true if active.nil? && lockfile_definitions.empty? && lockfile.nil? && gemfile.nil?
44
50
 
45
51
  # if a gemfile was provided, but not a lockfile, infer the default lockfile for that gemfile
46
52
  lockfile ||= "#{gemfile}.lock" if gemfile
47
- # allow short-form lockfile names
48
53
  lockfile = expand_lockfile(lockfile)
49
54
 
50
55
  raise ArgumentError, "Lockfile #{lockfile} is already defined" if lockfile_definitions.key?(lockfile)
@@ -139,9 +144,6 @@ module Bundler
139
144
 
140
145
  Bundler.ui.debug("Syncing to alternate lockfiles")
141
146
 
142
- attempts = 1
143
- previous_contents = Set.new
144
-
145
147
  default_root = Bundler.root
146
148
 
147
149
  cache = Cache.new
@@ -158,17 +160,6 @@ module Bundler
158
160
 
159
161
  relative_lockfile = lockfile_name.relative_path_from(Dir.pwd)
160
162
 
161
- # prevent infinite loops of tick-tocking back and forth between two versions
162
- current_contents = cache.contents(lockfile_name)
163
- if previous_contents.include?(current_contents)
164
- Bundler.ui.debug("Unable to converge on a single solution for #{lockfile_name}; " \
165
- "perhaps there are conflicting requirements?")
166
- attempts = 1
167
- previous_contents.clear
168
- next
169
- end
170
- previous_contents << current_contents
171
-
172
163
  # already up to date?
173
164
  up_to_date = false
174
165
  conflicts = Set.new
@@ -178,11 +169,7 @@ module Bundler
178
169
  checker.deep_check(lockfile_definition, conflicts:)
179
170
  end
180
171
  end
181
- if up_to_date
182
- attempts = 1
183
- previous_contents.clear
184
- next
185
- end
172
+ next if up_to_date
186
173
 
187
174
  # a missing lockfile that enforces pinned additional dependencies has nothing
188
175
  # to be out of sync with, so even a frozen bundle just generates it
@@ -201,7 +188,7 @@ module Bundler
201
188
  Bundler.ui.info("Installing gems for #{relative_lockfile}...")
202
189
  write_lockfile(lockfile_definition, lockfile_name, cache, install:)
203
190
  else
204
- Bundler.ui.info("Syncing to #{relative_lockfile}...") if attempts == 1
191
+ Bundler.ui.info("Syncing to #{relative_lockfile}...")
205
192
  synced_any = true
206
193
 
207
194
  parent_lockfile_name = lockfile_definition[:parent]
@@ -230,6 +217,9 @@ module Bundler
230
217
  TEXT
231
218
  end
232
219
 
220
+ added_platforms = []
221
+ removed_platforms = []
222
+ unlock_gems = Set.new
233
223
  if lockfile_name.exist?
234
224
  # if the lockfile already exists, "merge" it together
235
225
  parent_lockfile = if adjusted_parent_lockfile_contents == cache.contents(lockfile_name)
@@ -242,24 +232,13 @@ module Bundler
242
232
 
243
233
  dependency_changes = false
244
234
 
245
- spec_precedences = {}
246
-
247
- check_precedence = lambda do |spec, parent_spec|
248
- next :parent if spec.nil?
249
- next :self if parent_spec.nil?
250
- next spec_precedences[spec.name] if spec_precedences.key?(spec.name)
251
-
252
- precedence = if !(cache.reverse_dependencies(lockfile_name)[spec.name] & conflicts).empty? # rubocop:disable Style/ArrayIntersect -- not an array
253
- :parent
254
- elsif cache.conflicting_requirements?(lockfile_name,
255
- parent_lockfile_name,
256
- spec,
257
- parent_spec)
258
- :self
259
- end
260
-
261
- spec_precedences[spec.name] = precedence || :parent
262
- end
235
+ precedences, unlock_gems = spec_precedences(
236
+ cache,
237
+ lockfile_name,
238
+ parent_lockfile_name,
239
+ conflicts,
240
+ gemfile_requirements: gemfile_requirements(lockfile_definition)
241
+ )
263
242
 
264
243
  lockfile.sources.map! do |source|
265
244
  parent_lockfile.sources.find { |s| s == source } || source
@@ -268,7 +247,7 @@ module Bundler
268
247
  lockfile.specs.map! do |spec|
269
248
  parent_spec = cache.find_matching_spec(parent_specs, spec)
270
249
  next spec unless parent_spec
271
- next spec if check_precedence.call(spec, parent_spec) == :self
250
+ next spec if precedences[spec.name] == :self
272
251
 
273
252
  dependency_changes ||= spec != parent_spec
274
253
 
@@ -279,8 +258,63 @@ module Bundler
279
258
  parent_spec
280
259
  end
281
260
 
282
- lockfile.platforms.replace(parent_lockfile.platforms).uniq!
283
- lockfile.instance_variable_set(:@ruby_version, parent_lockfile.ruby_version) if lockfile.ruby_version
261
+ # the parent's versions might depend on gems this lockfile didn't need before.
262
+ # without them, Bundler considers the lockfile broken, and ignores what's
263
+ # locked in it, which can update everything
264
+ names = lockfile.specs.to_set(&:name)
265
+ pending = lockfile.specs.dup
266
+ until pending.empty?
267
+ pending.shift.dependencies.each do |dependency|
268
+ next if names.include?(dependency.name) || !(dependency_specs = parent_specs[dependency.name])
269
+
270
+ names << dependency.name
271
+ dependency_changes = true
272
+ dependency_specs.each_value do |dependency_spec|
273
+ source = dependency_spec.source
274
+ lockfile.sources << source unless lockfile.sources.include?(source)
275
+ lockfile.specs << dependency_spec
276
+ pending << dependency_spec
277
+ end
278
+ end
279
+ end
280
+
281
+ # leave the lockfile's platforms alone, and let Bundler add and remove them itself
282
+ # (in write_lockfile), like `bundle lock --add-platform` and `--remove-platform`,
283
+ # so that it also adds or removes the gems needed for them
284
+ removed_platforms = lockfile.platforms - parent_lockfile.platforms
285
+ added_platforms = parent_lockfile.platforms - lockfile.platforms
286
+
287
+ # the lockfiles can agree on platforms, but still not on the variants of gems for them (perhaps
288
+ # due to merging of semantics conflicts), so clean up and/or add any missing variants.
289
+ # (the parent lockfile's specs have had their paths rebased to be relative to this lockfile)
290
+ same_source = lambda do |spec, parent_spec|
291
+ same_source?(spec, parent_spec, lockfile_name, lockfile_name)
292
+ end
293
+ stale_variants, missing_variants = variant_drift(
294
+ lockfile.specs,
295
+ parent_lockfile.specs,
296
+ lockfile.platforms,
297
+ parent_lockfile.platforms,
298
+ dependency_names: lockfile.dependencies.keys,
299
+ dependency_options: cache.dependency_options(lockfile_name),
300
+ same_source:
301
+ )
302
+ stale_variants.map(&:platform).uniq.each do |platform|
303
+ entries = matching_platforms(lockfile.platforms, platform)
304
+ # Bundler can only remove a platform that's listed (and gems can have variants for
305
+ # platforms, like ruby, that never are)
306
+ lockfile.platforms << platform if entries.empty?
307
+ removed_platforms |= entries.empty? ? [platform] : entries
308
+ end
309
+ missing_variants.map(&:platform).uniq.each do |platform|
310
+ # (only platforms that the parent still lists)
311
+ entries = matching_platforms(lockfile.platforms, platform) & parent_lockfile.platforms
312
+ # and it can only add a platform that isn't listed
313
+ lockfile.platforms.reject! { |entry| entries.include?(entry) }
314
+ added_platforms |= entries
315
+ end
316
+ removed_platforms -= added_platforms
317
+ lockfile.instance_variable_set(:@ruby_version, parent_lockfile.ruby_version)
284
318
  unless lockfile.bundler_version == parent_lockfile.bundler_version
285
319
  unlocking_bundler = parent_lockfile.bundler_version
286
320
  lockfile.instance_variable_set(:@bundler_version, parent_lockfile.bundler_version)
@@ -307,24 +341,13 @@ module Bundler
307
341
  cache,
308
342
  install:,
309
343
  dependency_changes:,
310
- unlocking_bundler:)
344
+ unlocking_bundler:,
345
+ added_platforms:,
346
+ removed_platforms:,
347
+ unlock_gems:)
311
348
  end
312
349
  end
313
350
  cache.invalidate_lockfile(lockfile_name) if had_changes
314
-
315
- # if we had changes, bundler may have updated some common
316
- # dependencies beyond the default lockfile, so re-run it
317
- # once to reset them back to the default lockfile's version.
318
- # if it's already good, the `check` check at the beginning of
319
- # the loop will skip the second sync anyway.
320
- if had_changes
321
- attempts += 1
322
- Bundler.ui.debug("Re-running sync to #{relative_lockfile} to reset common dependencies")
323
- redo
324
- else
325
- attempts = 1
326
- previous_contents.clear
327
- end
328
351
  end
329
352
  end
330
353
  ensure
@@ -386,10 +409,509 @@ module Bundler
386
409
  return [] unless lockfile
387
410
  return [lockfile] if recovered
388
411
 
389
- # we can't tell if Bundler expanded a short name (or "active") to an
390
- # absolute path, or if it was written that way, so allow either
412
+ # we can't tell if Bundler expanded "active" to an absolute path,
413
+ # or if it was written that way, so allow either
391
414
  relative = Pathname.new(lockfile).relative_path_from(Pathname.pwd).to_s
392
- relative.include?("/") ? [lockfile] : [lockfile, relative]
415
+ (relative == "active") ? [lockfile, relative] : [lockfile]
416
+ end
417
+
418
+ # @!visibility private
419
+ # @param lockfile_definition [Hash]
420
+ # @return [Hash<String, Gem::Requirement>] the requirements that the lockfile's Gemfile has for its
421
+ # dependencies, which are only in the lockfile itself after it's synced
422
+ def gemfile_requirements(lockfile_definition)
423
+ gemfile = lockfile_definition[:gemfile]
424
+ prepare_block = lockfile_definition[:prepare]
425
+
426
+ builder = Dsl.new
427
+ builder.eval_gemfile(gemfile, &prepare_block) if prepare_block
428
+ builder.eval_gemfile(gemfile)
429
+ builder.dependencies.group_by(&:name).transform_values do |dependencies|
430
+ Gem::Requirement.new(dependencies.flat_map { |dependency| dependency.requirement.as_list })
431
+ end
432
+ end
433
+
434
+ # @!visibility private
435
+ # Decides, for each gem in a lockfile that is also in its parent lockfile, which one's version wins when
436
+ # syncing the lockfile.
437
+ #
438
+ # A gem follows the parent, unless something that's staying as it is requires its version, and the
439
+ # parent can't use that version either (so it's different on purpose). Gems that follow the parent
440
+ # have different requirements from what they had, so they don't count. Neither do gems that nothing
441
+ # needs any more, once the others follow the parent. If the parent can use the version, the gems that
442
+ # are in the way have to be updated instead, so they're unlocked. And they also don't count, since
443
+ # they'll have different requirements, once they're updated.
444
+ #
445
+ # Each of those can change the others, so this starts out assuming that every gem that's different from
446
+ # the parent is going to follow it, and repeats, until that matches which ones actually do. And then
447
+ # repeats that, until the gems that are unlocked don't change.
448
+ #
449
+ # @param cache [Cache]
450
+ # @param lockfile_name [Pathname]
451
+ # @param parent_lockfile_name [Pathname]
452
+ # @param conflicts [Set<String>] names of gems that are out of sync, with no reason to be
453
+ # @param gemfile_requirements [Hash<String, Gem::Requirement>, nil]
454
+ # the requirements of the lockfile's Gemfile. The lockfile's own copy of them may be about to change
455
+ # @return [Array(Hash<String, Symbol>, Set<String>)]
456
+ # gem name to +:parent+ or +:self+, and the names of the gems that have to be unlocked for the ones
457
+ # that follow the parent to be able to
458
+ def spec_precedences(cache, lockfile_name, parent_lockfile_name, conflicts, gemfile_requirements: nil)
459
+ parent_specs = cache.specs(parent_lockfile_name)
460
+ own_specs = cache.specs(lockfile_name)
461
+ roots = gemfile_requirements&.keys || cache.parser(lockfile_name).dependencies.keys
462
+
463
+ # every platform variant of a gem gets replaced with its parent's, so any of them can change
464
+ variants = Hash.new { |hash, name| hash[name] = [] }
465
+ cache.parser(lockfile_name).specs.each do |spec|
466
+ next unless (parent_spec = cache.find_matching_spec(parent_specs, spec))
467
+
468
+ variants[spec.name] << [spec, parent_spec]
469
+ end
470
+ # (and the ones that the variants the lockfile has are replaced with)
471
+ matched = variants.transform_values { |gem_variants| gem_variants.map(&:last).uniq }
472
+
473
+ # other variants of a gem that follows the parent, that the parent doesn't have a counterpart for,
474
+ # are kept as they are
475
+ unmatched = cache.parser(lockfile_name).specs.select do |spec|
476
+ variants.key?(spec.name) && !cache.find_matching_spec(parent_specs, spec)
477
+ end.group_by(&:name)
478
+
479
+ # a gem can change what it depends on without changing its version (like a git gem)
480
+ differing = variants.select do |_name, gem_variants|
481
+ gem_variants.any? { |spec, parent_spec| spec_changed?(spec, parent_spec) }
482
+ end.keys
483
+
484
+ # the variants that stay, but that cleaning up the variants after replacing the gems will remove
485
+ counterparts = {}.compare_by_identity
486
+ variants.each_value do |gem_variants|
487
+ gem_variants.each { |spec, parent_spec| counterparts[spec] = parent_spec }
488
+ end
489
+ removed = lambda do |following_parent|
490
+ merged = cache.parser(lockfile_name).specs.map do |spec|
491
+ following_parent.include?(spec.name) ? counterparts.fetch(spec, spec) : spec
492
+ end
493
+ stale, = variant_drift(merged,
494
+ cache.parser(parent_lockfile_name).specs,
495
+ cache.parser(lockfile_name).platforms,
496
+ cache.parser(parent_lockfile_name).platforms,
497
+ dependency_names: roots.to_a,
498
+ dependency_options: cache.dependency_options(lockfile_name),
499
+ same_source: lambda do |spec, parent_spec|
500
+ same_source?(spec, parent_spec, lockfile_name, parent_lockfile_name)
501
+ end)
502
+ stale.to_set & unmatched.values.flatten
503
+ end
504
+
505
+ # the specs of gems that only the parent has, that this lockfile could get (if the Gemfile can use them)
506
+ importable = lambda do |name|
507
+ parent_specs[name]&.values&.select do |spec|
508
+ !gemfile_requirements&.key?(name) || gemfile_requirements[name].satisfied_by?(spec.version)
509
+ end
510
+ end
511
+
512
+ unneeded = lambda do |following_parent, removed_variants|
513
+ staying = unmatched.transform_values { |specs| specs.reject { |spec| removed_variants.include?(spec) } }
514
+ own_specs.keys.to_set - needed_gems(roots, own_specs, importable, following_parent, matched, staying)
515
+ end
516
+
517
+ unlock = Set.new
518
+ loop do
519
+ precedences, blockers = converge_precedences(cache,
520
+ lockfile_name,
521
+ parent_lockfile_name,
522
+ variants,
523
+ conflicts.to_set | differing,
524
+ differing:,
525
+ unlock:,
526
+ removed:,
527
+ unneeded:,
528
+ roots:,
529
+ importable:,
530
+ retained: unmatched.values.flatten.to_set,
531
+ gemfile_requirements:)
532
+
533
+ return [precedences, unlock] if blockers.subset?(unlock)
534
+
535
+ unlock |= blockers
536
+ end
537
+ end
538
+
539
+ # @!visibility private
540
+ # (see #spec_precedences)
541
+ # @param variants [Hash<String, Array<Array(LazySpecification, LazySpecification)>>]
542
+ # gem name to each of its specs, and its spec in the parent lockfile
543
+ # @param out_of_sync [Set<String>] the gems assumed to follow the parent to start with
544
+ # @param differing [Array<String>] the gems that follow the parent, if they can
545
+ # @param unlock [Set<String>] gems that are already going to be unlocked
546
+ # @param removed [#call] given the gems that follow the parent, the variants that stay as they are, but
547
+ # that are going to be removed
548
+ # @param unneeded [#call] given the gems that follow the parent, and the variants that are going to be
549
+ # removed, the gems that nothing needs any more
550
+ # @param roots [Enumerable<String>] the names of the gems that are in the Gemfile
551
+ # @param importable [#call] given the name of a gem that only the parent has, the specs of it that could be used
552
+ # @param retained [Set<LazySpecification>] variants of gems that stay as they are, even if the gem follows
553
+ # the parent
554
+ # @return [Array(Hash<String, Symbol>, Set<String>)] the precedences, and the gems that are in the way
555
+ def converge_precedences(cache,
556
+ lockfile_name,
557
+ parent_lockfile_name,
558
+ variants,
559
+ out_of_sync,
560
+ differing:,
561
+ unlock:,
562
+ removed:,
563
+ unneeded:,
564
+ roots:,
565
+ importable:,
566
+ retained:,
567
+ gemfile_requirements:)
568
+ seen = Set.new
569
+ own_specs = cache.specs(lockfile_name)
570
+ # what gems are replaced with, which is all of the variants of gems that this lockfile gets
571
+ matched = variants.transform_values { |gem_variants| gem_variants.map(&:last).uniq }
572
+ effective_specs = ->(name) { matched[name] || importable.call(name) }
573
+
574
+ loop do
575
+ removed_now = removed.call(out_of_sync)
576
+ unneeded_now = unneeded.call(out_of_sync, removed_now)
577
+ ignored = out_of_sync | unneeded_now | unlock
578
+ # (a variant that stays doesn't require anything, if nothing needs the gem any more, or it's updated,
579
+ # or it's going to be removed)
580
+ kept = retained.reject do |spec|
581
+ unneeded_now.include?(spec.name) || unlock.include?(spec.name) || removed_now.include?(spec)
582
+ end
583
+
584
+ # gems that only the parent has, that gems following the parent bring along, require things too
585
+ # (but not gems that won't be needed any more, and only for the variants that are used)
586
+ following = out_of_sync - unneeded_now
587
+ following |= imported_gems(effective_specs, own_specs, following, roots)
588
+
589
+ blockers = Set.new
590
+ precedences = variants.to_h do |name, gem_variants|
591
+ follow_parent = true
592
+ blocking_gems = Set.new
593
+
594
+ gem_variants.each do |spec, parent_spec|
595
+ next if spec.version == parent_spec.version
596
+
597
+ blocking = cache.blocking_dependents(lockfile_name,
598
+ name,
599
+ parent_spec.version,
600
+ ignored,
601
+ retained: kept,
602
+ gemfile_requirements:)
603
+
604
+ # something that's still needed, and not out of sync, requires this version. if that's the
605
+ # Gemfile, it can't be changed. if the parent lockfile is also unable to use this version, it's
606
+ # different on purpose, unless a gem following the parent requires it to change. otherwise it
607
+ # still follows the parent, and what's in the way has to be updated, to be able to
608
+ if blocking.include?(nil) ||
609
+ (blocking.any? &&
610
+ !cache.requirements_satisfied?(parent_lockfile_name, name, spec.version) &&
611
+ !required_to_change?(cache.reverse_dependencies(parent_lockfile_name)[name] & following,
612
+ effective_specs,
613
+ name,
614
+ spec.version))
615
+ follow_parent = false
616
+ else
617
+ blocking_gems.merge(blocking)
618
+ end
619
+ end
620
+
621
+ blockers.merge(blocking_gems) if follow_parent
622
+ [name, follow_parent ? :parent : :self]
623
+ end
624
+
625
+ replaced = differing.select { |name| precedences[name] == :parent }.to_set
626
+ # (what a gem depends on can change which gems are needed in either direction, so it's possible,
627
+ # but unlikely, to go in circles. Bundler will sort out what's left over, in that case)
628
+ return [precedences, blockers] if replaced == out_of_sync || !seen.add?(replaced)
629
+
630
+ out_of_sync = replaced
631
+ end
632
+ end
633
+
634
+ # @!visibility private
635
+ # @param dependents [Enumerable<String>] gems that are following the parent, that depend on the gem in the
636
+ # parent lockfile
637
+ # @param effective_specs [#call] given a gem's name, the specs in the parent lockfile that it's replaced with
638
+ # @return [true, false] if what any of the gems require from the parent lockfile excludes +version+ of +name+
639
+ def required_to_change?(dependents, effective_specs, name, version)
640
+ dependents.any? do |dependent|
641
+ effective_specs.call(dependent)&.any? do |spec|
642
+ spec.dependencies.any? do |dependency|
643
+ dependency.name == name && !dependency.requirement.satisfied_by?(version)
644
+ end
645
+ end
646
+ end
647
+ end
648
+
649
+ # @!visibility private
650
+ # @return [true, false] if syncing +spec+ to +parent_spec+ changes what it is, or what it depends on
651
+ def spec_changed?(spec, parent_spec)
652
+ return true unless spec.version == parent_spec.version
653
+
654
+ dependencies = lambda do |other|
655
+ other.dependencies.map { |dependency| [dependency.name, dependency.requirement.as_list.sort] }.sort
656
+ end
657
+ dependencies.call(spec) != dependencies.call(parent_spec)
658
+ end
659
+
660
+ # @!visibility private
661
+ # @param roots [Enumerable<String>] the names of the gems that are in the Gemfile
662
+ # @param specs [Hash<String, Hash<Object, LazySpecification>>] a lockfile's specs
663
+ # @param importable [#call] given the name of a gem that only the parent has, the specs of it that could be used
664
+ # @param following_parent [Set<String>] gems that will have their dependencies from the parent lockfile
665
+ # @param matched [Hash<String, Array<LazySpecification>>]
666
+ # the specs in the parent lockfile that the variants of gems are replaced with
667
+ # @param unmatched [Hash<String, Array<LazySpecification>>]
668
+ # variants of gems that stay as they are, even for gems that are following the parent
669
+ # @return [Set<String>] the names of the gems that are needed by the Gemfile, once the gems that are
670
+ # following the parent do
671
+ def needed_gems(roots, specs, importable, following_parent, matched, unmatched)
672
+ needed = Set.new
673
+ pending = roots.to_a.dup
674
+ until pending.empty?
675
+ name = pending.shift
676
+ next unless needed.add?(name)
677
+
678
+ variants = if following_parent.include?(name) && matched.key?(name)
679
+ matched[name] + unmatched.fetch(name, [])
680
+ elsif specs[name]
681
+ specs[name].values
682
+ else
683
+ # (a gem that the parent has, that this lockfile will get)
684
+ importable.call(name)
685
+ end
686
+ variants&.each { |spec| pending.concat(spec.dependencies.map(&:name)) }
687
+ end
688
+ needed
689
+ end
690
+
691
+ # @!visibility private
692
+ # @param effective_specs [#call] given a gem's name, the specs in the parent lockfile that it's replaced with
693
+ # @param specs [Hash<String, Hash<Object, LazySpecification>>] the lockfile's specs
694
+ # @param following_parent [Set<String>] gems that will have their dependencies from the parent lockfile
695
+ # @param roots [Enumerable<String>] the names of the gems that are in the Gemfile
696
+ # @return [Set<String>] the names of the gems that the parent lockfile has, that the lockfile doesn't, but
697
+ # will get, because the Gemfile or the gems following the parent need them
698
+ def imported_gems(effective_specs, specs, following_parent, roots)
699
+ imported = Set.new
700
+ pending = following_parent.to_a
701
+ # (a gem that was just added to the Gemfile)
702
+ roots.each do |name|
703
+ pending << name if !specs.key?(name) && effective_specs.call(name) && imported.add?(name)
704
+ end
705
+ until pending.empty?
706
+ effective_specs.call(pending.shift)&.each do |spec|
707
+ spec.dependencies.each do |dependency|
708
+ next if specs.key?(dependency.name) || !imported.add?(dependency.name)
709
+
710
+ pending << dependency.name
711
+ end
712
+ end
713
+ end
714
+ imported
715
+ end
716
+
717
+ # @!visibility private
718
+ # Finds variants of gems (a gem version for a particular platform) that are in only one of two lockfiles,
719
+ # even though both of them have that version of the gem (or this one has a version left behind next to
720
+ # one the parent has), and the platform is one that only one of them is for. The source variant of a
721
+ # gem is never stale for a version of it that the parent lockfile has.
722
+ #
723
+ # The lockfiles can't be compared by their platforms alone, since gems can have variants for platforms
724
+ # (like ruby) that aren't listed, and what gems this lockfile needs on each platform depends on what its
725
+ # Gemfile declares (see {#gems_needed_on}). A variant is only missing if this lockfile needs the gem for
726
+ # the platform, and would use that variant.
727
+ #
728
+ # Gems that this lockfile has `platforms:` or `force_ruby_platform` for, and gems that it gets from a
729
+ # different source than the parent lockfile does, aren't expected to have the same variants.
730
+ #
731
+ # @param specs [Array<LazySpecification>]
732
+ # @param parent_specs [Array<LazySpecification>]
733
+ # @param platforms [Array<Gem::Platform, String>] the platforms listed in the lockfile for +specs+
734
+ # @param parent_platforms [Array<Gem::Platform, String>] the platforms listed in the parent lockfile
735
+ # @param dependency_names [Array<String>, nil]
736
+ # the gems that are dependencies in the Gemfile for +specs+ (nil if it's not known, so any gem is needed)
737
+ # @param dependency_options [Hash<String, Array<Hash>>] see {Cache#record_dependency_options}
738
+ # @param same_source [#call] given a spec and a parent lockfile spec, if they're from the same source
739
+ # @return [Array(Array<LazySpecification>, Array<LazySpecification>)]
740
+ # the variants only in +specs+ (that the parent lockfile's platforms don't include), and the ones only in
741
+ # +parent_specs+ (that +platforms+ do include, and +specs+ need)
742
+ def variant_drift(specs,
743
+ parent_specs,
744
+ platforms,
745
+ parent_platforms,
746
+ dependency_names: nil,
747
+ dependency_options: {},
748
+ same_source: ->(spec, parent_spec) { spec.source == parent_spec.source })
749
+ ours = specs.group_by { |spec| [spec.name, spec.version] }
750
+ theirs = parent_specs.group_by { |spec| [spec.name, spec.version] }
751
+ ours_by_name = specs.group_by(&:name)
752
+
753
+ # a version of a gem that the parent doesn't have is left behind (instead of intentionally different)
754
+ # if this lockfile also has a version of that gem that the parent lockfile does have
755
+ left_behind = lambda do |spec|
756
+ ours_by_name[spec.name].any? do |other|
757
+ other.version != spec.version &&
758
+ theirs[[other.name, other.version]]&.any? { |parent| same_platform?(parent.platform, other.platform) }
759
+ end
760
+ end
761
+
762
+ # (other variants of the gem from a different source are expected to be different)
763
+ # (and the source of this lockfile's spec is always the first one to compare)
764
+ from_same_source = ->(spec, parents) { parents.any? { |parent| same_source.call(spec, parent) } }
765
+
766
+ stale = specs.select do |spec|
767
+ next false if matching_platforms(parent_platforms, spec.platform).any?
768
+ # Bundler keeps (or adds) the source variant of a gem next to the platform specific ones, whether
769
+ # or not the ruby platform is listed, so it's not stale for a version of the gem that the parent has
770
+ next false if spec.platform == Gem::Platform::RUBY && theirs.key?([spec.name, spec.version])
771
+
772
+ if (parent_variants = theirs[[spec.name, spec.version]])
773
+ from_same_source.call(spec, parent_variants) &&
774
+ parent_variants.none? { |parent| same_platform?(parent.platform, spec.platform) }
775
+ else
776
+ left_behind.call(spec)
777
+ end
778
+ end
779
+
780
+ # the variants of the parent lockfile that this lockfile would use, for the platforms they both have,
781
+ # when it doesn't have a variant it can use instead on that platform
782
+ missing = Set.new
783
+ platforms.each do |platform|
784
+ next if parent_platforms.none? { |other| same_platform?(other, platform) }
785
+
786
+ gems_needed_on(platform, ours_by_name, dependency_names, dependency_options).each do |name, force_ruby|
787
+ # (a gem that's forced to the ruby platform only uses its source variant)
788
+ target = force_ruby ? Gem::Platform::RUBY : platform
789
+
790
+ # (not every gem that's needed is in the lockfile, like bundler itself)
791
+ ours_by_name.fetch(name, []).map(&:version).uniq.each do |version|
792
+ next unless (parent_variants = theirs[[name, version]])
793
+
794
+ variants = ours[[name, version]]
795
+ MatchPlatform.select_best_platform_match(parent_variants, target).each do |expected|
796
+ # (both lockfiles have to list the platform. if the parent doesn't, its variant is just a
797
+ # source variant that Bundler kept, and this lockfile having removed the platform is what
798
+ # needs to sync)
799
+ next unless matching_platforms(platforms, expected.platform).any? &&
800
+ matching_platforms(parent_platforms, expected.platform).any?
801
+ next unless variants.any? { |variant| same_source.call(variant, expected) }
802
+
803
+ missing << expected unless usable_instead?(variants, expected, target)
804
+ end
805
+ end
806
+ end
807
+ end
808
+ missing = missing.to_a
809
+
810
+ [stale, missing]
811
+ end
812
+
813
+ # @!visibility private
814
+ # If a variant of a gem (that the lockfile has) could be used on a platform in place of a variant of the
815
+ # same gem from the parent lockfile, because it's at least as specific to the platform.
816
+ #
817
+ # @param variants [Array<LazySpecification>] the variants of the gem that the lockfile has
818
+ # @param expected [LazySpecification] the variant of the gem from the parent lockfile
819
+ # @param platform [Gem::Platform, String] the platform the variants would be used for
820
+ def usable_instead?(variants, expected, platform)
821
+ specificity = ->(spec) { Gem::Platform.platform_specificity_match(spec.platform, platform) }
822
+
823
+ MatchPlatform.select_best_platform_match(variants, platform).any? do |variant|
824
+ specificity.call(variant) <= specificity.call(expected)
825
+ end
826
+ end
827
+
828
+ # @!visibility private
829
+ # The gems that a Gemfile needs on a platform, the same way Bundler works them out for a lockfile
830
+ # (see Bundler::Materialization): starting with the dependencies in the Gemfile that are for the
831
+ # platform, and then what the variant of each gem that would be used for the platform depends on.
832
+ # (A variant of a gem for a different platform may depend on different gems.)
833
+ #
834
+ # A gem can be declared in the Gemfile more than once, such as for different platforms, and only the
835
+ # declarations for the platform are considered.
836
+ #
837
+ # @param platform [Gem::Platform, String] one of the platforms listed in the lockfile
838
+ # @param specs_by_name [Hash<String, Array<LazySpecification>>] the specs of the lockfile
839
+ # @param dependency_names [Array<String>, nil] the gems that are dependencies in the Gemfile
840
+ # (nil if it's not known, so any gem is needed)
841
+ # @param dependency_options [Hash<String, Array<Hash>>] see {Cache#record_dependency_options}
842
+ # @return [Hash<String, true or false>] the gems that are needed, and if only the source variant is used
843
+ def gems_needed_on(platform, specs_by_name, dependency_names, dependency_options)
844
+ declarations_for = lambda do |name|
845
+ dependency_options.fetch(name, []).select do |declaration|
846
+ declaration[:platforms].nil? || declaration[:platforms].any? { |other| same_platform?(other, platform) }
847
+ end
848
+ end
849
+
850
+ pending = if dependency_names
851
+ dependency_names.select do |name|
852
+ !dependency_options.key?(name) || declarations_for.call(name).any?
853
+ end
854
+ else
855
+ specs_by_name.keys
856
+ end
857
+
858
+ needed = {}
859
+ until pending.empty?
860
+ name = pending.shift
861
+ next if needed.key?(name)
862
+
863
+ declarations = declarations_for.call(name)
864
+ force_ruby = declarations.any? && declarations.all? { |declaration| declaration[:force_ruby] }
865
+ needed[name] = force_ruby
866
+ next unless (variants = specs_by_name[name])
867
+
868
+ # (not `force_ruby:`, since that changes the specs)
869
+ selected = MatchPlatform.select_best_platform_match(variants, force_ruby ? Gem::Platform::RUBY : platform)
870
+ selected.each { |spec| pending.concat(spec.dependencies.map(&:name)) }
871
+ end
872
+ needed
873
+ end
874
+
875
+ # @!visibility private
876
+ # @return [true, false] if two specs, from different lockfiles, are from the same source
877
+ def same_source?(spec, parent_spec, lockfile_name, parent_lockfile_name)
878
+ # have to ensure Path sources are relative to their lockfile before comparing
879
+ if [parent_spec.source, spec.source].grep(Source::Path).length == 2
880
+ lockfile_name
881
+ .dirname
882
+ .join(spec.source.path)
883
+ .ascend
884
+ .any?(parent_lockfile_name.dirname.join(parent_spec.source.path))
885
+ else
886
+ parent_spec.source == spec.source
887
+ end
888
+ end
889
+
890
+ # @!visibility private
891
+ # @return [true, false] if gems for each platform could be used for the other (since a gem
892
+ # for Linux can be used for musl, but a musl gem can't be used for Linux), and both are for the same
893
+ # CPU (since a gem for a universal CPU can be used for any, but isn't the same as one for a specific one)
894
+ def same_platform?(platform, other)
895
+ return platform == other unless platform.is_a?(Gem::Platform) && other.is_a?(Gem::Platform)
896
+
897
+ platform.cpu == other.cpu &&
898
+ platform === other && other === platform # rubocop:disable Style/CaseEquality -- Gem::Platform's match
899
+ end
900
+
901
+ # @!visibility private
902
+ # @param platforms [Array<Gem::Platform, String>] platforms listed in a lockfile
903
+ # @param platform [Gem::Platform, String] the platform of a gem variant
904
+ # @return [Array<Gem::Platform, String>] the listed platforms that the variant could be used for
905
+ def matching_platforms(platforms, platform)
906
+ # (a gem for Linux can be used on musl, but not the other way around, and only the ruby platform
907
+ # uses source variants)
908
+ platforms.select do |other|
909
+ if platform.is_a?(Gem::Platform) && other.is_a?(Gem::Platform)
910
+ platform === other # rubocop:disable Style/CaseEquality -- Gem::Platform's match
911
+ else
912
+ platform == other
913
+ end
914
+ end
393
915
  end
394
916
 
395
917
  # @!visibility private
@@ -411,6 +933,8 @@ module Bundler
411
933
  def reset!
412
934
  @lockfile_definitions = {}
413
935
  @loaded = false
936
+ @primary_gemfile = nil
937
+ @primary_lockfile = nil
414
938
  end
415
939
 
416
940
  # @!visibility private
@@ -418,6 +942,40 @@ module Bundler
418
942
  lockfile_definitions[Bundler.default_lockfile(force_original: true)]
419
943
  end
420
944
 
945
+ # @!visibility private
946
+ # The definitions of the other lockfiles, when working with all of them (so not with
947
+ # BUNDLE_LOCKFILE), for packaging gems from all of them. Their gems need to have been
948
+ # installed already, by a regular `bundle install`.
949
+ #
950
+ # @return [Array<Definition>]
951
+ def alternate_definitions
952
+ return [] if lockfile_definitions.empty? || env_lockfile
953
+
954
+ # the primary lockfile doesn't have to be declared
955
+ candidates = lockfile_definitions.values
956
+ if @primary_lockfile && !lockfile_definitions.key?(@primary_lockfile)
957
+ candidates += [{ gemfile: @primary_gemfile, lockfile: @primary_lockfile, prepare: nil }]
958
+ end
959
+
960
+ default_root = Bundler.root
961
+ candidates.filter_map do |lockfile_definition|
962
+ lockfile = lockfile_definition[:lockfile]
963
+ # the one Bundler is already working with, which may be an alternate lockfile flagged as active
964
+ next if lockfile == Bundler.default_lockfile
965
+ next unless lockfile.exist?
966
+
967
+ Bundler.root = lockfile_definition[:gemfile].dirname
968
+ builder = Dsl.new
969
+ prepare_block = lockfile_definition[:prepare]
970
+ builder.eval_gemfile(lockfile_definition[:gemfile], &prepare_block) if prepare_block
971
+ builder.eval_gemfile(lockfile_definition[:gemfile])
972
+ # (as the default lockfile, so that it isn't swapped for the active one)
973
+ Bundler.with_default_lockfile(lockfile) { builder.to_definition(lockfile, {}) }
974
+ end
975
+ ensure
976
+ Bundler.root = default_root if default_root
977
+ end
978
+
421
979
  private
422
980
 
423
981
  # @return [Array(String, true or false), nil]
@@ -445,9 +1003,6 @@ module Bundler
445
1003
  end
446
1004
 
447
1005
  def expand_lockfile(lockfile)
448
- if lockfile.is_a?(String) && !(lockfile.include?("/") || lockfile.end_with?(".lock"))
449
- lockfile = "Gemfile.#{lockfile}.lock"
450
- end
451
1006
  # use absolute paths
452
1007
  lockfile = Bundler.root.join(lockfile).expand_path if lockfile
453
1008
  # use the default lockfile (Gemfile.lock) if none was given
@@ -504,7 +1059,10 @@ module Bundler
504
1059
  cache,
505
1060
  install:,
506
1061
  dependency_changes: false,
507
- unlocking_bundler: false)
1062
+ unlocking_bundler: false,
1063
+ added_platforms: [],
1064
+ removed_platforms: [],
1065
+ unlock_gems: Set.new)
508
1066
  prepare_block = lockfile_definition[:prepare]
509
1067
 
510
1068
  gemfile = lockfile_definition[:gemfile]
@@ -520,8 +1078,16 @@ module Bundler
520
1078
  builder.instance_variable_set(:@ruby_version, parent_ruby_version_requirement)
521
1079
  end
522
1080
 
523
- definition = builder.to_definition(lockfile, { bundler: unlocking_bundler })
1081
+ unlock = { bundler: unlocking_bundler }
1082
+ # only the gems that are in the way of syncing with the parent
1083
+ unless unlock_gems.empty?
1084
+ unlock[:gems] = unlock_gems.to_a
1085
+ unlock[:conservative] = true
1086
+ end
1087
+ definition = builder.to_definition(lockfile, unlock)
524
1088
  definition.instance_variable_set(:@dependency_changes, dependency_changes) if dependency_changes
1089
+ added_platforms.each { |platform| definition.add_platform(platform) }
1090
+ removed_platforms.each { |platform| definition.remove_platform(platform) }
525
1091
 
526
1092
  # match the parent lockfile in whether there are checksums
527
1093
  unless Bundler.frozen_bundle?
@@ -537,6 +1103,10 @@ module Bundler
537
1103
  definition.instance_variable_set(:@lockfile_contents, current_lockfile.read) if current_lockfile.exist?
538
1104
 
539
1105
  orig_definition = definition.dup # we might need it twice
1106
+ # `dup` shares the platforms array, and resolving locally changes it (like dropping the ruby platform,
1107
+ # and adding the local platform) before it might fail, which would leave a remote resolution
1108
+ # starting from the wrong platforms
1109
+ orig_definition.instance_variable_set(:@platforms, definition.instance_variable_get(:@platforms).dup)
540
1110
 
541
1111
  # install gems for the exact current version of the lockfile
542
1112
  # this ensures it doesn't re-resolve with only (different)
@@ -572,8 +1142,10 @@ module Bundler
572
1142
  previous_ui_level = Bundler.ui.level
573
1143
  Bundler.ui.level = "warn"
574
1144
  begin
575
- # force a remote resolution if intermediate gems are missing
576
- if definition.instance_variable_get(:@locked_spec_with_missing_deps) ||
1145
+ # force a remote resolution if intermediate gems are missing, or if platforms were added
1146
+ # (a gem's source variant would satisfy them locally, without its platform specific variants)
1147
+ if (added_platforms.any? && !local_only?) ||
1148
+ definition.instance_variable_get(:@locked_spec_with_missing_deps) ||
577
1149
  definition.instance_variable_get(:@locked_spec_with_invalid_deps) ||
578
1150
  definition.instance_variable_get(:@missing_lockfile_dep) ||
579
1151
  definition.instance_variable_get(:@invalid_lockfile_dep)
@@ -593,6 +1165,11 @@ module Bundler
593
1165
  spec.source.specs << spec
594
1166
  end
595
1167
  resolve_locally(definition)
1168
+ # a local resolution can only see the gems that are installed, so it can drop platforms
1169
+ # (like ruby) that remote gem variants would keep. the platforms should match the parent's
1170
+ parent_platforms = cache.parser(lockfile_definition[:parent]).platforms.map(&:to_s).sort
1171
+ raise SolveFailure unless local_only? || definition.platforms.map(&:to_s).sort == parent_platforms
1172
+
596
1173
  missing_checksums = fill_checksums(definition, lockfile_definition[:parent], cache)
597
1174
  # fetch checksums that aren't available locally
598
1175
  raise SolveFailure unless missing_checksums.empty? || local_only?
@@ -612,6 +1189,11 @@ module Bundler
612
1189
  "Run without `--local` to fetch them."
613
1190
  end
614
1191
  SharedHelpers.capture_filesystem_access do
1192
+ # a frozen bundle can't change its lockfile, and trying to just fails with a misleading
1193
+ # error from Bundler (like about updating Bundler itself). install what it locks, and
1194
+ # let the check afterwards report what's actually wrong with it
1195
+ next if Bundler.frozen_bundle?
1196
+
615
1197
  definition.instance_variable_set(:@resolved_bundler_version, unlocking_bundler) if unlocking_bundler
616
1198
 
617
1199
  # need to force it to _not_ preserve unknown sections, so that it
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-multilock
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.beta4
4
+ version: 2.0.0.beta6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: 4.1.0.beta2
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
- version: '5'
21
+ version: '4.2'
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,7 +28,7 @@ dependencies:
28
28
  version: 4.1.0.beta2
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
- version: '5'
31
+ version: '4.2'
32
32
  executables: []
33
33
  extensions: []
34
34
  extra_rdoc_files: []
@@ -41,6 +41,7 @@ files:
41
41
  - lib/bundler/multilock/ext/definition.rb
42
42
  - lib/bundler/multilock/ext/dsl.rb
43
43
  - lib/bundler/multilock/ext/plugin/dsl.rb
44
+ - lib/bundler/multilock/ext/runtime.rb
44
45
  - lib/bundler/multilock/ext/shared_helpers.rb
45
46
  - lib/bundler/multilock/ext/source.rb
46
47
  - lib/bundler/multilock/ext/source_list.rb