Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix conflicting gems from alternate lockfiles updating unexpectedly #29

Merged
merged 4 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,13 +38,16 @@ jobs:
lint:
runs-on: ubuntu-latest

env:
BUNDLE_LOCKFILE: active
steps:
- uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.0"
bundler-cache: true
rubygems: latest
- name: Run RuboCop
run: bin/rubocop
timeout-minutes: 2
67 changes: 45 additions & 22 deletions lib/bundler/multilock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -180,27 +183,27 @@ 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.")
exit 1
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?

Expand All @@ -220,22 +223,33 @@ 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
lockfile.specs.map! do |spec|
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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions lib/bundler/multilock/cache.rb
Original file line number Diff line number Diff line change
@@ -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<String, Gem::Requirement>] 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
Loading
Loading