From f1bdc45b224b4e935b8c44f89ad63839a787ece5 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 11:25:36 +0900 Subject: [PATCH 01/15] Add Bundler::Override value object Introduce a value object that holds a target/field/operation triple for the upcoming Gemfile `override` DSL. apply_to dispatches on the operation: a version spec string replaces the requirement absolutely, :ignore_upper strips < and <= while folding ~> into >=, and nil collapses to Gem::Requirement.default. The :ignore_upper logic is taken from Shopify/bundler-ignore-dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- Manifest.txt | 1 + bundler/lib/bundler.rb | 1 + bundler/lib/bundler/override.rb | 46 ++++++++++++++++++++ spec/bundler/override_spec.rb | 70 +++++++++++++++++++++++++++++++ spec/support/windows_tag_group.rb | 1 + 5 files changed, 119 insertions(+) create mode 100644 bundler/lib/bundler/override.rb create mode 100644 spec/bundler/override_spec.rb diff --git a/Manifest.txt b/Manifest.txt index 6152c06b2c08..72b806e54974 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -156,6 +156,7 @@ bundler/lib/bundler/match_platform.rb bundler/lib/bundler/match_remote_metadata.rb bundler/lib/bundler/materialization.rb bundler/lib/bundler/mirror.rb +bundler/lib/bundler/override.rb bundler/lib/bundler/plugin.rb bundler/lib/bundler/plugin/api.rb bundler/lib/bundler/plugin/api/source.rb diff --git a/bundler/lib/bundler.rb b/bundler/lib/bundler.rb index 8bec10c0db87..12dde90fc5d8 100644 --- a/bundler/lib/bundler.rb +++ b/bundler/lib/bundler.rb @@ -62,6 +62,7 @@ module Bundler autoload :MatchRemoteMetadata, File.expand_path("bundler/match_remote_metadata", __dir__) autoload :Materialization, File.expand_path("bundler/materialization", __dir__) autoload :NULL, File.expand_path("bundler/constants", __dir__) + autoload :Override, File.expand_path("bundler/override", __dir__) autoload :ProcessLock, File.expand_path("bundler/process_lock", __dir__) autoload :RemoteSpecification, File.expand_path("bundler/remote_specification", __dir__) autoload :Resolver, File.expand_path("bundler/resolver", __dir__) diff --git a/bundler/lib/bundler/override.rb b/bundler/lib/bundler/override.rb new file mode 100644 index 000000000000..d07dad00ea6b --- /dev/null +++ b/bundler/lib/bundler/override.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Bundler + class Override + LOWER_BOUND_OPERATORS = [">=", ">", "="].freeze + + attr_reader :target, :field, :operation + + def initialize(target, field, operation) + @target = target + @field = field + @operation = operation + end + + def apply_to(requirement) + case operation + when nil + Gem::Requirement.default + when :ignore_upper + remove_upper_bounds(requirement) + when String + Gem::Requirement.new(operation) + else + raise ArgumentError, "unsupported override operation: #{operation.inspect}" + end + end + + private + + def remove_upper_bounds(requirement) + return Gem::Requirement.default if requirement.nil? || requirement.none? + + lower_bounds = requirement.requirements.filter_map do |op, version| + if LOWER_BOUND_OPERATORS.include?(op) + [op, version] + elsif op == "~>" + [">=", version] + end + end + + return Gem::Requirement.default if lower_bounds.empty? + + Gem::Requirement.new(lower_bounds.map {|op, v| "#{op} #{v}" }) + end + end +end diff --git a/spec/bundler/override_spec.rb b/spec/bundler/override_spec.rb new file mode 100644 index 000000000000..bc813e52fc3f --- /dev/null +++ b/spec/bundler/override_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe Bundler::Override do + describe "#apply_to" do + context "when operation is a version spec string" do + it "replaces the existing requirement entirely" do + override = described_class.new("rails", :version, ">= 8.0") + result = override.apply_to(Gem::Requirement.new(">= 1.0", "< 2.0")) + expect(result).to eq(Gem::Requirement.new(">= 8.0")) + end + + it "ignores the existing requirement regardless of its content" do + override = described_class.new("rails", :version, "= 1.0") + result = override.apply_to(Gem::Requirement.new(">= 99.0")) + expect(result).to eq(Gem::Requirement.new("= 1.0")) + end + end + + context "when operation is :ignore_upper" do + it "removes < and <= operators" do + override = described_class.new("rails", :version, :ignore_upper) + result = override.apply_to(Gem::Requirement.new(">= 1.0", "< 2.0")) + expect(result).to eq(Gem::Requirement.new(">= 1.0")) + end + + it "keeps >, >=, = operators" do + override = described_class.new("rails", :version, :ignore_upper) + result = override.apply_to(Gem::Requirement.new("> 1.0", "<= 2.0")) + expect(result).to eq(Gem::Requirement.new("> 1.0")) + end + + it "converts ~> to >= preserving the lower bound" do + override = described_class.new("rails", :version, :ignore_upper) + result = override.apply_to(Gem::Requirement.new("~> 1.5")) + expect(result).to eq(Gem::Requirement.new(">= 1.5")) + end + + it "returns the default requirement when only upper bounds remain" do + override = described_class.new("rails", :version, :ignore_upper) + result = override.apply_to(Gem::Requirement.new("< 2.0")) + expect(result).to eq(Gem::Requirement.default) + end + + it "returns the default requirement when the input is nil" do + override = described_class.new("rails", :version, :ignore_upper) + expect(override.apply_to(nil)).to eq(Gem::Requirement.default) + end + + it "returns the default requirement when the input is already the default" do + override = described_class.new("rails", :version, :ignore_upper) + expect(override.apply_to(Gem::Requirement.default)).to eq(Gem::Requirement.default) + end + end + + context "when operation is nil" do + it "returns the default requirement" do + override = described_class.new("rails", :version, nil) + result = override.apply_to(Gem::Requirement.new(">= 1.0", "< 2.0")) + expect(result).to eq(Gem::Requirement.default) + end + end + + context "when operation is unsupported" do + it "raises ArgumentError" do + override = described_class.new("rails", :version, 42) + expect { override.apply_to(Gem::Requirement.default) }.to raise_error(ArgumentError, /unsupported override operation/) + end + end + end +end diff --git a/spec/support/windows_tag_group.rb b/spec/support/windows_tag_group.rb index b91deb7ed3e6..7b52b58743b6 100644 --- a/spec/support/windows_tag_group.rb +++ b/spec/support/windows_tag_group.rb @@ -189,6 +189,7 @@ module WindowsTagGroup "spec/bundler/uri_normalizer_spec.rb", "spec/install/gems/no_build_extension_spec.rb", "spec/install/gems/no_install_plugin_spec.rb", + "spec/bundler/override_spec.rb", ], }.freeze end From 444912f2bb54aaf1dfcf010bf1fe732989462680 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 11:27:22 +0900 Subject: [PATCH 02/15] Add overrides attribute to Bundler::Definition Reserve a slot on Definition for the upcoming Gemfile `override` DSL. This commit only stores the data; the DSL entry point and the resolver hookup come in later commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/definition.rb | 4 +++- spec/bundler/definition_spec.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index a620475c18cf..2435479f9441 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -10,13 +10,14 @@ class << self attr_accessor :no_lock end - attr_writer :lockfile + attr_writer :lockfile, :overrides attr_reader( :dependencies, :locked_checksums, :locked_deps, :locked_gems, + :overrides, :platforms, :ruby_version, :lockfile, @@ -88,6 +89,7 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti @specs = nil @ruby_version = ruby_version @gemfiles = gemfiles + @overrides = [] @lockfile = lockfile @lockfile_contents = String.new diff --git a/spec/bundler/definition_spec.rb b/spec/bundler/definition_spec.rb index 8c7d5667ac66..8c4a5a0331e2 100644 --- a/spec/bundler/definition_spec.rb +++ b/spec/bundler/definition_spec.rb @@ -3,6 +3,24 @@ require "bundler/definition" RSpec.describe Bundler::Definition do + describe "#overrides" do + before do + allow(Bundler::SharedHelpers).to receive(:find_gemfile) { bundled_app_gemfile } + end + + subject { Bundler::Definition.new(bundled_app_lock, [], Bundler::SourceList.new, {}) } + + it "defaults to an empty array" do + expect(subject.overrides).to eq([]) + end + + it "is writable" do + override = Bundler::Override.new("rails", :version, ">= 8.0") + subject.overrides = [override] + expect(subject.overrides).to eq([override]) + end + end + describe "#lock" do before do allow(Bundler::SharedHelpers).to receive(:find_gemfile) { bundled_app_gemfile } From b98fe41415bef86c6aff7d282ec39d0df972461b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 11:30:50 +0900 Subject: [PATCH 03/15] Add `override` DSL method to Bundler::Dsl Introduce Gemfile-level `override target, field: operation, ...` that collects Bundler::Override instances and forwards them to Definition via to_definition. Validation and resolver hookup come in later commits; this commit only wires the entry point. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/dsl.rb | 13 +++++++++++-- spec/bundler/dsl_spec.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 6f06c4e91879..a5df3235b14b 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -22,7 +22,7 @@ def self.evaluate(gemfile, lockfile, unlock) GITHUB_PULL_REQUEST_URL = %r{\Ahttps://github\.com/([A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+)/pull/(\d+)\z} GITLAB_MERGE_REQUEST_URL = %r{\Ahttps://gitlab\.com/([A-Za-z0-9_\-\./]+)/-/merge_requests/(\d+)\z} - attr_reader :gemspecs, :gemfile + attr_reader :gemspecs, :gemfile, :overrides attr_accessor :dependencies def initialize @@ -40,6 +40,7 @@ def initialize @gemfile = nil @gemfiles = [] @lockfile = nil + @overrides = [] add_git_sources end @@ -184,10 +185,18 @@ def github(repo, options = {}) with_source(git_source) { yield } end + def override(target, **operations) + operations.each do |field, operation| + @overrides << Override.new(target, field, operation) + end + end + def to_definition(lockfile, unlock) check_primary_source_safety lockfile = @lockfile unless @lockfile.nil? - Definition.new(lockfile, @dependencies, @sources, unlock, @ruby_version, @optional_groups, @gemfiles) + definition = Definition.new(lockfile, @dependencies, @sources, unlock, @ruby_version, @optional_groups, @gemfiles) + definition.overrides = @overrides + definition end def group(*args, &blk) diff --git a/spec/bundler/dsl_spec.rb b/spec/bundler/dsl_spec.rb index 6ba2e728b573..b0aa0fa05d1d 100644 --- a/spec/bundler/dsl_spec.rb +++ b/spec/bundler/dsl_spec.rb @@ -366,4 +366,36 @@ end end end + + describe "#override" do + it "stores an Override for a gem with a version: operation" do + subject.override("rails", version: ">= 8.0") + + expect(subject.overrides.size).to eq(1) + override = subject.overrides.first + expect(override.target).to eq("rails") + expect(override.field).to eq(:version) + expect(override.operation).to eq(">= 8.0") + end + + it "accepts :ignore_upper as the operation" do + subject.override("nokogiri", version: :ignore_upper) + expect(subject.overrides.first.operation).to eq(:ignore_upper) + end + + it "accepts nil as the operation" do + subject.override("legacy", version: nil) + expect(subject.overrides.first.operation).to be_nil + end + + it "appends to overrides across multiple statements" do + subject.override("rails", version: ">= 8.0") + subject.override("nokogiri", version: :ignore_upper) + expect(subject.overrides.map(&:target)).to eq(["rails", "nokogiri"]) + end + + it "is empty by default" do + expect(subject.overrides).to eq([]) + end + end end From b2d0155361a549d099a5f688fc7c31acb31dd10b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 11:50:19 +0900 Subject: [PATCH 04/15] Preserve != exclusions in Override :ignore_upper Switch remove_upper_bounds from a lower-bound allow-list to an upper-bound reject-list so that operators other than <, <=, and ~> are kept verbatim. The previous logic, inherited from Shopify/bundler-ignore-dependency, dropped != exclusions and could silently re-allow versions the user had explicitly pinned out (e.g. >= 1.0, != 1.5.0, < 2.0 collapsed to >= 1.0). Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/override.rb | 14 ++++++++------ spec/bundler/override_spec.rb | 6 ++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/bundler/lib/bundler/override.rb b/bundler/lib/bundler/override.rb index d07dad00ea6b..1ca6d2fde5d4 100644 --- a/bundler/lib/bundler/override.rb +++ b/bundler/lib/bundler/override.rb @@ -2,7 +2,7 @@ module Bundler class Override - LOWER_BOUND_OPERATORS = [">=", ">", "="].freeze + UPPER_BOUND_OPERATORS = ["<", "<="].freeze attr_reader :target, :field, :operation @@ -30,17 +30,19 @@ def apply_to(requirement) def remove_upper_bounds(requirement) return Gem::Requirement.default if requirement.nil? || requirement.none? - lower_bounds = requirement.requirements.filter_map do |op, version| - if LOWER_BOUND_OPERATORS.include?(op) - [op, version] + preserved = requirement.requirements.filter_map do |op, version| + if UPPER_BOUND_OPERATORS.include?(op) + nil elsif op == "~>" [">=", version] + else + [op, version] end end - return Gem::Requirement.default if lower_bounds.empty? + return Gem::Requirement.default if preserved.empty? - Gem::Requirement.new(lower_bounds.map {|op, v| "#{op} #{v}" }) + Gem::Requirement.new(preserved.map {|op, v| "#{op} #{v}" }) end end end diff --git a/spec/bundler/override_spec.rb b/spec/bundler/override_spec.rb index bc813e52fc3f..da3b5aad87a0 100644 --- a/spec/bundler/override_spec.rb +++ b/spec/bundler/override_spec.rb @@ -35,6 +35,12 @@ expect(result).to eq(Gem::Requirement.new(">= 1.5")) end + it "preserves != exclusion constraints" do + override = described_class.new("rails", :version, :ignore_upper) + result = override.apply_to(Gem::Requirement.new(">= 1.0", "!= 1.5.0", "< 2.0")) + expect(result).to eq(Gem::Requirement.new(">= 1.0", "!= 1.5.0")) + end + it "returns the default requirement when only upper bounds remain" do override = described_class.new("rails", :version, :ignore_upper) result = override.apply_to(Gem::Requirement.new("< 2.0")) From 45188358076c6f96615c1149971558dd090372e4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:04:49 +0900 Subject: [PATCH 05/15] Reject `override :all, version:` in Bundler::Dsl Version requirements are per-gem and have no meaning relative to the `:all` target. Raise ArgumentError at the DSL entry point and store nothing when this combination is given, even if other valid fields are mixed in the same call. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/dsl.rb | 4 ++++ spec/bundler/dsl_spec.rb | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index a5df3235b14b..7a4fc3909e97 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -186,6 +186,10 @@ def github(repo, options = {}) end def override(target, **operations) + if target == :all && operations.key?(:version) + raise ArgumentError, "`override :all, version:` is not allowed; version requirements are per-gem" + end + operations.each do |field, operation| @overrides << Override.new(target, field, operation) end diff --git a/spec/bundler/dsl_spec.rb b/spec/bundler/dsl_spec.rb index b0aa0fa05d1d..d5e5f830ce1a 100644 --- a/spec/bundler/dsl_spec.rb +++ b/spec/bundler/dsl_spec.rb @@ -397,5 +397,24 @@ it "is empty by default" do expect(subject.overrides).to eq([]) end + + it "raises ArgumentError when target is :all and version: is given" do + expect do + subject.override(:all, version: ">= 8.0") + end.to raise_error(ArgumentError, /`override :all, version:` is not allowed/) + end + + it "rejects :all + version: even when other fields are also given" do + expect do + subject.override(:all, required_ruby_version: :ignore_upper, version: ">= 8.0") + end.to raise_error(ArgumentError, /`override :all, version:` is not allowed/) + end + + it "does not record any override when :all + version: is rejected" do + expect do + subject.override(:all, version: ">= 8.0") + end.to raise_error(ArgumentError) + expect(subject.overrides).to eq([]) + end end end From c673f50e302e9a33548f703e55801fe43cde7ae6 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:12:43 +0900 Subject: [PATCH 06/15] Validate override target, field, and operation types Reject anything other than :all or a String for the target, fields outside `:version`, and operations that are not a String, nil, or one of the supported Symbol values (currently only :ignore_upper). Validation runs before any Override is recorded so that a multi-field call with an invalid entry leaves the DSL state untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/dsl.rb | 33 +++++++++++++++++++++++++++++++++ spec/bundler/dsl_spec.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 7a4fc3909e97..2f9d1f905474 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -185,11 +185,21 @@ def github(repo, options = {}) with_source(git_source) { yield } end + SUPPORTED_OVERRIDE_FIELDS = [:version].freeze + SUPPORTED_OVERRIDE_SYMBOL_OPERATIONS = [:ignore_upper].freeze + def override(target, **operations) + validate_override_target!(target) + if target == :all && operations.key?(:version) raise ArgumentError, "`override :all, version:` is not allowed; version requirements are per-gem" end + operations.each do |field, operation| + validate_override_field!(field) + validate_override_operation!(operation) + end + operations.each do |field, operation| @overrides << Override.new(target, field, operation) end @@ -257,6 +267,29 @@ def check_primary_source_safety private + def validate_override_target!(target) + return if target == :all + return if target.is_a?(String) + raise ArgumentError, "override target must be :all or a gem name string, got #{target.inspect}" + end + + def validate_override_field!(field) + return if SUPPORTED_OVERRIDE_FIELDS.include?(field) + raise ArgumentError, "unsupported override field `#{field}:`; only `version:` is currently supported" + end + + def validate_override_operation!(operation) + case operation + when String, 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 + end + def add_dependency(name, version = nil, options = {}) options["gemfile"] = @gemfile options["source"] ||= @source diff --git a/spec/bundler/dsl_spec.rb b/spec/bundler/dsl_spec.rb index d5e5f830ce1a..86bcb3be8dbc 100644 --- a/spec/bundler/dsl_spec.rb +++ b/spec/bundler/dsl_spec.rb @@ -416,5 +416,36 @@ end.to raise_error(ArgumentError) expect(subject.overrides).to eq([]) end + + it "raises ArgumentError when target is neither :all nor a string" do + expect do + subject.override(:rails, version: ">= 8.0") + end.to raise_error(ArgumentError, /target must be :all or a gem name string/) + end + + it "raises ArgumentError for an unsupported field" do + expect do + subject.override("rails", required_ruby_version: :ignore_upper) + end.to raise_error(ArgumentError, /unsupported override field `required_ruby_version:`/) + end + + it "raises ArgumentError for a non-string, non-symbol, non-nil operation" do + expect do + subject.override("rails", version: 42) + end.to raise_error(ArgumentError, /override operation must be a String, Symbol, or nil/) + end + + it "raises ArgumentError for an unsupported symbol operation" do + expect do + subject.override("rails", version: :explode) + end.to raise_error(ArgumentError, /unsupported override operation/) + end + + it "rejects atomically when one field in a multi-field call is invalid" do + expect do + subject.override("rails", version: ">= 8.0", required_ruby_version: :ignore_upper) + end.to raise_error(ArgumentError, /unsupported override field/) + expect(subject.overrides).to eq([]) + end end end From 2881ce1087ac5ce87f219930116a59bff05158c0 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:14:25 +0900 Subject: [PATCH 07/15] Reject duplicate target+field in override DSL Two override statements that target the same gem and the same field make it ambiguous which operation should win. Raise ArgumentError when the new (target, field) pair already exists in the recorded overrides, leaving the previously recorded entry untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/dsl.rb | 6 ++++++ spec/bundler/dsl_spec.rb | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 2f9d1f905474..7f14c0dca0cc 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -198,6 +198,7 @@ def override(target, **operations) operations.each do |field, operation| validate_override_field!(field) validate_override_operation!(operation) + validate_override_uniqueness!(target, field) end operations.each do |field, operation| @@ -290,6 +291,11 @@ def validate_override_operation!(operation) end end + def validate_override_uniqueness!(target, field) + return unless @overrides.any? {|o| o.target == target && o.field == field } + raise ArgumentError, "duplicate override for #{target.inspect} `#{field}:`" + end + def add_dependency(name, version = nil, options = {}) options["gemfile"] = @gemfile options["source"] ||= @source diff --git a/spec/bundler/dsl_spec.rb b/spec/bundler/dsl_spec.rb index 86bcb3be8dbc..39f745c05efe 100644 --- a/spec/bundler/dsl_spec.rb +++ b/spec/bundler/dsl_spec.rb @@ -447,5 +447,27 @@ end.to raise_error(ArgumentError, /unsupported override field/) expect(subject.overrides).to eq([]) end + + it "raises ArgumentError when the same target and field are overridden twice" do + subject.override("rails", version: ">= 8.0") + expect do + subject.override("rails", version: :ignore_upper) + end.to raise_error(ArgumentError, /duplicate override for "rails" `version:`/) + end + + it "keeps the original override when a duplicate is rejected" do + subject.override("rails", version: ">= 8.0") + expect do + subject.override("rails", version: :ignore_upper) + end.to raise_error(ArgumentError) + expect(subject.overrides.size).to eq(1) + expect(subject.overrides.first.operation).to eq(">= 8.0") + end + + it "allows different targets with the same field" do + subject.override("rails", version: ">= 8.0") + subject.override("nokogiri", version: :ignore_upper) + expect(subject.overrides.size).to eq(2) + end end end From d28542c88f0587c108baa167a963d474a0b32b08 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:23:55 +0900 Subject: [PATCH 08/15] Apply override version operations in Resolver Pass the Definition's overrides through to Resolver::Base and rewrite each dependency's requirement at the entry of to_dependency_hash so both direct and transitive deps are reshaped before PubGrub sees them. The hook is the same point Shopify/bundler-ignore-dependency uses, since prepare_dependencies and the @cached_dependencies closure both funnel through to_dependency_hash. Override#apply_to handles all three operation kinds, so :ignore_upper and nil also start working from this commit; integration coverage for those paths follows in later commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/definition.rb | 2 +- bundler/lib/bundler/resolver.rb | 12 ++++++++- bundler/lib/bundler/resolver/base.rb | 3 ++- spec/install/gemfile/override_spec.rb | 35 +++++++++++++++++++++++++++ spec/support/windows_tag_group.rb | 1 + 5 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 spec/install/gemfile/override_spec.rb diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 2435479f9441..523e6ff44810 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -1275,7 +1275,7 @@ def unlocked_resolution_base def new_resolution_base(last_resolve:, unlock:) new_resolution_platforms = @current_platform_missing ? @new_platforms + [Bundler.local_platform] : @new_platforms - Resolver::Base.new(source_requirements, expanded_dependencies, last_resolve, @platforms, locked_specs: @originally_locked_specs, unlock: unlock, prerelease: gem_version_promoter.pre?, prefer_local: @prefer_local, new_platforms: new_resolution_platforms) + Resolver::Base.new(source_requirements, expanded_dependencies, last_resolve, @platforms, locked_specs: @originally_locked_specs, unlock: unlock, prerelease: gem_version_promoter.pre?, prefer_local: @prefer_local, new_platforms: new_resolution_platforms, overrides: @overrides) end def new_resolver(base) diff --git a/bundler/lib/bundler/resolver.rb b/bundler/lib/bundler/resolver.rb index 3c361d8ea51a..5e934e2a12e6 100644 --- a/bundler/lib/bundler/resolver.rb +++ b/bundler/lib/bundler/resolver.rb @@ -510,7 +510,7 @@ def requirement_to_range(requirement) end def to_dependency_hash(dependencies, packages) - dependencies.inject({}) do |deps, dep| + apply_overrides(dependencies).inject({}) do |deps, dep| package = packages[dep.name] current_req = deps[package] @@ -526,6 +526,16 @@ def to_dependency_hash(dependencies, packages) end end + def apply_overrides(dependencies) + return dependencies if @base.overrides.empty? + + dependencies.map do |dep| + override = @base.overrides.find {|o| o.target == dep.name && o.field == :version } + next dep unless override + Gem::Dependency.new(dep.name, override.apply_to(dep.requirement)) + end + end + def bundler_not_found_message(conflict_dependencies) candidate_specs = filter_matching_specs(default_bundler_source.specs.search("bundler"), conflict_dependencies) diff --git a/bundler/lib/bundler/resolver/base.rb b/bundler/lib/bundler/resolver/base.rb index 932a92ff4156..00bdd08303df 100644 --- a/bundler/lib/bundler/resolver/base.rb +++ b/bundler/lib/bundler/resolver/base.rb @@ -5,9 +5,10 @@ module Bundler class Resolver class Base - attr_reader :packages, :requirements, :source_requirements, :locked_specs + attr_reader :packages, :requirements, :source_requirements, :locked_specs, :overrides def initialize(source_requirements, dependencies, base, platforms, options) + @overrides = options.delete(:overrides) || [] @source_requirements = source_requirements @locked_specs = options[:locked_specs] diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb new file mode 100644 index 000000000000..e37e47e94999 --- /dev/null +++ b/spec/install/gemfile/override_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe "override DSL" do + context "with a version: string operation" do + it "replaces a direct dependency requirement with the override version spec" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: "= 0.9.1" + gem "myrack" + G + + expect(the_bundle).to include_gems "myrack 0.9.1" + end + + it "replaces a transitive dependency requirement" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: "= 1.0.0" + gem "myrack_middleware" + G + + expect(the_bundle).to include_gems "myrack 1.0.0", "myrack_middleware 1.0" + end + + it "replaces the requirement even when the Gemfile pins a different version" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: "= 0.9.1" + gem "myrack", "= 1.0.0" + G + + expect(the_bundle).to include_gems "myrack 0.9.1" + end + end +end diff --git a/spec/support/windows_tag_group.rb b/spec/support/windows_tag_group.rb index 7b52b58743b6..fb9c0811493f 100644 --- a/spec/support/windows_tag_group.rb +++ b/spec/support/windows_tag_group.rb @@ -190,6 +190,7 @@ module WindowsTagGroup "spec/install/gems/no_build_extension_spec.rb", "spec/install/gems/no_install_plugin_spec.rb", "spec/bundler/override_spec.rb", + "spec/install/gemfile/override_spec.rb", ], }.freeze end From 0b0a348cb9ca94112ae4e5760279f8f57984ec8f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:37:10 +0900 Subject: [PATCH 09/15] Apply override at Definition level for direct dependencies The Resolver-only hook reshapes deps just before PubGrub sees them, but it does not influence the Bundler::Dependency objects fed into Definition#expanded_dependencies. As a result the Resolver::Package built from each direct dep keeps the original requirement, so its prerelease policy and (in later commits) lockfile change detection ignore the override entirely. Apply the override to direct deps at expanded_dependencies as well so that Package metadata and convergence see the effective requirement; the Resolver hook remains responsible for transitive deps fetched from gemspecs. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/definition.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 523e6ff44810..1308b4156339 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -635,7 +635,20 @@ def resolver end def expanded_dependencies - dependencies_with_bundler + metadata_dependencies + apply_overrides_to(dependencies_with_bundler) + metadata_dependencies + end + + def apply_overrides_to(deps) + return deps if @overrides.empty? + deps.map {|dep| apply_override_to(dep) } + end + + def apply_override_to(dep) + override = @overrides.find {|o| o.target == dep.name && o.field == :version } + return dep unless override + new_dep = dep.dup + new_dep.instance_variable_set(:@requirement, override.apply_to(dep.requirement)) + new_dep end def dependencies_with_bundler From 306437ce96b0ebb5e020c6a5c28482d79b3cf030 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:43:58 +0900 Subject: [PATCH 10/15] Mark overridden gems as changed against the existing lockfile converge_dependencies compared the original Gemfile dependency against the locked spec, so adding `override "foo", version: "= X"` to a previously installed bundle did not register as a change. additional_base_requirements_to_prevent_downgrades then kept forwarding the locked version as a >= base requirement, which intersected the override and made it a no-op. Pass overrides into Definition.new positionally so they are available before the constructor calls converge_dependencies, and compare each direct dep's effective requirement (after override) to the locked spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/definition.rb | 6 +++--- bundler/lib/bundler/dsl.rb | 4 +--- spec/install/gemfile/override_spec.rb | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index 1308b4156339..c2eab0efc6fb 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -59,7 +59,7 @@ def self.build(gemfile, lockfile, unlock) # to be updated or true if all gems should be updated # @param ruby_version [Bundler::RubyVersion, nil] Requested Ruby Version # @param optional_groups [Array(String)] A list of optional groups - def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, optional_groups = [], gemfiles = []) + def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, optional_groups = [], gemfiles = [], overrides = []) unlock ||= {} if unlock == true @@ -89,7 +89,7 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti @specs = nil @ruby_version = ruby_version @gemfiles = gemfiles - @overrides = [] + @overrides = overrides @lockfile = lockfile @lockfile_contents = String.new @@ -1044,7 +1044,7 @@ def converge_dependencies @locked_specs.delete(locked_specs.select {|s| s.source != dep.source }) end - unless dep.matches_spec?(locked_specs.first) + unless apply_override_to(dep).matches_spec?(locked_specs.first) @gems_to_unlock << name dep_changed = true end diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 7f14c0dca0cc..c7a7d855eef1 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -209,9 +209,7 @@ def override(target, **operations) def to_definition(lockfile, unlock) check_primary_source_safety lockfile = @lockfile unless @lockfile.nil? - definition = Definition.new(lockfile, @dependencies, @sources, unlock, @ruby_version, @optional_groups, @gemfiles) - definition.overrides = @overrides - definition + Definition.new(lockfile, @dependencies, @sources, unlock, @ruby_version, @optional_groups, @gemfiles, @overrides) end def group(*args, &blk) diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb index e37e47e94999..d3fdace4aef6 100644 --- a/spec/install/gemfile/override_spec.rb +++ b/spec/install/gemfile/override_spec.rb @@ -31,5 +31,24 @@ expect(the_bundle).to include_gems "myrack 0.9.1" end + + it "applies the override against an existing lockfile" do + install_gemfile <<-G + source "https://gem.repo1" + gem "myrack" + G + + expect(the_bundle).to include_gems "myrack 1.0.0" + + gemfile <<-G + source "https://gem.repo1" + override "myrack", version: "= 0.9.1" + gem "myrack" + G + + bundle :install + + expect(the_bundle).to include_gems "myrack 0.9.1" + end end end From 0aeacc86f5bfc181f5f6774d666d4b47d20e1732 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:45:02 +0900 Subject: [PATCH 11/15] Cover prerelease pin via override When the Gemfile dependency does not request a prerelease, Resolver::Package's prerelease policy normally filters them out. Because Definition#expanded_dependencies now feeds the effective (override-applied) dep into Resolver::Base, an override that pins an exact prerelease propagates into the package's prerelease decision and the prerelease becomes selectable. Lock that contract in with an integration test on a has_prerelease 1.0 / 1.1.pre fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/install/gemfile/override_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb index d3fdace4aef6..4f2eb8544d75 100644 --- a/spec/install/gemfile/override_spec.rb +++ b/spec/install/gemfile/override_spec.rb @@ -50,5 +50,20 @@ expect(the_bundle).to include_gems "myrack 0.9.1" end + + it "pins a prerelease version that the Gemfile dependency would otherwise filter out" do + build_repo2 do + build_gem "has_prerelease", "1.0" + build_gem "has_prerelease", "1.1.pre" + end + + install_gemfile <<-G + source "https://gem.repo2" + override "has_prerelease", version: "= 1.1.pre" + gem "has_prerelease" + G + + expect(the_bundle).to include_gems "has_prerelease 1.1.pre" + end end end From e636df48db296baf289156af81c08126cba548de Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:46:17 +0900 Subject: [PATCH 12/15] Add :ignore_upper integration coverage Verify end-to-end that override(..., version: :ignore_upper) drops < / <= bounds and folds ~> into >= so a Gemfile-pinned 0.9.1 ceiling no longer prevents myrack 1.0.0 from being chosen. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/install/gemfile/override_spec.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb index 4f2eb8544d75..0b5f868d42a0 100644 --- a/spec/install/gemfile/override_spec.rb +++ b/spec/install/gemfile/override_spec.rb @@ -66,4 +66,26 @@ expect(the_bundle).to include_gems "has_prerelease 1.1.pre" end end + + context "with a version: :ignore_upper operation" do + it "strips a < upper bound on a direct dependency" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: :ignore_upper + gem "myrack", "< 1.0" + G + + expect(the_bundle).to include_gems "myrack 1.0.0" + end + + it "folds ~> into >= so newer versions become reachable" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: :ignore_upper + gem "myrack", "~> 0.9.1" + G + + expect(the_bundle).to include_gems "myrack 1.0.0" + end + end end From 4abdfe299eb1848b259852bf5b601e318206002b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:47:07 +0900 Subject: [PATCH 13/15] Add version: nil integration coverage Verify end-to-end that override(..., version: nil) collapses the requirement to Gem::Requirement.default for both direct deps (a Gemfile pin to 0.9.1 is removed) and transitive deps (a myrack_middleware-imposed = 0.9.1 floor is removed). Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/install/gemfile/override_spec.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb index 0b5f868d42a0..8a00186d60a0 100644 --- a/spec/install/gemfile/override_spec.rb +++ b/spec/install/gemfile/override_spec.rb @@ -88,4 +88,26 @@ expect(the_bundle).to include_gems "myrack 1.0.0" end end + + context "with a version: nil operation" do + it "drops a direct dependency's pin entirely" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: nil + gem "myrack", "= 0.9.1" + G + + expect(the_bundle).to include_gems "myrack 1.0.0" + end + + it "drops a transitive dependency's pin entirely" do + install_gemfile <<-G + source "https://gem.repo1" + override "myrack", version: nil + gem "myrack_middleware" + G + + expect(the_bundle).to include_gems "myrack 1.0.0", "myrack_middleware 1.0" + end + end end From 30d0d7864bf4839c3b8f729a68aee75b54eb35ce Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 12:51:32 +0900 Subject: [PATCH 14/15] Document the override DSL in gemfile.5.ronn Add an OVERRIDE section between GEMSPEC and SOURCE PRIORITY that covers the syntax, the three operations (version spec string, :ignore_upper, nil), and the lockfile-vs-resolution boundary. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/man/bundle-add.1 | 2 +- bundler/lib/bundler/man/bundle-binstubs.1 | 2 +- bundler/lib/bundler/man/bundle-cache.1 | 2 +- bundler/lib/bundler/man/bundle-check.1 | 2 +- bundler/lib/bundler/man/bundle-clean.1 | 2 +- bundler/lib/bundler/man/bundle-config.1 | 2 +- bundler/lib/bundler/man/bundle-console.1 | 2 +- bundler/lib/bundler/man/bundle-doctor.1 | 2 +- bundler/lib/bundler/man/bundle-env.1 | 2 +- bundler/lib/bundler/man/bundle-exec.1 | 2 +- bundler/lib/bundler/man/bundle-fund.1 | 2 +- bundler/lib/bundler/man/bundle-gem.1 | 2 +- bundler/lib/bundler/man/bundle-help.1 | 2 +- bundler/lib/bundler/man/bundle-info.1 | 2 +- bundler/lib/bundler/man/bundle-init.1 | 2 +- bundler/lib/bundler/man/bundle-install.1 | 2 +- bundler/lib/bundler/man/bundle-issue.1 | 2 +- bundler/lib/bundler/man/bundle-licenses.1 | 2 +- bundler/lib/bundler/man/bundle-list.1 | 2 +- bundler/lib/bundler/man/bundle-lock.1 | 2 +- bundler/lib/bundler/man/bundle-open.1 | 2 +- bundler/lib/bundler/man/bundle-outdated.1 | 2 +- bundler/lib/bundler/man/bundle-platform.1 | 2 +- bundler/lib/bundler/man/bundle-plugin.1 | 2 +- bundler/lib/bundler/man/bundle-pristine.1 | 2 +- bundler/lib/bundler/man/bundle-remove.1 | 2 +- bundler/lib/bundler/man/bundle-show.1 | 2 +- bundler/lib/bundler/man/bundle-update.1 | 2 +- bundler/lib/bundler/man/bundle-version.1 | 2 +- bundler/lib/bundler/man/bundle.1 | 2 +- bundler/lib/bundler/man/gemfile.5 | 38 +++++++++++++++++++++- bundler/lib/bundler/man/gemfile.5.ronn | 39 +++++++++++++++++++++++ 32 files changed, 106 insertions(+), 31 deletions(-) diff --git a/bundler/lib/bundler/man/bundle-add.1 b/bundler/lib/bundler/man/bundle-add.1 index f49d8e568c9b..031eef686c94 100644 --- a/bundler/lib/bundler/man/bundle-add.1 +++ b/bundler/lib/bundler/man/bundle-add.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-ADD" "1" "April 2026" "" +.TH "BUNDLE\-ADD" "1" "May 2026" "" .SH "NAME" \fBbundle\-add\fR \- Add gem to the Gemfile and run bundle install .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-binstubs.1 b/bundler/lib/bundler/man/bundle-binstubs.1 index 9dbd4f1d3a1f..246daeae53ed 100644 --- a/bundler/lib/bundler/man/bundle-binstubs.1 +++ b/bundler/lib/bundler/man/bundle-binstubs.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-BINSTUBS" "1" "April 2026" "" +.TH "BUNDLE\-BINSTUBS" "1" "May 2026" "" .SH "NAME" \fBbundle\-binstubs\fR \- Install the binstubs of the listed gems .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-cache.1 b/bundler/lib/bundler/man/bundle-cache.1 index e2052ab0ac16..38ea04796124 100644 --- a/bundler/lib/bundler/man/bundle-cache.1 +++ b/bundler/lib/bundler/man/bundle-cache.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-CACHE" "1" "April 2026" "" +.TH "BUNDLE\-CACHE" "1" "May 2026" "" .SH "NAME" \fBbundle\-cache\fR \- Package your needed \fB\.gem\fR files into your application .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-check.1 b/bundler/lib/bundler/man/bundle-check.1 index 825a2889d57a..6cd474d90ab7 100644 --- a/bundler/lib/bundler/man/bundle-check.1 +++ b/bundler/lib/bundler/man/bundle-check.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-CHECK" "1" "April 2026" "" +.TH "BUNDLE\-CHECK" "1" "May 2026" "" .SH "NAME" \fBbundle\-check\fR \- Verifies if dependencies are satisfied by installed gems .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-clean.1 b/bundler/lib/bundler/man/bundle-clean.1 index 0eae33d08d19..eb90636c1717 100644 --- a/bundler/lib/bundler/man/bundle-clean.1 +++ b/bundler/lib/bundler/man/bundle-clean.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-CLEAN" "1" "April 2026" "" +.TH "BUNDLE\-CLEAN" "1" "May 2026" "" .SH "NAME" \fBbundle\-clean\fR \- Cleans up unused gems in your bundler directory .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-config.1 b/bundler/lib/bundler/man/bundle-config.1 index 8835ae75bd9e..61487ca55e79 100644 --- a/bundler/lib/bundler/man/bundle-config.1 +++ b/bundler/lib/bundler/man/bundle-config.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-CONFIG" "1" "April 2026" "" +.TH "BUNDLE\-CONFIG" "1" "May 2026" "" .SH "NAME" \fBbundle\-config\fR \- Set bundler configuration options .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-console.1 b/bundler/lib/bundler/man/bundle-console.1 index c86b90e3bd23..5d3f65365faa 100644 --- a/bundler/lib/bundler/man/bundle-console.1 +++ b/bundler/lib/bundler/man/bundle-console.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-CONSOLE" "1" "April 2026" "" +.TH "BUNDLE\-CONSOLE" "1" "May 2026" "" .SH "NAME" \fBbundle\-console\fR \- Open an IRB session with the bundle pre\-loaded .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-doctor.1 b/bundler/lib/bundler/man/bundle-doctor.1 index fe9a8a35b9af..4c59871b66ad 100644 --- a/bundler/lib/bundler/man/bundle-doctor.1 +++ b/bundler/lib/bundler/man/bundle-doctor.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-DOCTOR" "1" "April 2026" "" +.TH "BUNDLE\-DOCTOR" "1" "May 2026" "" .SH "NAME" \fBbundle\-doctor\fR \- Checks the bundle for common problems .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-env.1 b/bundler/lib/bundler/man/bundle-env.1 index 29c4ac2a8e21..25fcb648917f 100644 --- a/bundler/lib/bundler/man/bundle-env.1 +++ b/bundler/lib/bundler/man/bundle-env.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-ENV" "1" "April 2026" "" +.TH "BUNDLE\-ENV" "1" "May 2026" "" .SH "NAME" \fBbundle\-env\fR \- Print information about the environment Bundler is running under .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-exec.1 b/bundler/lib/bundler/man/bundle-exec.1 index fec7bee39c33..c3a6a09d578a 100644 --- a/bundler/lib/bundler/man/bundle-exec.1 +++ b/bundler/lib/bundler/man/bundle-exec.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-EXEC" "1" "April 2026" "" +.TH "BUNDLE\-EXEC" "1" "May 2026" "" .SH "NAME" \fBbundle\-exec\fR \- Execute a command in the context of the bundle .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-fund.1 b/bundler/lib/bundler/man/bundle-fund.1 index 2eb07a6c8d8c..caee1f81dd64 100644 --- a/bundler/lib/bundler/man/bundle-fund.1 +++ b/bundler/lib/bundler/man/bundle-fund.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-FUND" "1" "April 2026" "" +.TH "BUNDLE\-FUND" "1" "May 2026" "" .SH "NAME" \fBbundle\-fund\fR \- Lists information about gems seeking funding assistance .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-gem.1 b/bundler/lib/bundler/man/bundle-gem.1 index bdb84faebdd0..87d756824698 100644 --- a/bundler/lib/bundler/man/bundle-gem.1 +++ b/bundler/lib/bundler/man/bundle-gem.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-GEM" "1" "April 2026" "" +.TH "BUNDLE\-GEM" "1" "May 2026" "" .SH "NAME" \fBbundle\-gem\fR \- Generate a project skeleton for creating a rubygem .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-help.1 b/bundler/lib/bundler/man/bundle-help.1 index 6e6ad14624d7..3bcfd047e546 100644 --- a/bundler/lib/bundler/man/bundle-help.1 +++ b/bundler/lib/bundler/man/bundle-help.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-HELP" "1" "April 2026" "" +.TH "BUNDLE\-HELP" "1" "May 2026" "" .SH "NAME" \fBbundle\-help\fR \- Displays detailed help for each subcommand .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-info.1 b/bundler/lib/bundler/man/bundle-info.1 index b18b70309c50..49c2295f8c98 100644 --- a/bundler/lib/bundler/man/bundle-info.1 +++ b/bundler/lib/bundler/man/bundle-info.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-INFO" "1" "April 2026" "" +.TH "BUNDLE\-INFO" "1" "May 2026" "" .SH "NAME" \fBbundle\-info\fR \- Show information for the given gem in your bundle .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-init.1 b/bundler/lib/bundler/man/bundle-init.1 index 5ea1c3b47837..63e2376c3fd3 100644 --- a/bundler/lib/bundler/man/bundle-init.1 +++ b/bundler/lib/bundler/man/bundle-init.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-INIT" "1" "April 2026" "" +.TH "BUNDLE\-INIT" "1" "May 2026" "" .SH "NAME" \fBbundle\-init\fR \- Generates a Gemfile into the current working directory .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-install.1 b/bundler/lib/bundler/man/bundle-install.1 index c2f9bfeea1f7..a764d031ed12 100644 --- a/bundler/lib/bundler/man/bundle-install.1 +++ b/bundler/lib/bundler/man/bundle-install.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-INSTALL" "1" "April 2026" "" +.TH "BUNDLE\-INSTALL" "1" "May 2026" "" .SH "NAME" \fBbundle\-install\fR \- Install the dependencies specified in your Gemfile .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-issue.1 b/bundler/lib/bundler/man/bundle-issue.1 index e99cf67638f3..3af277ef867a 100644 --- a/bundler/lib/bundler/man/bundle-issue.1 +++ b/bundler/lib/bundler/man/bundle-issue.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-ISSUE" "1" "April 2026" "" +.TH "BUNDLE\-ISSUE" "1" "May 2026" "" .SH "NAME" \fBbundle\-issue\fR \- Get help reporting Bundler issues .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-licenses.1 b/bundler/lib/bundler/man/bundle-licenses.1 index eb5f7203ec1d..ab5996d2be7a 100644 --- a/bundler/lib/bundler/man/bundle-licenses.1 +++ b/bundler/lib/bundler/man/bundle-licenses.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-LICENSES" "1" "April 2026" "" +.TH "BUNDLE\-LICENSES" "1" "May 2026" "" .SH "NAME" \fBbundle\-licenses\fR \- Print the license of all gems in the bundle .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-list.1 b/bundler/lib/bundler/man/bundle-list.1 index 69276822d2cd..e759e0d4493b 100644 --- a/bundler/lib/bundler/man/bundle-list.1 +++ b/bundler/lib/bundler/man/bundle-list.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-LIST" "1" "April 2026" "" +.TH "BUNDLE\-LIST" "1" "May 2026" "" .SH "NAME" \fBbundle\-list\fR \- List all the gems in the bundle .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-lock.1 b/bundler/lib/bundler/man/bundle-lock.1 index ba1915af2e15..396c8ff6ca97 100644 --- a/bundler/lib/bundler/man/bundle-lock.1 +++ b/bundler/lib/bundler/man/bundle-lock.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-LOCK" "1" "April 2026" "" +.TH "BUNDLE\-LOCK" "1" "May 2026" "" .SH "NAME" \fBbundle\-lock\fR \- Creates / Updates a lockfile without installing .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-open.1 b/bundler/lib/bundler/man/bundle-open.1 index 99166e8580f4..2aab59f14b9b 100644 --- a/bundler/lib/bundler/man/bundle-open.1 +++ b/bundler/lib/bundler/man/bundle-open.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-OPEN" "1" "April 2026" "" +.TH "BUNDLE\-OPEN" "1" "May 2026" "" .SH "NAME" \fBbundle\-open\fR \- Opens the source directory for a gem in your bundle .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-outdated.1 b/bundler/lib/bundler/man/bundle-outdated.1 index 87725b9029e3..b739234d8daf 100644 --- a/bundler/lib/bundler/man/bundle-outdated.1 +++ b/bundler/lib/bundler/man/bundle-outdated.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-OUTDATED" "1" "April 2026" "" +.TH "BUNDLE\-OUTDATED" "1" "May 2026" "" .SH "NAME" \fBbundle\-outdated\fR \- List installed gems with newer versions available .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-platform.1 b/bundler/lib/bundler/man/bundle-platform.1 index 486e2d4406bf..39b711126353 100644 --- a/bundler/lib/bundler/man/bundle-platform.1 +++ b/bundler/lib/bundler/man/bundle-platform.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-PLATFORM" "1" "April 2026" "" +.TH "BUNDLE\-PLATFORM" "1" "May 2026" "" .SH "NAME" \fBbundle\-platform\fR \- Displays platform compatibility information .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-plugin.1 b/bundler/lib/bundler/man/bundle-plugin.1 index 1c3feead7630..d182c7789ba1 100644 --- a/bundler/lib/bundler/man/bundle-plugin.1 +++ b/bundler/lib/bundler/man/bundle-plugin.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-PLUGIN" "1" "April 2026" "" +.TH "BUNDLE\-PLUGIN" "1" "May 2026" "" .SH "NAME" \fBbundle\-plugin\fR \- Manage Bundler plugins .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-pristine.1 b/bundler/lib/bundler/man/bundle-pristine.1 index cbfc51399a70..f6cc06657161 100644 --- a/bundler/lib/bundler/man/bundle-pristine.1 +++ b/bundler/lib/bundler/man/bundle-pristine.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-PRISTINE" "1" "April 2026" "" +.TH "BUNDLE\-PRISTINE" "1" "May 2026" "" .SH "NAME" \fBbundle\-pristine\fR \- Restores installed gems to their pristine condition .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-remove.1 b/bundler/lib/bundler/man/bundle-remove.1 index f8981f9fcfcb..2ca40e74db4f 100644 --- a/bundler/lib/bundler/man/bundle-remove.1 +++ b/bundler/lib/bundler/man/bundle-remove.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-REMOVE" "1" "April 2026" "" +.TH "BUNDLE\-REMOVE" "1" "May 2026" "" .SH "NAME" \fBbundle\-remove\fR \- Removes gems from the Gemfile .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-show.1 b/bundler/lib/bundler/man/bundle-show.1 index aaf146fa271b..a2142694b8d3 100644 --- a/bundler/lib/bundler/man/bundle-show.1 +++ b/bundler/lib/bundler/man/bundle-show.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-SHOW" "1" "April 2026" "" +.TH "BUNDLE\-SHOW" "1" "May 2026" "" .SH "NAME" \fBbundle\-show\fR \- Shows all the gems in your bundle, or the path to a gem .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-update.1 b/bundler/lib/bundler/man/bundle-update.1 index e5f18f2a1e26..6a749644e31a 100644 --- a/bundler/lib/bundler/man/bundle-update.1 +++ b/bundler/lib/bundler/man/bundle-update.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-UPDATE" "1" "April 2026" "" +.TH "BUNDLE\-UPDATE" "1" "May 2026" "" .SH "NAME" \fBbundle\-update\fR \- Update your gems to the latest available versions .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle-version.1 b/bundler/lib/bundler/man/bundle-version.1 index 24b5dcef45d7..751a408312a9 100644 --- a/bundler/lib/bundler/man/bundle-version.1 +++ b/bundler/lib/bundler/man/bundle-version.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE\-VERSION" "1" "April 2026" "" +.TH "BUNDLE\-VERSION" "1" "May 2026" "" .SH "NAME" \fBbundle\-version\fR \- Prints Bundler version information .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/bundle.1 b/bundler/lib/bundler/man/bundle.1 index 492de63295a1..167815631a2b 100644 --- a/bundler/lib/bundler/man/bundle.1 +++ b/bundler/lib/bundler/man/bundle.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "BUNDLE" "1" "April 2026" "" +.TH "BUNDLE" "1" "May 2026" "" .SH "NAME" \fBbundle\fR \- Ruby Dependency Management .SH "SYNOPSIS" diff --git a/bundler/lib/bundler/man/gemfile.5 b/bundler/lib/bundler/man/gemfile.5 index db04250b8b8c..0874bb5a4a7b 100644 --- a/bundler/lib/bundler/man/gemfile.5 +++ b/bundler/lib/bundler/man/gemfile.5 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.10.1 .\" http://github.com/apjanke/ronn-ng/tree/0.10.1 -.TH "GEMFILE" "5" "April 2026" "" +.TH "GEMFILE" "5" "May 2026" "" .SH "NAME" \fBGemfile\fR \- A format for describing gem dependencies for Ruby programs .SH "SYNOPSIS" @@ -460,6 +460,42 @@ The \fBgemspec\fR method adds any runtime dependencies as gem requirements in th The \fBgemspec\fR method supports optional \fB:path\fR, \fB:glob\fR, \fB:name\fR, and \fB:development_group\fR options, which control where bundler looks for the \fB\.gemspec\fR, the glob it uses to look for the gemspec (defaults to: \fB{,*,*/*}\.gemspec\fR), what named \fB\.gemspec\fR it uses (if more than one is present), and which group development dependencies are included in\. .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\. +.IP "" 4 +.nf +override , : +.fi +.IP "" 0 +.P +\fB\fR is a gem name string\. \fB\fR is \fBversion:\fR\. \fB\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\. +.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 +Multiple \fBoverride\fR calls for distinct targets are allowed; declaring the same \fBtarget\fR and \fBfield\fR twice is an error\. +.IP "" 4 +.nf +source "https://rubygems\.org" + +# Force every reference to "rails" \-\- direct or transitive \-\- to >= 8\.0\. +override "rails", version: ">= 8\.0" + +# Strip the upper bound on nokogiri\. +override "nokogiri", version: :ignore_upper + +# Drop the version pin on legacy entirely\. +override "legacy", 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\. .SH "SOURCE PRIORITY" When attempting to locate a gem to satisfy a gem requirement, bundler uses the following priority order: .IP "1." 4 diff --git a/bundler/lib/bundler/man/gemfile.5.ronn b/bundler/lib/bundler/man/gemfile.5.ronn index 18d7bb826e44..1779fc0a0152 100644 --- a/bundler/lib/bundler/man/gemfile.5.ronn +++ b/bundler/lib/bundler/man/gemfile.5.ronn @@ -541,6 +541,45 @@ When a `gemspec` 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 `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 +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 , : + +`` is a gem name string. `` is `version:`. `` 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. + * `: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). + +Multiple `override` calls for distinct targets are allowed; declaring the +same `target` and `field` twice is an error. + + source "https://rubygems.org" + + # Force every reference to "rails" -- direct or transitive -- to >= 8.0. + override "rails", version: ">= 8.0" + + # Strip the upper bound on nokogiri. + override "nokogiri", version: :ignore_upper + + # Drop the version pin on legacy entirely. + override "legacy", version: nil + + gem "rails", "~> 7.0" + +The override only affects resolution; `Gemfile.lock` continues to reflect +the resolved versions, not the rewritten requirements. + ## SOURCE PRIORITY When attempting to locate a gem to satisfy a gem requirement, From e66bd3dcf951c48749949cdae0e18549846b3cc4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 1 May 2026 13:38:49 +0900 Subject: [PATCH 15/15] Mark transitive-only overrides as changed against the lockfile converge_dependencies only iterates @dependencies (Gemfile-declared direct deps), so an override that targets a gem present only as a transitive dependency never registered as a change. With an existing lockfile, @dependency_changes stayed false, the resolver was skipped, and the override was a silent no-op. After the direct-dep loop, inspect @overrides for any String target that is locked but not a direct dep and force it onto @gems_to_unlock / @changed_dependencies so resolution runs and the Resolver-side override hook applies. Co-Authored-By: Claude Opus 4.7 (1M context) --- bundler/lib/bundler/definition.rb | 16 ++++++++++++++++ spec/install/gemfile/override_spec.rb | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/bundler/lib/bundler/definition.rb b/bundler/lib/bundler/definition.rb index c2eab0efc6fb..a64b67cb44d0 100644 --- a/bundler/lib/bundler/definition.rb +++ b/bundler/lib/bundler/definition.rb @@ -1054,9 +1054,25 @@ def converge_dependencies @changed_dependencies << name if dep_changed end + converge_overrides_outside_dependencies + @changed_dependencies.any? end + def converge_overrides_outside_dependencies + @overrides.each do |override| + 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? + + @gems_to_unlock << name + @changed_dependencies << name + end + end + # Remove elements from the locked specs that are expired. This will most # commonly happen if the Gemfile has changed since the lockfile was last # generated diff --git a/spec/install/gemfile/override_spec.rb b/spec/install/gemfile/override_spec.rb index 8a00186d60a0..7a7f8078a817 100644 --- a/spec/install/gemfile/override_spec.rb +++ b/spec/install/gemfile/override_spec.rb @@ -109,5 +109,24 @@ expect(the_bundle).to include_gems "myrack 1.0.0", "myrack_middleware 1.0" end + + it "applies a transitive-only override against an existing lockfile" do + install_gemfile <<-G + source "https://gem.repo1" + gem "myrack_middleware" + G + + expect(the_bundle).to include_gems "myrack 0.9.1", "myrack_middleware 1.0" + + gemfile <<-G + source "https://gem.repo1" + override "myrack", version: "= 1.0.0" + gem "myrack_middleware" + G + + bundle :install + + expect(the_bundle).to include_gems "myrack 1.0.0", "myrack_middleware 1.0" + end end end