From 1385e7712a9d5a37acdfc50b48180f79af5d62d9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 15:58:34 +0900 Subject: [PATCH 1/2] Cache a shared git source only once per command When multiple gems come from the same git block, Runtime#cache invokes the shared Source::Git once per gem, and cache_to re-cloned and re-checked out the whole worktree every time. For large repositories this made bundle cache/update/install significantly slower. Memoize the written app cache path and skip the copy when it was already populated during the same command. https://github.com/ruby/rubygems/issues/8592 Co-Authored-By: Claude Fable 5 --- lib/bundler/source/git.rb | 8 ++++++++ spec/bundler/source/git_spec.rb | 28 ++++++++++++++++++++++++++++ spec/cache/git_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index a002a2570a8a..4911fb079700 100644 --- a/lib/bundler/source/git.rb +++ b/lib/bundler/source/git.rb @@ -30,6 +30,7 @@ def initialize(options) @copied = false @local = false + @cached_app_cache_path = nil end def remote! @@ -277,6 +278,11 @@ def cache_to(custom_path, try_migrate: false) app_cache_path = app_cache_path(custom_path) + # When several gems share a single git source, this is called once per + # gem. Copying the repository is expensive for large repos, so skip it + # if we already populated this cache during the same command. + return if @cached_app_cache_path == app_cache_path + migrate = try_migrate ? bare_repo?(app_cache_path) : false set_cache_path!(nil) if migrate @@ -288,6 +294,8 @@ def cache_to(custom_path, try_migrate: false) git_proxy.checkout if migrate || requires_checkout? git_proxy.copy_to(app_cache_path, @submodules) serialize_gemspecs_in(app_cache_path) + + @cached_app_cache_path = app_cache_path end def checkout diff --git a/spec/bundler/source/git_spec.rb b/spec/bundler/source/git_spec.rb index 14e91c6bdce2..5f7fb0596a06 100644 --- a/spec/bundler/source/git_spec.rb +++ b/spec/bundler/source/git_spec.rb @@ -120,4 +120,32 @@ end end end + + describe "#cache" do + let(:options) do + { "uri" => uri, "revision" => "123abc" } + end + let(:app_cache_path) { Pathname.new("vendor/cache/bar-123abc") } + let(:git_proxy_stub) do + instance_double(Bundler::Source::Git::GitProxy, revision: "123abc", copy_to: nil) + end + + before do + allow(Bundler::Source::Git::GitProxy).to receive(:new).and_return(git_proxy_stub) + allow(Bundler.settings).to receive(:[]).and_call_original + allow(Bundler.settings).to receive(:[]).with(:cache_all).and_return(true) + allow(subject).to receive(:app_cache_path).and_return(app_cache_path) + allow(subject).to receive(:cache_path).and_return(Pathname.new("global/git/bar-123abc")) + allow(subject).to receive(:requires_checkout?).and_return(false) + allow(subject).to receive(:serialize_gemspecs_in) + allow(FileUtils).to receive(:rm_rf) + end + + it "copies the repository only once when several gems share the same source" do + subject.cache(double("spec for gem a")) + subject.cache(double("spec for gem b")) + + expect(git_proxy_stub).to have_received(:copy_to).once + end + end end diff --git a/spec/cache/git_spec.rb b/spec/cache/git_spec.rb index f0976ecac7c9..5e51d4e5e6cb 100644 --- a/spec/cache/git_spec.rb +++ b/spec/cache/git_spec.rb @@ -83,6 +83,29 @@ expect(the_bundle).to include_gems "foo 1.0" end + it "caches a repository shared by multiple gems only once" do + build_lib "foo", path: lib_path("shared/foo") + build_lib "bar", path: lib_path("shared/bar") + build_git "foo", path: lib_path("shared") + git = build_git "bar", path: lib_path("shared") + ref = git.ref_for("main", 11) + + install_gemfile <<-G + source "https://gem.repo1" + git "#{lib_path("shared")}" do + gem "foo" + gem "bar" + end + G + + bundle :cache + + expect(bundled_app("vendor/cache/shared-#{ref}")).to exist + + FileUtils.rm_r lib_path("shared") + expect(the_bundle).to include_gems "foo 1.0", "bar 1.0" + end + it "tracks updates" do git = build_git "foo" old_ref = git.ref_for("main", 11) From 85208c7b7d0be88ef5d3d28f72212d8fd18fed2f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 21 Jul 2026 14:07:00 +0900 Subject: [PATCH 2/2] Rename the shared git source cache example The name claimed the repository is copied only once, but the example just checks that the cache is usable, and it passes either way because the copy is idempotent. The copy count is asserted in the unit spec, which can observe copy_to in-process. Co-Authored-By: Claude Fable 5 --- spec/cache/git_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/cache/git_spec.rb b/spec/cache/git_spec.rb index 5e51d4e5e6cb..7111f9ef8009 100644 --- a/spec/cache/git_spec.rb +++ b/spec/cache/git_spec.rb @@ -83,7 +83,9 @@ expect(the_bundle).to include_gems "foo 1.0" end - it "caches a repository shared by multiple gems only once" do + # The copy count is asserted in spec/bundler/source/git_spec.rb, which can + # observe `copy_to` in-process. + it "caches a repository shared by multiple gems" do build_lib "foo", path: lib_path("shared/foo") build_lib "bar", path: lib_path("shared/bar") build_git "foo", path: lib_path("shared")