From fefe97c0bd428b8127192cb02b4bb0dc353cf886 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Thu, 14 Sep 2023 12:21:10 -0600 Subject: [PATCH 1/7] Add bundler before/after eval hooks for plugins --- bundler/lib/bundler/definition.rb | 5 ++- bundler/lib/bundler/plugin/events.rb | 12 +++++++ spec/plugins/hook_spec.rb | 51 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 6f5ab29b1556..ae51be9e35c3 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -38,7 +38,10 @@ def self.build(gemfile, lockfile, unlock) raise GemfileNotFound, "#{gemfile} not found" unless gemfile.file? - Dsl.evaluate(gemfile, lockfile, unlock) + Plugin.hook(Plugin::Events::GEM_BEFORE_EVAL, gemfile, lockfile) + Dsl.evaluate(gemfile, lockfile, unlock).tap do |definition| + Plugin.hook(Plugin::Events::GEM_AFTER_EVAL, definition) + end end # diff --git a/bundler/lib/bundler/plugin/events.rb b/bundler/lib/bundler/plugin/events.rb index 29c05098ae14..672cfd7ac7b9 100644 --- a/bundler/lib/bundler/plugin/events.rb +++ b/bundler/lib/bundler/plugin/events.rb @@ -80,6 +80,18 @@ def self.defined_event?(event) # Includes an Array of Bundler::Dependency objects. # GEM_AFTER_REQUIRE_ALL = "after-require-all" define :GEM_AFTER_REQUIRE_ALL, "after-require-all" + + # @!parse + # A hook called before the Gemfile is evaluated + # Includes the Gemfile path and the Lockfile path + # GEM_BEFORE_EVAL = "before-eval" + define :GEM_BEFORE_EVAL, "before-eval" + + # @!parse + # A hook called after the Gemfile is evaluated + # Includes a Bundler::Definition + # GEM_AFTER_EVAL = "after-eval" + define :GEM_AFTER_EVAL, "after-eval" end end end diff --git a/spec/plugins/hook_spec.rb b/spec/plugins/hook_spec.rb index 3f9053bbc83d..63fd70f11c95 100644 --- a/spec/plugins/hook_spec.rb +++ b/spec/plugins/hook_spec.rb @@ -193,6 +193,57 @@ end end + context "before-eval hook" do + before do + build_repo2 do + build_plugin "before-eval-plugin" do |s| + s.write "plugins.rb", <<-RUBY + Bundler::Plugin::API.hook Bundler::Plugin::Events::GEM_BEFORE_EVAL do |gemfile, lockfile| + puts "hooked eval start of \#{File.basename(gemfile)} to \#{File.basename(lockfile)}" + end + RUBY + end + end + + bundle "plugin install before-eval-plugin --source https://gem.repo2" + end + + it "runs before the Gemfile is evaluated" do + install_gemfile <<-G + source "https://gem.repo1" + gem "rake" + G + + expect(out).to include "hooked eval start of Gemfile to Gemfile.lock" + end + end + + context "after-eval hook" do + before do + build_repo2 do + build_plugin "after-eval-plugin" do |s| + s.write "plugins.rb", <<-RUBY + Bundler::Plugin::API.hook Bundler::Plugin::Events::GEM_AFTER_EVAL do |defn| + puts "hooked eval after with gems \#{defn.dependencies.map(&:name).join(", ")}" + end + RUBY + end + end + + bundle "plugin install after-eval-plugin --source https://gem.repo2" + end + + it "runs after the Gemfile is evaluated" do + install_gemfile <<-G + source "https://gem.repo1" + gem "myrack" + gem "rake" + G + + expect(out).to include "hooked eval after with gems myrack, rake" + end + end + def install_gemfile_and_bundler_require install_gemfile <<-G source "https://gem.repo1" From 34c4a46ef2949c0bc4aed19957fe84b58eb13964 Mon Sep 17 00:00:00 2001 From: Marvin Frick Date: Thu, 16 Apr 2026 13:26:56 +0900 Subject: [PATCH 2/7] Add plugin hooks around gem fetch and git source fetch Adds four new hook points: - before-fetch / after-fetch: fires in Source::Rubygems#download_gem around actual network downloads, avoiding noise from cache hits. - before-git-fetch / after-git-fetch: fires in Source::Git#specs around fetch/checkout operations. Based on the original proposal in #8162 with adjustments: - Moved gem fetch hooks from fetch_gem_if_possible to download_gem so they only fire on actual network I/O. - Dropped the source argument since spec.source provides it. - Renamed git hooks to before-git-fetch / after-git-fetch for consistency with the existing before-*/after-* pattern. - Removed GEM_BEFORE_FETCH/GEM_AFTER_FETCH from Source::Git#install since using gem fetch events for git sources is semantically inconsistent. Co-Authored-By: Claude Opus 4.6 (1M context) --- bundler/lib/bundler/plugin/events.rb | 24 ++++++++++++++++++++++++ bundler/lib/bundler/source/git.rb | 2 ++ bundler/lib/bundler/source/rubygems.rb | 2 ++ 3 files changed, 28 insertions(+) diff --git a/bundler/lib/bundler/plugin/events.rb b/bundler/lib/bundler/plugin/events.rb index 672cfd7ac7b9..9db64fd0a842 100644 --- a/bundler/lib/bundler/plugin/events.rb +++ b/bundler/lib/bundler/plugin/events.rb @@ -45,6 +45,30 @@ def self.defined_event?(event) # GEM_AFTER_INSTALL = "after-install" define :GEM_AFTER_INSTALL, "after-install" + # @!parse + # A hook called before each individual gem is downloaded from a remote source. + # Includes a Gem::Specification. Does not fire on cache hits. + # GEM_BEFORE_FETCH = "before-fetch" + define :GEM_BEFORE_FETCH, "before-fetch" + + # @!parse + # A hook called after each individual gem is downloaded from a remote source. + # Includes a Gem::Specification. Does not fire on cache hits. + # GEM_AFTER_FETCH = "after-fetch" + define :GEM_AFTER_FETCH, "after-fetch" + + # @!parse + # A hook called before a git source is fetched. + # Includes a Bundler::Source::Git reference. + # GIT_BEFORE_FETCH = "before-git-fetch" + define :GIT_BEFORE_FETCH, "before-git-fetch" + + # @!parse + # A hook called after a git source is fetched. + # Includes a Bundler::Source::Git reference. + # GIT_AFTER_FETCH = "after-git-fetch" + define :GIT_AFTER_FETCH, "after-git-fetch" + # @!parse # A hook called before any gems install # Includes an Array of Bundler::Dependency objects diff --git a/bundler/lib/bundler/source/git.rb b/bundler/lib/bundler/source/git.rb index bb669ebba39d..77a46964e55c 100644 --- a/bundler/lib/bundler/source/git.rb +++ b/bundler/lib/bundler/source/git.rb @@ -191,8 +191,10 @@ def specs(*) set_cache_path!(app_cache_path) if use_app_cache? if requires_checkout? && !@copied + Plugin.hook(Plugin::Events::GIT_BEFORE_FETCH, self) fetch unless use_app_cache? checkout + Plugin.hook(Plugin::Events::GIT_AFTER_FETCH, self) end local_specs diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index b5c3b9169d16..5a070f2b4e33 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -477,9 +477,11 @@ def download_gem(spec, download_cache_path, previous_spec = nil) Bundler.ui.confirm("Fetching #{version_message(spec, previous_spec)}") gem_remote_fetcher = remote_fetchers.fetch(spec.remote).gem_remote_fetcher + Plugin.hook(Plugin::Events::GEM_BEFORE_FETCH, spec) Gem.time("Downloaded #{spec.name} in", 0, true) do Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) end + Plugin.hook(Plugin::Events::GEM_AFTER_FETCH, spec) end # Returns the global cache path of the calling Rubygems::Source object. From a08ea29297dfcbc84c82733741d7a21a1e844975 Mon Sep 17 00:00:00 2001 From: Marvin Frick Date: Thu, 16 Apr 2026 13:28:11 +0900 Subject: [PATCH 3/7] Add specs for fetch and git fetch plugin hooks Covers the four new hook events added in the previous commit: before-fetch, after-fetch, before-git-fetch, after-git-fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/plugins/hook_spec.rb | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/spec/plugins/hook_spec.rb b/spec/plugins/hook_spec.rb index 63fd70f11c95..ad8a4daeff9c 100644 --- a/spec/plugins/hook_spec.rb +++ b/spec/plugins/hook_spec.rb @@ -244,6 +244,78 @@ end end + context "before-fetch and after-fetch hooks" do + before do + build_repo2 do + build_plugin "fetch-timing-plugin" do |s| + s.write "plugins.rb", <<-RUBY + @timing_start = nil + Bundler::Plugin::API.hook Bundler::Plugin::Events::GEM_BEFORE_FETCH do |spec| + @timing_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + puts "gem \#{spec.name} started fetch at \#{@timing_start}" + end + Bundler::Plugin::API.hook Bundler::Plugin::Events::GEM_AFTER_FETCH do |spec| + timing_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + puts "gem \#{spec.name} took \#{timing_end - @timing_start} to fetch" + @timing_start = nil + end + RUBY + end + end + + bundle "plugin install fetch-timing-plugin --source https://gem.repo2" + end + + it "runs around each gem download" do + install_gemfile <<-G + source "https://gem.repo1" + gem "rake" + gem "myrack" + G + + expect(out).to include "gem rake started fetch at" + expect(out).to match(/gem rake took \d+\.\d+ to fetch/) + expect(out).to include "gem myrack started fetch at" + expect(out).to match(/gem myrack took \d+\.\d+ to fetch/) + end + end + + context "before-git-fetch and after-git-fetch hooks" do + before do + build_repo2 do + build_plugin "git-fetch-timing-plugin" do |s| + s.write "plugins.rb", <<-RUBY + @timing_start = nil + Bundler::Plugin::API.hook Bundler::Plugin::Events::GIT_BEFORE_FETCH do |source| + @timing_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + puts "git source \#{source.name} started fetch at \#{@timing_start}" + end + Bundler::Plugin::API.hook Bundler::Plugin::Events::GIT_AFTER_FETCH do |source| + timing_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + puts "git source \#{source.name} took \#{timing_end - @timing_start} to fetch" + @timing_start = nil + end + RUBY + end + end + + bundle "plugin install git-fetch-timing-plugin --source https://gem.repo2" + end + + it "runs around each git source fetch" do + build_git "foo", "1.0", path: lib_path("foo") + + relative_path = lib_path("foo").relative_path_from(bundled_app) + install_gemfile <<-G, verbose: true + source "https://gem.repo1" + gem "foo", :git => "#{relative_path}" + G + + expect(out).to include "git source foo started fetch at" + expect(out).to match(/git source foo took \d+\.\d+ to fetch/) + end + end + def install_gemfile_and_bundler_require install_gemfile <<-G source "https://gem.repo1" From acc0659ffc650f0a91a32a3b40060f492a09c5ef Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 16 Apr 2026 14:24:39 +0900 Subject: [PATCH 4/7] Reorder plugin event definitions chronologically Arrange events.rb by actual firing order so the file reads as a timeline of bundler's lifecycle: Gemfile eval, install-all bracket (with per-gem fetch, git fetch, and install nested inside), then require-all bracket (with per-gem require nested inside). Also clarify the git fetch hook docstrings: the hook fires around both remote fetch and checkout, not only fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- bundler/lib/bundler/plugin/events.rb | 72 ++++++++++++++-------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/bundler/lib/bundler/plugin/events.rb b/bundler/lib/bundler/plugin/events.rb index 9db64fd0a842..7337ee77b17c 100644 --- a/bundler/lib/bundler/plugin/events.rb +++ b/bundler/lib/bundler/plugin/events.rb @@ -31,19 +31,22 @@ def self.defined_event?(event) end # @!parse - # A hook called before each individual gem is installed - # Includes a Bundler::ParallelInstaller::SpecInstallation. - # No state, error, post_install_message will be present as nothing has installed yet - # GEM_BEFORE_INSTALL = "before-install" - define :GEM_BEFORE_INSTALL, "before-install" + # A hook called before the Gemfile is evaluated + # Includes the Gemfile path and the Lockfile path + # GEM_BEFORE_EVAL = "before-eval" + define :GEM_BEFORE_EVAL, "before-eval" # @!parse - # A hook called after each individual gem is installed - # Includes a Bundler::ParallelInstaller::SpecInstallation. - # - If state is failed, an error will be present. - # - If state is success, a post_install_message may be present. - # GEM_AFTER_INSTALL = "after-install" - define :GEM_AFTER_INSTALL, "after-install" + # A hook called after the Gemfile is evaluated + # Includes a Bundler::Definition + # GEM_AFTER_EVAL = "after-eval" + define :GEM_AFTER_EVAL, "after-eval" + + # @!parse + # A hook called before any gems install + # Includes an Array of Bundler::Dependency objects + # GEM_BEFORE_INSTALL_ALL = "before-install-all" + define :GEM_BEFORE_INSTALL_ALL, "before-install-all" # @!parse # A hook called before each individual gem is downloaded from a remote source. @@ -58,22 +61,31 @@ def self.defined_event?(event) define :GEM_AFTER_FETCH, "after-fetch" # @!parse - # A hook called before a git source is fetched. + # A hook called before a git source is fetched or checked out. # Includes a Bundler::Source::Git reference. # GIT_BEFORE_FETCH = "before-git-fetch" define :GIT_BEFORE_FETCH, "before-git-fetch" # @!parse - # A hook called after a git source is fetched. + # A hook called after a git source is fetched or checked out. # Includes a Bundler::Source::Git reference. # GIT_AFTER_FETCH = "after-git-fetch" define :GIT_AFTER_FETCH, "after-git-fetch" # @!parse - # A hook called before any gems install - # Includes an Array of Bundler::Dependency objects - # GEM_BEFORE_INSTALL_ALL = "before-install-all" - define :GEM_BEFORE_INSTALL_ALL, "before-install-all" + # A hook called before each individual gem is installed + # Includes a Bundler::ParallelInstaller::SpecInstallation. + # No state, error, post_install_message will be present as nothing has installed yet + # GEM_BEFORE_INSTALL = "before-install" + define :GEM_BEFORE_INSTALL, "before-install" + + # @!parse + # A hook called after each individual gem is installed + # Includes a Bundler::ParallelInstaller::SpecInstallation. + # - If state is failed, an error will be present. + # - If state is success, a post_install_message may be present. + # GEM_AFTER_INSTALL = "after-install" + define :GEM_AFTER_INSTALL, "after-install" # @!parse # A hook called after any gems install @@ -81,6 +93,12 @@ def self.defined_event?(event) # GEM_AFTER_INSTALL_ALL = "after-install-all" define :GEM_AFTER_INSTALL_ALL, "after-install-all" + # @!parse + # A hook called before any gems require + # Includes an Array of Bundler::Dependency objects. + # GEM_BEFORE_REQUIRE_ALL = "before-require-all" + define :GEM_BEFORE_REQUIRE_ALL, "before-require-all" + # @!parse # A hook called before each individual gem is required # Includes a Bundler::Dependency. @@ -93,29 +111,11 @@ def self.defined_event?(event) # GEM_AFTER_REQUIRE = "after-require" define :GEM_AFTER_REQUIRE, "after-require" - # @!parse - # A hook called before any gems require - # Includes an Array of Bundler::Dependency objects. - # GEM_BEFORE_REQUIRE_ALL = "before-require-all" - define :GEM_BEFORE_REQUIRE_ALL, "before-require-all" - # @!parse # A hook called after all gems required # Includes an Array of Bundler::Dependency objects. # GEM_AFTER_REQUIRE_ALL = "after-require-all" - define :GEM_AFTER_REQUIRE_ALL, "after-require-all" - - # @!parse - # A hook called before the Gemfile is evaluated - # Includes the Gemfile path and the Lockfile path - # GEM_BEFORE_EVAL = "before-eval" - define :GEM_BEFORE_EVAL, "before-eval" - - # @!parse - # A hook called after the Gemfile is evaluated - # Includes a Bundler::Definition - # GEM_AFTER_EVAL = "after-eval" - define :GEM_AFTER_EVAL, "after-eval" + define :GEM_AFTER_REQUIRE_ALL, "after-require-all" end end end From d33e3eb4a47a6090459fb75194d62bf7a6bbff0e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 16 Apr 2026 14:35:06 +0900 Subject: [PATCH 5/7] Fire after-fetch and after-git-fetch hooks even on failure Wrap the fetch/checkout operations in begin/ensure so the after hook fires even when the underlying fetch raises. This matches the existing GEM_AFTER_INSTALL hook, which fires on both success and failure paths (via internal error-to-state conversion in ParallelInstaller#do_install). Without this, plugins relying on before/after pairs for cleanup or timing would see unbalanced hook invocations whenever a network or checkout error occurs. Co-Authored-By: Claude Opus 4.6 (1M context) --- bundler/lib/bundler/source/git.rb | 9 ++++++--- bundler/lib/bundler/source/rubygems.rb | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bundler/lib/bundler/source/git.rb b/bundler/lib/bundler/source/git.rb index 77a46964e55c..a002a2570a8a 100644 --- a/bundler/lib/bundler/source/git.rb +++ b/bundler/lib/bundler/source/git.rb @@ -192,9 +192,12 @@ def specs(*) if requires_checkout? && !@copied Plugin.hook(Plugin::Events::GIT_BEFORE_FETCH, self) - fetch unless use_app_cache? - checkout - Plugin.hook(Plugin::Events::GIT_AFTER_FETCH, self) + begin + fetch unless use_app_cache? + checkout + ensure + Plugin.hook(Plugin::Events::GIT_AFTER_FETCH, self) + end end local_specs diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index 5a070f2b4e33..7a94c1399afd 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -478,10 +478,13 @@ def download_gem(spec, download_cache_path, previous_spec = nil) gem_remote_fetcher = remote_fetchers.fetch(spec.remote).gem_remote_fetcher Plugin.hook(Plugin::Events::GEM_BEFORE_FETCH, spec) - Gem.time("Downloaded #{spec.name} in", 0, true) do - Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) + begin + Gem.time("Downloaded #{spec.name} in", 0, true) do + Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) + end + ensure + Plugin.hook(Plugin::Events::GEM_AFTER_FETCH, spec) end - Plugin.hook(Plugin::Events::GEM_AFTER_FETCH, spec) end # Returns the global cache path of the calling Rubygems::Source object. From a8c04c4536e07b342b2d4e8c66b0f6f4a2365163 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 16 Apr 2026 16:49:09 +0900 Subject: [PATCH 6/7] Allow File.writable?/File.readable? fallbacks in doctor specs doctor_spec.rb strict-stubs File.writable? and File.readable? with specific paths, which was safe as long as the doctor command did not trigger Plugin.hook. The new before-eval/after-eval hooks fire during Bundler.definition, which the doctor command calls, and Plugin.hook initializes Plugin::Index, which touches Bundler.user_home and calls File.writable? on the test home path. Those calls do not match the stubs and RSpec raises. Match the existing File.exist? pattern and add and_call_original fallbacks so unrelated paths fall through to the real methods, while the specific stubs continue to control the paths under test. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/commands/doctor_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/commands/doctor_spec.rb b/spec/commands/doctor_spec.rb index 5ceaf37f29c0..d350b4b3d10d 100644 --- a/spec/commands/doctor_spec.rb +++ b/spec/commands/doctor_spec.rb @@ -34,6 +34,8 @@ allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) allow(Find).to receive(:find).with(Bundler.bundle_path.to_s) { [unwritable_file] } allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:writable?).and_call_original + allow(File).to receive(:readable?).and_call_original allow(File).to receive(:exist?).with(unwritable_file).and_return(true) allow(File).to receive(:stat).with(unwritable_file) { stat } allow(stat).to receive(:uid) { Process.uid } @@ -108,6 +110,8 @@ allow(Bundler::SharedHelpers).to receive(:find_gemfile).and_return(bundled_app_gemfile) allow(Find).to receive(:find).with(Bundler.bundle_path.to_s) { [@unwritable_file] } allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:writable?).and_call_original + allow(File).to receive(:readable?).and_call_original allow(File).to receive(:exist?).with(@unwritable_file) { true } allow(File).to receive(:stat).with(@unwritable_file) { @stat } end From 93fe617ea8a5c59b0bc941363f0a93be009b1a83 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 16 Apr 2026 16:53:54 +0900 Subject: [PATCH 7/7] Clarify before-fetch/after-fetch hook docstrings The hook receives a Bundler spec proxy (Bundler::EndpointSpecification or Bundler::RemoteSpecification) that responds to the Gem::Specification API but is not itself a Gem::Specification instance. Plugins doing strict is_a? checks would break on the previous wording. Also clarify the cache-hit language: the hook does not fire when the initial download-cache check in fetch_gem finds the .gem already on disk, but Bundler.rubygems.download_gem also has a race-protection early return on the same path, which the previous wording obscured. Co-Authored-By: Claude Opus 4.6 (1M context) --- bundler/lib/bundler/plugin/events.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bundler/lib/bundler/plugin/events.rb b/bundler/lib/bundler/plugin/events.rb index 7337ee77b17c..3fbf60307ec1 100644 --- a/bundler/lib/bundler/plugin/events.rb +++ b/bundler/lib/bundler/plugin/events.rb @@ -50,13 +50,19 @@ def self.defined_event?(event) # @!parse # A hook called before each individual gem is downloaded from a remote source. - # Includes a Gem::Specification. Does not fire on cache hits. + # Includes a spec-like object responding to the Gem::Specification API + # (for example, a Bundler spec proxy such as Bundler::EndpointSpecification + # or Bundler::RemoteSpecification). Does not fire when the gem is already + # present at the initial download-cache check. # GEM_BEFORE_FETCH = "before-fetch" define :GEM_BEFORE_FETCH, "before-fetch" # @!parse # A hook called after each individual gem is downloaded from a remote source. - # Includes a Gem::Specification. Does not fire on cache hits. + # Includes a spec-like object responding to the Gem::Specification API + # (for example, a Bundler spec proxy such as Bundler::EndpointSpecification + # or Bundler::RemoteSpecification). Does not fire when the gem is already + # present at the initial download-cache check. # GEM_AFTER_FETCH = "after-fetch" define :GEM_AFTER_FETCH, "after-fetch"