diff --git a/lib/bundler/multilock.rb b/lib/bundler/multilock.rb index 22af071..f38f11d 100644 --- a/lib/bundler/multilock.rb +++ b/lib/bundler/multilock.rb @@ -27,11 +27,6 @@ class << self # BUNDLE_LOCKFILE will still override a lockfile tagged as active # @param parent [String] The parent lockfile to sync dependencies from. # Also used for comparing enforce_pinned_additional_dependencies against. - # @param allow_mismatched_dependencies [true, false] - # Allows version differences in dependencies between this lockfile and - # the default lockfile. Note that even with this option, only top-level - # dependencies that differ from the default lockfile, and their transitive - # depedencies, are allowed to mismatch. # @param enforce_pinned_additional_dependencies [true, false] # If dependencies are present in this lockfile that are not present in the # default lockfile, enforce that they are pinned. @@ -44,12 +39,15 @@ def add_lockfile(lockfile = nil, active: nil, default: nil, parent: nil, - allow_mismatched_dependencies: true, + allow_mismatched_dependencies: nil, enforce_pinned_additional_dependencies: false, &block) # backcompat active = default if active.nil? Bundler.ui.warn("lockfile(default:) is deprecated. Use lockfile(active:) instead.") if default + unless allow_mismatched_dependencies.nil? + Bundler.ui.warn("lockfile(allow_mismatched_dependencies:) is deprecated.") + end active = true if active.nil? && lockfile_definitions.empty? && lockfile.nil? && gemfile.nil? @@ -81,7 +79,6 @@ def add_lockfile(lockfile = nil, active: active, prepare: block, parent: parent, - allow_mismatched_dependencies: allow_mismatched_dependencies, enforce_pinned_additional_dependencies: enforce_pinned_additional_dependencies }) @@ -171,7 +168,7 @@ def after_install_all(install: true) Bundler.settings.temporary(frozen: true) do Bundler.ui.silence do up_to_date = checker.base_check(lockfile_definition) && - checker.check(lockfile_definition, allow_mismatched_dependencies: false) + checker.check(lockfile_definition) end end if up_to_date diff --git a/lib/bundler/multilock/check.rb b/lib/bundler/multilock/check.rb index b7f4ad0..4b7a87b 100644 --- a/lib/bundler/multilock/check.rb +++ b/lib/bundler/multilock/check.rb @@ -90,7 +90,7 @@ def base_check(lockfile_definition, log_missing: false, return_missing: false) # this checks for mismatches between the parent lockfile and the given lockfile, # and for pinned dependencies in lockfiles requiring them - def check(lockfile_definition, allow_mismatched_dependencies: true) + def check(lockfile_definition) success = true proven_pinned = Set.new needs_pin_check = [] @@ -109,36 +109,8 @@ def check(lockfile_definition, allow_mismatched_dependencies: true) success = false end - specs = lockfile.specs.group_by(&:name) - if allow_mismatched_dependencies - allow_mismatched_dependencies = lockfile_definition[:allow_mismatched_dependencies] - end - - # build list of top-level dependencies that differ from the parent lockfile, - # and all _their_ transitive dependencies - if allow_mismatched_dependencies - transitive_dependencies = Set.new - # only dependencies that differ from the parent lockfile - pending_transitive_dependencies = lockfile.dependencies.reject do |name, dep| - parent_lockfile.dependencies[name] == dep - end.map(&:first) - - until pending_transitive_dependencies.empty? - dep = pending_transitive_dependencies.shift - next if transitive_dependencies.include?(dep) - - transitive_dependencies << dep - platform_specs = specs[dep] - unless platform_specs - # should only be bundler that's missing a spec - raise "Could not find spec for dependency #{dep}" unless dep == "bundler" - - next - end - - pending_transitive_dependencies.concat(platform_specs.flat_map(&:dependencies).map(&:name).uniq) - end - end + reverse_dependencies = cache_reverse_dependencies(lockfile) + parent_reverse_dependencies = cache_reverse_dependencies(parent_lockfile) # look through top-level explicit dependencies for pinned requirements if lockfile_definition[:enforce_pinned_additional_dependencies] @@ -146,7 +118,7 @@ def check(lockfile_definition, allow_mismatched_dependencies: true) end # check for conflicting requirements (and build list of pins, in the same loop) - specs.values.flatten.each do |spec| + lockfile.specs.each do |spec| parent_spec = lockfile_specs[parent][[spec.name, spec.platform]] if lockfile_definition[:enforce_pinned_additional_dependencies] @@ -170,7 +142,15 @@ def check(lockfile_definition, allow_mismatched_dependencies: true) end next if parent_spec.version == spec.version && same_source - next if allow_mismatched_dependencies && transitive_dependencies.include?(spec.name) + + # the version in the parent lockfile cannot possibly satisfy the requirements + # in this lockfile, and vice versa, so we assume it's intentional and allow it + unless reverse_dependencies[spec.name].satisfied_by?(parent_spec.version) || + parent_reverse_dependencies[spec.name].satisfied_by?(spec.version) + # we're allowing it to differ from the parent, so pin check requirement comes into play + needs_pin_check << spec if lockfile_definition[:enforce_pinned_additional_dependencies] + next + end Bundler.ui.error("#{spec}#{spec.git_version} in #{lockfile_path} " \ "does not match the parent lockfile's version " \ @@ -206,6 +186,21 @@ def check(lockfile_definition, allow_mismatched_dependencies: true) private + def cache_reverse_dependencies(lockfile) + reverse_dependencies = Hash.new { |h, k| h[k] = Gem::Requirement.default_prerelease } + + lockfile.dependencies.each_value do |spec| + reverse_dependencies[spec.name].requirements.concat(spec.requirement.requirements) + end + lockfile.specs.each do |spec| + spec.dependencies.each do |dependency| + reverse_dependencies[dependency.name].requirements.concat(dependency.requirement.requirements) + end + end + + reverse_dependencies + end + def find_pinned_dependencies(proven_pinned, dependencies) dependencies.each do |dependency| dependency.requirement.requirements.each do |requirement| diff --git a/spec/bundler/multilock_spec.rb b/spec/bundler/multilock_spec.rb index e6cf753..872b5c0 100644 --- a/spec/bundler/multilock_spec.rb +++ b/spec/bundler/multilock_spec.rb @@ -303,7 +303,7 @@ it "syncs from a parent lockfile" do with_gemfile(<<~RUBY) do lockfile do - gem "activesupport", "> 6.0", "< 7.1" + gem "activesupport", "~> 7.0.0" end lockfile("6.1") do @@ -359,16 +359,16 @@ it "notifies about mismatched versions between different lockfiles" do with_gemfile(<<~RUBY) do lockfile do - gem "activesupport", "~> 6.1.0" + gem "activesupport", ">= 6.1", "< 7.2" end - lockfile("full", allow_mismatched_dependencies: false) do - gem "activesupport", "7.0.4.3" + lockfile("full") do + gem "activesupport", "6.1.7.6" end RUBY expect do invoke_bundler("install") - end.to raise_error(Regexp.new("activesupport \\(7.0.4.3\\) in Gemfile.full.lock " \ + end.to raise_error(Regexp.new("activesupport \\(6.1.7.6\\) in Gemfile.full.lock " \ "does not match the parent lockfile's version")) end end @@ -377,7 +377,7 @@ with_gemfile(<<~RUBY) do gem "activesupport", "7.0.4.3" # depends on tzinfo ~> 2.0, so will get >= 2.0.6 - lockfile("full", allow_mismatched_dependencies: false) do + lockfile("full") do gem "tzinfo", "2.0.5" end @@ -410,7 +410,6 @@ it "disallows mismatched implicit dependencies" do with_gemfile(<<~RUBY) do lockfile("local_test/Gemfile.lock", - allow_mismatched_dependencies: false, gemfile: "local_test/Gemfile") gem "snaky_hash", "2.0.1" @@ -635,12 +634,12 @@ # so that it won't downgrade if that's all you have available it "installs missing deps from alternate lockfiles before syncing" do Bundler.with_unbundled_env do - `gem uninstall activesupport -a --force 2> #{File::NULL}` - `gem install activesupport -v 6.1.7.6` + `gem uninstall activemodel -a --force 2> #{File::NULL}` + `gem install activemodel -v 6.1.7.6` end with_gemfile(<<~RUBY) do - lockfile() do + lockfile do gem "activemodel", ">= 6.0" end @@ -648,19 +647,19 @@ gem "activemodel", "~> 6.1.0" end RUBY - invoke_bundler("install") + invoke_bundler("install --local") expect(invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("6.1.7.6") Bundler.with_unbundled_env do - `gem uninstall activesupport -v 6.1.7.6 --force 2> #{File::NULL}` - `gem install activesupport -v 6.1.6` + `gem uninstall activemodel -v 6.1.7.6 --force 2> #{File::NULL}` + `gem install activemodel -v 6.1.6` end expect { invoke_bundler("check") }.to raise_error(/The following gems are missing/) invoke_bundler("install") # it should have re-installed 6.1.7.6, leaving the lockfile alone - expect(invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("6.1.7.6") + expect(invoke_bundler("info activemodel", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("6.1.7.6") end end @@ -699,6 +698,24 @@ end end + it "does not re-sync lockfiles that have conflicting sub-dependencies" do + with_gemfile(<<~RUBY) do + lockfile do + gem "activemodel", "~> 7.1.0" + end + + lockfile("rails-7.0") do + gem "activemodel", "~> 7.0.0" + end + RUBY + output = invoke_bundler("install") + expect(output).to include("Syncing") + + output = invoke_bundler("install") + expect(output).not_to include("Syncing") + end + end + private def create_local_gem(name, content = "", subdirectory: true)