Skip to content

Commit

Permalink
allow specifying the parent lockfile to sync from (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
ccutrer authored Oct 6, 2023
1 parent 9f483bf commit 71c3059
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 64 deletions.
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

0 comments on commit 71c3059

Please sign in to comment.