summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnthony Panozzo <panozzaj@gmail.com>2026-01-01 19:21:42 -0500
committergit <svn-admin@ruby-lang.org>2026-02-12 17:06:36 +0000
commit2101c1bf091c30379daf96d4426a59e84002cccf (patch)
tree1ca4bb8e2e70a2cab45e3d973050a6f799867382
parentfd9dd36dfc38fd818dba0558bacb7c2df9bcdb79 (diff)
[ruby/rubygems] 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. https://github.com/ruby/rubygems/commit/417e10d6a1
-rw-r--r--lib/bundler/source/rubygems.rb2
-rw-r--r--lib/rubygems/config_file.rb13
-rw-r--r--lib/rubygems/defaults.rb9
-rw-r--r--lib/rubygems/remote_fetcher.rb25
-rw-r--r--test/rubygems/test_gem_config_file.rb27
-rw-r--r--test/rubygems/test_gem_remote_fetcher.rb70
6 files changed, 142 insertions, 4 deletions
diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb
index e1e030ffc8..1ad1a74fbd 100644
--- a/lib/bundler/source/rubygems.rb
+++ b/lib/bundler/source/rubygems.rb
@@ -504,7 +504,7 @@ module Bundler
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 e58a83f6b7..f2c90cce3c 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
@@ -156,6 +157,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
attr_reader :ssl_client_cert
@@ -192,6 +199,7 @@ class Gem::ConfigFile
@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 @@ class Gem::ConfigFile
@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 @@ class Gem::ConfigFile
@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 90f09fc191..2247c49c81 100644
--- a/lib/rubygems/defaults.rb
+++ b/lib/rubygems/defaults.rb
@@ -149,6 +149,15 @@ module Gem
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.
def self.data_home
diff --git a/lib/rubygems/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb
index 151c6fd4d8..0c63f075f5 100644
--- a/lib/rubygems/remote_fetcher.rb
+++ b/lib/rubygems/remote_fetcher.rb
@@ -111,6 +111,17 @@ class Gem::RemoteFetcher
# 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 @@ class Gem::RemoteFetcher
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 @@ class Gem::RemoteFetcher
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 4230eda4d3..79bf5f582c 100644
--- a/test/rubygems/test_gem_config_file.rb
+++ b/test/rubygems/test_gem_config_file.rb
@@ -83,6 +83,33 @@ class TestGemConfigFile < Gem::TestCase
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 9badd75b42..e914e91677 100644
--- a/test/rubygems/test_gem_remote_fetcher.rb
+++ b/test/rubygems/test_gem_remote_fetcher.rb
@@ -575,6 +575,76 @@ class TestGemRemoteFetcher < Gem::TestCase
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)