Skip to content

Commit 231ecdf

Browse files
hsbtclaude
andcommitted
Let override drop or replace one dependency of a gem
Some gems depend on a gem that only one of their features loads, or on a gem that an API-compatible one can stand in for. `override "B", from: "A", to: nil` drops the dependency of A on B, and `to: "C"` makes A depend on C instead. The lockfile keeps every dependency as published, the same way `version:` overrides leave it, and the completeness checks, the installer and the source lookup apply the override instead. #9517 (comment) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 83d5924 commit 231ecdf

14 files changed

Lines changed: 585 additions & 24 deletions

‎lib/bundler/definition.rb‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@ def dependencies_for(groups)
346346
#
347347
# @return [SpecSet] resolved dependencies
348348
def resolve
349-
@resolve ||= if Bundler.frozen_bundle?
349+
return @resolve if @resolve
350+
351+
@resolve = if Bundler.frozen_bundle?
350352
Bundler.ui.debug "Frozen, using resolution from the lockfile"
351353
@locked_specs
352354
elsif no_resolve_needed?
@@ -366,6 +368,8 @@ def resolve
366368

367369
start_resolution
368370
end
371+
warn_inert_overrides(@resolve)
372+
@resolve
369373
end
370374

371375
def spec_git_paths
@@ -1109,6 +1113,16 @@ def converge_dependencies
11091113
@changed_dependencies.any?
11101114
end
11111115

1116+
# A misspelled `from:` gem leaves the override with nothing to rewrite.
1117+
def warn_inert_overrides(specs)
1118+
@overrides.each do |override|
1119+
next unless override.from
1120+
next if specs[override.from].any? {|s| s.dependencies.any? {|d| d.name == override.target } }
1121+
1122+
Bundler.ui.warn "#{override} has no effect because the bundle has no #{override.from} that depends on #{override.target}"
1123+
end
1124+
end
1125+
11121126
def converge_overrides_outside_dependencies
11131127
@overrides.each do |override|
11141128
# :all and metadata overrides are intentionally not pre-unlocked. The
@@ -1356,7 +1370,7 @@ def remove_invalid_platforms!
13561370
end
13571371

13581372
def source_map
1359-
@source_map ||= SourceMap.new(sources, dependencies, @locked_specs)
1373+
@source_map ||= SourceMap.new(sources, dependencies, @locked_specs, @overrides.filter_map {|o| o.operation if o.from })
13601374
end
13611375

13621376
def new_resolver_for_full_update

‎lib/bundler/dsl.rb‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ def github(repo, options = {})
200200

201201
def override(target, **operations)
202202
validate_override_target!(target)
203+
source_location = caller_locations(1, 1)&.first
204+
205+
if operations.key?(:from) || operations.key?(:to)
206+
add_dependency_override(target, operations, source_location)
207+
return
208+
end
203209

204210
if target == :all && operations.key?(:version)
205211
raise ArgumentError, "`override :all, version:` is not allowed; version requirements are per-gem"
@@ -211,14 +217,14 @@ def override(target, **operations)
211217
validate_override_uniqueness!(target, field)
212218
end
213219

214-
source_location = caller_locations(1, 1)&.first
215220
operations.each do |field, operation|
216221
@overrides << Override.new(target, field, operation, source_location: source_location)
217222
end
218223
end
219224

220225
def to_definition(lockfile, unlock)
221226
check_primary_source_safety
227+
check_dropped_dependencies
222228
lockfile = @lockfile unless @lockfile.nil?
223229
Definition.new(lockfile, @dependencies, @sources, unlock, @ruby_version, @optional_groups, @gemfiles, @overrides)
224230
end
@@ -317,6 +323,54 @@ def validate_override_uniqueness!(target, field)
317323
raise ArgumentError, "duplicate override for #{target.inspect} `#{field}:`"
318324
end
319325

326+
def add_dependency_override(target, operations, source_location)
327+
unless operations.key?(:from)
328+
raise ArgumentError, "`override #{target.inspect}, to:` requires `from:` naming the gem whose dependency is rewritten"
329+
end
330+
unless operations.key?(:to)
331+
raise ArgumentError, "`override #{target.inspect}, from:` requires `to:`; use `to: nil` to drop the dependency or `to: \"<gem>\"` to replace it"
332+
end
333+
334+
from = operations[:from]
335+
to = operations[:to]
336+
requirement = operations[:version]
337+
338+
raise ArgumentError, "`override :all` does not accept `from:`" if target == :all
339+
raise ArgumentError, "`from:` must be a gem name string, got #{from.inspect}" unless from.is_a?(String)
340+
raise ArgumentError, "`to:` must be a gem name string or nil, got #{to.inspect}" unless to.nil? || to.is_a?(String)
341+
raise ArgumentError, "`to:` must name a gem other than #{target.inspect} and #{from.inspect}" if to == target || to == from
342+
validate_gem_name!(from)
343+
validate_gem_name!(to) if to
344+
345+
unsupported = operations.keys - [:from, :to, :version]
346+
if unsupported.any?
347+
raise ArgumentError, "unsupported override field `#{unsupported.first}:` with `from:`; supported fields: `to:`, `version:`"
348+
end
349+
350+
if operations.key?(:version)
351+
raise ArgumentError, "`version:` with `from:` requires `to:` to name a replacement gem" if to.nil?
352+
raise ArgumentError, "`version:` with `from:` must be a version requirement string, got #{requirement.inspect}" unless requirement.is_a?(String)
353+
validate_override_operation!(requirement)
354+
end
355+
356+
if @overrides.any? {|o| o.target == target && o.from == from }
357+
raise ArgumentError, "duplicate override for #{target.inspect} `from: #{from.inspect}`"
358+
end
359+
360+
@overrides << Override.new(target, :to, to, from: from, requirement: requirement, source_location: source_location)
361+
end
362+
363+
# Dropping a gem the Gemfile requires directly would leave it installed
364+
# anyway, with only the parent's requirement gone.
365+
def check_dropped_dependencies
366+
@overrides.each do |override|
367+
next unless override.from && override.operation.nil?
368+
next unless @dependencies.any? {|d| d.name == override.target }
369+
370+
raise GemfileError, "#{override} cannot drop #{override.target} because the Gemfile depends on it directly"
371+
end
372+
end
373+
320374
def add_dependency(name, version = nil, options = {})
321375
options["gemfile"] = @gemfile
322376
options["source"] ||= @source
@@ -460,14 +514,18 @@ def valid_keys
460514
@valid_keys ||= VALID_KEYS
461515
end
462516

463-
def normalize_options(name, version, opts)
517+
def validate_gem_name!(name)
464518
if name.is_a?(Symbol)
465519
raise GemfileError, %(You need to specify gem names as Strings. Use 'gem "#{name}"' instead)
466520
end
467521
if /\s/.match?(name)
468522
raise GemfileError, %('#{name}' is not a valid gem name because it contains whitespace)
469523
end
470524
raise GemfileError, %(an empty gem name is not valid) if name.empty?
525+
end
526+
527+
def normalize_options(name, version, opts)
528+
validate_gem_name!(name)
471529

472530
normalize_hash(opts)
473531

‎lib/bundler/installer.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def generate_bundler_executable_stubs(spec, options = {})
103103

104104
if options[:binstubs_cmd] && spec.executables.empty?
105105
options = {}
106-
spec.runtime_dependencies.each do |dep|
106+
Override.rewrite_dependencies(@definition.overrides, spec.name, spec.runtime_dependencies).each do |dep|
107107
bins = @definition.specs[dep].first.executables
108108
options[dep.name] = bins unless bins.empty?
109109
end

‎lib/bundler/installer/parallel_installer.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ def initialize(installer, all_specs, size, standalone, force, local: false, skip
6969
@specs = all_specs.map {|s| SpecInstallation.new(s) }
7070
specs_by_name = @specs.to_h {|s| [s.name, s] }
7171
@specs.each do |spec_install|
72-
spec_install.dependencies = spec_install.spec.dependencies.filter_map do |dep|
72+
dependencies = Override.rewrite_dependencies(installer.definition.overrides, spec_install.name, spec_install.spec.dependencies)
73+
spec_install.dependencies = dependencies.filter_map do |dep|
7374
specs_by_name[dep.name] unless dep.type == :development || dep.name == spec_install.name
7475
end
7576
end

‎lib/bundler/lazy_specification.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def choose_compatible(candidates, fallback_to_non_installable: Bundler.frozen_bu
286286
# which would affect `bundler/setup` performance.
287287
def validate_dependencies(spec)
288288
if !@materialization_options[:strict] && spec.is_a?(StubSpecification)
289-
spec.dependencies = dependencies
289+
spec.dependencies = Override.rewrite_dependencies(overrides, name, dependencies)
290290
else
291291
if !source.is_a?(Source::Path) && spec.runtime_dependencies.sort != dependencies.sort
292292
raise IncorrectLockfileDependencies.new(self, spec.runtime_dependencies, dependencies)

‎lib/bundler/materialization.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ def specs
3030
end
3131

3232
def dependencies
33-
(materialized_spec || specs.first).runtime_dependencies.map {|d| [d, platform] }
33+
spec = materialized_spec || specs.first
34+
overrides = specs.first.overrides if specs.first.is_a?(LazySpecification)
35+
Override.rewrite_dependencies(overrides, spec.name, spec.runtime_dependencies).map {|d| [d, platform] }
3436
end
3537

3638
def materialized_spec

‎lib/bundler/override.rb‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ def self.find_for(overrides, name, field)
99
overrides.find {|o| o.target == :all && o.field == field }
1010
end
1111

12+
# Drops or replaces the dependencies of the gem named `from` that a
13+
# `from:`/`to:` override targets. The requirement `from` put on the
14+
# replaced gem is discarded.
15+
def self.rewrite_dependencies(overrides, from, dependencies)
16+
return dependencies if overrides.nil? || overrides.none? {|o| o.from == from }
17+
18+
dependencies.filter_map do |dep|
19+
override = overrides.find {|o| o.from == from && o.target == dep.name }
20+
next dep unless override
21+
next unless override.operation
22+
Gem::Dependency.new(override.operation, *Array(override.requirement))
23+
end
24+
end
25+
1226
# Attach the given overrides onto every LazySpecification in `specs` so
1327
# downstream consumers (LazySpecification#choose_compatible, the install-
1428
# time compatibility check, etc.) can read the override list off the spec
@@ -19,12 +33,14 @@ def self.attach(specs, overrides)
1933
specs.each {|s| s.overrides = overrides if s.is_a?(LazySpecification) }
2034
end
2135

22-
attr_reader :target, :field, :operation, :source_location
36+
attr_reader :target, :field, :operation, :from, :requirement, :source_location
2337

24-
def initialize(target, field, operation, source_location: nil)
38+
def initialize(target, field, operation, from: nil, requirement: nil, source_location: nil)
2539
@target = target
2640
@field = field
2741
@operation = operation
42+
@from = from
43+
@requirement = requirement
2844
@source_location = source_location
2945
end
3046

@@ -33,7 +49,19 @@ def source_location_label
3349
"#{File.basename(@source_location.path)}:#{@source_location.lineno}"
3450
end
3551

52+
def to_s
53+
options = if @from
54+
["from: #{@from.inspect}", "to: #{@operation.inspect}", *("version: #{@requirement.inspect}" if @requirement)].join(", ")
55+
else
56+
"#{@field}: #{@operation.inspect}"
57+
end
58+
location = source_location_label
59+
"override #{@target == :all ? ":all" : @target.inspect}, #{options}#{" (declared at #{location})" if location}"
60+
end
61+
3662
def apply_to(requirement)
63+
raise ArgumentError, "#{self} rewrites a dependency and has no requirement to apply" if @from
64+
3765
case operation
3866
when nil
3967
Gem::Requirement.default

‎lib/bundler/resolver.rb‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def setup_solver
7171
@cached_dependencies = Hash.new do |dependencies, package|
7272
dependencies[package] = Hash.new do |versions, version|
7373
deps = version.dependencies.reject {|d| d.name == package.name }
74+
deps = Override.rewrite_dependencies(@base.overrides, package.name, deps)
7475
deps = apply_metadata_overrides(deps, package.name)
7576
versions[version] = to_dependency_hash(deps, @packages)
7677
end
@@ -88,6 +89,7 @@ def solve_versions(root:, logger:)
8889
solver = Gem::PubGrub::VersionSolver.new(source: self, root: root, strategy: Strategy.new(self), logger: logger)
8990
result = solver.solve
9091
resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) }
92+
check_replaced_gems(resolved_specs)
9193
Override.attach(resolved_specs, @base.overrides)
9294
spec_set = SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
9395
@cooldown_skipped = cooldown_skipped_summary(spec_set)
@@ -137,15 +139,37 @@ def solve_versions(root:, logger:)
137139
raise SolveFailure.new(explanation)
138140
end
139141

142+
# A replacement is only useful when it provides the files of the gem it
143+
# replaces, so the two cannot be loaded side by side.
144+
def check_replaced_gems(specs)
145+
names = specs.map(&:name)
146+
147+
@base.overrides.each do |override|
148+
next unless override.from && override.operation
149+
next unless names.include?(override.target) && names.include?(override.operation)
150+
next unless specs.any? {|s| s.name == override.from && s.dependencies.any? {|d| d.name == override.target } }
151+
152+
dependents = specs.select do |spec|
153+
Override.rewrite_dependencies(@base.overrides, spec.name, spec.dependencies).any? {|d| d.name == override.target }
154+
end.map(&:name).uniq
155+
156+
lines = ["#{override} would leave both #{override.target} and #{override.operation} in the bundle because #{override.target} is still required by:"]
157+
lines << " the Gemfile" if @base.requirements.any? {|d| d.name == override.target }
158+
lines.concat(dependents.map {|name| " #{name}" })
159+
if dependents.any?
160+
lines << "Replace it for each of them, for example:"
161+
lines << " #{Override.new(override.target, :to, override.operation, from: dependents.first)}"
162+
end
163+
raise GemfileError, lines.join("\n")
164+
end
165+
end
166+
140167
def override_diagnostic_summary
141168
return nil if @base.overrides.empty?
142169

143170
lines = ["Bundler applied the following overrides while resolving:"]
144171
@base.overrides.each do |override|
145-
target = override.target == :all ? ":all" : override.target.inspect
146-
location = override.source_location_label
147-
lines << " override #{target}, #{override.field}: #{override.operation.inspect}" \
148-
"#{location ? " (declared at #{location})" : ""}"
172+
lines << " #{override}"
149173
end
150174
"\n\n#{lines.join("\n")}"
151175
end

‎lib/bundler/source/rubygems_aggregate.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def to_s
2929

3030
def build_index
3131
Index.build do |idx|
32-
dependency_names = source_map.pinned_spec_names
32+
dependency_names = source_map.pinned_spec_names | source_map.replacement_names
3333

3434
sources.all_sources.each do |source|
3535
next if @excluded_sources.include?(source)

‎lib/bundler/source_map.rb‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
module Bundler
44
class SourceMap
5-
attr_reader :sources, :dependencies, :locked_specs
5+
attr_reader :sources, :dependencies, :locked_specs, :replacement_names
66

7-
def initialize(sources, dependencies, locked_specs)
7+
def initialize(sources, dependencies, locked_specs, replacement_names = [])
88
@sources = sources
99
@dependencies = dependencies
1010
@locked_specs = locked_specs
11+
# No gemspec names a gem that a `from:`/`to:` override substitutes, so
12+
# the dependency API never fetches it unless asked.
13+
@replacement_names = replacement_names
1114
end
1215

1316
def pinned_spec_names(skip = nil)
@@ -22,6 +25,7 @@ def all_requirements(excluded_sources = [])
2225
end
2326

2427
unmet_deps = explicit_sources.map do |source|
28+
source.add_dependency_names(replacement_names)
2529
(source.spec_names - pinned_spec_names).each do |indirect_dependency_name|
2630
previous_source = requirements[indirect_dependency_name]
2731
if previous_source.nil?
@@ -39,7 +43,7 @@ def all_requirements(excluded_sources = [])
3943
source.unmet_deps
4044
end
4145

42-
sources.default_source.add_dependency_names(unmet_deps.flatten - requirements.keys)
46+
sources.default_source.add_dependency_names(unmet_deps.flatten + replacement_names - requirements.keys)
4347

4448
requirements
4549
end

0 commit comments

Comments
 (0)