Skip to content

Commit 4493ec3

Browse files
committed
Make prefer-local work well
lib/bootstrap/bundler.rb: 1. Added jruby_bundled_specs method to Source::Rubygems that returns specs from JRuby's specifications/ directory 2. Modified specs method to merge jruby_bundled_specs with higher precedence when prefer_local is true 3. Updated install patch to skip installation for gems that already exist in JRuby's bundled gems directory 4. Track @jruby_bundled_specs_dir alongside @jruby_default_specs_dir lib/pluginmanager/command.rb: 1. Added explicit filter to not touch JRuby's bundled gems in remove_orphan_dependencies! The behavior now: - JRuby bundled gems are preferred over remote versions - If a version constraint requires a newer version, it will be fetched from remote - No unnecessary copying of already-available gems to vendor/bundle
1 parent d02fb9f commit 4493ec3

File tree

5 files changed

+296
-13
lines changed

5 files changed

+296
-13
lines changed

lib/bootstrap/bundler.rb

Lines changed: 282 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ module Bundler
2020
extend self
2121

2222
def patch!
23+
24+
return if @bundler_patched
25+
@bundler_patched = true
26+
2327
# Patch to prevent Bundler to save a .bundle/config file in the root
2428
# of the application
2529
::Bundler::Settings.module_exec do
@@ -40,15 +44,15 @@ def self.reset_paths!
4044
end
4145
end
4246

43-
# When preparing offline packs or generally when installing gems, bundler wants to have `.gem` files
47+
# When preparing offline packs or generally when installing gems, bundler wants to have `.gem` files
4448
# cached. We ship a default set of gems that inclue all of the unpacked code. During dependency
45-
# resolution bundler still wants to ensure`.gem` files exist. This patch updates two paths in bundler where
49+
# resolution bundler still wants to ensure`.gem` files exist. This patch updates two paths in bundler where
4650
# it natively it would *fail* when a `.gem` file is not found. Instead of failing we force the cache to be
4751
# updated with a `.gem` file. This preserves the original patch behavior. There is still an open question of
48-
# *how* to potentially update the files we vendor or the way we set up bundler to avoid carrying this patch.
52+
# *how* to potentially update the files we vendor or the way we set up bundler to avoid carrying this patch.
4953
# As of JRuby 9.4.13.0 rubygems (bundler) is at 3.6.3. There have been some releases and changes in bundler code
5054
# since then but it does not seem to have changed the way it handles gem files. Obviously carrying a patch like this
51-
# carries a maintenance burden so prioritizing a packaging solution may be
55+
# carries a maintenance burden so prioritizing a packaging solution may be
5256
::Bundler::Source::Rubygems.module_exec do
5357
def fetch_gem_if_possible(spec, previous_spec = nil)
5458
path = if spec.remote
@@ -75,18 +79,278 @@ def cache(spec, custom_path = nil)
7579
raise InstallError, e.message
7680
end
7781
end
82+
83+
#
84+
# BACKPORT: Fix `--prefer-local` flag (from rubygems/rubygems commits 607a3bf479, 209b93a, 23047a0)
85+
#
86+
# The original implementation of --prefer-local was too naive:
87+
# 1. It didn't pass prefer_local to Package objects
88+
# 2. It returned empty array when no local specs exist (instead of falling back to remote)
89+
# 3. It didn't properly handle default gems
90+
#
91+
# These patches fix:
92+
# - PR #7951: Fix `--prefer-local` flag (propagate to packages, add fallback logic)
93+
# - PR #8412: Fix `--prefer-local` not respecting default gems
94+
# - PR #8484: Fix `bundle install --prefer-local` sometimes installing very old versions
95+
#
96+
97+
# Patch Source base class to add prefer_local! method
98+
::Bundler::Source.class_eval do
99+
def prefer_local!
100+
# Base implementation - does nothing, subclasses override
101+
end
102+
end
103+
104+
# Patch Source::Rubygems to track prefer_local state and handle default_specs properly
105+
# Also add support for JRuby bundled gems (gems in vendor/jruby/.../specifications/)
106+
::Bundler::Source::Rubygems.class_eval do
107+
# Add prefer_local! method
108+
def prefer_local!
109+
@prefer_local = true
110+
end
111+
112+
# Return specs from JRuby's bundled gem directory (specifications/, not specifications/default/)
113+
# These are gems that ship with JRuby but aren't "default gems" in the Ruby sense
114+
def jruby_bundled_specs
115+
@jruby_bundled_specs ||= begin
116+
idx = ::Bundler::Index.new
117+
jruby_specs_dir = LogStash::Bundler.instance_variable_get(:@jruby_bundled_specs_dir)
118+
jruby_default_specs_dir = LogStash::Bundler.instance_variable_get(:@jruby_default_specs_dir)
119+
120+
if jruby_specs_dir && ::File.directory?(jruby_specs_dir)
121+
# Get gemspecs from specifications/ but NOT from specifications/default/
122+
::Dir[::File.join(jruby_specs_dir, "*.gemspec")].each do |path|
123+
# Skip if this is actually in the default directory
124+
next if jruby_default_specs_dir && path.start_with?(jruby_default_specs_dir)
125+
126+
stub = ::Gem::StubSpecification.gemspec_stub(path, jruby_specs_dir, jruby_specs_dir)
127+
# Create a Bundler::StubSpecification from the Gem::StubSpecification
128+
bundler_spec = ::Bundler::StubSpecification.from_stub(stub)
129+
# Set source to self (the Source::Rubygems instance) - required for materialization
130+
bundler_spec.source = self
131+
idx << bundler_spec
132+
end
133+
end
134+
idx
135+
end
136+
end
137+
138+
# Override specs method to handle prefer_local for default_specs AND jruby_bundled_specs
139+
alias_method :original_specs, :specs
140+
141+
def specs
142+
@specs ||= begin
143+
# remote_specs usually generates a way larger Index than the other
144+
# sources, and large_idx.merge! small_idx is way faster than
145+
# small_idx.merge! large_idx.
146+
index = @allow_remote ? remote_specs.dup : ::Bundler::Index.new
147+
index.merge!(cached_specs) if @allow_cached
148+
index.merge!(installed_specs) if @allow_local
149+
150+
if @allow_local
151+
if @prefer_local
152+
# With prefer_local, merge jruby_bundled_specs and default_specs so they take precedence
153+
# over remote/cached/installed specs. This ensures JRuby's bundled gems are preferred.
154+
index.merge!(jruby_bundled_specs)
155+
index.merge!(default_specs)
156+
else
157+
# complete with default specs, only if not already available in the
158+
# index through remote, cached, or installed specs
159+
index.use(jruby_bundled_specs)
160+
index.use(default_specs)
161+
end
162+
end
163+
164+
index
165+
end
166+
end
167+
end
168+
169+
# Patch SourceList to propagate prefer_local! to all sources
170+
::Bundler::SourceList.class_eval do
171+
def prefer_local!
172+
all_sources.each(&:prefer_local!)
173+
end
174+
end
175+
176+
# Patch Definition to call sources.prefer_local! when prefer_local! is called
177+
::Bundler::Definition.class_eval do
178+
alias_method :original_prefer_local!, :prefer_local!
179+
180+
def prefer_local!
181+
@prefer_local = true
182+
sources.prefer_local!
183+
end
184+
end
185+
186+
# Patch Package to add prefer_local support
187+
::Bundler::Resolver::Package.class_eval do
188+
def prefer_local?
189+
@prefer_local
190+
end
191+
192+
def consider_remote_versions!
193+
@prefer_local = false
194+
end
195+
end
196+
197+
# Patch Resolver::Base to propagate prefer_local to packages and add include_remote_specs
198+
::Bundler::Resolver::Base.class_eval do
199+
alias_method :original_base_initialize, :initialize
200+
201+
def initialize(source_requirements, dependencies, base, platforms, options)
202+
@prefer_local_option = options[:prefer_local]
203+
original_base_initialize(source_requirements, dependencies, base, platforms, options)
204+
end
205+
206+
alias_method :original_get_package, :get_package
207+
208+
def get_package(name)
209+
package = original_get_package(name)
210+
# Inject prefer_local into packages since older Bundler doesn't pass it through
211+
if @prefer_local_option && !package.instance_variable_get(:@prefer_local)
212+
package.instance_variable_set(:@prefer_local, true)
213+
end
214+
package
215+
end
216+
217+
def include_remote_specs(names)
218+
names.each do |name|
219+
get_package(name).consider_remote_versions!
220+
end
221+
end
222+
end
223+
224+
# Patch Resolver to fix filter_remote_specs with proper fallback
225+
::Bundler::Resolver.class_eval do
226+
# Override filter_remote_specs with the fixed version from Bundler 2.7+
227+
# This fixes the issue where --prefer-local would return empty specs
228+
# when no local gems are installed, instead of falling back to remote
229+
def filter_remote_specs(specs, package)
230+
if package.prefer_local?
231+
local_specs = specs.select {|s| s.is_a?(::Bundler::StubSpecification) }
232+
233+
if local_specs.empty?
234+
# BACKPORT FIX: If no local specs exist, fall back to remote specs
235+
# instead of returning empty array
236+
package.consider_remote_versions!
237+
specs
238+
else
239+
local_specs
240+
end
241+
else
242+
specs
243+
end
244+
end
245+
end
246+
247+
# Patch Source::Rubygems#install to skip installation for default gems and JRuby bundled gems
248+
# The original condition `spec.default_gem? && !cached_built_in_gem(...)` has a side effect:
249+
# cached_built_in_gem fetches from remote if not in cache. For default gems and JRuby bundled gems,
250+
# we should skip installation entirely without needing a cached .gem file.
251+
::Bundler::Source::Rubygems.class_eval do
252+
alias_method :original_rubygems_install, :install
253+
254+
def install(spec, options = {})
255+
# For default gems, skip installation entirely - they're already available
256+
if spec.default_gem?
257+
print_using_message "Using #{version_message(spec, options[:previous_spec])}"
258+
return nil
259+
end
260+
261+
# For JRuby bundled gems, also skip installation - they're already available
262+
# Check if this exact gem (name + version) exists in JRuby's bundled gems
263+
jruby_specs_dir = LogStash::Bundler.instance_variable_get(:@jruby_bundled_specs_dir)
264+
if jruby_specs_dir && ::File.directory?(jruby_specs_dir)
265+
jruby_gemspec_path = ::File.join(jruby_specs_dir, "#{spec.name}-#{spec.version}.gemspec")
266+
if ::File.exist?(jruby_gemspec_path)
267+
print_using_message "Using #{version_message(spec, options[:previous_spec])}"
268+
return nil
269+
end
270+
end
271+
272+
original_rubygems_install(spec, options)
273+
end
274+
end
78275
end
79276

80277

278+
# Capture JRuby's default gem directory before paths are changed
279+
# This is needed so that default gems (like json) can be found with --prefer-local
280+
def preserve_jruby_default_gems_path
281+
return @jruby_default_gem_dir if defined?(@jruby_default_gem_dir)
282+
283+
# The Gradle/JRuby setup already changes Gem.default_dir to a temp path before
284+
# this code runs, so we need to construct the actual JRuby path from LOGSTASH_HOME
285+
logstash_home = ENV["LOGSTASH_HOME"] || ::File.expand_path("../../..", __FILE__)
286+
jruby_gems_dir = ::File.join(logstash_home, "vendor", "jruby", "lib", "ruby", "gems", "shared")
287+
jruby_default_specs = ::File.join(jruby_gems_dir, "specifications", "default")
288+
jruby_bundled_specs = ::File.join(jruby_gems_dir, "specifications")
289+
290+
if ::File.directory?(jruby_default_specs)
291+
@jruby_default_gem_dir = jruby_gems_dir
292+
@jruby_default_specs_dir = jruby_default_specs
293+
@jruby_bundled_specs_dir = jruby_bundled_specs
294+
else
295+
# Fall back to Gem.default_dir if vendor/jruby doesn't exist
296+
@jruby_default_gem_dir = ::Gem.default_dir
297+
@jruby_default_specs_dir = ::Gem.default_specifications_dir
298+
@jruby_bundled_specs_dir = nil
299+
end
300+
301+
@jruby_default_gem_dir
302+
end
303+
304+
# Patch Gem::Specification.default_stubs to also look in JRuby's original default specs directory
305+
# This is needed because Gem.default_specifications_dir only returns a single path,
306+
# and after Gem.paths = ENV it points to Logstash's gem home, not JRuby's installation
307+
def patch_default_stubs!
308+
return if @default_stubs_patched || !defined?(@jruby_default_specs_dir) || @jruby_default_specs_dir.nil?
309+
@default_stubs_patched = true
310+
311+
jruby_specs_dir = @jruby_default_specs_dir
312+
313+
::Gem::Specification.singleton_class.class_eval do
314+
alias_method :original_default_stubs, :default_stubs
315+
316+
define_method(:default_stubs) do |pattern = "*.gemspec"|
317+
# Get stubs from the current default_specifications_dir
318+
stubs = original_default_stubs(pattern)
319+
320+
# Also look in JRuby's original default specs directory if it exists and is different
321+
if jruby_specs_dir && ::File.directory?(jruby_specs_dir) && jruby_specs_dir != ::Gem.default_specifications_dir
322+
base_dir = jruby_specs_dir
323+
::Dir[::File.join(base_dir, pattern)].each do |path|
324+
# Use default_gemspec_stub to mark these as default gems (default_gem = true)
325+
stub = ::Gem::StubSpecification.default_gemspec_stub(path, base_dir, base_dir)
326+
stubs << stub unless stubs.any? { |s| s.name == stub.name && s.version == stub.version }
327+
end
328+
end
329+
330+
stubs
331+
end
332+
end
333+
end
334+
81335
# prepare bundler's environment variables, but do not invoke ::Bundler::setup
82336
def prepare(options = {})
83337
options = {:without => [:development]}.merge(options)
84338
options[:without] = Array(options[:without])
85339

340+
# Capture JRuby default gems path BEFORE clearing
341+
jruby_gem_dir = preserve_jruby_default_gems_path
342+
86343
::Gem.clear_paths
87-
ENV['GEM_HOME'] = ENV['GEM_PATH'] = Environment.logstash_gem_home
344+
# Include both Logstash gem home AND JRuby's default gem directory in GEM_PATH
345+
# This ensures default gems can be discovered by Gem::Specification.default_stubs
346+
gem_path = [Environment.logstash_gem_home, jruby_gem_dir].compact.uniq.join(::File::PATH_SEPARATOR)
347+
ENV['GEM_HOME'] = Environment.logstash_gem_home
348+
ENV['GEM_PATH'] = gem_path
88349
::Gem.paths = ENV
89350

351+
# Patch default_stubs to also look in JRuby's original location
352+
patch_default_stubs!
353+
90354
# set BUNDLE_GEMFILE ENV before requiring bundler to avoid bundler recurse and load unrelated Gemfile(s)
91355
ENV["BUNDLE_GEMFILE"] = Environment::GEMFILE_PATH
92356

@@ -127,9 +391,21 @@ def invoke!(options = {})
127391
:jobs => 12, :all => false, :package => false, :without => [:development]}.merge(options)
128392
options[:without] = Array(options[:without])
129393
options[:update] = Array(options[:update]) if options[:update]
394+
395+
# Capture JRuby default gems path BEFORE clearing
396+
jruby_gem_dir = preserve_jruby_default_gems_path
397+
130398
::Gem.clear_paths
131-
ENV['GEM_HOME'] = ENV['GEM_PATH'] = LogStash::Environment.logstash_gem_home
399+
# Include both Logstash gem home AND JRuby's default gem directory in GEM_PATH
400+
# This ensures default gems can be discovered by Gem::Specification.default_stubs
401+
gem_path = [LogStash::Environment.logstash_gem_home, jruby_gem_dir].compact.uniq.join(::File::PATH_SEPARATOR)
402+
ENV['GEM_HOME'] = LogStash::Environment.logstash_gem_home
403+
ENV['GEM_PATH'] = gem_path
132404
::Gem.paths = ENV
405+
406+
# Patch default_stubs to also look in JRuby's original location
407+
patch_default_stubs!
408+
133409
# set BUNDLE_GEMFILE ENV before requiring bundler to avoid bundler recurse and load unrelated Gemfile(s).
134410
# in the context of calling Bundler::CLI this is not really required since Bundler::CLI will look at
135411
# Bundler.settings[:gemfile] unlike Bundler.setup. For the sake of consistency and defensive/future proofing, let's keep it here.

lib/pluginmanager/bundler/logstash_uninstall.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def initialize(gemfile_path, lockfile_path)
3535
end
3636

3737
def uninstall!(gems_to_remove)
38+
3839
gems_to_remove = Array(gems_to_remove)
3940

4041
unsatisfied_dependency_mapping = Dsl.evaluate(gemfile_path, lockfile_path, {}).specs.each_with_object({}) do |spec, memo|

lib/pluginmanager/command.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ def remove_unused_locally_installed_gems!
5050

5151
def remove_orphan_dependencies!
5252
locked_gem_names = ::Bundler::LockfileParser.new(File.read(LogStash::Environment::LOCKFILE)).specs.map(&:full_name).to_set
53+
bundle_path = LogStash::Environment::BUNDLE_DIR
54+
# JRuby bundled gems path - never touch these
55+
jruby_gems_path = File.join(LogStash::Environment::LOGSTASH_HOME, "vendor", "jruby", "lib", "ruby", "gems")
5356
orphan_gem_specs = ::Gem::Specification.each
5457
.reject(&:stubbed?) # skipped stubbed (uninstalled) gems
5558
.reject(&:default_gem?) # don't touch jruby-included default gems
59+
.reject { |spec| spec.full_gem_path.start_with?(jruby_gems_path) } # don't touch jruby bundled gems
60+
.select { |spec| spec.full_gem_path.start_with?(bundle_path) } # only gems in bundle path
5661
.reject{ |spec| locked_gem_names.include?(spec.full_name) }
5762
.sort
5863

rakelib/plugin.rake

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ namespace "plugin" do
9090
task.reenable # Allow this task to be run again
9191
end # task "install"
9292

93-
9493
task "clean-duplicate-gems" do
9594
shared_gems_path = File.join(LogStash::Environment::LOGSTASH_HOME,
9695
'vendor/jruby/lib/ruby/gems/shared/gems')
@@ -133,14 +132,15 @@ namespace "plugin" do
133132

134133
# Remove default gem gemspecs only
135134
default_duplicates.each do |gem_name|
136-
# For stdlib default gems we only remove the gemspecs as removing the source code
135+
# For stdlib default gems we only remove the gemspecs as removing the source code
137136
# files results in code loading errors and ruby warnings
138137
FileUtils.rm_rf(Dir.glob("#{default_gemspecs_path}/#{gem_name}-*.gemspec"))
139138
end
140-
139+
141140
task.reenable
142141
end
143142

143+
144144
task "install-default" => "bootstrap" do
145145
puts("[plugin:install-default] Installing default plugins")
146146

0 commit comments

Comments
 (0)