From 04d53913e4ed602375f1723c0c2b0f33ce65733d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 09:55:14 +0900 Subject: [PATCH 1/3] Install default bundler executables under the same root as its gemspec `gem update --system` wrote the new default bundler gemspec next to the previous one, but extracted its executables under `Gem.default_dir`. On rubies where `Gem.default_specifications_dir` lives under a different root than `Gem.default_dir`, like Homebrew ruby, activating the default bundler then computes an executable path that does not exist, breaking the `bundle` binstub. Extract the executables and remove the previous default gem under the same root as the gemspec instead. https://github.com/ruby/rubygems/issues/9685 Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/setup_command.rb | 22 +++++++++++++++---- .../test_gem_commands_setup_command.rb | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/commands/setup_command.rb b/lib/rubygems/commands/setup_command.rb index ad4aaab384a0..07eb7893a84a 100644 --- a/lib/rubygems/commands/setup_command.rb +++ b/lib/rubygems/commands/setup_command.rb @@ -355,16 +355,30 @@ def install_default_bundler_gem(bin_dir) loaded_from = current_default_spec.loaded_from File.delete(loaded_from) - # Remove previous default gem executables if they were not shadowed by a regular gem - FileUtils.rm_rf current_default_spec.full_gem_path if all_specs_current_version.size == 1 + previous_specs_dir = File.dirname(loaded_from) + + # Remove previous default gem executables if they were not shadowed by a + # regular gem. They live under the same root as the previous default + # gemspec, which is not necessarily default_dir. + if all_specs_current_version.size == 1 + previous_root = File.dirname(File.dirname(previous_specs_dir)) + FileUtils.rm_rf File.join(previous_root, "gems", current_default_spec.full_name) + end - File.dirname(loaded_from) + previous_specs_dir else target_specs_dir = File.join(default_dir, "specifications", "default") mkdir_p target_specs_dir, mode: 0o755 target_specs_dir end + # Root directory that specs_dir belongs to. It may differ from default_dir + # when Gem.default_specifications_dir is customized to live under a + # different root, like Homebrew does. Executables must be extracted under + # the same root as the default gemspec, since that's where activation of + # the default gem will look for them. + bundler_install_dir = File.dirname(File.dirname(specs_dir)) + new_bundler_spec = Gem::Specification.load("bundler.gemspec") full_name = new_bundler_spec.full_name gemspec_path = "#{full_name}.gemspec" @@ -397,7 +411,7 @@ def install_default_bundler_gem(bin_dir) format_executable: options[:format_executable], force: options[:force], bin_dir: bin_dir, - install_dir: default_dir, + install_dir: bundler_install_dir, wrappers: true ) # We need to install only executable and default spec files. diff --git a/test/rubygems/test_gem_commands_setup_command.rb b/test/rubygems/test_gem_commands_setup_command.rb index 7df59ecfc6db..b29a9f59227c 100644 --- a/test/rubygems/test_gem_commands_setup_command.rb +++ b/test/rubygems/test_gem_commands_setup_command.rb @@ -241,17 +241,35 @@ def test_install_default_bundler_gem def test_install_default_bundler_gem_with_default_gems_not_installed_at_default_dir @cmd.extend FileUtils + # Simulate Homebrew's layout, where Gem.default_dir is moved to a + # different root, while Gem.default_specifications_dir stays inside the + # ruby install tree. gemhome2 = File.join(@tempdir, "gemhome2") Gem.instance_variable_set(:@default_dir, gemhome2) FileUtils.mkdir_p gemhome2 bin_dir = File.join(gemhome2, "bin") + previous_default_gem_dir = File.join(@gemhome, "gems", "bundler-1.15.4") + write_file File.join(previous_default_gem_dir, "exe", "bundle") + @cmd.install_default_bundler_gem bin_dir # expect to remove other versions of bundler gemspecs on default specification directory. assert_path_not_exist previous_bundler_specification_path assert_path_exist new_bundler_specification_path + + # expect to remove the previous default version executables, which live + # next to the removed gemspec, not under the new default dir. + assert_path_not_exist previous_default_gem_dir + + # expect executables to be installed under the same root as the default + # gemspec, since that's where activation of the default gem looks for them. + spec = Gem::Specification.load(new_bundler_specification_path) + spec.executables.each do |e| + assert_path_exist File.join(@gemhome, "gems", spec.full_name, spec.bindir, e) + end + assert_path_not_exist File.join(gemhome2, "gems", spec.full_name) end def test_install_default_bundler_gem_with_force_flag From c69d5ce7c4fcfe870cad22338eae31f4b94d38b9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 14:09:52 +0900 Subject: [PATCH 2/3] Stop writing the default bundler spec twice The default gemspec is already written to the default specifications directory before building the gem, so `installer.write_default_spec` just wrote the same file a second time. Drop the redundant call and let the installer only extract and generate the executables. --- lib/rubygems/commands/setup_command.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/commands/setup_command.rb b/lib/rubygems/commands/setup_command.rb index 07eb7893a84a..20a484e3bfd9 100644 --- a/lib/rubygems/commands/setup_command.rb +++ b/lib/rubygems/commands/setup_command.rb @@ -414,11 +414,11 @@ def install_default_bundler_gem(bin_dir) install_dir: bundler_install_dir, wrappers: true ) - # We need to install only executable and default spec files. - # lib/bundler.rb and lib/bundler/* are available under the site_ruby directory. + # We only need to install the executables here. The default spec was + # already written above, and lib/bundler.rb and lib/bundler/* are + # available under the site_ruby directory. installer.extract_bin installer.generate_bin - installer.write_default_spec ensure FileUtils.rm_f built_gem end From 721bcb969164ff2a516c89bf364a06303b911f38 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 14:11:50 +0900 Subject: [PATCH 3/3] Remove the promoted regular bundler gem from the default dir When a regular gem of the vendored bundler version was already installed, its files were removed from the default gemspec's gems directory instead of from default_dir. On rubies where those roots differ, like Homebrew ruby, the regular gem files were left behind after being promoted to the default gem. Remove them from default_dir, where regular gems actually live. --- lib/rubygems/commands/setup_command.rb | 15 ++++++++------- test/rubygems/test_gem_commands_setup_command.rb | 9 +++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/rubygems/commands/setup_command.rb b/lib/rubygems/commands/setup_command.rb index 20a484e3bfd9..e6c3c7d0de2a 100644 --- a/lib/rubygems/commands/setup_command.rb +++ b/lib/rubygems/commands/setup_command.rb @@ -386,19 +386,20 @@ def install_default_bundler_gem(bin_dir) default_spec_path = File.join(specs_dir, gemspec_path) Gem.write_binary(default_spec_path, new_bundler_spec.to_ruby) - bundler_spec = Gem::Specification.load(default_spec_path) - # Remove gemspec that was same version of vendored bundler. normal_gemspec = File.join(default_dir, "specifications", gemspec_path) if File.file? normal_gemspec File.delete normal_gemspec end - # Remove gem files that were same version of vendored bundler. - if File.directory? bundler_spec.gems_dir - Dir.entries(bundler_spec.gems_dir). - select {|default_gem| File.basename(default_gem) == full_name }. - each {|default_gem| rm_r File.join(bundler_spec.gems_dir, default_gem) } + # Remove gem files that were same version of vendored bundler. A regular + # gem lives under default_dir, which is not necessarily the same root as + # the default gemspec. + normal_gems_dir = File.join(default_dir, "gems") + if File.directory? normal_gems_dir + Dir.entries(normal_gems_dir). + select {|normal_gem| File.basename(normal_gem) == full_name }. + each {|normal_gem| rm_r File.join(normal_gems_dir, normal_gem) } end require_relative "../installer" diff --git a/test/rubygems/test_gem_commands_setup_command.rb b/test/rubygems/test_gem_commands_setup_command.rb index b29a9f59227c..ba410d64e635 100644 --- a/test/rubygems/test_gem_commands_setup_command.rb +++ b/test/rubygems/test_gem_commands_setup_command.rb @@ -253,6 +253,12 @@ def test_install_default_bundler_gem_with_default_gems_not_installed_at_default_ previous_default_gem_dir = File.join(@gemhome, "gems", "bundler-1.15.4") write_file File.join(previous_default_gem_dir, "exe", "bundle") + # A regular gem of the same version installed at the default dir, which is + # promoted to the default gem and must be removed. Its files live under + # default_dir, not under the default gemspec root. + normal_gem_dir = File.join(gemhome2, "gems", "bundler-#{bundler_version}") + write_file File.join(normal_gem_dir, "lib", "bundler.rb") + @cmd.install_default_bundler_gem bin_dir # expect to remove other versions of bundler gemspecs on default specification directory. @@ -263,6 +269,9 @@ def test_install_default_bundler_gem_with_default_gems_not_installed_at_default_ # next to the removed gemspec, not under the new default dir. assert_path_not_exist previous_default_gem_dir + # expect to remove the promoted regular gem's files under default_dir. + assert_path_not_exist normal_gem_dir + # expect executables to be installed under the same root as the default # gemspec, since that's where activation of the default gem looks for them. spec = Gem::Specification.load(new_bundler_specification_path)