diff --git a/lib/bundler/installer.rb b/lib/bundler/installer.rb index aa0c5f1c8e7..528dee177e1 100644 --- a/lib/bundler/installer.rb +++ b/lib/bundler/installer.rb @@ -20,6 +20,7 @@ class << self # For more information see the #run method on this class. def self.install(root, definition, options = {}) installer = new(root, definition) + Plugin.hook("before-install-all", definition.dependencies) installer.run(options) installer end diff --git a/lib/bundler/plugin.rb b/lib/bundler/plugin.rb index cd3843fc0de..1f0297f29bd 100644 --- a/lib/bundler/plugin.rb +++ b/lib/bundler/plugin.rb @@ -16,8 +16,16 @@ class UnknownSourceError < PluginError; end module_function - @commands = {} - @sources = {} + def reset! + instance_variables.each {|i| remove_instance_variable(i) } + + @sources = {} + @commands = {} + @hooks_by_event = Hash.new {|h, k| h[k] = [] } + @loaded_plugin_names = [] + end + + reset! # Installs a new plugin by the given name # @@ -30,7 +38,7 @@ def install(names, options) save_plugins names, specs rescue PluginError => e specs.values.map {|spec| Bundler.rm_rf(spec.full_gem_path) } if specs - Bundler.ui.error "Failed to install plugin #{name}: #{e.message}\n #{e.backtrace[0]}" + Bundler.ui.error "Failed to install plugin #{name}: #{e.message}\n #{e.backtrace.join("\n ")}" end # Evaluates the Gemfile with a limited DSL and installs the plugins @@ -137,6 +145,29 @@ def source_from_lock(locked_opts) src.new(locked_opts.merge("uri" => locked_opts["remote"])) end + # To be called via the API to register a hooks and corresponding block that + # will be called to handle the hook + def add_hook(event, &block) + @hooks_by_event[event.to_s] << block + end + + # Runs all the hooks that are registered for the passed event + # + # It passes the passed arguments and block to the block registered with + # the api. + # + # @param [String] event + def hook(event, *args, &arg_blk) + return unless Bundler.settings[:plugins] + + plugins = index.hook_plugins(event) + return unless plugins.any? + + (plugins - @loaded_plugin_names).each {|name| load_plugin(name) } + + @hooks_by_event[event].each {|blk| blk.call(*args, &arg_blk) } + end + # currently only intended for specs # # @return [String, nil] installed path @@ -144,14 +175,6 @@ def installed?(plugin) Index.new.installed?(plugin) end - # Used by specs - def reset! - instance_variables.each {|i| remove_instance_variable(i) } - - @sources = {} - @commands = {} - end - # Post installation processing and registering with index # # @param [Array] plugins list to be installed @@ -162,7 +185,7 @@ def save_plugins(plugins, specs, optional_plugins = []) plugins.each do |name| spec = specs[name] validate_plugin! Pathname.new(spec.full_gem_path) - installed = register_plugin name, spec, optional_plugins.include?(name) + installed = register_plugin(name, spec, optional_plugins.include?(name)) Bundler.ui.info "Installed plugin #{name}" if installed end end @@ -190,9 +213,11 @@ def validate_plugin!(plugin_path) def register_plugin(name, spec, optional_plugin = false) commands = @commands sources = @sources + hooks = @hooks_by_event @commands = {} @sources = {} + @hooks_by_event = Hash.new {|h, k| h[k] = [] } load_paths = spec.load_paths add_to_load_path(load_paths) @@ -208,12 +233,14 @@ def register_plugin(name, spec, optional_plugin = false) Bundler.rm_rf(path) false else - index.register_plugin name, path.to_s, load_paths, @commands.keys, @sources.keys + index.register_plugin(name, path.to_s, load_paths, @commands.keys, + @sources.keys, @hooks_by_event.keys) true end ensure @commands = commands @sources = sources + @hooks_by_event = hooks end # Executes the plugins.rb file @@ -228,6 +255,8 @@ def load_plugin(name) add_to_load_path(index.load_paths(name)) load path.join(PLUGIN_FILE_NAME) + + @loaded_plugin_names << name rescue => e Bundler.ui.error "Failed loading plugin #{name}: #{e.message}" raise diff --git a/lib/bundler/plugin/api.rb b/lib/bundler/plugin/api.rb index 9ff4b7385bb..5bd8792e55b 100644 --- a/lib/bundler/plugin/api.rb +++ b/lib/bundler/plugin/api.rb @@ -44,6 +44,10 @@ def self.source(source, cls = self) Plugin.add_source source, cls end + def self.hook(event, &block) + Plugin.add_hook(event, &block) + end + # The cache dir to be used by the plugins for storage # # @return [Pathname] path of the cache dir diff --git a/lib/bundler/plugin/index.rb b/lib/bundler/plugin/index.rb index 918b3a4392b..7f89d26178b 100644 --- a/lib/bundler/plugin/index.rb +++ b/lib/bundler/plugin/index.rb @@ -24,6 +24,7 @@ def initialize @plugin_paths = {} @commands = {} @sources = {} + @hooks = {} @load_paths = {} load_index(global_index_file, true) @@ -39,7 +40,7 @@ def initialize # @param [Array] load_paths for the plugin # @param [Array] commands that are handled by the plugin # @param [Array] sources that are handled by the plugin - def register_plugin(name, path, load_paths, commands, sources) + def register_plugin(name, path, load_paths, commands, sources, hooks) old_commands = @commands.dup common = commands & @commands.keys @@ -50,6 +51,8 @@ def register_plugin(name, path, load_paths, commands, sources) raise SourceConflict.new(name, common) unless common.empty? sources.each {|k| @sources[k] = name } + hooks.each {|e| (@hooks[e] ||= []) << name } + @plugin_paths[name] = path @load_paths[name] = load_paths save_index @@ -98,6 +101,11 @@ def source_plugin(name) @sources[name] end + # Returns the list of plugin names handling the passed event + def hook_plugins(event) + @hooks[event] || [] + end + private # Reads the index file from the directory and initializes the instance @@ -112,12 +120,14 @@ def load_index(index_file, global = false) break unless valid_file data = index_f.read + require "bundler/yaml_serializer" index = YAMLSerializer.load(data) - @plugin_paths.merge!(index["plugin_paths"]) - @load_paths.merge!(index["load_paths"]) @commands.merge!(index["commands"]) + @hooks.merge!(index["hooks"]) + @load_paths.merge!(index["load_paths"]) + @plugin_paths.merge!(index["plugin_paths"]) @sources.merge!(index["sources"]) unless global end end @@ -127,10 +137,11 @@ def load_index(index_file, global = false) # to be only String key value pairs) def save_index index = { + "commands" => @commands, + "hooks" => @hooks, + "load_paths" => @load_paths, "plugin_paths" => @plugin_paths, - "load_paths" => @load_paths, - "commands" => @commands, - "sources" => @sources, + "sources" => @sources, } require "bundler/yaml_serializer" diff --git a/spec/bundler/plugin/api_spec.rb b/spec/bundler/plugin/api_spec.rb index a227d315915..0eba52301a7 100644 --- a/spec/bundler/plugin/api_spec.rb +++ b/spec/bundler/plugin/api_spec.rb @@ -38,6 +38,17 @@ UserPluginClass.source "a_source", NewClass end end + + describe "#hook" do + it "accepts a block and passes it to Plugin module" do + foo = double("tester") + expect(foo).to receive(:called) + + expect(Bundler::Plugin).to receive(:add_hook).with("post-foo").and_yield + + Bundler::Plugin::API.hook("post-foo") { foo.called } + end + end end context "bundler interfaces provided" do diff --git a/spec/bundler/plugin/index_spec.rb b/spec/bundler/plugin/index_spec.rb index 337182abf19..5a754f355c1 100644 --- a/spec/bundler/plugin/index_spec.rb +++ b/spec/bundler/plugin/index_spec.rb @@ -4,86 +4,113 @@ describe Bundler::Plugin::Index do Index = Bundler::Plugin::Index - subject(:index) { Index.new } - before do gemfile "" + path = lib_path(plugin_name) + index.register_plugin("new-plugin", path.to_s, [path.join("lib").to_s], commands, sources, hooks) end - describe "#register plugin" do - before do - path = lib_path("new-plugin") - index.register_plugin("new-plugin", path.to_s, [path.join("lib").to_s], [], []) - end + let(:plugin_name) { "new-plugin" } + let(:commands) { [] } + let(:sources) { [] } + let(:hooks) { [] } + subject(:index) { Index.new } + + describe "#register plugin" do it "is available for retrieval" do - expect(index.plugin_path("new-plugin")).to eq(lib_path("new-plugin")) + expect(index.plugin_path(plugin_name)).to eq(lib_path(plugin_name)) end it "load_paths is available for retrival" do - expect(index.load_paths("new-plugin")).to eq([lib_path("new-plugin").join("lib").to_s]) + expect(index.load_paths(plugin_name)).to eq([lib_path(plugin_name).join("lib").to_s]) end it "is persistent" do new_index = Index.new - expect(new_index.plugin_path("new-plugin")).to eq(lib_path("new-plugin")) + expect(new_index.plugin_path(plugin_name)).to eq(lib_path(plugin_name)) end it "load_paths are persistant" do new_index = Index.new - expect(new_index.load_paths("new-plugin")).to eq([lib_path("new-plugin").join("lib").to_s]) + expect(new_index.load_paths(plugin_name)).to eq([lib_path(plugin_name).join("lib").to_s]) end end describe "commands" do - before do - path = lib_path("cplugin") - index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["newco"], []) - end + let(:commands) { ["newco"] } it "returns the plugins name on query" do - expect(index.command_plugin("newco")).to eq("cplugin") + expect(index.command_plugin("newco")).to eq(plugin_name) end it "raises error on conflict" do expect do - index.register_plugin("aplugin", lib_path("aplugin").to_s, lib_path("aplugin").join("lib").to_s, ["newco"], []) + index.register_plugin("aplugin", lib_path("aplugin").to_s, lib_path("aplugin").join("lib").to_s, ["newco"], [], []) end.to raise_error(Index::CommandConflict) end it "is persistent" do new_index = Index.new - expect(new_index.command_plugin("newco")).to eq("cplugin") + expect(new_index.command_plugin("newco")).to eq(plugin_name) end end describe "source" do - before do - path = lib_path("splugin") - index.register_plugin("splugin", path.to_s, [path.join("lib").to_s], [], ["new_source"]) - end + let(:sources) { ["new_source"] } it "returns the plugins name on query" do - expect(index.source_plugin("new_source")).to eq("splugin") + expect(index.source_plugin("new_source")).to eq(plugin_name) end it "raises error on conflict" do expect do - index.register_plugin("aplugin", lib_path("aplugin").to_s, lib_path("aplugin").join("lib").to_s, [], ["new_source"]) + index.register_plugin("aplugin", lib_path("aplugin").to_s, lib_path("aplugin").join("lib").to_s, [], ["new_source"], []) end.to raise_error(Index::SourceConflict) end it "is persistent" do new_index = Index.new - expect(new_index.source_plugin("new_source")).to eq("splugin") + expect(new_index.source_plugin("new_source")).to eq(plugin_name) + end + end + + describe "hook" do + let(:hooks) { ["after-bar"] } + + it "returns the plugins name on query" do + expect(index.hook_plugins("after-bar")).to include(plugin_name) + end + + it "is persistent" do + new_index = Index.new + expect(new_index.hook_plugins("after-bar")).to eq([plugin_name]) + end + + context "that are not registered", :focused do + let(:file) { double("index-file") } + + before do + index.hook_plugins("not-there") + allow(File).to receive(:open).and_yield(file) + end + + it "should not save it with next registed hook" do + expect(file).to receive(:puts) do |content| + expect(content).not_to include("not-there") + end + + index.register_plugin("aplugin", lib_path("aplugin").to_s, lib_path("aplugin").join("lib").to_s, [], [], []) + end end end describe "global index" do before do Dir.chdir(tmp) do + Bundler::Plugin.reset! path = lib_path("gplugin") - index.register_plugin("gplugin", path.to_s, [path.join("lib").to_s], [], ["glb_source"]) + index.register_plugin("gplugin", path.to_s, [path.join("lib").to_s], [], ["glb_source"], []) end end @@ -94,10 +121,9 @@ end describe "after conflict" do - before do - path = lib_path("aplugin") - index.register_plugin("aplugin", path.to_s, [path.join("lib").to_s], ["foo"], ["bar"]) - end + let(:commands) { ["foo"] } + let(:sources) { ["bar"] } + let(:hooks) { ["hoook"] } shared_examples "it cleans up" do it "the path" do @@ -111,13 +137,17 @@ it "the source" do expect(index.source_plugin("xbar")).to be_falsy end + + it "the hook" do + expect(index.hook_plugins("xhoook")).to be_empty + end end context "on command conflict it cleans up" do before do expect do path = lib_path("cplugin") - index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["foo"], ["xbar"]) + index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["foo"], ["xbar"], ["xhoook"]) end.to raise_error(Index::CommandConflict) end @@ -128,7 +158,7 @@ before do expect do path = lib_path("cplugin") - index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["xfoo"], ["bar"]) + index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["xfoo"], ["bar"], ["xhoook"]) end.to raise_error(Index::SourceConflict) end @@ -139,7 +169,7 @@ before do expect do path = lib_path("cplugin") - index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["foo"], ["bar"]) + index.register_plugin("cplugin", path.to_s, [path.join("lib").to_s], ["foo"], ["bar"], ["xhoook"]) end.to raise_error(Index::CommandConflict) end diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb index f6f10201b31..540278036c3 100644 --- a/spec/bundler/plugin_spec.rb +++ b/spec/bundler/plugin_spec.rb @@ -228,4 +228,65 @@ end end end + + describe "#hook" do + before do + path = lib_path("foo-plugin") + build_lib "foo-plugin", :path => path do |s| + s.write "plugins.rb", code + end + + allow(index).to receive(:hook_plugins).with(event). + and_return(["foo-plugin"]) + allow(index).to receive(:plugin_path).with("foo-plugin").and_return(path) + allow(index).to receive(:load_paths).with("foo-plugin").and_return([]) + end + + let(:code) { <<-RUBY } + Bundler::Plugin::API.hook("event-1") { puts "hook for event 1" } + RUBY + + let(:event) { "event-1" } + + it "executes the hook" do + out = capture(:stdout) do + Plugin.hook("event-1") + end.strip + + expect(out).to eq("hook for event 1") + end + + context "single plugin declaring more than one hook" do + let(:code) { <<-RUBY } + Bundler::Plugin::API.hook("event-1") {} + Bundler::Plugin::API.hook("event-2") {} + puts "loaded" + RUBY + + let(:event) { /event-1|event-2/ } + + it "evals plugins.rb once" do + out = capture(:stdout) do + Plugin.hook("event-1") + Plugin.hook("event-2") + end.strip + + expect(out).to eq("loaded") + end + end + + context "a block is passed" do + let(:code) { <<-RUBY } + Bundler::Plugin::API.hook("#{event}") { |&blk| blk.call } + RUBY + + it "is passed to the hook" do + out = capture(:stdout) do + Plugin.hook("event-1") { puts "win" } + end.strip + + expect(out).to eq("win") + end + end + end end diff --git a/spec/plugins/hook_spec.rb b/spec/plugins/hook_spec.rb new file mode 100644 index 00000000000..bafe688d5ef --- /dev/null +++ b/spec/plugins/hook_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "hook plugins" do + before do + build_repo2 do + build_plugin "before-install-plugin" do |s| + s.write "plugins.rb", <<-RUBY + Bundler::Plugin::API.hook "before-install-all" do |deps| + puts "gems to be installed \#{deps.map(&:name).join(", ")}" + end + RUBY + end + end + + bundle "plugin install before-install-plugin --source file://#{gem_repo2}" + end + + it "runs after a rubygem is installed" do + install_gemfile <<-G + source "file://#{gem_repo1}" + gem "rake" + gem "rack" + G + + expect(out).to include "gems to be installed rake, rack" + end +end