Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f2e4135
Consolidate Override lookup with Override.find_for
hsbt May 7, 2026
cae03db
Validate override version requirement at Gemfile evaluation time
hsbt May 7, 2026
36f82ee
Assert that override directives never leak into the lockfile
hsbt May 7, 2026
88f55b5
Accept required_ruby_version and required_rubygems_version overrides
hsbt May 7, 2026
101de5f
Apply per-gem metadata overrides in Resolver
hsbt May 7, 2026
0cad863
Unlock direct deps too for metadata-field overrides
hsbt May 7, 2026
102114b
Cover required_ruby_version and required_rubygems_version overrides
hsbt May 7, 2026
ff360be
Allow :all target for metadata-field overrides
hsbt May 7, 2026
da9868e
Fall back from per-gem lookup to an :all override
hsbt May 7, 2026
9872f71
Unlock every locked spec when an :all override is present
hsbt May 7, 2026
b71a977
Cover :all target metadata-field override end-to-end
hsbt May 7, 2026
d52b9ef
Honor metadata overrides in MatchMetadata
hsbt May 7, 2026
91e57fd
Cover install-time compatibility for metadata overrides
hsbt May 7, 2026
f3bb2de
Capture Gemfile source location for each override
hsbt May 7, 2026
b9ac8f1
Show active overrides when resolution fails
hsbt May 7, 2026
8ddc154
Document Phase 2 override DSL extensions in gemfile.5
hsbt May 8, 2026
a192432
Thread overrides explicitly instead of through a Bundler global
hsbt May 7, 2026
92172a1
Stop blanket-unlocking the lockfile on :all overrides
hsbt May 7, 2026
de4fca0
Propagate overrides into Definition#resolve lockfile-reuse paths
hsbt May 7, 2026
e9d0979
Preserve overrides when SpecSet self-derives a validation set
hsbt May 7, 2026
1f230b5
Avoid forcing a remote gemspec load when seeding LazySpec overrides
hsbt May 8, 2026
e865b1e
Move overrides off SpecSet onto LazySpecification
hsbt May 8, 2026
2b08b24
Honor LazySpec overrides in SpecSet#complete_platform
hsbt May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions bundler/lib/bundler/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti
@locked_bundler_version = @locked_gems.bundler_version
@locked_ruby_version = @locked_gems.ruby_version
@locked_deps = @locked_gems.dependencies
Override.attach(@locked_gems.specs, @overrides)
@originally_locked_specs = SpecSet.new(@locked_gems.specs)
@originally_locked_sources = @locked_gems.sources
@locked_checksums = @locked_gems.checksums
Expand Down Expand Up @@ -644,7 +645,7 @@ def apply_overrides_to(deps)
end

def apply_override_to(dep)
override = @overrides.find {|o| o.target == dep.name && o.field == :version }
override = Override.find_for(@overrides, dep.name, :version)
return dep unless override
new_dep = dep.dup
new_dep.instance_variable_set(:@requirement, override.apply_to(dep.requirement))
Expand Down Expand Up @@ -1061,12 +1062,19 @@ def converge_dependencies

def converge_overrides_outside_dependencies
@overrides.each do |override|
# :all overrides are intentionally not pre-unlocked. They take effect on
# fresh resolution (no lockfile) or when the user runs `bundle update`.
# Forcing a full re-resolve from a single :all directive would surprise
# users with unrelated dependency churn.
next unless override.target.is_a?(String)

name = override.target
next if @changed_dependencies.include?(name)
next if @dependencies.any? {|d| d.name == name }
next if @originally_locked_specs[name].empty?
# version: overrides on direct deps are detected in the per-dep
# converge_dependencies loop via apply_override_to + matches_spec?.
# Other fields are not visible there, so they always reach here.
next if override.field == :version && @dependencies.any? {|d| d.name == name }

@gems_to_unlock << name
@changed_dependencies << name
Expand Down
14 changes: 10 additions & 4 deletions bundler/lib/bundler/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def github(repo, options = {})
with_source(git_source) { yield }
end

SUPPORTED_OVERRIDE_FIELDS = [:version].freeze
SUPPORTED_OVERRIDE_FIELDS = [:version, :required_ruby_version, :required_rubygems_version].freeze
SUPPORTED_OVERRIDE_SYMBOL_OPERATIONS = [:ignore_upper].freeze

def override(target, **operations)
Expand All @@ -201,8 +201,9 @@ def override(target, **operations)
validate_override_uniqueness!(target, field)
end

source_location = caller_locations(1, 1)&.first
operations.each do |field, operation|
@overrides << Override.new(target, field, operation)
@overrides << Override.new(target, field, operation, source_location: source_location)
end
end

Expand Down Expand Up @@ -274,19 +275,24 @@ def validate_override_target!(target)

def validate_override_field!(field)
return if SUPPORTED_OVERRIDE_FIELDS.include?(field)
raise ArgumentError, "unsupported override field `#{field}:`; only `version:` is currently supported"
supported = SUPPORTED_OVERRIDE_FIELDS.map {|f| "`#{f}:`" }.join(", ")
raise ArgumentError, "unsupported override field `#{field}:`; supported fields: #{supported}"
end

def validate_override_operation!(operation)
case operation
when String, nil
when String
Gem::Requirement.new(operation)
when nil
# ok
when Symbol
return if SUPPORTED_OVERRIDE_SYMBOL_OPERATIONS.include?(operation)
raise ArgumentError, "unsupported override operation: #{operation.inspect}"
else
raise ArgumentError, "override operation must be a String, Symbol, or nil, got #{operation.inspect}"
end
rescue Gem::Requirement::BadRequirementError => e
raise ArgumentError, "invalid override version requirement #{operation.inspect}: #{e.message}"
end

def validate_override_uniqueness!(target, field)
Expand Down
5 changes: 3 additions & 2 deletions bundler/lib/bundler/installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,13 @@ def load_plugins
end

def ensure_specs_are_compatible!
overrides = @definition.overrides
@definition.specs.each do |spec|
unless spec.matches_current_ruby?
unless spec.matches_current_ruby_with_overrides?(overrides)
raise InstallError, "#{spec.full_name} requires ruby version #{spec.required_ruby_version}, " \
"which is incompatible with the current version, #{Gem.ruby_version}"
end
unless spec.matches_current_rubygems?
unless spec.matches_current_rubygems_with_overrides?(overrides)
raise InstallError, "#{spec.full_name} requires rubygems version #{spec.required_rubygems_version}, " \
"which is incompatible with the current version, #{Gem.rubygems_version}"
end
Expand Down
6 changes: 4 additions & 2 deletions bundler/lib/bundler/lazy_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class LazySpecification
include ForcePlatform

attr_reader :name, :version, :platform, :materialization
attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version
attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version, :overrides

#
# For backwards compatibility with existing lockfiles, if the most specific
Expand All @@ -30,6 +30,7 @@ def self.from_spec(s)
lazy_spec.dependencies = s.runtime_dependencies
lazy_spec.required_ruby_version = s.required_ruby_version
lazy_spec.required_rubygems_version = s.required_rubygems_version
lazy_spec.overrides = s.overrides if s.is_a?(LazySpecification)
lazy_spec
end

Expand Down Expand Up @@ -234,8 +235,9 @@ def materialize(query)
# about the mismatch higher up the stack, right before trying to install the
# bad gem.
def choose_compatible(candidates, fallback_to_non_installable: Bundler.frozen_bundle?)
override_list = overrides || []
search = candidates.reverse.find do |spec|
spec.is_a?(StubSpecification) || spec.matches_current_metadata?
spec.is_a?(StubSpecification) || spec.matches_current_metadata_with_overrides?(override_list)
end
if search.nil? && fallback_to_non_installable
search = candidates.last
Expand Down
16 changes: 12 additions & 4 deletions bundler/lib/bundler/man/gemfile.5
Original file line number Diff line number Diff line change
Expand Up @@ -461,22 +461,24 @@ The \fBgemspec\fR method supports optional \fB:path\fR, \fB:glob\fR, \fB:name\fR
.P
When a \fBgemspec\fR dependency encounters version conflicts during resolution, the local version under development will always be selected \-\- even if there are remote versions that better match other requirements for the \fBgemspec\fR gem\.
.SH "OVERRIDE"
The \fBoverride\fR directive rewrites the version requirement on another gem before resolution runs\. It targets the common case where an upstream gem's published metadata is too narrow on the current project's machine \-\- a stale upper bound, an unwanted floor, or a transitive pin that has to be lifted\.
The \fBoverride\fR directive rewrites a constraint on another gem before resolution runs\. It targets the common case where an upstream gem's published metadata is too narrow on the current project's machine \-\- a stale upper bound, an unwanted floor, or a transitive pin that has to be lifted\.
.IP "" 4
.nf
override <target>, <field>: <operation>
.fi
.IP "" 0
.P
\fB<target>\fR is a gem name string\. \fB<field>\fR is \fBversion:\fR\. \fB<operation>\fR is one of:
\fB<target>\fR is a gem name string or \fB:all\fR\. \fB<field>\fR is one of \fBversion:\fR, \fBrequired_ruby_version:\fR, or \fBrequired_rubygems_version:\fR\. \fB<operation>\fR is one of:
.IP "\(bu" 4
a version requirement string (e\.g\. \fB">= 8\.0"\fR), which \fBreplaces\fR the target's version requirement absolutely\. The original requirement, both direct and transitive, is discarded in favour of the override\.
a version requirement string (e\.g\. \fB">= 8\.0"\fR), which \fBreplaces\fR the target's requirement absolutely\. The original requirement, both direct and transitive, is discarded in favour of the override\.
.IP "\(bu" 4
\fB:ignore_upper\fR, which removes upper\-bound operators (\fB<\fR and \fB<=\fR) from the existing requirement and folds \fB~>\fR into its lower bound (\fB~> 1\.5\fR becomes \fB>= 1\.5\fR)\. Other operators, including \fB!=\fR, are preserved\.
.IP "\(bu" 4
\fBnil\fR, which collapses the requirement to \fB>= 0\fR (no constraint at all)\.
.IP "" 0
.P
\fB:all\fR only applies to \fBrequired_ruby_version:\fR and \fBrequired_rubygems_version:\fR\. A per\-gem override on the same field takes precedence over an \fB:all\fR override\. \fB:all + version:\fR is rejected: version requirements only make sense per gem\.
.P
Multiple \fBoverride\fR calls for distinct targets are allowed; declaring the same \fBtarget\fR and \fBfield\fR twice is an error\.
.IP "" 4
.nf
Expand All @@ -491,11 +493,17 @@ override "nokogiri", version: :ignore_upper
# Drop the version pin on legacy entirely\.
override "legacy", version: nil

# Loosen every gem's required_ruby_version upper bound\.
override :all, required_ruby_version: :ignore_upper

# Override one specific gem's required_rubygems_version\.
override "tricky", required_rubygems_version: nil

gem "rails", "~> 7\.0"
.fi
.IP "" 0
.P
The override only affects resolution; \fBGemfile\.lock\fR continues to reflect the resolved versions, not the rewritten requirements\.
The override only affects resolution and the install\-time Ruby/RubyGems compatibility checks; \fBGemfile\.lock\fR continues to reflect the resolved versions, not the rewritten requirements\. When resolution still fails, Bundler appends the active overrides (with their Gemfile location) to the error message so it is clear which override shaped the constraint set\.
.SH "SOURCE PRIORITY"
When attempting to locate a gem to satisfy a gem requirement, bundler uses the following priority order:
.IP "1." 4
Expand Down
28 changes: 21 additions & 7 deletions bundler/lib/bundler/man/gemfile.5.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -543,24 +543,29 @@ remote versions that better match other requirements for the `gemspec` gem.

## OVERRIDE

The `override` directive rewrites the version requirement on another gem
before resolution runs. It targets the common case where an upstream gem's
The `override` directive rewrites a constraint on another gem before
resolution runs. It targets the common case where an upstream gem's
published metadata is too narrow on the current project's machine -- a stale
upper bound, an unwanted floor, or a transitive pin that has to be lifted.

override <target>, <field>: <operation>

`<target>` is a gem name string. `<field>` is `version:`. `<operation>` is
`<target>` is a gem name string or `:all`. `<field>` is one of `version:`,
`required_ruby_version:`, or `required_rubygems_version:`. `<operation>` is
one of:

* a version requirement string (e.g. `">= 8.0"`), which **replaces** the
target's version requirement absolutely. The original requirement, both
direct and transitive, is discarded in favour of the override.
target's requirement absolutely. The original requirement, both direct
and transitive, is discarded in favour of the override.
* `:ignore_upper`, which removes upper-bound operators (`<` and `<=`) from
the existing requirement and folds `~>` into its lower bound (`~> 1.5`
becomes `>= 1.5`). Other operators, including `!=`, are preserved.
* `nil`, which collapses the requirement to `>= 0` (no constraint at all).

`:all` only applies to `required_ruby_version:` and `required_rubygems_version:`.
A per-gem override on the same field takes precedence over an `:all` override.
`:all + version:` is rejected: version requirements only make sense per gem.

Multiple `override` calls for distinct targets are allowed; declaring the
same `target` and `field` twice is an error.

Expand All @@ -575,10 +580,19 @@ same `target` and `field` twice is an error.
# Drop the version pin on legacy entirely.
override "legacy", version: nil

# Loosen every gem's required_ruby_version upper bound.
override :all, required_ruby_version: :ignore_upper

# Override one specific gem's required_rubygems_version.
override "tricky", required_rubygems_version: nil

gem "rails", "~> 7.0"

The override only affects resolution; `Gemfile.lock` continues to reflect
the resolved versions, not the rewritten requirements.
The override only affects resolution and the install-time Ruby/RubyGems
compatibility checks; `Gemfile.lock` continues to reflect the resolved
versions, not the rewritten requirements. When resolution still fails,
Bundler appends the active overrides (with their Gemfile location) to the
error message so it is clear which override shaped the constraint set.

## SOURCE PRIORITY

Expand Down
20 changes: 20 additions & 0 deletions bundler/lib/bundler/match_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ def matches_current_rubygems?
@required_rubygems_version.satisfied_by?(Gem.rubygems_version)
end

def matches_current_metadata_with_overrides?(overrides)
matches_current_ruby_with_overrides?(overrides) && matches_current_rubygems_with_overrides?(overrides)
end

def matches_current_ruby_with_overrides?(overrides)
effective_required_version(@required_ruby_version, :required_ruby_version, overrides).satisfied_by?(Gem.ruby_version)
end

def matches_current_rubygems_with_overrides?(overrides)
effective_required_version(@required_rubygems_version, :required_rubygems_version, overrides).satisfied_by?(Gem.rubygems_version)
end

def expanded_dependencies
runtime_dependencies + [
metadata_dependency("Ruby", @required_ruby_version),
Expand All @@ -26,5 +38,13 @@ def metadata_dependency(name, requirement)

Gem::Dependency.new("#{name}\0", requirement)
end

private

def effective_required_version(requirement, field, overrides)
return requirement if overrides.nil? || overrides.empty?
override = Override.find_for(overrides, name, field)
override ? override.apply_to(requirement) : requirement
end
end
end
27 changes: 21 additions & 6 deletions bundler/lib/bundler/match_remote_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,34 @@ module FetchMetadata
# API didn't include that field, so some marshalled specs in the index have it
# set to +nil+.
def matches_current_ruby?
@required_ruby_version ||= _remote_specification.required_ruby_version || Gem::Requirement.default

ensure_required_ruby_version_loaded
super
end

def matches_current_rubygems?
# A fallback is included because the original version of the specification
# API didn't include that field, so some marshalled specs in the index have it
# set to +nil+.
@required_rubygems_version ||= _remote_specification.required_rubygems_version || Gem::Requirement.default
ensure_required_rubygems_version_loaded
super
end

def matches_current_ruby_with_overrides?(overrides)
ensure_required_ruby_version_loaded
super
end

def matches_current_rubygems_with_overrides?(overrides)
ensure_required_rubygems_version_loaded
super
end

private

def ensure_required_ruby_version_loaded
@required_ruby_version ||= _remote_specification.required_ruby_version || Gem::Requirement.default # rubocop:disable Naming/MemoizedInstanceVariableName
end

def ensure_required_rubygems_version_loaded
@required_rubygems_version ||= _remote_specification.required_rubygems_version || Gem::Requirement.default # rubocop:disable Naming/MemoizedInstanceVariableName
end
end

module MatchRemoteMetadata
Expand Down
25 changes: 23 additions & 2 deletions bundler/lib/bundler/override.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,33 @@ module Bundler
class Override
UPPER_BOUND_OPERATORS = ["<", "<="].freeze

attr_reader :target, :field, :operation
def self.find_for(overrides, name, field)
overrides.find {|o| o.target == name && o.field == field } ||
overrides.find {|o| o.target == :all && o.field == field }
end

# Attach the given overrides onto every LazySpecification in `specs` so
# downstream consumers (LazySpecification#choose_compatible, the install-
# time compatibility check, etc.) can read the override list off the spec
# itself. Non-LazySpec entries (StubSpecification, Gem::Specification, ...)
# are left untouched.
def self.attach(specs, overrides)
return if overrides.nil? || overrides.empty?
specs.each {|s| s.overrides = overrides if s.is_a?(LazySpecification) }
end

def initialize(target, field, operation)
attr_reader :target, :field, :operation, :source_location

def initialize(target, field, operation, source_location: nil)
@target = target
@field = field
@operation = operation
@source_location = source_location
end

def source_location_label
return nil unless @source_location
"#{File.basename(@source_location.path)}:#{@source_location.lineno}"
end

def apply_to(requirement)
Expand Down
Loading
Loading