bundler-multilock 2.0.0.beta5 → 2.0.0.beta7

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: 730bc7b4e1a107a6b3fd9527014b630b97918ed317e703b8cf045c67d6f6fae9
4
- data.tar.gz: 11943fc5875e3a07ba89f66b38e6b4ab6d100fe0d47168e24d5d3ae023609ebb
3
+ metadata.gz: 4be5320cfc5b4d3e13288c22fae8811f3be04f88ca74cc234f4959adcb918f48
4
+ data.tar.gz: 338049458fd3ac3c8b0878803c131824e392747cd30d4ca66abd90ac5bc5d6bc
5
5
  SHA512:
6
- metadata.gz: 998f0024a84771dc5d61ada77c069bd267c9d0da5c1d09acd11c098a02bdcd6c537a32e5e8bfd3005ebb9050d1fea9afed8a6f4cc32877e5af8224089431cd00
7
- data.tar.gz: dcf0c9c3e0089f48f796857eec57f555381ff215327ba904a11ef294c1b8bd45611613be3548b8def4c0dc7a4abd6f2fa65f7082251bfe83d216c092891a822e
6
+ metadata.gz: 67360e0dab15f5bb6c5fe2fb65ce7b78bbe9db065a37b8d9a35e640ddeab456f94a13cad9ae1d6ccc84b79525f3341f94f494e849194840e1bc6a6ff7cdfa544
7
+ data.tar.gz: 72452dda2334ed58087d938b68961a779acfa42529265181c36209f494e91ac27f07033710f56881a22f90bf2ace8b14561929376251ff9d54625e6d5300f6be
@@ -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
@@ -45,6 +45,10 @@ module Bundler
45
45
  __getobj__
46
46
  end
47
47
 
48
+ def overrides_to_lock
49
+ Array(overrides).map(&:to_lock).uniq
50
+ end
51
+
48
52
  def bundler_version_to_lock
49
53
  bundler_version
50
54
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bundler
4
4
  module Multilock
5
- VERSION = "2.0.0.beta5"
5
+ VERSION = "2.0.0.beta7"
6
6
  end
7
7
  end
@@ -219,6 +219,7 @@ module Bundler
219
219
 
220
220
  added_platforms = []
221
221
  removed_platforms = []
222
+ unlock_gems = Set.new
222
223
  if lockfile_name.exist?
223
224
  # if the lockfile already exists, "merge" it together
224
225
  parent_lockfile = if adjusted_parent_lockfile_contents == cache.contents(lockfile_name)
@@ -231,24 +232,13 @@ module Bundler
231
232
 
232
233
  dependency_changes = false
233
234
 
234
- spec_precedences = {}
235
-
236
- check_precedence = lambda do |spec, parent_spec|
237
- next :parent if spec.nil?
238
- next :self if parent_spec.nil?
239
- next spec_precedences[spec.name] if spec_precedences.key?(spec.name)
240
-
241
- precedence = if !(cache.reverse_dependencies(lockfile_name)[spec.name] & conflicts).empty? # rubocop:disable Style/ArrayIntersect -- not an array
242
- :parent
243
- elsif cache.conflicting_requirements?(lockfile_name,
244
- parent_lockfile_name,
245
- spec,
246
- parent_spec)
247
- :self
248
- end
249
-
250
- spec_precedences[spec.name] = precedence || :parent
251
- 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
+ )
252
242
 
253
243
  lockfile.sources.map! do |source|
254
244
  parent_lockfile.sources.find { |s| s == source } || source
@@ -257,7 +247,7 @@ module Bundler
257
247
  lockfile.specs.map! do |spec|
258
248
  parent_spec = cache.find_matching_spec(parent_specs, spec)
259
249
  next spec unless parent_spec
260
- next spec if check_precedence.call(spec, parent_spec) == :self
250
+ next spec if precedences[spec.name] == :self
261
251
 
262
252
  dependency_changes ||= spec != parent_spec
263
253
 
@@ -293,6 +283,37 @@ module Bundler
293
283
  # so that it also adds or removes the gems needed for them
294
284
  removed_platforms = lockfile.platforms - parent_lockfile.platforms
295
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
296
317
  lockfile.instance_variable_set(:@ruby_version, parent_lockfile.ruby_version)
297
318
  unless lockfile.bundler_version == parent_lockfile.bundler_version
298
319
  unlocking_bundler = parent_lockfile.bundler_version
@@ -322,7 +343,8 @@ module Bundler
322
343
  dependency_changes:,
323
344
  unlocking_bundler:,
324
345
  added_platforms:,
325
- removed_platforms:)
346
+ removed_platforms:,
347
+ unlock_gems:)
326
348
  end
327
349
  end
328
350
  cache.invalidate_lockfile(lockfile_name) if had_changes
@@ -393,6 +415,505 @@ module Bundler
393
415
  (relative == "active") ? [lockfile, relative] : [lockfile]
394
416
  end
395
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
915
+ end
916
+
396
917
  # @!visibility private
397
918
  # @return [Bundler::CLI, nil] the bundler command that's running, if any
398
919
  def cli
@@ -540,7 +1061,8 @@ module Bundler
540
1061
  dependency_changes: false,
541
1062
  unlocking_bundler: false,
542
1063
  added_platforms: [],
543
- removed_platforms: [])
1064
+ removed_platforms: [],
1065
+ unlock_gems: Set.new)
544
1066
  prepare_block = lockfile_definition[:prepare]
545
1067
 
546
1068
  gemfile = lockfile_definition[:gemfile]
@@ -556,7 +1078,13 @@ module Bundler
556
1078
  builder.instance_variable_set(:@ruby_version, parent_ruby_version_requirement)
557
1079
  end
558
1080
 
559
- 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)
560
1088
  definition.instance_variable_set(:@dependency_changes, dependency_changes) if dependency_changes
561
1089
  added_platforms.each { |platform| definition.add_platform(platform) }
562
1090
  removed_platforms.each { |platform| definition.remove_platform(platform) }
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.beta5
4
+ version: 2.0.0.beta7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 4.1.0.beta2
18
+ version: 4.1.0.beta3
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '4.2'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 4.1.0.beta2
28
+ version: 4.1.0.beta3
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '4.2'