From 71c3059275905570c0684f9df391e4f997204bac Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Fri, 6 Oct 2023 11:13:02 -0600 Subject: [PATCH] allow specifying the parent lockfile to sync from (#9) --- lib/bundler/multilock.rb | 88 ++++++++++++++++++++-------------- lib/bundler/multilock/check.rb | 64 +++++++++++++++---------- spec/bundler/multilock_spec.rb | 42 ++++++++++++++-- 3 files changed, 130 insertions(+), 64 deletions(-) diff --git a/lib/bundler/multilock.rb b/lib/bundler/multilock.rb index 22a0c5b..1ff2830 100644 --- a/lib/bundler/multilock.rb +++ b/lib/bundler/multilock.rb @@ -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 @@ -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) @@ -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 @@ -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 }) @@ -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 @@ -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: @@ -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 @@ -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('"', %(["']))) diff --git a/lib/bundler/multilock/check.rb b/lib/bundler/multilock/check.rb index e09cf70..b7f4ad0 100644 --- a/lib/bundler/multilock/check.rb +++ b/lib/bundler/multilock/check.rb @@ -5,6 +5,8 @@ module Bundler module Multilock class Check + attr_reader :lockfiles, :lockfile_contents, :lockfile_specs + class << self def run new.run @@ -12,23 +14,32 @@ def run 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.") @@ -77,7 +88,7 @@ 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 @@ -85,13 +96,16 @@ def check(lockfile_definition, allow_mismatched_dependencies: true) 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 @@ -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? @@ -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 @@ -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 diff --git a/spec/bundler/multilock_spec.rb b/spec/bundler/multilock_spec.rb index 96f3a03..afc7acc 100644 --- a/spec/bundler/multilock_spec.rb +++ b/spec/bundler/multilock_spec.rb @@ -130,6 +130,14 @@ end end + it "validates parent lockfile exists" do + with_gemfile(<<~RUBY) do + lockfile("full", parent: "missing") + RUBY + expect { invoke_bundler("install") }.to raise_error(/Parent lockfile .+missing\.lock is not defined/) + end + end + it "generates custom lockfiles with varying versions" do with_gemfile(<<~RUBY) do lockfile do @@ -292,6 +300,34 @@ end end + it "syncs from a parent lockfile" do + with_gemfile(<<~RUBY) do + lockfile do + gem "activesupport", "> 6.0", "< 7.1" + end + + lockfile("6.1") do + gem "activesupport", "~> 6.1.0" + end + + lockfile("6.1-alt", parent: "6.1") do + gem "activesupport", "> 6.0", "< 7.2" + end + RUBY + invoke_bundler("install") + + default = invoke_bundler("info activesupport").split("\n").first + six_one = invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "6.1" }).split("\n").first + alt = invoke_bundler("info activesupport", env: { "BUNDLE_LOCKFILE" => "6.1-alt" }).split("\n").first + + expect(default).to include("7.0") + expect(default).not_to eq six_one + expect(six_one).to include("6.1") + # alt is the same as 6.1, even though it should allow 7.1 + expect(alt).to eq six_one + end + end + it "whines about non-pinned dependencies in flagged gemfiles" do with_gemfile(<<~RUBY) do lockfile("full", enforce_pinned_additional_dependencies: true) do @@ -333,7 +369,7 @@ expect do invoke_bundler("install") end.to raise_error(Regexp.new("activesupport \\(7.0.4.3\\) in Gemfile.full.lock " \ - "does not match the default lockfile's version")) + "does not match the parent lockfile's version")) end end @@ -348,7 +384,7 @@ RUBY expect do invoke_bundler("install") - end.to raise_error(/tzinfo \(2.0.5\) in Gemfile.full.lock does not match the default lockfile's version/) + end.to raise_error(/tzinfo \(2.0.5\) in Gemfile.full.lock does not match the parent lockfile's version/) end end end @@ -386,7 +422,7 @@ expect do invoke_bundler("install") end.to raise_error(Regexp.new("hashie \\(4[0-9.]+\\) in local_test/Gemfile.lock " \ - "does not match the default lockfile's version \\(@([0-9.]+)\\)")) + "does not match the parent lockfile's version \\(@([0-9.]+)\\)")) end end