From 5bb065ddabee4012f1f6d761d5bf5abd5e69e0eb Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Thu, 13 Jun 2024 13:15:58 -0600 Subject: [PATCH 01/13] Fix plugin installation from gemfile Several things are fixed: * Don't re-install a plugin referenced from the gemfile with every call to `bundle install` * If the version of a plugin referenced in the gemfile conflicts with what's in the plugin index, _do_ re-install it * If plugins aren't installed yet, don't throw cryptic errors from commands that don't implicitly install gems, such as `bundle check` and `bundle info`. This also applies if the plugin index references a system gem, and that gem is removed. This is all accomplished by actuallying including plugins as regular dependencies in the Gemfile, so that they end up in the lockfile, and then just using the regular lockfile with the plugins-only pass of the gemfile in the Plugin infrastructure. This also means that non-specific version constraints can be used for plugins, and you can update them with `bundle update ` just like any other gem. Co-authored-by: Diogo Fernandes --- Manifest.txt | 1 + bundler/lib/bundler/cli/install.rb | 2 +- bundler/lib/bundler/cli/update.rb | 26 +- bundler/lib/bundler/definition.rb | 29 ++ bundler/lib/bundler/dependency.rb | 10 +- bundler/lib/bundler/dsl.rb | 16 +- bundler/lib/bundler/man/bundle-config.1 | 2 + bundler/lib/bundler/man/bundle-config.1.ronn | 2 + bundler/lib/bundler/plugin.rb | 79 +++-- bundler/lib/bundler/plugin/dsl.rb | 8 +- bundler/lib/bundler/plugin/dummy_source.rb | 9 + bundler/lib/bundler/plugin/installer.rb | 18 +- bundler/lib/bundler/plugin/installer/path.rb | 2 +- bundler/lib/bundler/settings.rb | 2 + spec/bundler/plugin_spec.rb | 21 +- spec/bundler/source_list_spec.rb | 2 +- spec/other/major_deprecation_spec.rb | 4 +- spec/plugins/install_spec.rb | 289 ++++++++++++++++++- spec/plugins/list_spec.rb | 2 +- spec/plugins/source/example_spec.rb | 6 + spec/support/matchers.rb | 11 + 21 files changed, 470 insertions(+), 71 deletions(-) create mode 100644 bundler/lib/bundler/plugin/dummy_source.rb diff --git a/Manifest.txt b/Manifest.txt index 83328c03670e..542f369bdd47 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -161,6 +161,7 @@ bundler/lib/bundler/plugin.rb bundler/lib/bundler/plugin/api.rb bundler/lib/bundler/plugin/api/source.rb bundler/lib/bundler/plugin/dsl.rb +bundler/lib/bundler/plugin/dummy_source.rb bundler/lib/bundler/plugin/events.rb bundler/lib/bundler/plugin/index.rb bundler/lib/bundler/plugin/installer.rb diff --git a/bundler/lib/bundler/cli/install.rb b/bundler/lib/bundler/cli/install.rb index 69affd1a109a..cf21afbb06e0 100644 --- a/bundler/lib/bundler/cli/install.rb +++ b/bundler/lib/bundler/cli/install.rb @@ -38,7 +38,7 @@ def run Bundler::Fetcher.disable_endpoint = options["full-index"] - Plugin.gemfile_install(Bundler.default_gemfile) if Bundler.settings[:plugins] + Plugin.gemfile_install(Bundler.default_gemfile, Bundler.default_lockfile) if Bundler.settings[:plugins] # For install we want to enable strict validation # (rather than some optimizations we perform at app runtime). diff --git a/bundler/lib/bundler/cli/update.rb b/bundler/lib/bundler/cli/update.rb index d92ffd995f36..0e1769f07c27 100644 --- a/bundler/lib/bundler/cli/update.rb +++ b/bundler/lib/bundler/cli/update.rb @@ -15,8 +15,6 @@ def run Bundler.self_manager.update_bundler_and_restart_with_it_if_needed(update_bundler) if update_bundler - Plugin.gemfile_install(Bundler.default_gemfile) if Bundler.settings[:plugins] - sources = Array(options[:source]) groups = Array(options[:group]).map(&:to_sym) @@ -33,29 +31,39 @@ def run conservative = options[:conservative] - if full_update + unlock = if full_update if conservative - Bundler.definition(conservative: conservative) + { conservative: conservative } else - Bundler.definition(true) + true end else unless Bundler.default_lockfile.exist? raise GemfileLockNotFound, "This Bundle hasn't been installed yet. " \ "Run `bundle install` to update and install the bundled gems." end - Bundler::CLI::Common.ensure_all_gems_in_lockfile!(gems) + explicit_gems = gems if groups.any? deps = Bundler.definition.dependencies.select {|d| (d.groups & groups).any? } gems.concat(deps.map(&:name)) end - Bundler.definition(gems: gems, sources: sources, ruby: options[:ruby], - conservative: conservative, - bundler: update_bundler) + { + gems: gems, + sources: sources, + ruby: options[:ruby], + conservative: conservative, + bundler: update_bundler, + } end + Plugin.gemfile_install(Bundler.default_gemfile, Bundler.default_lockfile, unlock.dup) if Bundler.settings[:plugins] + + Bundler::CLI::Common.ensure_all_gems_in_lockfile!(explicit_gems) if explicit_gems + + Bundler.definition(unlock) + Bundler::CLI::Common.configure_gem_version_promoter(Bundler.definition, options) Bundler::Fetcher.disable_endpoint = options["full-index"] diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 7a9567147103..610c24989922 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -135,6 +135,8 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti Bundler::SharedHelpers.feature_removed! msg end + + remove_plugin_dependencies_if_necessary else @locked_gems = nil @locked_platforms = [] @@ -283,6 +285,10 @@ def requested_dependencies dependencies_for(requested_groups) end + def plugin_dependencies + requested_dependencies.select {|dep| dep.type == :plugin } + end + def current_dependencies filter_relevant(dependencies) end @@ -478,6 +484,7 @@ def ensure_equivalent_gemfile_and_lockfile(explicit_flag = false) def validate_runtime! validate_ruby! validate_platforms! + validate_plugins! end def validate_ruby! @@ -513,6 +520,19 @@ def normalize_platforms @resolve = SpecSet.new(resolve.for(current_dependencies, @platforms)) end + def validate_plugins! + missing_plugins_list = [] + plugin_dependencies.each do |plugin| + missing_plugins_list << plugin unless Plugin.installed?(plugin.name) + end + missing_plugins_list.map! {|p| "#{p.name} (#{p.requirement})" } + if missing_plugins_list.size > 1 + raise GemNotFound, "Plugins #{missing_plugins_list.join(", ")} are not installed" + elsif missing_plugins_list.any? + raise GemNotFound, "Plugin #{missing_plugins_list.join(", ")} is not installed" + end + end + def add_platform(platform) return if @platforms.include?(platform) @@ -1325,5 +1345,14 @@ def new_resolution_base(last_resolve:, unlock:) def new_resolver(base) Resolver.new(base, gem_version_promoter, @most_specific_locked_platform) end + + def remove_plugin_dependencies_if_necessary + return if Bundler.settings[:plugins_in_lockfile] + # we already have plugin dependencies in the lockfile; continue to do so regardless + # of the current setting + return if @dependencies.any? {|d| d.type == :plugin && @locked_deps.key?(d.name) } + + @dependencies.reject! {|d| d.type == :plugin } + end end end diff --git a/bundler/lib/bundler/dependency.rb b/bundler/lib/bundler/dependency.rb index cb9c7a76eab8..63832c4b7d63 100644 --- a/bundler/lib/bundler/dependency.rb +++ b/bundler/lib/bundler/dependency.rb @@ -7,7 +7,15 @@ module Bundler class Dependency < Gem::Dependency def initialize(name, version, options = {}, &blk) type = options["type"] || :runtime - super(name, version, type) + if type == :plugin + # RubyGems doesn't support plugin type, which only + # makes sense in the context of Bundler, so bypass + # the RubyGems validation + super(name, version, :runtime) + @type = type + else + super(name, version, type) + end @options = options end diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 6e2638a8be4c..078c7b01669b 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -124,9 +124,15 @@ def source(source, *args, &blk) if options.key?("type") options["type"] = options["type"].to_s - unless Plugin.source?(options["type"]) + unless (source_plugin = Plugin.source_plugin(options["type"])) raise InvalidOption, "No plugin sources available for #{options["type"]}" end + # Implicitly add a dependency on source plugins who are named bundler-source-, + # and aren't already mentioned in the Gemfile. + # See also Plugin::DSL#source + if source_plugin.start_with?("bundler-source-") && !@dependencies.any? {|d| d.name == source_plugin } + plugin(source_plugin) + end unless block_given? raise InvalidOption, "You need to pass a block to #source with :type option" @@ -257,7 +263,13 @@ def env(name) end def plugin(*args) - # Pass on + options = args.last.is_a?(Hash) ? args.pop.dup : {} + + normalize_hash(options) + options["type"] = :plugin + options["require"] = false + + gem(*args, options) end def method_missing(name, *args) diff --git a/bundler/lib/bundler/man/bundle-config.1 b/bundler/lib/bundler/man/bundle-config.1 index c055c8a415b0..9905c7e78e3e 100644 --- a/bundler/lib/bundler/man/bundle-config.1 +++ b/bundler/lib/bundler/man/bundle-config.1 @@ -157,6 +157,8 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP "\(bu" 4 \fBplugins\fR (\fBBUNDLE_PLUGINS\fR): Enable Bundler's experimental plugin system\. .IP "\(bu" 4 +\fBplugins_in_lockfile\fR (\fBBUNDLE_PLUGINS_IN_LOCKFILE\fR): Include plugins as regular dependencies in the lockfile\. +.IP "\(bu" 4 \fBprefer_patch\fR (\fBBUNDLE_PREFER_PATCH\fR): Prefer updating only to next patch version during updates\. Makes \fBbundle update\fR calls equivalent to \fBbundler update \-\-patch\fR\. .IP "\(bu" 4 \fBredirect\fR (\fBBUNDLE_REDIRECT\fR): The number of redirects allowed for network requests\. Defaults to \fB5\fR\. diff --git a/bundler/lib/bundler/man/bundle-config.1.ronn b/bundler/lib/bundler/man/bundle-config.1.ronn index 72f891b428d5..e8d018f2de0c 100644 --- a/bundler/lib/bundler/man/bundle-config.1.ronn +++ b/bundler/lib/bundler/man/bundle-config.1.ronn @@ -261,6 +261,8 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). Whether Bundler will install gems into the default system path (`Gem.dir`). * `plugins` (`BUNDLE_PLUGINS`): Enable Bundler's experimental plugin system. +* `plugins_in_lockfile` (`BUNDLE_PLUGINS_IN_LOCKFILE`): + Include plugins as regular dependencies in the lockfile. * `prefer_patch` (`BUNDLE_PREFER_PATCH`): Prefer updating only to next patch version during updates. Makes `bundle update` calls equivalent to `bundler update --patch`. * `redirect` (`BUNDLE_REDIRECT`): diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index faca6bea5306..ec8ca9fc47be 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -4,11 +4,12 @@ module Bundler module Plugin - autoload :DSL, File.expand_path("plugin/dsl", __dir__) - autoload :Events, File.expand_path("plugin/events", __dir__) - autoload :Index, File.expand_path("plugin/index", __dir__) - autoload :Installer, File.expand_path("plugin/installer", __dir__) - autoload :SourceList, File.expand_path("plugin/source_list", __dir__) + autoload :DSL, File.expand_path("plugin/dsl", __dir__) + autoload :DummySource, File.expand_path("plugin/dummy_source", __dir__) + autoload :Events, File.expand_path("plugin/events", __dir__) + autoload :Index, File.expand_path("plugin/index", __dir__) + autoload :Installer, File.expand_path("plugin/installer", __dir__) + autoload :SourceList, File.expand_path("plugin/source_list", __dir__) class MalformattedPlugin < PluginError; end class UndefinedCommandError < PluginError; end @@ -16,6 +17,7 @@ class UnknownSourceError < PluginError; end class PluginInstallError < PluginError; end PLUGIN_FILE_NAME = "plugins.rb" + @gemfile_parse = false module_function @@ -26,6 +28,7 @@ def reset! @commands = {} @hooks_by_event = Hash.new {|h, k| h[k] = [] } @loaded_plugin_names = [] + @index = nil end reset! @@ -40,7 +43,7 @@ def install(names, options) specs = Installer.new.install(names, options) - save_plugins names, specs + save_plugins specs rescue PluginError specs_to_delete = specs.select {|k, _v| names.include?(k) && !index.commands.values.include?(k) } specs_to_delete.each_value {|spec| Bundler.rm_rf(spec.full_gem_path) } @@ -100,8 +103,22 @@ def list # # @param [Pathname] gemfile path # @param [Proc] block that can be evaluated for (inline) Gemfile - def gemfile_install(gemfile = nil, &inline) - Bundler.settings.temporary(frozen: false, deployment: false) do + def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline) + # skip the update if unlocking specific gems, but none of them are our plugins + if unlock.is_a?(Hash) && unlock[:gems] && !unlock[:gems].empty? && + (unlock[:gems] & index.installed_plugins).empty? + unlock = {} + end + + @gemfile_parse = true + # plugins_in_lockfile is the user facing setting to force plugins to be + # included in the lockfile as regular dependencies. But during this + # first pass over the Gemfile where we're installing the plugins, we + # need that setting to be set, so that we can find the plugins and + # install them. We don't persist a lockfile during this pass, so it won't + # have any user-facing impact. + Bundler.settings.temporary(plugins_in_lockfile: true) do + Bundler.configure builder = DSL.new if block_given? builder.instance_eval(&inline) @@ -109,20 +126,21 @@ def gemfile_install(gemfile = nil, &inline) builder.eval_gemfile(gemfile) end builder.check_primary_source_safety - definition = builder.to_definition(nil, true) + definition = builder.to_definition(lockfile, unlock) return if definition.dependencies.empty? - plugins = definition.dependencies.map(&:name) installed_specs = Installer.new.install_definition(definition) - save_plugins plugins, installed_specs, builder.inferred_plugins + save_plugins installed_specs, builder.inferred_plugins end rescue RuntimeError => e unless e.is_a?(GemfileError) Bundler.ui.error "Failed to install plugin: #{e.message}\n #{e.backtrace[0]}" end raise + ensure + @gemfile_parse = false end # The index object used to store the details about the plugin @@ -183,12 +201,17 @@ def add_source(source, cls) # Checks if any plugin declares the source def source?(name) - !index.source_plugin(name.to_s).nil? + !!source_plugin(name) + end + + # Returns the plugin that handles the source +name+ if any + def source_plugin(name) + index.source_plugin(name.to_s) end # @return [Class] that handles the source. The class includes API::Source def source(name) - raise UnknownSourceError, "Source #{name} not found" unless source? name + raise UnknownSourceError, "Source #{name} not found" unless source_plugin(name) load_plugin(index.source_plugin(name)) unless @sources.key? name @@ -199,9 +222,14 @@ def source(name) # @return [API::Source] the instance of the class that handles the source # type passed in locked_opts def from_lock(locked_opts) + opts = locked_opts.merge("uri" => locked_opts["remote"]) + # when reading the lockfile while doing the plugin-install-from-gemfile phase, + # we need to ignore any plugin sources + return DummySource.new(opts) if @gemfile_parse + src = source(locked_opts["type"]) - src.new(locked_opts.merge("uri" => locked_opts["remote"])) + src.new(opts) end # To be called via the API to register a hooks and corresponding block that @@ -237,7 +265,9 @@ def hook(event, *args, &arg_blk) # # @return [String, nil] installed path def installed?(plugin) - Index.new.installed?(plugin) + (path = index.installed?(plugin)) && + index.plugin_path(plugin).join(PLUGIN_FILE_NAME).file? && + path end # @return [true, false] whether the plugin is loaded @@ -247,19 +277,11 @@ def loaded?(plugin) # Post installation processing and registering with index # - # @param [Array] plugins list to be installed # @param [Hash] specs of plugins mapped to installation path (currently they # contain all the installed specs, including plugins) # @param [Array] names of inferred source plugins that can be ignored - def save_plugins(plugins, specs, optional_plugins = []) - plugins.each do |name| - spec = specs[name] - - # It's possible that the `plugin` found in the Gemfile don't appear in the specs. For instance when - # calling `BUNDLE_WITHOUT=default bundle install`, the plugins will not get installed. - next if spec.nil? - next if index.up_to_date?(spec) - + def save_plugins(specs, optional_plugins = []) + specs.each do |name, spec| save_plugin(name, spec, optional_plugins.include?(name)) end end @@ -284,7 +306,10 @@ def validate_plugin!(plugin_path) # # @raise [PluginInstallError] if validation or registration raises any error def save_plugin(name, spec, optional_plugin = false) - validate_plugin! Pathname.new(spec.full_gem_path) + path = Pathname.new(spec.full_gem_path) + return if index.installed?(name) && index.plugin_path(name) == path + + validate_plugin!(path) installed = register_plugin(name, spec, optional_plugin) Bundler.ui.info "Installed plugin #{name}" if installed rescue PluginError => e @@ -319,7 +344,7 @@ def register_plugin(name, spec, optional_plugin = false) raise MalformattedPlugin, "#{e.class}: #{e.message}" end - if optional_plugin && @sources.keys.any? {|s| source? s } + if optional_plugin && @sources.keys.any? {|s| source_plugin(s) } Bundler.rm_rf(path) false else diff --git a/bundler/lib/bundler/plugin/dsl.rb b/bundler/lib/bundler/plugin/dsl.rb index da751d1774ec..b4ae76fd77b2 100644 --- a/bundler/lib/bundler/plugin/dsl.rb +++ b/bundler/lib/bundler/plugin/dsl.rb @@ -5,12 +5,11 @@ module Plugin # Dsl to parse the Gemfile looking for plugins to install class DSL < Bundler::Dsl class PluginGemfileError < PluginError; end - alias_method :_gem, :gem # To use for plugin installation as gem # So that we don't have to override all there methods to dummy ones # explicitly. # They will be handled by method_missing - [:gemspec, :gem, :install_if, :platforms, :env].each {|m| undef_method m } + [:gemspec, :install_if, :platforms, :env].each {|m| undef_method m } # This lists the plugins that was added automatically and not specified by # the user. @@ -24,12 +23,11 @@ class PluginGemfileError < PluginError; end def initialize super - @sources = Plugin::SourceList.new @inferred_plugins = [] # The source plugins inferred from :type end - def plugin(name, *args) - _gem(name, *args) + def gem(*args) + super if args.last.is_a?(Hash) && args.last["type"] == :plugin end def method_missing(name, *args) diff --git a/bundler/lib/bundler/plugin/dummy_source.rb b/bundler/lib/bundler/plugin/dummy_source.rb new file mode 100644 index 000000000000..63e63a683349 --- /dev/null +++ b/bundler/lib/bundler/plugin/dummy_source.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + class DummySource + include API::Source + end + end +end diff --git a/bundler/lib/bundler/plugin/installer.rb b/bundler/lib/bundler/plugin/installer.rb index 9be8b36843bb..7d6529d96164 100644 --- a/bundler/lib/bundler/plugin/installer.rb +++ b/bundler/lib/bundler/plugin/installer.rb @@ -32,12 +32,18 @@ def install(names, options) # # @param [Definition] definition object # @return [Hash] map of names to their specs they are installed with - def install_definition(definition) + def install_definition(definition, latest = false) def definition.lock(*); end - definition.remotely! + + if latest || definition.missing_specs? + definition.remotely! + else + definition.with_cache! + end + specs = definition.specs - install_from_specs specs + install_from_specs(specs) end private @@ -89,14 +95,14 @@ def install_rubygems(names, version, sources) end def install_all_sources(names, version, source_list, source = nil) - deps = names.map {|name| Dependency.new(name, version, { "source" => source }) } + deps = names.map {|name| Dependency.new(name, version, { "source" => source, "type" => :plugin }) } Bundler.configure_gem_home_and_path(Plugin.root) Bundler.settings.temporary(deployment: false, frozen: false) do definition = Definition.new(nil, deps, source_list, true) - install_definition(definition) + install_definition(definition, true) end end @@ -110,6 +116,8 @@ def install_from_specs(specs) paths = {} specs.each do |spec| + next if spec.name == "bundler" + spec.source.download(spec) spec.source.install(spec) diff --git a/bundler/lib/bundler/plugin/installer/path.rb b/bundler/lib/bundler/plugin/installer/path.rb index 58c4924eb0cb..02ec62d41749 100644 --- a/bundler/lib/bundler/plugin/installer/path.rb +++ b/bundler/lib/bundler/plugin/installer/path.rb @@ -5,7 +5,7 @@ module Plugin class Installer class Path < Bundler::Source::Path def root - SharedHelpers.in_bundle? ? Bundler.root : Plugin.root + Plugin.root end def eql?(other) diff --git a/bundler/lib/bundler/settings.rb b/bundler/lib/bundler/settings.rb index fd77c2f7fc76..901730496182 100644 --- a/bundler/lib/bundler/settings.rb +++ b/bundler/lib/bundler/settings.rb @@ -36,6 +36,7 @@ class Settings no_prune path.system plugins + plugins_in_lockfile prefer_patch silence_deprecations silence_root_warning @@ -89,6 +90,7 @@ class Settings "BUNDLE_LOCKFILE_CHECKSUMS" => true, "BUNDLE_CACHE_ALL" => true, "BUNDLE_PLUGINS" => true, + "BUNDLE_PLUGINS_IN_LOCKFILE" => true, "BUNDLE_GLOBAL_GEM_CACHE" => false, "BUNDLE_UPDATE_REQUIRES_ALL_FLAG" => false, }.freeze diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb index b379594c6f9a..5f770e22ba4e 100644 --- a/spec/bundler/plugin_spec.rb +++ b/spec/bundler/plugin_spec.rb @@ -115,6 +115,7 @@ let(:gemfile) { bundled_app_gemfile } before do + allow(Bundler).to receive(:configure) allow(Plugin::DSL).to receive(:new) { builder } allow(builder).to receive(:eval_gemfile).with(gemfile) allow(builder).to receive(:check_primary_source_safety) @@ -138,8 +139,8 @@ end before do - allow(index).to receive(:up_to_date?) { nil } - allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0"), Bundler::Dependency.new("another-plugin", ">=0")] } + allow(index).to receive(:installed?) { nil } + allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "type" => :plugin), Bundler::Dependency.new("another-plugin", ">=0", "type" => :plugin)] } allow(installer).to receive(:install_definition) { plugin_specs } end @@ -187,18 +188,16 @@ end end - describe "#source?" do - it "returns true value for sources in index" do + describe "#source_plugin" do + it "returns the plugin for sources in index" do allow(index). - to receive(:command_plugin).with("foo-source") { "my-plugin" } - result = subject.command? "foo-source" - expect(result).to be_truthy + to receive(:source_plugin).with("foo-source") { "my-plugin" } + expect(subject.source_plugin("foo-source")).to eql "my-plugin" end - it "returns false value for source not in index" do - allow(index).to receive(:command_plugin).with("foo-source") { nil } - result = subject.command? "foo-source" - expect(result).to be_falsy + it "returns nil value for source not in index" do + allow(index).to receive(:source_plugin).with("foo-source") { nil } + expect(subject.source_plugin("foo-source")).to be_nil end end diff --git a/spec/bundler/source_list_spec.rb b/spec/bundler/source_list_spec.rb index 61bd99b063b4..7a8e885b2e1b 100644 --- a/spec/bundler/source_list_spec.rb +++ b/spec/bundler/source_list_spec.rb @@ -6,7 +6,7 @@ stub_const "ASourcePlugin", Class.new(Bundler::Plugin::API) ASourcePlugin.source "new_source" - allow(Bundler::Plugin).to receive(:source?).with("new_source").and_return(true) + allow(Bundler::Plugin).to receive(:source_plugin).with("new_source").and_return("new_source") end subject(:source_list) { Bundler::SourceList.new } diff --git a/spec/other/major_deprecation_spec.rb b/spec/other/major_deprecation_spec.rb index ab7589d698d2..55cb1996a066 100644 --- a/spec/other/major_deprecation_spec.rb +++ b/spec/other/major_deprecation_spec.rb @@ -88,9 +88,9 @@ end end - describe "bundle update --quiet" do + describe "bundle update --all --quiet" do it "does not print any deprecations" do - bundle :update, quiet: true, raise_on_error: false + bundle :update, all: true, quiet: true, raise_on_error: false expect(deprecations).to be_empty end end diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index dcacf764bef6..f58ac7a157c5 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -321,6 +321,157 @@ def exec(command, args) expect(out).to include("Bundle complete!") end + it "installs plugins in included groups" do + gemfile <<-G + source 'https://gem.repo2' + group :development do + plugin 'foo' + end + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installed plugin foo") + + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed("foo") + end + + it "does not install plugins in excluded groups" do + gemfile <<-G + source 'https://gem.repo2' + group :development do + plugin 'foo' + end + gem 'myrack', "1.0.0" + G + + bundle "config set --local without development" + bundle "install" + + expect(out).not_to include("Installed plugin foo") + + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_not_be_installed("foo") + end + + it "upgrade plugins version listed in gemfile" do + update_repo2 do + build_plugin "foo", "1.4.0" + build_plugin "foo", "1.5.0" + end + + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo', "1.4.0" + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installing foo 1.4.0") + expect(out).to include("Installed plugin foo") + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed_with_version("foo", "1.4.0") + + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo', "1.5.0" + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installing foo 1.5.0") + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed_with_version("foo", "1.5.0") + end + + it "downgrade plugins version listed in gemfile" do + update_repo2 do + build_plugin "foo", "1.4.0" + build_plugin "foo", "1.5.0" + end + + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo', "1.5.0" + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installing foo 1.5.0") + expect(out).to include("Installed plugin foo") + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed_with_version("foo", "1.5.0") + + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo', "1.4.0" + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installing foo 1.4.0") + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed_with_version("foo", "1.4.0") + end + + it "install only plugins not installed yet listed in gemfile" do + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + gem 'myrack', "1.0.0" + G + + 2.times { bundle "install" } + + expect(out).to_not include("Fetching gem metadata") + expect(out).to_not include("Fetching foo") + expect(out).to_not include("Installed plugin foo") + + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed("foo") + + gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + plugin 'kung-foo' + gem 'myrack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installing kung-foo") + expect(out).to include("Installed plugin kung-foo") + + expect(out).to_not include("Fetching foo") + expect(out).to_not include("Installed plugin foo") + + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed("foo") + plugin_should_be_installed("kung-foo") + end + it "accepts git sources" do build_git "ga-plugin" do |s| s.write "plugins.rb" @@ -368,24 +519,150 @@ def exec(command, args) it "installs plugins" do install_gemfile <<-G source 'https://gem.repo2' + plugin 'foo' gem 'myrack', "1.0.0" G + expect(out).to include("Installed plugin foo") + bundle_config "deployment true" + bundle "install" + + expect(out).to include("Bundle complete!") + + expect(the_bundle).to include_gems("myrack 1.0.0") + plugin_should_be_installed("foo") + end + end + + context "with plugins_in_lockfile" do + it "includes plugins as dependencies for a new lockfile" do install_gemfile <<-G source 'https://gem.repo2' plugin 'foo' gem 'myrack', "1.0.0" G - expect(out).to include("Installed plugin foo") + expect(the_bundle).to include_gems("foo 1.0.0") + end - expect(out).to include("Bundle complete!") + it "does not include plugins as dependencies for an existing lockfile when disabled" do + bundle_config "plugins_in_lockfile false" - expect(the_bundle).to include_gems("myrack 1.0.0") - plugin_should_be_installed("foo") + install_gemfile <<-G + source 'https://gem.repo2' + gem 'myrack', "1.0.0" + G + + expect(the_bundle).not_to include_gems("foo 1.0.0") + + install_gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + gem 'myrack', "1.0.0" + G + + expect(the_bundle).not_to include_gems("foo 1.0.0") + + # it adds the plugins to the lockfile when specifically instructed + bundle_config "plugins_in_lockfile true" + bundle "install" + + expect(the_bundle).to include_gems("foo 1.0.0") + + # but will not remove them, once they're there, regardless of the setting + bundle_config "plugins_in_lockfile false" + bundle "install" + + expect(the_bundle).to include_gems("foo 1.0.0") + end + + it "includes plugins as dependencies for an existing lockfile" do + install_gemfile <<-G + source 'https://gem.repo2' + gem 'myrack', "1.0.0" + G + + expect(the_bundle).not_to include_gems("foo 1.0.0") + + install_gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + gem 'myrack', "1.0.0" + G + + expect(the_bundle).to include_gems("foo 1.0.0") + end + end + end + + it "fails bundle commands if plugins are not yet installed" do + allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) + + gemfile <<-G + source 'https://gem.repo2' + group :development do + plugin 'foo' end + source 'https://gem.repo1' do + gem 'rake' + end + G + + plugin_should_not_be_installed("foo") + + bundle "check", raise_on_error: false + expect(err).to include("Plugin foo (>= 0) is not installed") + + bundle "exec rake", raise_on_error: false + expect(err).to include("Plugin foo (>= 0) is not installed") + + bundle "config set --local without development" + bundle "install" + bundle "config unset --local without" + + plugin_should_not_be_installed("foo") + + bundle "check", raise_on_error: false + expect(err).to include("Plugin foo (>= 0) is not installed") + + bundle "exec rake", raise_on_error: false + expect(err).to include("Plugin foo (>= 0) is not installed") + + plugin_should_not_be_installed("foo") + + bundle "install" + plugin_should_be_installed("foo") + + bundle "check" + bundle "exec rake -T", raise_on_error: false + expect(err).not_to include("Plugin foo (>= 0) is not installed") + end + + it "fails bundle commands gracefully when a plugin index reference is left dangling" do + allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) + + build_lib "ga-plugin" do |s| + s.write "plugins.rb" end + + install_gemfile <<-G + source "https://gem.repo1" + plugin 'ga-plugin', :path => "#{lib_path("ga-plugin-1.0")}" + G + + expect(out).to include("Installed plugin ga-plugin") + plugin_should_be_installed("ga-plugin") + + FileUtils.rm_rf(lib_path("ga-plugin-1.0")) + + plugin_should_not_be_installed("ga-plugin") + + bundle "check", raise_on_error: false + expect(err).to include("Plugin ga-plugin (>= 0) is not installed") + + bundle "exec rake -T", raise_on_error: false + expect(err).to include("Plugin ga-plugin (>= 0) is not installed") end context "inline gemfiles" do @@ -400,7 +677,9 @@ def exec(command, args) RUBY ruby code, artifice: "compact_index", env: { "BUNDLER_VERSION" => Bundler::VERSION } - expect(local_plugin_gem("foo-1.0", "plugins.rb")).to exist + + allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) + plugin_should_be_installed("foo") end end diff --git a/spec/plugins/list_spec.rb b/spec/plugins/list_spec.rb index 30e3f82467e6..74ede33f5495 100644 --- a/spec/plugins/list_spec.rb +++ b/spec/plugins/list_spec.rb @@ -53,7 +53,7 @@ def exec(command, args) plugin_should_be_installed("foo", "bar") bundle "plugin list" - expected_output = "foo\n-----\n shout\n\nbar\n-----\n scream" + expected_output = "bar\n-----\n scream\n\nfoo\n-----\n shout" expect(out).to include(expected_output) end end diff --git a/spec/plugins/source/example_spec.rb b/spec/plugins/source/example_spec.rb index 4cd4a1a9318f..4332f2179870 100644 --- a/spec/plugins/source/example_spec.rb +++ b/spec/plugins/source/example_spec.rb @@ -72,6 +72,7 @@ def install(spec, opts) checksums = checksums_section_when_enabled do |c| c.no_checksum "a-path-gem", "1.0" + c.checksum gem_repo2, "bundler-source-mpath", "1.0" end expect(lockfile).to eq <<~G @@ -84,12 +85,14 @@ def install(spec, opts) GEM remote: https://gem.repo2/ specs: + bundler-source-mpath (1.0) PLATFORMS #{lockfile_platforms} DEPENDENCIES a-path-gem! + bundler-source-mpath #{checksums} BUNDLED WITH #{Bundler::VERSION} @@ -338,6 +341,7 @@ def installed? bundle "install" checksums = checksums_section_when_enabled do |c| + c.checksum gem_repo2, "bundler-source-gitp", "1.0" c.no_checksum "ma-gitp-gem", "1.0" end @@ -352,11 +356,13 @@ def installed? GEM remote: https://gem.repo2/ specs: + bundler-source-gitp (1.0) PLATFORMS #{lockfile_platforms} DEPENDENCIES + bundler-source-gitp ma-gitp-gem! #{checksums} BUNDLED WITH diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb index 5a3c38a4db36..efd56b44f68f 100644 --- a/spec/support/matchers.rb +++ b/spec/support/matchers.rb @@ -211,6 +211,7 @@ def indent(string, padding = 4, indent_character = " ") RSpec::Matchers.alias_matcher :include_gem, :include_gems def plugin_should_be_installed(*names) + Bundler::Plugin.instance_variable_set(:@index, nil) names.each do |name| expect(Bundler::Plugin).to be_installed(name) path = Pathname.new(Bundler::Plugin.installed?(name)) @@ -218,7 +219,17 @@ def plugin_should_be_installed(*names) end end + def plugin_should_be_installed_with_version(name, version) + Bundler::Plugin.instance_variable_set(:@index, nil) + expect(Bundler::Plugin).to be_installed(name) + path = Pathname.new(Bundler::Plugin.installed?(name)) + + expect(File.basename(path)).to eq("#{name}-#{version}") + expect(path + "plugins.rb").to exist + end + def plugin_should_not_be_installed(*names) + Bundler::Plugin.instance_variable_set(:@index, nil) names.each do |name| expect(Bundler::Plugin).not_to be_installed(name) end From 7698bf7245592611ff7f2429c8373f8fe6b68cbd Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Wed, 9 Apr 2025 09:46:13 -0600 Subject: [PATCH 02/13] Fix plugin dep handling Since #8480, we can't use the undocumented "type" option, but the add_dependency helper was added that lets us easily validate first, then add our custom options, then directly add the now custom dependency. This conveniently simplifies the Plugin version of the DSL, because `plugin` no longer flows through `gem`, so it doesn't need to special case it. --- bundler/lib/bundler/dsl.rb | 7 ++++--- bundler/lib/bundler/plugin/dsl.rb | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 078c7b01669b..0d6a59283f5f 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -262,14 +262,15 @@ def env(name) @env = old end - def plugin(*args) + def plugin(name, *args) options = args.last.is_a?(Hash) ? args.pop.dup : {} + version = args || [">= 0"] - normalize_hash(options) + normalize_options(name, version, options) options["type"] = :plugin options["require"] = false - gem(*args, options) + add_dependency(name, version, options) end def method_missing(name, *args) diff --git a/bundler/lib/bundler/plugin/dsl.rb b/bundler/lib/bundler/plugin/dsl.rb index b4ae76fd77b2..b2140934ff3e 100644 --- a/bundler/lib/bundler/plugin/dsl.rb +++ b/bundler/lib/bundler/plugin/dsl.rb @@ -27,7 +27,7 @@ def initialize end def gem(*args) - super if args.last.is_a?(Hash) && args.last["type"] == :plugin + # Ignore regular dependencies when doing the plugins-only pre-parse end def method_missing(name, *args) From 9725d54134586ee41aeb4271b3027fc85534ec15 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Wed, 9 Apr 2025 10:28:10 -0600 Subject: [PATCH 03/13] Change how plugin deps are identified Since #8486, hax to make any dependency type work inside bundler have been removed, so we mark plugins as type: :plugin. Instead, follow that PR's lead and just make it a dedicated option to Bundler::Dependency. --- bundler/lib/bundler/definition.rb | 6 +++--- bundler/lib/bundler/dependency.rb | 14 +++++--------- bundler/lib/bundler/dsl.rb | 2 +- bundler/lib/bundler/plugin/installer.rb | 2 +- spec/bundler/plugin_spec.rb | 2 +- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 610c24989922..cbeec440d364 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -286,7 +286,7 @@ def requested_dependencies end def plugin_dependencies - requested_dependencies.select {|dep| dep.type == :plugin } + requested_dependencies.select(&:plugin?) end def current_dependencies @@ -1350,9 +1350,9 @@ def remove_plugin_dependencies_if_necessary return if Bundler.settings[:plugins_in_lockfile] # we already have plugin dependencies in the lockfile; continue to do so regardless # of the current setting - return if @dependencies.any? {|d| d.type == :plugin && @locked_deps.key?(d.name) } + return if @dependencies.any? {|d| d.plugin? && @locked_deps.key?(d.name) } - @dependencies.reject! {|d| d.type == :plugin } + @dependencies.reject!(&:plugin?) end end end diff --git a/bundler/lib/bundler/dependency.rb b/bundler/lib/bundler/dependency.rb index 63832c4b7d63..7a7f7944c849 100644 --- a/bundler/lib/bundler/dependency.rb +++ b/bundler/lib/bundler/dependency.rb @@ -7,15 +7,7 @@ module Bundler class Dependency < Gem::Dependency def initialize(name, version, options = {}, &blk) type = options["type"] || :runtime - if type == :plugin - # RubyGems doesn't support plugin type, which only - # makes sense in the context of Bundler, so bypass - # the RubyGems validation - super(name, version, :runtime) - @type = type - else - super(name, version, type) - end + super(name, version, type) @options = options end @@ -126,6 +118,10 @@ def gemfile_dep? !gemspec_dev_dep? end + def plugin? + @plugin ||= @options.fetch("plugin", false) + end + def current_env? return true unless env if env.is_a?(Hash) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 0d6a59283f5f..e7504d0455d5 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -267,7 +267,7 @@ def plugin(name, *args) version = args || [">= 0"] normalize_options(name, version, options) - options["type"] = :plugin + options["plugin"] = true options["require"] = false add_dependency(name, version, options) diff --git a/bundler/lib/bundler/plugin/installer.rb b/bundler/lib/bundler/plugin/installer.rb index 7d6529d96164..c2a94177379b 100644 --- a/bundler/lib/bundler/plugin/installer.rb +++ b/bundler/lib/bundler/plugin/installer.rb @@ -95,7 +95,7 @@ def install_rubygems(names, version, sources) end def install_all_sources(names, version, source_list, source = nil) - deps = names.map {|name| Dependency.new(name, version, { "source" => source, "type" => :plugin }) } + deps = names.map {|name| Dependency.new(name, version, { "source" => source, "plugin" => true }) } Bundler.configure_gem_home_and_path(Plugin.root) diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb index 5f770e22ba4e..32e2cdb5cc36 100644 --- a/spec/bundler/plugin_spec.rb +++ b/spec/bundler/plugin_spec.rb @@ -140,7 +140,7 @@ before do allow(index).to receive(:installed?) { nil } - allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "type" => :plugin), Bundler::Dependency.new("another-plugin", ">=0", "type" => :plugin)] } + allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "plugin" => true), Bundler::Dependency.new("another-plugin", ">=0", "plugin" => true)] } allow(installer).to receive(:install_definition) { plugin_specs } end From cfe66376d1bcb274f75346d5fe2aefa2b69e8ba5 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Wed, 9 Apr 2025 10:32:14 -0600 Subject: [PATCH 04/13] remove validate_plugins! it's not core to this PR, and can be debated separately --- bundler/lib/bundler/definition.rb | 14 ------- spec/plugins/install_spec.rb | 69 ------------------------------- 2 files changed, 83 deletions(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index cbeec440d364..4bab883b9adf 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -484,7 +484,6 @@ def ensure_equivalent_gemfile_and_lockfile(explicit_flag = false) def validate_runtime! validate_ruby! validate_platforms! - validate_plugins! end def validate_ruby! @@ -520,19 +519,6 @@ def normalize_platforms @resolve = SpecSet.new(resolve.for(current_dependencies, @platforms)) end - def validate_plugins! - missing_plugins_list = [] - plugin_dependencies.each do |plugin| - missing_plugins_list << plugin unless Plugin.installed?(plugin.name) - end - missing_plugins_list.map! {|p| "#{p.name} (#{p.requirement})" } - if missing_plugins_list.size > 1 - raise GemNotFound, "Plugins #{missing_plugins_list.join(", ")} are not installed" - elsif missing_plugins_list.any? - raise GemNotFound, "Plugin #{missing_plugins_list.join(", ")} is not installed" - end - end - def add_platform(platform) return if @platforms.include?(platform) diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index f58ac7a157c5..704b1b624243 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -596,75 +596,6 @@ def exec(command, args) end end - it "fails bundle commands if plugins are not yet installed" do - allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) - - gemfile <<-G - source 'https://gem.repo2' - group :development do - plugin 'foo' - end - source 'https://gem.repo1' do - gem 'rake' - end - G - - plugin_should_not_be_installed("foo") - - bundle "check", raise_on_error: false - expect(err).to include("Plugin foo (>= 0) is not installed") - - bundle "exec rake", raise_on_error: false - expect(err).to include("Plugin foo (>= 0) is not installed") - - bundle "config set --local without development" - bundle "install" - bundle "config unset --local without" - - plugin_should_not_be_installed("foo") - - bundle "check", raise_on_error: false - expect(err).to include("Plugin foo (>= 0) is not installed") - - bundle "exec rake", raise_on_error: false - expect(err).to include("Plugin foo (>= 0) is not installed") - - plugin_should_not_be_installed("foo") - - bundle "install" - plugin_should_be_installed("foo") - - bundle "check" - bundle "exec rake -T", raise_on_error: false - expect(err).not_to include("Plugin foo (>= 0) is not installed") - end - - it "fails bundle commands gracefully when a plugin index reference is left dangling" do - allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) - - build_lib "ga-plugin" do |s| - s.write "plugins.rb" - end - - install_gemfile <<-G - source "https://gem.repo1" - plugin 'ga-plugin', :path => "#{lib_path("ga-plugin-1.0")}" - G - - expect(out).to include("Installed plugin ga-plugin") - plugin_should_be_installed("ga-plugin") - - FileUtils.rm_rf(lib_path("ga-plugin-1.0")) - - plugin_should_not_be_installed("ga-plugin") - - bundle "check", raise_on_error: false - expect(err).to include("Plugin ga-plugin (>= 0) is not installed") - - bundle "exec rake -T", raise_on_error: false - expect(err).to include("Plugin ga-plugin (>= 0) is not installed") - end - context "inline gemfiles" do it "installs the listed plugins" do code = <<-RUBY From 025ea16e66aff414f5dc61c1b6225d48b7ae2b0f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 13:32:11 +0900 Subject: [PATCH 05/13] Use Index#up_to_date? to skip already-registered plugins save_plugin grew an equivalent manual path comparison on this branch while master extracted Index#up_to_date? for the same purpose, so use the helper instead. Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/plugin.rb | 5 ++--- spec/bundler/plugin_spec.rb | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index ec8ca9fc47be..0f8d768cf06e 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -306,10 +306,9 @@ def validate_plugin!(plugin_path) # # @raise [PluginInstallError] if validation or registration raises any error def save_plugin(name, spec, optional_plugin = false) - path = Pathname.new(spec.full_gem_path) - return if index.installed?(name) && index.plugin_path(name) == path + return if index.up_to_date?(spec) - validate_plugin!(path) + validate_plugin! Pathname.new(spec.full_gem_path) installed = register_plugin(name, spec, optional_plugin) Bundler.ui.info "Installed plugin #{name}" if installed rescue PluginError => e diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb index 32e2cdb5cc36..25c1f3c56f8d 100644 --- a/spec/bundler/plugin_spec.rb +++ b/spec/bundler/plugin_spec.rb @@ -139,7 +139,7 @@ end before do - allow(index).to receive(:installed?) { nil } + allow(index).to receive(:up_to_date?) { nil } allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "plugin" => true), Bundler::Dependency.new("another-plugin", ">=0", "plugin" => true)] } allow(installer).to receive(:install_definition) { plugin_specs } end From 3547170ed8aa4f64e4578935dcda702c6e26dd9b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 13:32:22 +0900 Subject: [PATCH 06/13] Adjust plugin specs to lockfile-based plugin installation Plugins from the Gemfile now count as regular dependencies and are installed into the bundle path instead of the plugin root. Co-Authored-By: Claude Fable 5 --- spec/plugins/install_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index 704b1b624243..4580848535ee 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -286,7 +286,7 @@ def exec(command, args) bundle "install" - expected = local_plugin_gem("foo-2.0.0", "lib").to_s + expected = system_gem_path("gems", "foo-2.0.0", "lib").to_s expect(Bundler::Plugin.index.load_paths("foo")).to eq([expected]) end @@ -299,7 +299,7 @@ def exec(command, args) bundle "install", env: { "BUNDLE_WITHOUT" => "default" } - expect(out).to include("Bundle complete! 1 Gemfile dependency, 0 gems now installed.") + expect(out).to include("Bundle complete! 2 Gemfile dependencies, 0 gems now installed.") end it "accepts plugin version" do From 8592e1c44f2f51b8948d3efeca6d6eca052ba61f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 14:00:11 +0900 Subject: [PATCH 07/13] Remove plugins_in_lockfile setting Plugins declared in the Gemfile are now always tracked in the lockfile, so that checksums and cooldown verification apply to them unconditionally. An opt-out would leave plugins outside those protections, so it is not provided. Also drop Definition#plugin_dependencies, which lost its last caller when validate_plugins! was removed. Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/definition.rb | 15 --------- bundler/lib/bundler/man/bundle-config.1 | 2 -- bundler/lib/bundler/man/bundle-config.1.ronn | 2 -- bundler/lib/bundler/plugin.rb | 32 +++++++------------ bundler/lib/bundler/settings.rb | 2 -- spec/plugins/install_spec.rb | 33 +------------------- 6 files changed, 13 insertions(+), 73 deletions(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 4bab883b9adf..7a9567147103 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -135,8 +135,6 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti Bundler::SharedHelpers.feature_removed! msg end - - remove_plugin_dependencies_if_necessary else @locked_gems = nil @locked_platforms = [] @@ -285,10 +283,6 @@ def requested_dependencies dependencies_for(requested_groups) end - def plugin_dependencies - requested_dependencies.select(&:plugin?) - end - def current_dependencies filter_relevant(dependencies) end @@ -1331,14 +1325,5 @@ def new_resolution_base(last_resolve:, unlock:) def new_resolver(base) Resolver.new(base, gem_version_promoter, @most_specific_locked_platform) end - - def remove_plugin_dependencies_if_necessary - return if Bundler.settings[:plugins_in_lockfile] - # we already have plugin dependencies in the lockfile; continue to do so regardless - # of the current setting - return if @dependencies.any? {|d| d.plugin? && @locked_deps.key?(d.name) } - - @dependencies.reject!(&:plugin?) - end end end diff --git a/bundler/lib/bundler/man/bundle-config.1 b/bundler/lib/bundler/man/bundle-config.1 index 9905c7e78e3e..c055c8a415b0 100644 --- a/bundler/lib/bundler/man/bundle-config.1 +++ b/bundler/lib/bundler/man/bundle-config.1 @@ -157,8 +157,6 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated .IP "\(bu" 4 \fBplugins\fR (\fBBUNDLE_PLUGINS\fR): Enable Bundler's experimental plugin system\. .IP "\(bu" 4 -\fBplugins_in_lockfile\fR (\fBBUNDLE_PLUGINS_IN_LOCKFILE\fR): Include plugins as regular dependencies in the lockfile\. -.IP "\(bu" 4 \fBprefer_patch\fR (\fBBUNDLE_PREFER_PATCH\fR): Prefer updating only to next patch version during updates\. Makes \fBbundle update\fR calls equivalent to \fBbundler update \-\-patch\fR\. .IP "\(bu" 4 \fBredirect\fR (\fBBUNDLE_REDIRECT\fR): The number of redirects allowed for network requests\. Defaults to \fB5\fR\. diff --git a/bundler/lib/bundler/man/bundle-config.1.ronn b/bundler/lib/bundler/man/bundle-config.1.ronn index e8d018f2de0c..72f891b428d5 100644 --- a/bundler/lib/bundler/man/bundle-config.1.ronn +++ b/bundler/lib/bundler/man/bundle-config.1.ronn @@ -261,8 +261,6 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). Whether Bundler will install gems into the default system path (`Gem.dir`). * `plugins` (`BUNDLE_PLUGINS`): Enable Bundler's experimental plugin system. -* `plugins_in_lockfile` (`BUNDLE_PLUGINS_IN_LOCKFILE`): - Include plugins as regular dependencies in the lockfile. * `prefer_patch` (`BUNDLE_PREFER_PATCH`): Prefer updating only to next patch version during updates. Makes `bundle update` calls equivalent to `bundler update --patch`. * `redirect` (`BUNDLE_REDIRECT`): diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index 0f8d768cf06e..8ab5ba37067a 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -111,29 +111,21 @@ def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline) end @gemfile_parse = true - # plugins_in_lockfile is the user facing setting to force plugins to be - # included in the lockfile as regular dependencies. But during this - # first pass over the Gemfile where we're installing the plugins, we - # need that setting to be set, so that we can find the plugins and - # install them. We don't persist a lockfile during this pass, so it won't - # have any user-facing impact. - Bundler.settings.temporary(plugins_in_lockfile: true) do - Bundler.configure - builder = DSL.new - if block_given? - builder.instance_eval(&inline) - else - builder.eval_gemfile(gemfile) - end - builder.check_primary_source_safety - definition = builder.to_definition(lockfile, unlock) + Bundler.configure + builder = DSL.new + if block_given? + builder.instance_eval(&inline) + else + builder.eval_gemfile(gemfile) + end + builder.check_primary_source_safety + definition = builder.to_definition(lockfile, unlock) - return if definition.dependencies.empty? + return if definition.dependencies.empty? - installed_specs = Installer.new.install_definition(definition) + installed_specs = Installer.new.install_definition(definition) - save_plugins installed_specs, builder.inferred_plugins - end + save_plugins installed_specs, builder.inferred_plugins rescue RuntimeError => e unless e.is_a?(GemfileError) Bundler.ui.error "Failed to install plugin: #{e.message}\n #{e.backtrace[0]}" diff --git a/bundler/lib/bundler/settings.rb b/bundler/lib/bundler/settings.rb index 901730496182..fd77c2f7fc76 100644 --- a/bundler/lib/bundler/settings.rb +++ b/bundler/lib/bundler/settings.rb @@ -36,7 +36,6 @@ class Settings no_prune path.system plugins - plugins_in_lockfile prefer_patch silence_deprecations silence_root_warning @@ -90,7 +89,6 @@ class Settings "BUNDLE_LOCKFILE_CHECKSUMS" => true, "BUNDLE_CACHE_ALL" => true, "BUNDLE_PLUGINS" => true, - "BUNDLE_PLUGINS_IN_LOCKFILE" => true, "BUNDLE_GLOBAL_GEM_CACHE" => false, "BUNDLE_UPDATE_REQUIRES_ALL_FLAG" => false, }.freeze diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index 4580848535ee..0fd4a704ebe1 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -535,7 +535,7 @@ def exec(command, args) end end - context "with plugins_in_lockfile" do + context "plugins in the lockfile" do it "includes plugins as dependencies for a new lockfile" do install_gemfile <<-G source 'https://gem.repo2' @@ -546,37 +546,6 @@ def exec(command, args) expect(the_bundle).to include_gems("foo 1.0.0") end - it "does not include plugins as dependencies for an existing lockfile when disabled" do - bundle_config "plugins_in_lockfile false" - - install_gemfile <<-G - source 'https://gem.repo2' - gem 'myrack', "1.0.0" - G - - expect(the_bundle).not_to include_gems("foo 1.0.0") - - install_gemfile <<-G - source 'https://gem.repo2' - plugin 'foo' - gem 'myrack', "1.0.0" - G - - expect(the_bundle).not_to include_gems("foo 1.0.0") - - # it adds the plugins to the lockfile when specifically instructed - bundle_config "plugins_in_lockfile true" - bundle "install" - - expect(the_bundle).to include_gems("foo 1.0.0") - - # but will not remove them, once they're there, regardless of the setting - bundle_config "plugins_in_lockfile false" - bundle "install" - - expect(the_bundle).to include_gems("foo 1.0.0") - end - it "includes plugins as dependencies for an existing lockfile" do install_gemfile <<-G source 'https://gem.repo2' From c198351e218fa6211c2662aedbf680e6ccbfa8e4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 14:16:15 +0900 Subject: [PATCH 08/13] Rename Plugin::DummySource to UnloadedSource The name now reflects why the placeholder exists: it stands in for a source whose handler plugin is not loaded yet while the lockfile is parsed during the plugin install pass. Co-Authored-By: Claude Fable 5 --- Manifest.txt | 2 +- bundler/lib/bundler/plugin.rb | 14 +++++++------- bundler/lib/bundler/plugin/dummy_source.rb | 9 --------- bundler/lib/bundler/plugin/unloaded_source.rb | 11 +++++++++++ 4 files changed, 19 insertions(+), 17 deletions(-) delete mode 100644 bundler/lib/bundler/plugin/dummy_source.rb create mode 100644 bundler/lib/bundler/plugin/unloaded_source.rb diff --git a/Manifest.txt b/Manifest.txt index 542f369bdd47..fed1f772c476 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -161,7 +161,6 @@ bundler/lib/bundler/plugin.rb bundler/lib/bundler/plugin/api.rb bundler/lib/bundler/plugin/api/source.rb bundler/lib/bundler/plugin/dsl.rb -bundler/lib/bundler/plugin/dummy_source.rb bundler/lib/bundler/plugin/events.rb bundler/lib/bundler/plugin/index.rb bundler/lib/bundler/plugin/installer.rb @@ -169,6 +168,7 @@ bundler/lib/bundler/plugin/installer/git.rb bundler/lib/bundler/plugin/installer/path.rb bundler/lib/bundler/plugin/installer/rubygems.rb bundler/lib/bundler/plugin/source_list.rb +bundler/lib/bundler/plugin/unloaded_source.rb bundler/lib/bundler/process_lock.rb bundler/lib/bundler/remote_specification.rb bundler/lib/bundler/resolver.rb diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index 8ab5ba37067a..a8a76af8764d 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -4,12 +4,12 @@ module Bundler module Plugin - autoload :DSL, File.expand_path("plugin/dsl", __dir__) - autoload :DummySource, File.expand_path("plugin/dummy_source", __dir__) - autoload :Events, File.expand_path("plugin/events", __dir__) - autoload :Index, File.expand_path("plugin/index", __dir__) - autoload :Installer, File.expand_path("plugin/installer", __dir__) - autoload :SourceList, File.expand_path("plugin/source_list", __dir__) + autoload :DSL, File.expand_path("plugin/dsl", __dir__) + autoload :Events, File.expand_path("plugin/events", __dir__) + autoload :Index, File.expand_path("plugin/index", __dir__) + autoload :Installer, File.expand_path("plugin/installer", __dir__) + autoload :SourceList, File.expand_path("plugin/source_list", __dir__) + autoload :UnloadedSource, File.expand_path("plugin/unloaded_source", __dir__) class MalformattedPlugin < PluginError; end class UndefinedCommandError < PluginError; end @@ -217,7 +217,7 @@ def from_lock(locked_opts) opts = locked_opts.merge("uri" => locked_opts["remote"]) # when reading the lockfile while doing the plugin-install-from-gemfile phase, # we need to ignore any plugin sources - return DummySource.new(opts) if @gemfile_parse + return UnloadedSource.new(opts) if @gemfile_parse src = source(locked_opts["type"]) diff --git a/bundler/lib/bundler/plugin/dummy_source.rb b/bundler/lib/bundler/plugin/dummy_source.rb deleted file mode 100644 index 63e63a683349..000000000000 --- a/bundler/lib/bundler/plugin/dummy_source.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Bundler - module Plugin - class DummySource - include API::Source - end - end -end diff --git a/bundler/lib/bundler/plugin/unloaded_source.rb b/bundler/lib/bundler/plugin/unloaded_source.rb new file mode 100644 index 000000000000..bec91582e7e4 --- /dev/null +++ b/bundler/lib/bundler/plugin/unloaded_source.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + # Stands in for a source handled by a plugin that is not loaded yet, so + # that the lockfile can still be parsed during the plugin install pass. + class UnloadedSource + include API::Source + end + end +end From d9c222dd2e4043cb397c19c969724b214fbcd411 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 14:35:01 +0900 Subject: [PATCH 09/13] Don't register plugin dependencies as plugins save_plugins received every spec materialized for the plugin definition, so transitive dependencies of a plugin went through validate_plugin! and failed with MalformattedPlugin because they have no plugins.rb. Register only the requested plugins. Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/plugin.rb | 10 ++++++---- spec/plugins/install_spec.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index a8a76af8764d..00833e02975a 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -43,7 +43,7 @@ def install(names, options) specs = Installer.new.install(names, options) - save_plugins specs + save_plugins specs.slice(*names) rescue PluginError specs_to_delete = specs.select {|k, _v| names.include?(k) && !index.commands.values.include?(k) } specs_to_delete.each_value {|spec| Bundler.rm_rf(spec.full_gem_path) } @@ -119,13 +119,15 @@ def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline) builder.eval_gemfile(gemfile) end builder.check_primary_source_safety - definition = builder.to_definition(lockfile, unlock) - return if definition.dependencies.empty? + plugins = builder.dependencies.map(&:name) + return if plugins.empty? + + definition = builder.to_definition(lockfile, unlock) installed_specs = Installer.new.install_definition(definition) - save_plugins installed_specs, builder.inferred_plugins + save_plugins installed_specs.slice(*plugins), builder.inferred_plugins rescue RuntimeError => e unless e.is_a?(GemfileError) Bundler.ui.error "Failed to install plugin: #{e.message}\n #{e.backtrace[0]}" diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index 0fd4a704ebe1..9d202af31b9c 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -64,6 +64,21 @@ plugin_should_be_installed("foo", "kung-foo") end + it "installs a plugin with dependencies, without registering them as plugins" do + update_repo2 do + build_gem "foo-dep" + build_plugin "foo-with-dep" do |s| + s.add_dependency "foo-dep" + end + end + + bundle "plugin install foo-with-dep --source https://gem.repo2" + + expect(out).to include("Installed plugin foo-with-dep") + plugin_should_be_installed("foo-with-dep") + plugin_should_not_be_installed("foo-dep") + end + it "uses the same version for multiple plugins" do update_repo2 do build_plugin "foo", "1.1" @@ -265,6 +280,24 @@ def exec(command, args) plugin_should_be_installed("foo") end + it "installs plugins with dependencies, without registering them as plugins" do + update_repo2 do + build_gem "foo-dep" + build_plugin "foo-with-dep" do |s| + s.add_dependency "foo-dep" + end + end + + install_gemfile <<-G + source 'https://gem.repo2' + plugin 'foo-with-dep' + G + + expect(out).to include("Installed plugin foo-with-dep") + plugin_should_be_installed("foo-with-dep") + plugin_should_not_be_installed("foo-dep") + end + it "overrides the index with the new plugin version" do gemfile <<-G source 'https://gem.repo2' From 52d68df2e4f5fcaf03d68e06ff2f9d58d3872d7b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 14:50:59 +0900 Subject: [PATCH 10/13] Fix bundle update not updating plugins The unlock skip was based on index.installed_plugins, which misses plugins not yet installed locally, and install_definition resolved from cache whenever the lockfile was satisfiable, so an unlocked plugin never saw new versions and the plugin index kept the old one. Decide the skip from the plugin declarations in the Gemfile and force remote resolution when unlocking. The unlocking check must happen before building the definition because Definition#initialize consumes the unlock hash. Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/plugin.rb | 20 ++++++++++++------- spec/plugins/install_spec.rb | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index 00833e02975a..9257e8e72ccf 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -104,12 +104,6 @@ def list # @param [Pathname] gemfile path # @param [Proc] block that can be evaluated for (inline) Gemfile def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline) - # skip the update if unlocking specific gems, but none of them are our plugins - if unlock.is_a?(Hash) && unlock[:gems] && !unlock[:gems].empty? && - (unlock[:gems] & index.installed_plugins).empty? - unlock = {} - end - @gemfile_parse = true Bundler.configure builder = DSL.new @@ -123,9 +117,21 @@ def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline) plugins = builder.dependencies.map(&:name) return if plugins.empty? + # skip the update if unlocking specific gems, but none of them are plugins + # declared in the Gemfile + if unlock.is_a?(Hash) && unlock[:gems] && !unlock[:gems].empty? && + (unlock[:gems] & plugins).empty? + unlock = {} + end + + # resolve remotely when unlocking, so that plugins can be updated. + # Definition#initialize consumes the unlock hash, so this must be decided + # before building the definition. + updating = unlock == true || (unlock.is_a?(Hash) && !unlock.empty?) + definition = builder.to_definition(lockfile, unlock) - installed_specs = Installer.new.install_definition(definition) + installed_specs = Installer.new.install_definition(definition, updating) save_plugins installed_specs.slice(*plugins), builder.inferred_plugins rescue RuntimeError => e diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb index 9d202af31b9c..42ef686fb032 100644 --- a/spec/plugins/install_spec.rb +++ b/spec/plugins/install_spec.rb @@ -298,6 +298,43 @@ def exec(command, args) plugin_should_not_be_installed("foo-dep") end + it "updates a plugin with bundle update" do + install_gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + G + + plugin_should_be_installed_with_version("foo", "1.0") + + update_repo2 do + build_plugin "foo", "1.1" + end + + bundle "update foo" + + plugin_should_be_installed_with_version("foo", "1.1") + end + + it "updates a plugin that is not installed locally" do + install_gemfile <<-G + source 'https://gem.repo2' + plugin 'foo' + G + + plugin_should_be_installed_with_version("foo", "1.0") + + update_repo2 do + build_plugin "foo", "1.1" + end + + # simulate a machine where the plugin has not been installed yet + FileUtils.rm_rf bundled_app(".bundle/plugin") + + bundle "update foo" + + plugin_should_be_installed_with_version("foo", "1.1") + end + it "overrides the index with the new plugin version" do gemfile <<-G source 'https://gem.repo2' From 781b37bf0f553bf7ff71d6303d4f79f02f8be4b4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 14:50:59 +0900 Subject: [PATCH 11/13] Adjust specs to plugin registration changes Registering only the requested plugins restores the CLI argument order in the plugin index, matching the original expectation on master, and gemfile_install now reads plugin names from the DSL builder instead of the definition. Co-Authored-By: Claude Fable 5 --- spec/bundler/plugin_spec.rb | 4 ++-- spec/plugins/list_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb index 25c1f3c56f8d..c0cfdf6bf49f 100644 --- a/spec/bundler/plugin_spec.rb +++ b/spec/bundler/plugin_spec.rb @@ -124,7 +124,7 @@ end it "doesn't calls installer without any plugins" do - allow(definition).to receive(:dependencies) { [] } + allow(builder).to receive(:dependencies) { [] } allow(installer).to receive(:install_definition).never subject.gemfile_install(gemfile) @@ -140,7 +140,7 @@ before do allow(index).to receive(:up_to_date?) { nil } - allow(definition).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "plugin" => true), Bundler::Dependency.new("another-plugin", ">=0", "plugin" => true)] } + allow(builder).to receive(:dependencies) { [Bundler::Dependency.new("new-plugin", ">=0", "plugin" => true), Bundler::Dependency.new("another-plugin", ">=0", "plugin" => true)] } allow(installer).to receive(:install_definition) { plugin_specs } end diff --git a/spec/plugins/list_spec.rb b/spec/plugins/list_spec.rb index 74ede33f5495..30e3f82467e6 100644 --- a/spec/plugins/list_spec.rb +++ b/spec/plugins/list_spec.rb @@ -53,7 +53,7 @@ def exec(command, args) plugin_should_be_installed("foo", "bar") bundle "plugin list" - expected_output = "bar\n-----\n scream\n\nfoo\n-----\n shout" + expected_output = "foo\n-----\n shout\n\nbar\n-----\n scream" expect(out).to include(expected_output) end end From d9ccf28116eab7946f625a99617133ef6e258daa Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 15:01:41 +0900 Subject: [PATCH 12/13] Validate only explicitly requested gems against the lockfile explicit_gems aliased the same array that the --group handling mutates with concat, so group-derived gems leaked into the lockfile presence check and bundle update --group failed on gems newly added to the group. Copy the array before it gets mutated. Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/cli/update.rb | 2 +- spec/commands/update_spec.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/cli/update.rb b/bundler/lib/bundler/cli/update.rb index 0e1769f07c27..33f9ac87406a 100644 --- a/bundler/lib/bundler/cli/update.rb +++ b/bundler/lib/bundler/cli/update.rb @@ -42,7 +42,7 @@ def run raise GemfileLockNotFound, "This Bundle hasn't been installed yet. " \ "Run `bundle install` to update and install the bundled gems." end - explicit_gems = gems + explicit_gems = gems.dup if groups.any? deps = Bundler.definition.dependencies.select {|d| (d.groups & groups).any? } diff --git a/spec/commands/update_spec.rb b/spec/commands/update_spec.rb index 03a3786d80a2..f100bdfb9edb 100644 --- a/spec/commands/update_spec.rb +++ b/spec/commands/update_spec.rb @@ -744,6 +744,23 @@ expect(the_bundle).not_to include_gems "myrack 1.2" end + it "doesn't fail when a gem was added to the group but is not in the lockfile yet" do + install_gemfile <<-G + source "https://gem.repo2" + gem "activesupport", :group => :development + G + + gemfile <<-G + source "https://gem.repo2" + gem "activesupport", :group => :development + gem "myrack", :group => :development + G + + bundle "update --group development" + + expect(the_bundle).to include_gems "myrack 1.0.0" + end + context "when conservatively updating a group with non-group sub-deps" do it "should update only specified group gems" do install_gemfile <<-G From f868e59f6b5d53d4dc0fa0d8a9b2e07d2b5a0f15 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 11 Jun 2026 15:17:10 +0900 Subject: [PATCH 13/13] Document single-thread assumption of @gemfile_parse Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/plugin.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index 9257e8e72ccf..081ec8e18031 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -17,6 +17,13 @@ class UnknownSourceError < PluginError; end class PluginInstallError < PluginError; end PLUGIN_FILE_NAME = "plugins.rb" + + # Module-level flag set while .gemfile_install parses the Gemfile and + # consulted by .from_lock to substitute plugin sources with + # UnloadedSource. It relies on definitions being built one at a time in + # a single thread; if they are ever built concurrently or reentrantly, + # this needs to be replaced by explicit state passed down to the + # lockfile parser. @gemfile_parse = false module_function