From 417e10d6a1b569b01070d2c31cdf04e2bf21ffa0 Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Thu, 1 Jan 2026 19:21:42 -0500 Subject: [PATCH 1/5] Add global gem cache shared by RubyGems and Bundler Add opt-in support for a global .gem file cache at ~/.cache/gem/gems (respects XDG_CACHE_HOME). This allows sharing cached gems across all Ruby installations and between RubyGems and Bundler. Enable via: - Environment: RUBYGEMS_GLOBAL_GEM_CACHE=true - gemrc: global_gem_cache: true - Bundler: bundle config set global_gem_cache true When enabled, RubyGems checks the global cache before downloading and copies downloaded gems to the cache. Bundler's existing global_gem_cache setting now uses the same unified cache location. --- bundler/lib/bundler/source/rubygems.rb | 2 +- lib/rubygems/config_file.rb | 13 ++++- lib/rubygems/defaults.rb | 9 +++ lib/rubygems/remote_fetcher.rb | 25 ++++++++- test/rubygems/test_gem_config_file.rb | 27 +++++++++ test/rubygems/test_gem_remote_fetcher.rb | 70 ++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 4 deletions(-) diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index e1e030ffc899..1ad1a74fbd46 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -504,7 +504,7 @@ def download_cache_path(spec) return unless remote = spec.remote return unless cache_slug = remote.cache_slug - Bundler.user_cache.join("gems", cache_slug) + Pathname.new(Gem.global_gem_cache_path).join(cache_slug) end def extension_cache_slug(spec) diff --git a/lib/rubygems/config_file.rb b/lib/rubygems/config_file.rb index e58a83f6b75a..f2c90cce3cb7 100644 --- a/lib/rubygems/config_file.rb +++ b/lib/rubygems/config_file.rb @@ -49,6 +49,7 @@ class Gem::ConfigFile DEFAULT_IPV4_FALLBACK_ENABLED = false # TODO: Use false as default value for this option in RubyGems 4.0 DEFAULT_INSTALL_EXTENSION_IN_LIB = true + DEFAULT_GLOBAL_GEM_CACHE = false ## # For Ruby packagers to set configuration defaults. Set in @@ -155,6 +156,12 @@ class Gem::ConfigFile attr_accessor :ipv4_fallback_enabled + ## + # Use a global cache for .gem files shared across all Ruby installations. + # When enabled, gems are cached to ~/.cache/gem/gems (or XDG_CACHE_HOME/gem/gems). + + attr_accessor :global_gem_cache + ## # Path name of directory or file of openssl client certificate, used for remote https connection with client authentication @@ -192,6 +199,7 @@ def initialize(args) @cert_expiration_length_days = DEFAULT_CERT_EXPIRATION_LENGTH_DAYS @install_extension_in_lib = DEFAULT_INSTALL_EXTENSION_IN_LIB @ipv4_fallback_enabled = ENV["IPV4_FALLBACK_ENABLED"] == "true" || DEFAULT_IPV4_FALLBACK_ENABLED + @global_gem_cache = ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] == "true" || DEFAULT_GLOBAL_GEM_CACHE operating_system_config = Marshal.load Marshal.dump(OPERATING_SYSTEM_DEFAULTS) platform_config = Marshal.load Marshal.dump(PLATFORM_DEFAULTS) @@ -213,8 +221,8 @@ def initialize(args) @hash.transform_keys! do |k| # gemhome and gempath are not working with symbol keys if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days - install_extension_in_lib ipv4_fallback_enabled sources disable_default_gem_server - ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k) + install_extension_in_lib ipv4_fallback_enabled global_gem_cache sources + disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k) k.to_sym else k @@ -230,6 +238,7 @@ def initialize(args) @cert_expiration_length_days = @hash[:cert_expiration_length_days] if @hash.key? :cert_expiration_length_days @install_extension_in_lib = @hash[:install_extension_in_lib] if @hash.key? :install_extension_in_lib @ipv4_fallback_enabled = @hash[:ipv4_fallback_enabled] if @hash.key? :ipv4_fallback_enabled + @global_gem_cache = @hash[:global_gem_cache] if @hash.key? :global_gem_cache @home = @hash[:gemhome] if @hash.key? :gemhome @path = @hash[:gempath] if @hash.key? :gempath diff --git a/lib/rubygems/defaults.rb b/lib/rubygems/defaults.rb index 90f09fc19174..2247c49c81ec 100644 --- a/lib/rubygems/defaults.rb +++ b/lib/rubygems/defaults.rb @@ -148,6 +148,15 @@ def self.cache_home @cache_home ||= ENV["XDG_CACHE_HOME"] || File.join(Gem.user_home, ".cache") end + ## + # The path to the global gem cache directory. + # This is used when global_gem_cache is enabled to share .gem files + # across all Ruby installations. + + def self.global_gem_cache_path + File.join(cache_home, "gem", "gems") + end + ## # The path to standard location of the user's data directory. diff --git a/lib/rubygems/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb index 805f7aaf82ed..6eee31f8ced4 100644 --- a/lib/rubygems/remote_fetcher.rb +++ b/lib/rubygems/remote_fetcher.rb @@ -111,6 +111,17 @@ def download_to_cache(dependency) # always replaced. def download(spec, source_uri, install_dir = Gem.dir) + gem_file_name = File.basename spec.cache_file + + # Check global cache first if enabled + if Gem.configuration.global_gem_cache + global_cache_path = File.join(Gem.global_gem_cache_path, gem_file_name) + if File.exist?(global_cache_path) + verbose "Using cached gem #{global_cache_path}" + return global_cache_path + end + end + install_cache_dir = File.join install_dir, "cache" cache_dir = if Dir.pwd == install_dir # see fetch_command @@ -121,7 +132,6 @@ def download(spec, source_uri, install_dir = Gem.dir) File.join Gem.user_dir, "cache" end - gem_file_name = File.basename spec.cache_file local_gem_path = File.join cache_dir, gem_file_name require "fileutils" @@ -196,6 +206,19 @@ def download(spec, source_uri, install_dir = Gem.dir) raise ArgumentError, "unsupported URI scheme #{source_uri.scheme}" end + # Copy to global cache if enabled + if Gem.configuration.global_gem_cache && File.exist?(local_gem_path) + global_cache_path = File.join(Gem.global_gem_cache_path, gem_file_name) + unless File.exist?(global_cache_path) + begin + FileUtils.mkdir_p(Gem.global_gem_cache_path) + FileUtils.cp(local_gem_path, global_cache_path) + rescue SystemCallError + # Ignore errors when copying to global cache (e.g., permission denied) + end + end + end + local_gem_path end diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 4230eda4d399..79bf5f582c94 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -83,6 +83,33 @@ def test_initialize_ipv4_fallback_enabled_env util_config_file %W[--config-file #{@temp_conf}] assert_equal true, @cfg.ipv4_fallback_enabled + ensure + ENV.delete("IPV4_FALLBACK_ENABLED") + end + + def test_initialize_global_gem_cache_default + util_config_file %W[--config-file #{@temp_conf}] + + assert_equal false, @cfg.global_gem_cache + end + + def test_initialize_global_gem_cache_env + ENV["RUBYGEMS_GLOBAL_GEM_CACHE"] = "true" + util_config_file %W[--config-file #{@temp_conf}] + + assert_equal true, @cfg.global_gem_cache + ensure + ENV.delete("RUBYGEMS_GLOBAL_GEM_CACHE") + end + + def test_initialize_global_gem_cache_gemrc + File.open @temp_conf, "w" do |fp| + fp.puts "global_gem_cache: true" + end + + util_config_file %W[--config-file #{@temp_conf}] + + assert_equal true, @cfg.global_gem_cache end def test_initialize_handle_arguments_config_file diff --git a/test/rubygems/test_gem_remote_fetcher.rb b/test/rubygems/test_gem_remote_fetcher.rb index 9badd75b4271..e914e91677f2 100644 --- a/test/rubygems/test_gem_remote_fetcher.rb +++ b/test/rubygems/test_gem_remote_fetcher.rb @@ -575,6 +575,76 @@ def test_yaml_error_on_size end end + def test_download_with_global_gem_cache + # Use a temp directory to safely test global cache behavior + test_cache_dir = File.join(@tempdir, "global_gem_cache_test") + + Gem.stub :global_gem_cache_path, test_cache_dir do + Gem.configuration.global_gem_cache = true + + # Use the real RemoteFetcher with stubbed fetch_path + fetcher = Gem::RemoteFetcher.fetcher + def fetcher.fetch_path(uri, *rest) + File.binread File.join(@test_gem_dir, "a-1.gem") + end + fetcher.instance_variable_set(:@test_gem_dir, File.dirname(@a1_gem)) + + a1_cache_gem = @a1.cache_file + assert_equal a1_cache_gem, fetcher.download(@a1, "http://gems.example.com") + + # Verify gem was also copied to global cache + global_cache_gem = File.join(test_cache_dir, @a1.file_name) + assert File.exist?(global_cache_gem), "Gem should be copied to global cache" + end + ensure + Gem.configuration.global_gem_cache = false + end + + def test_download_uses_global_gem_cache + # Use a temp directory to safely test global cache behavior + test_cache_dir = File.join(@tempdir, "global_gem_cache_test") + + Gem.stub :global_gem_cache_path, test_cache_dir do + Gem.configuration.global_gem_cache = true + + # Pre-populate global cache + FileUtils.mkdir_p test_cache_dir + global_cache_gem = File.join(test_cache_dir, @a1.file_name) + FileUtils.cp @a1_gem, global_cache_gem + + fetcher = Gem::RemoteFetcher.fetcher + + # Should return global cache path without downloading + result = fetcher.download(@a1, "http://gems.example.com") + assert_equal global_cache_gem, result + end + ensure + Gem.configuration.global_gem_cache = false + end + + def test_download_without_global_gem_cache + # Use a temp directory to safely test global cache behavior + test_cache_dir = File.join(@tempdir, "global_gem_cache_test") + + Gem.stub :global_gem_cache_path, test_cache_dir do + Gem.configuration.global_gem_cache = false + + # Use the real RemoteFetcher with stubbed fetch_path + fetcher = Gem::RemoteFetcher.fetcher + def fetcher.fetch_path(uri, *rest) + File.binread File.join(@test_gem_dir, "a-1.gem") + end + fetcher.instance_variable_set(:@test_gem_dir, File.dirname(@a1_gem)) + + a1_cache_gem = @a1.cache_file + assert_equal a1_cache_gem, fetcher.download(@a1, "http://gems.example.com") + + # Verify gem was NOT copied to global cache + global_cache_gem = File.join(test_cache_dir, @a1.file_name) + refute File.exist?(global_cache_gem), "Gem should not be copied to global cache when disabled" + end + end + private def assert_error(exception_class = Exception) From 48ce0ecc32ef00fde86f53d978d954172d9113f8 Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Mon, 5 Jan 2026 13:03:54 -0500 Subject: [PATCH 2/5] Simplify global cache integration in RemoteFetcher#download Instead of checking global cache separately and copying after download, integrate global_gem_cache as the first option in cache_dir selection. This removes ~20 lines while achieving the same behavior. Suggested by Aaron Patterson. --- lib/rubygems/remote_fetcher.rb | 26 +++--------------------- test/rubygems/test_gem_remote_fetcher.rb | 8 +++----- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/lib/rubygems/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb index 6eee31f8ced4..0321600ffb74 100644 --- a/lib/rubygems/remote_fetcher.rb +++ b/lib/rubygems/remote_fetcher.rb @@ -113,18 +113,11 @@ def download_to_cache(dependency) def download(spec, source_uri, install_dir = Gem.dir) gem_file_name = File.basename spec.cache_file - # Check global cache first if enabled - if Gem.configuration.global_gem_cache - global_cache_path = File.join(Gem.global_gem_cache_path, gem_file_name) - if File.exist?(global_cache_path) - verbose "Using cached gem #{global_cache_path}" - return global_cache_path - end - end - install_cache_dir = File.join install_dir, "cache" cache_dir = - if Dir.pwd == install_dir # see fetch_command + if Gem.configuration.global_gem_cache + Gem.global_gem_cache_path + elsif Dir.pwd == install_dir # see fetch_command install_dir elsif File.writable?(install_cache_dir) || (File.writable?(install_dir) && !File.exist?(install_cache_dir)) install_cache_dir @@ -206,19 +199,6 @@ def download(spec, source_uri, install_dir = Gem.dir) raise ArgumentError, "unsupported URI scheme #{source_uri.scheme}" end - # Copy to global cache if enabled - if Gem.configuration.global_gem_cache && File.exist?(local_gem_path) - global_cache_path = File.join(Gem.global_gem_cache_path, gem_file_name) - unless File.exist?(global_cache_path) - begin - FileUtils.mkdir_p(Gem.global_gem_cache_path) - FileUtils.cp(local_gem_path, global_cache_path) - rescue SystemCallError - # Ignore errors when copying to global cache (e.g., permission denied) - end - end - end - local_gem_path end diff --git a/test/rubygems/test_gem_remote_fetcher.rb b/test/rubygems/test_gem_remote_fetcher.rb index e914e91677f2..922819315224 100644 --- a/test/rubygems/test_gem_remote_fetcher.rb +++ b/test/rubygems/test_gem_remote_fetcher.rb @@ -589,12 +589,10 @@ def fetcher.fetch_path(uri, *rest) end fetcher.instance_variable_set(:@test_gem_dir, File.dirname(@a1_gem)) - a1_cache_gem = @a1.cache_file - assert_equal a1_cache_gem, fetcher.download(@a1, "http://gems.example.com") - - # Verify gem was also copied to global cache + # With global cache enabled, gem goes directly to global cache global_cache_gem = File.join(test_cache_dir, @a1.file_name) - assert File.exist?(global_cache_gem), "Gem should be copied to global cache" + assert_equal global_cache_gem, fetcher.download(@a1, "http://gems.example.com") + assert File.exist?(global_cache_gem), "Gem should be in global cache" end ensure Gem.configuration.global_gem_cache = false From a4bb3a20f803aa56493238c791b0caa2f1cf3658 Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Mon, 5 Jan 2026 13:04:14 -0500 Subject: [PATCH 3/5] Add backwards compatibility for Bundler with older RubyGems Guard against missing Gem.global_gem_cache_path method when Bundler is used with an older RubyGems version that doesn't have the global cache feature. Falls back to the previous Bundler.user_cache location. Suggested by skipkayhil. --- bundler/lib/bundler/source/rubygems.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index 1ad1a74fbd46..e679010e97dc 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -504,7 +504,12 @@ def download_cache_path(spec) return unless remote = spec.remote return unless cache_slug = remote.cache_slug - Pathname.new(Gem.global_gem_cache_path).join(cache_slug) + if Gem.respond_to?(:global_gem_cache_path) + Pathname.new(Gem.global_gem_cache_path).join(cache_slug) + else + # Fall back to old location for older RubyGems versions + Bundler.user_cache.join("gems", cache_slug) + end end def extension_cache_slug(spec) From 8f845a72e7714c125117093c41bf52c4171a52db Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Tue, 6 Jan 2026 10:47:41 -0500 Subject: [PATCH 4/5] Update global cache test to use XDG cache path The global gem cache is now at ~/.cache/gem/gems/ (XDG standard) instead of ~/.bundle/cache/gems/ to align with the shared RubyGems/Bundler cache implementation. --- bundler/spec/install/global_cache_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundler/spec/install/global_cache_spec.rb b/bundler/spec/install/global_cache_spec.rb index 0a7daf173c8b..53786361824a 100644 --- a/bundler/spec/install/global_cache_spec.rb +++ b/bundler/spec/install/global_cache_spec.rb @@ -8,7 +8,7 @@ let(:source2) { "http://gemserver.example.org" } def cache_base - home(".bundle", "cache", "gems") + home(".cache", "gem", "gems") end def source_global_cache(*segments) From a6fc1d862be6d9c69580b71266e25624a174a381 Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Wed, 7 Jan 2026 13:04:57 -0500 Subject: [PATCH 5/5] Fix global_cache_spec to work with system RubyGems When running Bundler tests against system RubyGems (which doesn't have Gem.global_gem_cache_path), the test needs to use the fallback cache location (~/.bundle/cache/gems/) that Bundler uses in that case. --- bundler/spec/install/global_cache_spec.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bundler/spec/install/global_cache_spec.rb b/bundler/spec/install/global_cache_spec.rb index 53786361824a..77ab94609ee5 100644 --- a/bundler/spec/install/global_cache_spec.rb +++ b/bundler/spec/install/global_cache_spec.rb @@ -8,7 +8,13 @@ let(:source2) { "http://gemserver.example.org" } def cache_base - home(".cache", "gem", "gems") + # Use the unified global gem cache path if available (from RubyGems), + # otherwise fall back to the Bundler-specific cache location + if Gem.respond_to?(:global_gem_cache_path) + Pathname.new(Gem.global_gem_cache_path) + else + home(".bundle", "cache", "gems") + end end def source_global_cache(*segments)