diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61d7de0..4191a15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} + rubygems: latest bundler: ${{ matrix.bundler-version }} bundler-cache: true - name: Run tests @@ -37,6 +38,8 @@ jobs: lint: runs-on: ubuntu-latest + env: + BUNDLE_LOCKFILE: active steps: - uses: actions/checkout@v3 - name: Set up Ruby @@ -44,6 +47,7 @@ jobs: with: ruby-version: "3.0" bundler-cache: true + rubygems: latest - name: Run RuboCop run: bin/rubocop timeout-minutes: 2 diff --git a/lib/bundler/multilock.rb b/lib/bundler/multilock.rb index 79c2a9d..abf84b2 100644 --- a/lib/bundler/multilock.rb +++ b/lib/bundler/multilock.rb @@ -153,24 +153,27 @@ def after_install_all(install: true) default_root = Bundler.root - checker = Check.new + cache = Cache.new + checker = Check.new(cache) synced_any = false + local_parser_cache = {} Bundler.settings.temporary(cache_all_platforms: true, suppress_install_using_messages: true) do lockfile_definitions.each do |lockfile_definition| + lockfile_name = lockfile_definition[:lockfile] # we already wrote the default lockfile - next if lockfile_definition[:lockfile] == Bundler.default_lockfile(force_original: true) + next if lockfile_name == Bundler.default_lockfile(force_original: true) # root needs to be set so that paths are output relative to the correct root in the lockfile Bundler.root = lockfile_definition[:gemfile].dirname - relative_lockfile = lockfile_definition[:lockfile].relative_path_from(Dir.pwd) + relative_lockfile = lockfile_name.relative_path_from(Dir.pwd) # already up to date? up_to_date = false Bundler.settings.temporary(frozen: true) do Bundler.ui.silence do up_to_date = checker.base_check(lockfile_definition, check_missing_deps: true) && - checker.check(lockfile_definition) + checker.deep_check(lockfile_definition) end end if up_to_date @@ -180,7 +183,7 @@ def after_install_all(install: true) if Bundler.frozen_bundle? # if we're frozen, you have to use the pre-existing lockfile - unless lockfile_definition[:lockfile].exist? + unless lockfile_name.exist? Bundler.ui.error("The bundle is locked, but #{relative_lockfile} is missing. " \ "Please make sure you have checked #{relative_lockfile} " \ "into version control before deploying.") @@ -188,19 +191,19 @@ def after_install_all(install: true) end Bundler.ui.info("Installing gems for #{relative_lockfile}...") - write_lockfile(lockfile_definition, lockfile_definition[:lockfile], install: install) + write_lockfile(lockfile_definition, lockfile_name, cache, install: install) else Bundler.ui.info("Syncing to #{relative_lockfile}...") if attempts == 1 synced_any = true - parent = lockfile_definition[:parent] - parent_root = parent.dirname - checker.load_lockfile(parent) - parent_specs = checker.lockfile_specs[parent] + specs = lockfile_name.exist? ? cache.specs(lockfile_name) : {} + parent_lockfile_name = lockfile_definition[:parent] + parent_root = parent_lockfile_name.dirname + parent_specs = cache.specs(parent_lockfile_name) # adjust locked paths from the parent lockfile to be relative to _this_ gemfile adjusted_parent_lockfile_contents = - checker.lockfile_contents[parent].gsub(/PATH\n remote: ([^\n]+)\n/) do |remote| + cache.contents(parent_lockfile_name).gsub(/PATH\n remote: ([^\n]+)\n/) do |remote| remote_path = Pathname.new($1) next remote if remote_path.absolute? @@ -220,10 +223,15 @@ def after_install_all(install: true) TEXT end - if lockfile_definition[:lockfile].exist? + if lockfile_name.exist? # if the lockfile already exists, "merge" it together - parent_lockfile = LockfileParser.new(adjusted_parent_lockfile_contents) - lockfile = LockfileParser.new(lockfile_definition[:lockfile].read) + parent_lockfile = if adjusted_parent_lockfile_contents == cache.contents(lockfile_name) + cache.parser(parent_lockfile_name) + else + local_parser_cache[adjusted_parent_lockfile_contents] ||= + LockfileParser.new(adjusted_parent_lockfile_contents) + end + lockfile = cache.parser(lockfile_name) dependency_changes = false # replace any duplicate specs with what's in the default lockfile @@ -231,11 +239,17 @@ def after_install_all(install: true) parent_spec = parent_specs[[spec.name, spec.platform]] next spec unless parent_spec + # they're conflicting on purpose; don't inherit from the parent lockfile + next spec if cache.conflicting_requirements?(lockfile_name, parent_lockfile_name, spec, parent_spec) + dependency_changes ||= spec != parent_spec parent_spec end - lockfile.specs.replace(parent_lockfile.specs + lockfile.specs).uniq! + missing_specs = parent_specs.each_value.reject do |parent_spec| + specs.include?([parent_spec.name, parent_spec.platform]) + end + lockfile.specs.replace(missing_specs + lockfile.specs) unless missing_specs.empty? lockfile.sources.replace(parent_lockfile.sources + lockfile.sources).uniq! lockfile.platforms.replace(parent_lockfile.platforms).uniq! # prune more specific platforms @@ -246,7 +260,7 @@ def after_install_all(install: true) end lockfile.instance_variable_set(:@ruby_version, parent_lockfile.ruby_version) unless lockfile.bundler_version == parent_lockfile.bundler_version - unlocking_bundler = true + unlocking_bundler = parent_lockfile.bundler_version lockfile.instance_variable_set(:@bundler_version, parent_lockfile.bundler_version) end @@ -263,12 +277,14 @@ def after_install_all(install: true) temp_lockfile.write(new_contents) temp_lockfile.flush - had_changes = write_lockfile(lockfile_definition, - temp_lockfile.path, - install: install, - dependency_changes: dependency_changes, - unlocking_bundler: unlocking_bundler) + had_changes ||= write_lockfile(lockfile_definition, + temp_lockfile.path, + cache, + install: install, + dependency_changes: dependency_changes, + unlocking_bundler: unlocking_bundler) end + cache.invalidate_lockfile(lockfile_name) if had_changes # if we had changes, bundler may have updated some common # dependencies beyond the default lockfile, so re-run it @@ -406,7 +422,12 @@ def inject_specific_preamble(gemfile, gemfiles, injection_point, preamble, add_n true end - def write_lockfile(lockfile_definition, lockfile, install:, dependency_changes: false, unlocking_bundler: false) + def write_lockfile(lockfile_definition, + lockfile, + cache, + install:, + dependency_changes: false, + unlocking_bundler: false) prepare_block = lockfile_definition[:prepare] gemfile = lockfile_definition[:gemfile] @@ -436,6 +457,7 @@ def write_lockfile(lockfile_definition, lockfile, install:, dependency_changes: current_definition.resolve_with_cache! if current_definition.missing_specs.any? + cache.invalidate_checks(current_lockfile) Bundler.with_default_lockfile(current_lockfile) do Installer.install(gemfile.dirname, current_definition, {}) end @@ -470,6 +492,7 @@ def write_lockfile(lockfile_definition, lockfile, install:, dependency_changes: resolved_remotely = true end SharedHelpers.capture_filesystem_access do + definition.instance_variable_set(:@resolved_bundler_version, unlocking_bundler) if unlocking_bundler if Bundler.gem_version >= Gem::Version.new("2.5.6") definition.instance_variable_set(:@lockfile, lockfile_definition[:lockfile]) definition.lock(true) diff --git a/lib/bundler/multilock/cache.rb b/lib/bundler/multilock/cache.rb new file mode 100644 index 0000000..b2fe613 --- /dev/null +++ b/lib/bundler/multilock/cache.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require_relative "ui/capture" + +module Bundler + module Multilock + # caches lockfiles across multiple lockfile checks or sync runs + class Cache + def initialize + @contents = {} + @parsers = {} + @specs = {} + @reverse_dependencies = {} + @base_checks = {} + @deep_checks = {} + @base_check_messages = {} + @deep_check_messages = {} + @missing_specs = Set.new + @logged_missing = false + end + + # Removes a given lockfile's associated cached data + # + # Should be called if the lockfile is modified + # @param lockfile_name [Pathname] + # @return [void] + def invalidate_lockfile(lockfile_name) + @contents.delete(lockfile_name) + @parsers.delete(lockfile_name) + @specs.delete(lockfile_name) + @reverse_dependencies.delete(lockfile_name) + invalidate_checks(lockfile_name) + end + + def invalidate_checks(lockfile_name) + @base_checks.delete(lockfile_name) + @base_check_messages.delete(lockfile_name) + # must clear them all; downstream lockfiles may depend on the state of this lockfile + @deep_checks.clear + @deep_check_messages.clear + end + + # @param lockfile_name [Pathname] + # @return [String] the raw contents of the lockfile + def contents(lockfile_name) + @contents[lockfile_name] ||= lockfile_name.read.freeze + end + + # @param lockfile_name [Pathname] + # @return [LockfileParser] + def parser(lockfile_name) + @parsers[lockfile_name] ||= LockfileParser.new(contents(lockfile_name)) + end + + def specs(lockfile_name) + @specs[lockfile_name] ||= parser(lockfile_name).specs.to_h do |spec| + [[spec.name, spec.platform], spec] + end + end + + # @param lockfile_name [Pathname] + # @return [Hash] hash of gem name to requirement for that gem + def reverse_dependencies(lockfile_name) + @reverse_dependencies[lockfile_name] ||= begin + # can use Gem::Requirement.default_prelease when Ruby 2.6 support is dropped + reverse_dependencies = Hash.new { |h, k| h[k] = Gem::Requirement.new(">= 0.a") } + + lockfile = parser(lockfile_name) + + 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 + end + + def conflicting_requirements?(lockfile1_name, lockfile2_name, spec1, spec2) + reverse_dependencies1 = reverse_dependencies(lockfile1_name)[spec1.name] + reverse_dependencies2 = reverse_dependencies(lockfile2_name)[spec1.name] + + !reverse_dependencies1.satisfied_by?(spec2.version) && + !reverse_dependencies2.satisfied_by?(spec1.version) + end + + def log_missing_spec(spec) + return if @missing_specs.include?(spec) + + Bundler.ui.error "The following gems are missing" if @missing_specs.empty? + @missing_specs << spec + Bundler.ui.error(" * #{spec.name} (#{spec.version})") + end + + %i[base deep].each do |type| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 # rubocop:disable Style/DocumentDynamicEvalDefinition + def #{type}_check(lockfile_name) + if @#{type}_checks.key?(lockfile_name) + @#{type}_check_messages[lockfile_name].replay + @#{type}_checks[lockfile_name] + else + result = nil + messages = Bundler::Multilock::UI::Capture.capture do + result = @#{type}_checks[lockfile_name] = yield + end + @#{type}_check_messages[lockfile_name] = messages.tap(&:replay) + result + end + end + RUBY + end + end + end +end diff --git a/lib/bundler/multilock/check.rb b/lib/bundler/multilock/check.rb index 2c73d36..59d7094 100644 --- a/lib/bundler/multilock/check.rb +++ b/lib/bundler/multilock/check.rb @@ -2,31 +2,19 @@ require "set" +require_relative "cache" + module Bundler module Multilock class Check - attr_reader :lockfiles, :lockfile_contents, :lockfile_specs - class << self def run new.run end end - def initialize - @lockfiles = {} - @lockfile_contents = {} - @lockfile_specs = {} - end - - def load_lockfile(lockfile) - return if lockfile_contents.key?(lockfile) - - contents = lockfile_contents[lockfile] = lockfile.read.freeze - parser = lockfiles[lockfile] = LockfileParser.new(contents) - lockfile_specs[lockfile] = parser.specs.to_h do |spec| - [[spec.name, spec.platform], spec] - end + def initialize(cache = Cache.new) + @cache = cache end def run(skip_base_checks: false) @@ -34,9 +22,8 @@ def run(skip_base_checks: false) success = true unless skip_base_checks - missing_specs = base_check({ gemfile: Bundler.default_gemfile, - lockfile: Bundler.default_lockfile(force_original: true) }, - return_missing: true).to_set + base_check({ gemfile: Bundler.default_gemfile, + lockfile: Bundler.default_lockfile(force_original: true) }) end Multilock.lockfile_definitions.each do |lockfile_definition| next if lockfile_definition[:lockfile] == Bundler.default_lockfile(force_original: true) @@ -44,167 +31,155 @@ def run(skip_base_checks: false) unless lockfile_definition[:lockfile].exist? Bundler.ui.error("Lockfile #{lockfile_definition[:lockfile]} does not exist.") success = false + next end - unless skip_base_checks - new_missing = base_check(lockfile_definition, log_missing: missing_specs, return_missing: true) - success = false unless new_missing.empty? - missing_specs.merge(new_missing) - end - success = false unless check(lockfile_definition) + success &&= base_check(lockfile_definition) && deep_check(lockfile_definition) end success end # this is mostly equivalent to the built in checks in `bundle check`, but even # more conservative, and returns false instead of exiting on failure - def base_check(lockfile_definition, log_missing: false, return_missing: false, check_missing_deps: false) - return return_missing ? [] : false unless lockfile_definition[:lockfile].file? - - Multilock.prepare_block = lockfile_definition[:prepare] - definition = Definition.build(lockfile_definition[:gemfile], lockfile_definition[:lockfile], false) - return return_missing ? [] : false unless definition.send(:current_platform_locked?) - - begin - definition.validate_runtime! - not_installed = Bundler.ui.silence { definition.missing_specs } - rescue RubyVersionMismatch, GemNotFound, SolveFailure - return return_missing ? [] : false - end + def base_check(lockfile_definition, check_missing_deps: false) + lockfile_name = lockfile_definition[:lockfile] + default_root = Bundler.root + + result = @cache.base_check(lockfile_name) do + next false unless lockfile_name.file? + + Multilock.prepare_block = lockfile_definition[:prepare] + # root needs to be set so that paths are output relative to the correct root in the lockfile + Bundler.root = lockfile_definition[:gemfile].dirname - if log_missing - not_installed.each do |spec| - next if log_missing.include?(spec) + definition = Definition.build(lockfile_definition[:gemfile], lockfile_name, false) + next false unless definition.send(:current_platform_locked?) - Bundler.ui.error "The following gems are missing" if log_missing.empty? - Bundler.ui.error(" * #{spec.name} (#{spec.version})") + begin + definition.validate_runtime! + not_installed = Bundler.ui.silence { definition.missing_specs } + rescue RubyVersionMismatch, GemNotFound, SolveFailure + next false + end + + if Bundler.ui.error? + not_installed.each do |spec| + @cache.log_missing_spec(spec) + end end - end - return not_installed if return_missing + next false unless not_installed.empty? && definition.no_resolve_needed? + + # cache a sentinel so that we can share a cache regardless of the check_missing_deps argument + next :missing_deps unless (definition.locked_gems.dependencies.values - definition.dependencies).empty? + + true + end - return false unless not_installed.empty? && definition.no_resolve_needed? - return true unless check_missing_deps + return !check_missing_deps if result == :missing_deps - (definition.locked_gems.dependencies.values - definition.dependencies).empty? + result ensure Multilock.prepare_block = nil + Bundler.root = default_root end # this checks for mismatches between the parent lockfile and the given lockfile, # and for pinned dependencies in lockfiles requiring them - def check(lockfile_definition) - success = true - proven_pinned = Set.new - needs_pin_check = [] - lockfile = LockfileParser.new(lockfile_definition[:lockfile].read) - lockfile_path = lockfile_definition[:lockfile].relative_path_from(Dir.pwd) - parent = lockfile_definition[:parent] - load_lockfile(parent) - parent_lockfile = lockfiles[parent] - unless lockfile.platforms == parent_lockfile.platforms - Bundler.ui.error("The platforms in #{lockfile_path} do not match the parent lockfile.") - success = false - end - unless lockfile.bundler_version == parent_lockfile.bundler_version - Bundler.ui.error("bundler (#{lockfile.bundler_version}) in #{lockfile_path} " \ - "does not match the parent lockfile's version (@#{parent_lockfile.bundler_version}).") - success = false - 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] - find_pinned_dependencies(proven_pinned, lockfile.dependencies.each_value) - end - - # check for conflicting requirements (and build list of pins, in the same loop) - lockfile.specs.each do |spec| - parent_spec = lockfile_specs[parent][[spec.name, spec.platform]] + def deep_check(lockfile_definition) + lockfile_name = lockfile_definition[:lockfile] + @cache.deep_check(lockfile_name) do + success = true + proven_pinned = Set.new + needs_pin_check = [] + parser = @cache.parser(lockfile_name) + lockfile_path = lockfile_name.relative_path_from(Dir.pwd) + parent_lockfile_name = lockfile_definition[:parent] + parent_parser = @cache.parser(parent_lockfile_name) + unless parser.platforms == parent_parser.platforms + Bundler.ui.error("The platforms in #{lockfile_path} do not match the parent lockfile.") + success = false + end + unless parser.bundler_version == parent_parser.bundler_version + Bundler.ui.error("bundler (#{parser.bundler_version}) in #{lockfile_path} " \ + "does not match the parent lockfile's version (@#{parent_parser.bundler_version}).") + success = false + end + # look through top-level explicit dependencies for pinned requirements if lockfile_definition[:enforce_pinned_additional_dependencies] - # look through what this spec depends on, and keep track of all pinned requirements - find_pinned_dependencies(proven_pinned, spec.dependencies) - - needs_pin_check << spec unless parent_spec + find_pinned_dependencies(proven_pinned, parser.dependencies.each_value) end - next unless parent_spec - - # have to ensure Path sources are relative to their lockfile before comparing - same_source = if [parent_spec.source, spec.source].grep(Source::Path).length == 2 - lockfile_definition[:lockfile] - .dirname - .join(spec.source.path) - .ascend - .any?(parent.dirname.join(parent_spec.source.path)) - else - parent_spec.source == spec.source - end - - next if parent_spec.version == spec.version && same_source - - # 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 + # check for conflicting requirements (and build list of pins, in the same loop) + parser.specs.each do |spec| + parent_spec = @cache.specs(parent_lockfile_name)[[spec.name, spec.platform]] + + if lockfile_definition[:enforce_pinned_additional_dependencies] + # look through what this spec depends on, and keep track of all pinned requirements + find_pinned_dependencies(proven_pinned, spec.dependencies) + + needs_pin_check << spec unless parent_spec + end + + next unless parent_spec + + # have to ensure Path sources are relative to their lockfile before comparing + same_source = if [parent_spec.source, spec.source].grep(Source::Path).length == 2 + lockfile_name + .dirname + .join(spec.source.path) + .ascend + .any?(parent_lockfile_name.dirname.join(parent_spec.source.path)) + else + parent_spec.source == spec.source + end + + next if parent_spec.version == spec.version && same_source + + # 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 + if @cache.conflicting_requirements?(lockfile_name, parent_lockfile_name, spec, parent_spec) + # 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 " \ + "(@#{parent_spec.version}#{parent_spec.git_version}); " \ + "this may be due to a conflicting requirement, which would require manual resolution.") + success = false end - Bundler.ui.error("#{spec}#{spec.git_version} in #{lockfile_path} " \ - "does not match the parent lockfile's version " \ - "(@#{parent_spec.version}#{parent_spec.git_version}); " \ - "this may be due to a conflicting requirement, which would require manual resolution.") - success = false - end + # now that we have built a list of every gem that is pinned, go through + # the gems that were in this lockfile, but not the parent lockfile, and + # ensure it's pinned _somehow_ + needs_pin_check.each do |spec| + pinned = case spec.source + when Source::Git + spec.source.ref == spec.source.revision + when Source::Path + true + when Source::Rubygems + proven_pinned.include?(spec.name) + else + false + end + + next if pinned + + Bundler.ui.error("#{spec} in #{lockfile_path} has not been pinned to a specific version, " \ + "which is required since it is not part of the parent lockfile.") + success = false + end - # now that we have built a list of every gem that is pinned, go through - # the gems that were in this lockfile, but not the parent lockfile, and - # ensure it's pinned _somehow_ - needs_pin_check.each do |spec| - pinned = case spec.source - when Source::Git - spec.source.ref == spec.source.revision - when Source::Path - true - when Source::Rubygems - proven_pinned.include?(spec.name) - else - false - end - - next if pinned - - Bundler.ui.error("#{spec} in #{lockfile_path} has not been pinned to a specific version, " \ - "which is required since it is not part of the parent lockfile.") - success = false + success end - - success end private - def cache_reverse_dependencies(lockfile) - # can use Gem::Requirement.default_prelease when Ruby 2.6 support is dropped - reverse_dependencies = Hash.new { |h, k| h[k] = Gem::Requirement.new(">= 0.a") } - - 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/lib/bundler/multilock/ui/capture.rb b/lib/bundler/multilock/ui/capture.rb new file mode 100644 index 0000000..657bd53 --- /dev/null +++ b/lib/bundler/multilock/ui/capture.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Bundler + module Multilock + module UI + class Capture < Bundler::UI::Silent + class << self + def capture + original_ui = Bundler.ui + Bundler.ui = new + yield + Bundler.ui + ensure + Bundler.ui = original_ui + end + end + + def initialize + @messages = [] + + super + end + + def replay + @messages.each do |(level, args)| + Bundler.ui.send(level, *args) + end + nil + end + + def add_color(string, _color) + string + end + + %i[info confirm warn error debug].each do |level| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{level}(message = nil, newline = nil) # def info(message = nil, newline = nil) + @messages << [:#{level}, [message, newline]] # @messages << [:info, [message, newline]] + end # end + # + def #{level}? # def info? + true # true + end # end + RUBY + + def trace(message, newline = nil, force = false) # rubocop:disable Style/OptionalBooleanParameter + @messages << [:trace, [message, newline, force]] + end + end + end + end + end +end diff --git a/spec/bundler/multilock_spec.rb b/spec/bundler/multilock_spec.rb index c45ea32..5a44ff9 100644 --- a/spec/bundler/multilock_spec.rb +++ b/spec/bundler/multilock_spec.rb @@ -252,7 +252,12 @@ RUBY invoke_bundler("install") - replace_lockfile_pin("Gemfile.new.lock", "concurrent-ruby", "9.9.9") + replace_lockfile_pin("Gemfile.lock", "concurrent-ruby", "1.2.2") + replace_lockfile_pin("Gemfile.new.lock", "concurrent-ruby", "1.2.2") + invoke_bundler("install") + + replace_lockfile_pin("Gemfile.new.lock", "concurrent-ruby", "1.2.3") + invoke_bundler("install", env: { "BUNDLE_LOCKFILE" => "new" }) expect { invoke_bundler("check") }.to raise_error(/concurrent-ruby.*does not match/m) end @@ -346,7 +351,7 @@ RUBY expect do invoke_bundler("install") - end.to raise_error(/net-smtp \([0-9.]+\) in Gemfile.full.lock has not been pinned/) + end.to raise_error(/net-smtp \([0-9.]+\) in Gemfile.full.lock has not been pinned/m) # not only have to pin net-smtp, but also its transitive dependencies write_gemfile(<<~RUBY) @@ -763,6 +768,52 @@ end end + it "doesn't update versions in alternate lockfiles when syncing" do + # first + with_gemfile(<<~RUBY) do + gem "rubocop", "1.45.0" + + lockfile do + gem "activesupport", "6.0.0" + end + + lockfile "rails-6.1" do + gem "activesupport", "6.1.0" + end + RUBY + invoke_bundler("install") + + write_gemfile(<<~RUBY) + gem "rubocop", "~> 1.45.0" + + lockfile do + gem "activesupport", "~> 6.0.0" + end + + lockfile "rails-6.1" do + gem "activesupport", "~> 6.1.0" + end + RUBY + + # first, unpin, but ensure no gems update during this process + invoke_bundler("install") + + expect(invoke_bundler("info rubocop")).to include("1.45.0") + expect(invoke_bundler("info activesupport")).to include("6.0.0") + expect(invoke_bundler("info rubocop", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("1.45.0") + expect(invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("6.1.0") + + # now, update an unrelated gem, but _only_ that gem + # this should not update other gems in the alternate lockfiles + invoke_bundler("update rubocop --conservative") + + expect(invoke_bundler("info rubocop")).to include("1.45.1") + expect(invoke_bundler("info activesupport")).to include("6.0.0") + expect(invoke_bundler("info rubocop", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("1.45.1") + expect(invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "rails-6.1" })).to include("6.1.0") + end + end + private def create_local_gem(name, content = "", subdirectory: true)