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

allow specifying the parent lockfile to sync from #9

Merged
merged 1 commit into from
Oct 6, 2023
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
88 changes: 52 additions & 36 deletions lib/bundler/multilock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class << self
# @param active [Boolean]
# If this lockfile should be the default (instead of Gemfile.lock)
# 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
Expand All @@ -41,6 +43,7 @@ def add_lockfile(lockfile = nil,
gemfile: nil,
active: nil,
default: nil,
parent: nil,
allow_mismatched_dependencies: true,
enforce_pinned_additional_dependencies: false,
&block)
Expand All @@ -50,19 +53,14 @@ def add_lockfile(lockfile = nil,

active = true if active.nil? && lockfile_definitions.empty? && lockfile.nil? && gemfile.nil?

# allow short-form lockfile names
if lockfile.is_a?(String) && !(lockfile.include?("/") || lockfile.end_with?(".lock"))
lockfile = "Gemfile.#{lockfile}.lock"
end
# if a gemfile was provided, but not a lockfile, infer the default lockfile for that gemfile
lockfile ||= "#{gemfile}.lock" if gemfile
# use absolute paths
lockfile = Bundler.root.join(lockfile).expand_path if lockfile
# use the default lockfile (Gemfile.lock) if none was given
lockfile ||= Bundler.default_lockfile(force_original: true)
raise ArgumentError, "Lockfile #{lockfile} is already defined" if lockfile_definitions.any? do |definition|
definition[:lockfile] == lockfile
end
# allow short-form lockfile names
lockfile = expand_lockfile(lockfile)

if lockfile_definitions.find { |definition| definition[:lockfile] == lockfile }
raise ArgumentError, "Lockfile #{lockfile} is already defined"
end

env_lockfile = ENV["BUNDLE_LOCKFILE"]
if env_lockfile
Expand All @@ -77,11 +75,18 @@ def add_lockfile(lockfile = nil,
raise ArgumentError, "Only one lockfile (#{old_active[:lockfile]}) can be flagged as the default"
end

parent = expand_lockfile(parent)
if parent != Bundler.default_lockfile(force_original: true) &&
!lockfile_definitions.find { |definition| definition[:lockfile] == parent }
raise ArgumentError, "Parent lockfile #{parent} is not defined"
end

lockfile_definitions << (lockfile_def = {
gemfile: (gemfile && Bundler.root.join(gemfile).expand_path) || Bundler.default_gemfile,
lockfile: lockfile,
active: active,
prepare: block,
parent: parent,
allow_mismatched_dependencies: allow_mismatched_dependencies,
enforce_pinned_additional_dependencies: enforce_pinned_additional_dependencies
})
Expand Down Expand Up @@ -152,14 +157,10 @@ def after_install_all(install: true)
Bundler.ui.debug("Syncing to alternate lockfiles")
Bundler.ui.info ""

default_lockfile_contents = Bundler.default_lockfile.read.freeze
default_specs = LockfileParser.new(default_lockfile_contents).specs.to_h do |spec|
[[spec.name, spec.platform], spec]
end
default_root = Bundler.root

attempts = 1

default_root = Bundler.root

checker = Check.new
synced_any = false
Bundler.settings.temporary(cache_all_platforms: true, suppress_install_using_messages: true) do
Expand Down Expand Up @@ -200,21 +201,26 @@ def after_install_all(install: true)
Bundler.ui.info("Syncing to #{relative_lockfile}...") if attempts == 1
synced_any = true

# adjust locked paths from the default lockfile to be relative to _this_ gemfile
adjusted_default_lockfile_contents =
default_lockfile_contents.gsub(/PATH\n remote: ([^\n]+)\n/) do |remote|
parent = lockfile_definition[:parent]
parent_root = parent.dirname
checker.load_lockfile(parent)
parent_specs = checker.lockfile_specs[parent]

# 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|
remote_path = Pathname.new($1)
next remote if remote_path.absolute?

relative_remote_path = remote_path.expand_path(default_root).relative_path_from(Bundler.root).to_s
relative_remote_path = remote_path.expand_path(parent_root).relative_path_from(Bundler.root).to_s
remote.sub($1, relative_remote_path)
end

# add a source for the current gem
gem_spec = default_specs[[File.basename(Bundler.root), "ruby"]]
gem_spec = parent_specs[[File.basename(Bundler.root), "ruby"]]

if gem_spec
adjusted_default_lockfile_contents += <<~TEXT
adjusted_parent_lockfile_contents += <<~TEXT
PATH
remote: .
specs:
Expand All @@ -224,39 +230,39 @@ def after_install_all(install: true)

if lockfile_definition[:lockfile].exist?
# if the lockfile already exists, "merge" it together
default_lockfile = LockfileParser.new(adjusted_default_lockfile_contents)
parent_lockfile = LockfileParser.new(adjusted_parent_lockfile_contents)
lockfile = LockfileParser.new(lockfile_definition[:lockfile].read)

dependency_changes = false
# replace any duplicate specs with what's in the default lockfile
lockfile.specs.map! do |spec|
default_spec = default_specs[[spec.name, spec.platform]]
next spec unless default_spec
parent_spec = parent_specs[[spec.name, spec.platform]]
next spec unless parent_spec

dependency_changes ||= spec != default_spec
default_spec
dependency_changes ||= spec != parent_spec
parent_spec
end

lockfile.specs.replace(default_lockfile.specs + lockfile.specs).uniq!
lockfile.sources.replace(default_lockfile.sources + lockfile.sources).uniq!
lockfile.platforms.replace(default_lockfile.platforms).uniq!
lockfile.specs.replace(parent_lockfile.specs + lockfile.specs).uniq!
lockfile.sources.replace(parent_lockfile.sources + lockfile.sources).uniq!
lockfile.platforms.replace(parent_lockfile.platforms).uniq!
# prune more specific platforms
lockfile.platforms.delete_if do |p1|
lockfile.platforms.any? do |p2|
p2 != "ruby" && p1 != p2 && MatchPlatform.platforms_match?(p2, p1)
end
end
lockfile.instance_variable_set(:@ruby_version, default_lockfile.ruby_version)
unless lockfile.bundler_version == default_lockfile.bundler_version
lockfile.instance_variable_set(:@ruby_version, parent_lockfile.ruby_version)
unless lockfile.bundler_version == parent_lockfile.bundler_version
unlocking_bundler = true
lockfile.instance_variable_set(:@bundler_version, default_lockfile.bundler_version)
lockfile.instance_variable_set(:@bundler_version, parent_lockfile.bundler_version)
end

new_contents = LockfileGenerator.generate(lockfile)
else
# no lockfile? just start out with the default lockfile's contents to inherit its
# no lockfile? just start out with the parent lockfile's contents to inherit its
# locked gems
new_contents = adjusted_default_lockfile_contents
new_contents = adjusted_parent_lockfile_contents
end

had_changes = false
Expand Down Expand Up @@ -384,6 +390,16 @@ def reset!

private

def expand_lockfile(lockfile)
if lockfile.is_a?(String) && !(lockfile.include?("/") || lockfile.end_with?(".lock"))
lockfile = "Gemfile.#{lockfile}.lock"
end
# use absolute paths
lockfile = Bundler.root.join(lockfile).expand_path if lockfile
# use the default lockfile (Gemfile.lock) if none was given
lockfile || Bundler.default_lockfile(force_original: true)
end

def inject_specific_preamble(gemfile, gemfiles, injection_point, preamble, add_newline:, match: nil)
# allow either type of quotes
match ||= Regexp.new(Regexp.escape(preamble).gsub('"', %(["'])))
Expand Down
64 changes: 39 additions & 25 deletions lib/bundler/multilock/check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,41 @@
module Bundler
module Multilock
class Check
attr_reader :lockfiles, :lockfile_contents, :lockfile_specs

class << self
def run
new.run
end
end

def initialize
default_lockfile_contents = Bundler.default_lockfile.read
@default_lockfile = LockfileParser.new(default_lockfile_contents)
@default_specs = @default_lockfile.specs.to_h do |spec|
@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
end

def run(skip_base_checks: false)
return true unless Bundler.default_lockfile.exist?
return true unless Bundler.default_lockfile(force_original: true).exist?

success = true
unless skip_base_checks
missing_specs = base_check({ gemfile: Bundler.default_gemfile, lockfile: Bundler.default_lockfile },
missing_specs = base_check({ gemfile: Bundler.default_gemfile,
lockfile: Bundler.default_lockfile(force_original: true) },
return_missing: true).to_set
end
Multilock.lockfile_definitions.each do |lockfile_definition|
next if lockfile_definition[:lockfile] == Bundler.default_lockfile
next if lockfile_definition[:lockfile] == Bundler.default_lockfile(force_original: true)

unless lockfile_definition[:lockfile].exist?
Bundler.ui.error("Lockfile #{lockfile_definition[:lockfile]} does not exist.")
Expand Down Expand Up @@ -77,21 +88,24 @@ def base_check(lockfile_definition, log_missing: false, return_missing: false)
Multilock.prepare_block = nil
end

# this checks for mismatches between the default lockfile and the given lockfile,
# 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)
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)
unless lockfile.platforms == @default_lockfile.platforms
Bundler.ui.error("The platforms in #{lockfile_path} do not match the default lockfile.")
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 == @default_lockfile.bundler_version
unless lockfile.bundler_version == parent_lockfile.bundler_version
Bundler.ui.error("bundler (#{lockfile.bundler_version}) in #{lockfile_path} " \
"does not match the default lockfile's version (@#{@default_lockfile.bundler_version}).")
"does not match the parent lockfile's version (@#{parent_lockfile.bundler_version}).")
success = false
end

Expand All @@ -100,13 +114,13 @@ def check(lockfile_definition, allow_mismatched_dependencies: true)
allow_mismatched_dependencies = lockfile_definition[:allow_mismatched_dependencies]
end

# build list of top-level dependencies that differ from the default lockfile,
# 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 default lockfile
# only dependencies that differ from the parent lockfile
pending_transitive_dependencies = lockfile.dependencies.reject do |name, dep|
@default_lockfile.dependencies[name] == dep
parent_lockfile.dependencies[name] == dep
end.map(&:first)

until pending_transitive_dependencies.empty?
Expand All @@ -133,40 +147,40 @@ def check(lockfile_definition, allow_mismatched_dependencies: true)

# check for conflicting requirements (and build list of pins, in the same loop)
specs.values.flatten.each do |spec|
default_spec = @default_specs[[spec.name, spec.platform]]
parent_spec = lockfile_specs[parent][[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 default_spec
needs_pin_check << spec unless parent_spec
end

next unless default_spec
next unless parent_spec

# have to ensure Path sources are relative to their lockfile before comparing
same_source = if [default_spec.source, spec.source].grep(Source::Path).length == 2
same_source = if [parent_spec.source, spec.source].grep(Source::Path).length == 2
lockfile_definition[:lockfile]
.dirname
.join(spec.source.path)
.ascend
.any?(Bundler.default_lockfile.dirname.join(default_spec.source.path))
.any?(parent.dirname.join(parent_spec.source.path))
else
default_spec.source == spec.source
parent_spec.source == spec.source
end

next if default_spec.version == spec.version && same_source
next if parent_spec.version == spec.version && same_source
next if allow_mismatched_dependencies && transitive_dependencies.include?(spec.name)

Bundler.ui.error("#{spec}#{spec.git_version} in #{lockfile_path} " \
"does not match the default lockfile's version " \
"(@#{default_spec.version}#{default_spec.git_version}); " \
"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 default lockfile, and
# 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
Expand All @@ -183,7 +197,7 @@ def check(lockfile_definition, allow_mismatched_dependencies: true)
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 default lockfile.")
"which is required since it is not part of the parent lockfile.")
success = false
end

Expand Down
Loading