From 1385e7712a9d5a37acdfc50b48180f79af5d62d9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 15:58:34 +0900 Subject: [PATCH] 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)