Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bundler/lib/bundler/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,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

#
Expand Down
68 changes: 55 additions & 13 deletions bundler/lib/bundler/plugin/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,54 @@ def self.defined_event?(event)
@events.key?(event)
end

# @!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"

# @!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.
# 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 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"

# @!parse
# 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 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 each individual gem is installed
# Includes a Bundler::ParallelInstaller::SpecInstallation.
Expand All @@ -45,18 +93,18 @@ def self.defined_event?(event)
# GEM_AFTER_INSTALL = "after-install"
define :GEM_AFTER_INSTALL, "after-install"

# @!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 after any gems install
# Includes an Array of Bundler::Dependency objects
# 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.
Expand All @@ -69,17 +117,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"
define :GEM_AFTER_REQUIRE_ALL, "after-require-all"
end
end
end
9 changes: 7 additions & 2 deletions bundler/lib/bundler/source/git.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,13 @@ def specs(*)
set_cache_path!(app_cache_path) if use_app_cache?

if requires_checkout? && !@copied
fetch unless use_app_cache?
checkout
Plugin.hook(Plugin::Events::GIT_BEFORE_FETCH, self)
begin
fetch unless use_app_cache?
checkout
ensure
Plugin.hook(Plugin::Events::GIT_AFTER_FETCH, self)
end
Comment on lines +194 to +200
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since GIT_AFTER_FETCH is now guaranteed via ensure, it would be good to add a spec that exercises the failure path (e.g., invalid git URI or stubbing fetch/checkout to raise) and asserts the after-git-fetch hook still runs. That prevents regressions where future refactors accidentally remove the ensure semantics.

Copilot uses AI. Check for mistakes.
end

local_specs
Expand Down
9 changes: 7 additions & 2 deletions bundler/lib/bundler/source/rubygems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,13 @@ 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

Gem.time("Downloaded #{spec.name} in", 0, true) do
Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher)
Plugin.hook(Plugin::Events::GEM_BEFORE_FETCH, spec)
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
Comment on lines +480 to 487
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new begin/ensure behavior is intended to guarantee GEM_AFTER_FETCH fires even when the download raises, but there’s no spec covering the error path. Consider adding a plugin hook spec that forces a gem download to fail (e.g., by stubbing Bundler.rubygems.download_gem or using an Artifice endpoint that returns a 404) and asserting the after-fetch hook still ran.

Copilot uses AI. Check for mistakes.
end

Expand Down
4 changes: 4 additions & 0 deletions spec/commands/doctor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions spec/plugins/hook_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,129 @@
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

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"
Expand Down
Loading