summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHomu <homu@barosl.com>2016-07-09 20:36:25 +0900
committerHomu <homu@barosl.com>2016-07-09 20:36:25 +0900
commita4169b08b22af64cccb84fb0f6f96c13b8a69c6c (patch)
tree2a9a69dc7fde0636d5b8483bd931ac073296dd7b
parent14b2aba1072846a8eb15a59436c7c47a7ab54beb (diff)
parent263c1879e6e892810b70dca556b27d36ff4ffddf (diff)
downloadbundler-a4169b08b22af64cccb84fb0f6f96c13b8a69c6c.tar.gz
Auto merge of #4676 - chrismo:conservative_updates, r=segiddins
Conservative updates A port of bundler-patch to bundler proper. Core team questions: - [x] bundler-patch also works in vulnerable gem updates based on ruby-advisory-db content. I presume the core team would consider this out of scope for Bundler proper? I probably would, and there's no reason bundler-patch couldn't continue to exist as a plugin with this behavior. - [x] "resolves foo only to latest patch - changing dependency case" - more comments on that spec. It's a weird edge case. Hopefully the specs and comments make it clear. - [x] Name of new class: GemVersionPromoter. It's gone through a few renames already and every option I think of I hate now :) - [x] The `--minimal` flag is also something bundler-patch has that has debatable value. Would love to hear opinions on it and could be easily swayed to remove it or keep it. If I'm on 1.0.1, and both 1.0.2 and 1.0.15 exist, `--minimal` will resolve to `1.0.2` instead of the latest `1.0.15`. Dunno if there's value in that. - [x] even without `--strict` mode, this code will _remove_ older versions from the options handed back to Molinillo (in `--patch` and `--minor` cases). This is a weird case, but I have seen `bundle update` proper regress a gem version backwards if its parent gem changed to an older dependency (I guess in a bug fix case). I'm ok with this, but it could be considered an inconsistency with the default `--major` case. - [x] if we roll this out first as an undocumented feature, do we require a config option to toggle it on? We wouldn't have to have one, none of the new behavior will kick in until a new flag (`--patch` or `--minor`) is passed. --- Notes for issues to create that could be handled separately after this PR, esp. if we release undocumented/unsupported to begin with. - Updating `outdated` to use the new flags and resolution - adding some warnings when dependent updates go past --patch or --minor level (with an instruction to use --strict if they don’t want that) - adding a warning when an unlocked gem doesn't move (possibly w/ a reason why?). the lack of feedback otherwise can be disconcerting, like "did I not do it right?" - possibly adding a dry-run flag - though after looking at that today, that would make the most sense inside Installer, and make that flag an option for both install and update (drilling through all of the calls to push that option in looks like it would be annoying). - man page documentation
-rw-r--r--lib/bundler.rb1
-rw-r--r--lib/bundler/cli.rb8
-rw-r--r--lib/bundler/cli/update.rb7
-rw-r--r--lib/bundler/definition.rb21
-rw-r--r--lib/bundler/gem_version_promoter.rb172
-rw-r--r--lib/bundler/resolver.rb16
-rw-r--r--lib/bundler/spec_set.rb2
-rw-r--r--spec/bundler/definition_spec.rb47
-rw-r--r--spec/bundler/gem_version_promoter_spec.rb169
-rw-r--r--spec/commands/update_spec.rb91
-rw-r--r--spec/plugins/install_spec.rb2
-rw-r--r--spec/resolver/basic_spec.rb129
-rw-r--r--spec/support/indexes.rb24
13 files changed, 674 insertions, 15 deletions
diff --git a/lib/bundler.rb b/lib/bundler.rb
index 0ba58e518c..b8eed25d44 100644
--- a/lib/bundler.rb
+++ b/lib/bundler.rb
@@ -30,6 +30,7 @@ module Bundler
autoload :Fetcher, "bundler/fetcher"
autoload :GemHelper, "bundler/gem_helper"
autoload :GemHelpers, "bundler/gem_helpers"
+ autoload :GemVersionPromoter, "bundler/gem_version_promoter"
autoload :Graph, "bundler/graph"
autoload :Index, "bundler/index"
autoload :Installer, "bundler/installer"
diff --git a/lib/bundler/cli.rb b/lib/bundler/cli.rb
index 03029e0967..d6afd275e5 100644
--- a/lib/bundler/cli.rb
+++ b/lib/bundler/cli.rb
@@ -208,6 +208,14 @@ module Bundler
"Update ruby specified in Gemfile.lock"
method_option "bundler", :type => :string, :lazy_default => "> 0.a", :banner =>
"Update the locked version of bundler"
+ method_option "patch", :type => :boolean, :hide => true, :banner =>
+ "Prefer updating only to next patch version"
+ method_option "minor", :type => :boolean, :hide => true, :banner =>
+ "Prefer updating only to next minor version"
+ method_option "major", :type => :boolean, :hide => true, :banner =>
+ "Prefer updating to next major version (default)"
+ method_option "strict", :type => :boolean, :hide => true, :banner =>
+ "Do not allow any gem to be updated past latest --patch/--minor/--major"
def update(*gems)
require "bundler/cli/update"
Update.new(options, gems).run
diff --git a/lib/bundler/cli/update.rb b/lib/bundler/cli/update.rb
index bef62f3b78..1c35659e0b 100644
--- a/lib/bundler/cli/update.rb
+++ b/lib/bundler/cli/update.rb
@@ -39,6 +39,13 @@ module Bundler
Bundler.definition(:gems => gems, :sources => sources, :ruby => options[:ruby])
end
+ patch_level = [:major, :minor, :patch].select {|v| options.keys.include?(v.to_s) }
+ raise ProductionError, "Provide only one of the following options: #{patch_level.join(", ")}" unless patch_level.length <= 1
+ Bundler.definition.gem_version_promoter.tap do |gvp|
+ gvp.level = patch_level.first || :major
+ gvp.strict = options[:strict]
+ end
+
Bundler::Fetcher.disable_endpoint = options["full-index"]
opts = options.dup
diff --git a/lib/bundler/definition.rb b/lib/bundler/definition.rb
index 7932d2d7e2..b7db1179a0 100644
--- a/lib/bundler/definition.rb
+++ b/lib/bundler/definition.rb
@@ -7,7 +7,7 @@ module Bundler
class Definition
include GemHelpers
- attr_reader :dependencies, :platforms, :ruby_version, :locked_deps
+ attr_reader :dependencies, :platforms, :ruby_version, :locked_deps, :gem_version_promoter
# Given a gemfile and lockfile creates a Bundler definition
#
@@ -94,6 +94,8 @@ module Bundler
end
@unlocking ||= @unlock[:ruby] ||= (!@locked_ruby_version ^ !@ruby_version)
+ @gem_version_promoter = create_gem_version_promoter
+
current_platform = Bundler.rubygems.platforms.map {|p| generic(p) }.compact.last
add_platform(current_platform)
@@ -123,6 +125,21 @@ module Bundler
end
end
+ def create_gem_version_promoter
+ locked_specs = begin
+ if @unlocking && @locked_specs.empty? && !@lockfile_contents.empty?
+ # Definition uses an empty set of locked_specs to indicate all gems
+ # are unlocked, but GemVersionPromoter needs the locked_specs
+ # for conservative comparison.
+ locked = Bundler::LockfileParser.new(@lockfile_contents)
+ Bundler::SpecSet.new(locked.specs)
+ else
+ @locked_specs
+ end
+ end
+ GemVersionPromoter.new(locked_specs, @unlock[:gems])
+ end
+
def resolve_with_cache!
raise "Specs already loaded" if @specs
sources.cached!
@@ -221,7 +238,7 @@ module Bundler
else
# Run a resolve against the locally available gems
Bundler.ui.debug("Found changes from the lockfile, re-resolving dependencies because #{change_reason}")
- last_resolve.merge Resolver.resolve(expanded_dependencies, index, source_requirements, last_resolve, ruby_version)
+ last_resolve.merge Resolver.resolve(expanded_dependencies, index, source_requirements, last_resolve, ruby_version, gem_version_promoter)
end
end
end
diff --git a/lib/bundler/gem_version_promoter.rb b/lib/bundler/gem_version_promoter.rb
new file mode 100644
index 0000000000..d9debb899f
--- /dev/null
+++ b/lib/bundler/gem_version_promoter.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+module Bundler
+ # This class contains all of the logic for determining the next version of a
+ # Gem to update to based on the requested level (patch, minor, major).
+ # Primarily designed to work with Resolver which will provide it the list of
+ # available dependency versions as found in its index, before returning it to
+ # to the resolution engine to select the best version.
+ class GemVersionPromoter
+ attr_reader :level, :locked_specs, :unlock_gems
+
+ # By default, strict is false, meaning every available version of a gem
+ # is returned from sort_versions. The order gives preference to the
+ # requested level (:patch, :minor, :major) but in complicated requirement
+ # cases some gems will by necessity by promoted past the requested level,
+ # or even reverted to older versions.
+ #
+ # If strict is set to true, the results from sort_versions will be
+ # truncated, eliminating any version outside the current level scope.
+ # This can lead to unexpected outcomes or even VersionConflict exceptions
+ # that report a version of a gem not existing for versions that indeed do
+ # existing in the referenced source.
+ attr_accessor :strict
+
+ # Given a list of locked_specs and a list of gems to unlock creates a
+ # GemVersionPromoter instance.
+ #
+ # @param locked_specs [SpecSet] All current locked specs. Unlike Definition
+ # where this list is empty if all gems are being updated, this should
+ # always be populated for all gems so this class can properly function.
+ # @param unlock_gems [String] List of gem names being unlocked. If empty,
+ # all gems will be considered unlocked.
+ # @return [GemVersionPromoter]
+ def initialize(locked_specs = SpecSet.new([]), unlock_gems = [])
+ @level = :major
+ @strict = false
+ @locked_specs = locked_specs
+ @unlock_gems = unlock_gems
+ @sort_versions = {}
+ end
+
+ # @param value [Symbol] One of three Symbols: :major, :minor or :patch.
+ def level=(value)
+ v = case value
+ when String, Symbol
+ value.to_sym
+ end
+
+ raise ArgumentError, "Unexpected level #{v}. Must be :major, :minor or :patch" unless [:major, :minor, :patch].include?(v)
+ @level = v
+ end
+
+ # Given a Dependency and an Array of SpecGroups of available versions for a
+ # gem, this method will return the Array of SpecGroups sorted (and possibly
+ # truncated if strict is true) in an order to give preference to the current
+ # level (:major, :minor or :patch) when resolution is deciding what versions
+ # best resolve all dependencies in the bundle.
+ # @param dep [Dependency] The Dependency of the gem.
+ # @param spec_groups [SpecGroup] An array of SpecGroups for the same gem
+ # named in the @dep param.
+ # @return [SpecGroup] A new instance of the SpecGroup Array sorted and
+ # possibly filtered.
+ def sort_versions(dep, spec_groups)
+ before_result = "before sort_versions: #{debug_format_result(dep, spec_groups).inspect}" if ENV["DEBUG_RESOLVER"]
+
+ @sort_versions[dep] ||= begin
+ gem_name = dep.name
+
+ # An Array per version returned, different entries for different platforms.
+ # We only need the version here so it's ok to hard code this to the first instance.
+ locked_spec = locked_specs[gem_name].first
+
+ if strict
+ filter_dep_specs(spec_groups, locked_spec)
+ else
+ sort_dep_specs(spec_groups, locked_spec)
+ end.tap do |specs|
+ if ENV["DEBUG_RESOLVER"]
+ STDERR.puts before_result
+ STDERR.puts " after sort_versions: #{debug_format_result(dep, specs).inspect}"
+ end
+ end
+ end
+ end
+
+ # @return [bool] Convenience method for testing value of level variable.
+ def major?
+ level == :major
+ end
+
+ # @return [bool] Convenience method for testing value of level variable.
+ def minor?
+ level == :minor
+ end
+
+ private
+
+ def filter_dep_specs(spec_groups, locked_spec)
+ res = spec_groups.select do |spec_group|
+ if locked_spec && !major?
+ gsv = spec_group.version
+ lsv = locked_spec.version
+
+ must_match = minor? ? [0] : [0, 1]
+
+ matches = must_match.map {|idx| gsv.segments[idx] == lsv.segments[idx] }
+ (matches.uniq == [true]) ? (gsv >= lsv) : false
+ else
+ true
+ end
+ end
+
+ sort_dep_specs(res, locked_spec)
+ end
+
+ def sort_dep_specs(spec_groups, locked_spec)
+ return spec_groups unless locked_spec
+ gem_name = locked_spec.name
+ locked_version = locked_spec.version
+
+ spec_groups.sort do |a, b|
+ a_ver = a.version
+ b_ver = b.version
+ case
+ when major?
+ a_ver <=> b_ver
+ when either_version_older_than_locked(locked_version, a_ver, b_ver)
+ a_ver <=> b_ver
+ when segments_do_not_match(:major, a_ver, b_ver)
+ b_ver <=> a_ver
+ when !minor? && segments_do_not_match(:minor, a_ver, b_ver)
+ b_ver <=> a_ver
+ else
+ a_ver <=> b_ver
+ end
+ end.tap do |result|
+ # default :major behavior in Bundler does not do this
+ unless major?
+ unless unlocking_gem?(gem_name)
+ move_version_to_end(spec_groups, locked_version, result)
+ end
+ end
+ end
+ end
+
+ def either_version_older_than_locked(locked_version, a_ver, b_ver)
+ a_ver < locked_version || b_ver < locked_version
+ end
+
+ def segments_do_not_match(level, a_ver, b_ver)
+ index = [:major, :minor].index(level)
+ a_ver.segments[index] != b_ver.segments[index]
+ end
+
+ def unlocking_gem?(gem_name)
+ unlock_gems.empty? || unlock_gems.include?(gem_name)
+ end
+
+ def move_version_to_end(spec_groups, version, result)
+ spec_group = spec_groups.detect {|s| s.version.to_s == version.to_s }
+ return unless spec_group
+ result.reject! {|s| s.version.to_s == version.to_s }
+ result << spec_group
+ end
+
+ def debug_format_result(dep, spec_groups)
+ a = [dep.to_s,
+ spec_groups.map {|sg| [sg.version, sg.dependencies_for_activated_platforms.map {|dp| [dp.name, dp.requirement.to_s] }] }]
+ last_map = a.last.map {|sg_data| [sg_data.first.version, sg_data.last.map {|aa| aa.join(" ") }] }
+ [a.first, last_map, level, strict ? :strict : :not_strict]
+ end
+ end
+end
diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb
index 54b8fa4222..e1df04efb1 100644
--- a/lib/bundler/resolver.rb
+++ b/lib/bundler/resolver.rb
@@ -172,14 +172,14 @@ module Bundler
# ==== Returns
# <GemBundle>,nil:: If the list of dependencies can be resolved, a
# collection of gemspecs is returned. Otherwise, nil is returned.
- def self.resolve(requirements, index, source_requirements = {}, base = [], ruby_version = nil)
+ def self.resolve(requirements, index, source_requirements = {}, base = [], ruby_version = nil, gem_version_promoter = GemVersionPromoter.new)
base = SpecSet.new(base) unless base.is_a?(SpecSet)
- resolver = new(index, source_requirements, base, ruby_version)
+ resolver = new(index, source_requirements, base, ruby_version, gem_version_promoter)
result = resolver.start(requirements)
SpecSet.new(result)
end
- def initialize(index, source_requirements, base, ruby_version)
+ def initialize(index, source_requirements, base, ruby_version, gem_version_promoter)
@index = index
@source_requirements = source_requirements
@base = base
@@ -188,6 +188,7 @@ module Bundler
@base_dg = Molinillo::DependencyGraph.new
@base.each {|ls| @base_dg.add_vertex(ls.name, Dependency.new(ls.name, ls.version), true) }
@ruby_version = ruby_version
+ @gem_version_promoter = gem_version_promoter
end
def start(requirements)
@@ -249,7 +250,7 @@ module Bundler
if vertex = @base_dg.vertex_named(dependency.name)
locked_requirement = vertex.payload.requirement
end
- if results.any?
+ spec_groups = if results.any?
nested = []
results.each do |spec|
version, specs = nested.last
@@ -266,6 +267,13 @@ module Bundler
else
[]
end
+ # GVP handles major itself, but it's still a bit risky to trust it with it
+ # until we get it settled with new behavior. For 2.x it can take over all cases.
+ if @gem_version_promoter.major?
+ spec_groups
+ else
+ @gem_version_promoter.sort_versions(dependency, spec_groups)
+ end
end
search.select {|sg| sg.for?(platform, @ruby_version) }.each {|sg| sg.activate_platform!(platform) }
end
diff --git a/lib/bundler/spec_set.rb b/lib/bundler/spec_set.rb
index bfc5a23ba7..f2ccac4742 100644
--- a/lib/bundler/spec_set.rb
+++ b/lib/bundler/spec_set.rb
@@ -7,7 +7,7 @@ module Bundler
extend Forwardable
include TSort, Enumerable
- def_delegators :@specs, :<<, :length, :add, :remove, :size
+ def_delegators :@specs, :<<, :length, :add, :remove, :size, :empty?
def_delegators :sorted, :each
def initialize(specs)
diff --git a/spec/bundler/definition_spec.rb b/spec/bundler/definition_spec.rb
index a8ee3080f1..c72f50f0d1 100644
--- a/spec/bundler/definition_spec.rb
+++ b/spec/bundler/definition_spec.rb
@@ -133,4 +133,51 @@ describe Bundler::Definition do
G
end
end
+
+ describe "initialize" do
+ context "gem version promoter" do
+ context "with lockfile" do
+ before :each do
+ install_gemfile <<-G
+ source "file://#{gem_repo1}"
+ gem "foo"
+ G
+ end
+
+ it "should get a locked specs list when updating all" do
+ definition = Bundler::Definition.new(bundled_app("Gemfile.lock"), [], Bundler::SourceList.new, true)
+ locked_specs = definition.gem_version_promoter.locked_specs
+ expect(locked_specs.to_a.map(&:name)).to eq ["foo"]
+ expect(definition.instance_variable_get("@locked_specs").empty?).to eq true
+ end
+ end
+
+ context "without gemfile or lockfile" do
+ it "should not attempt to parse empty lockfile contents" do
+ definition = Bundler::Definition.new(nil, [], mock_source_list, true)
+ expect(definition.gem_version_promoter.locked_specs.to_a).to eq []
+ end
+ end
+
+ def mock_source_list
+ Class.new do
+ def all_sources
+ []
+ end
+
+ def path_sources
+ []
+ end
+
+ def rubygems_remotes
+ []
+ end
+
+ def replace_sources!(arg)
+ nil
+ end
+ end.new
+ end
+ end
+ end
end
diff --git a/spec/bundler/gem_version_promoter_spec.rb b/spec/bundler/gem_version_promoter_spec.rb
new file mode 100644
index 0000000000..5cbc74bcd9
--- /dev/null
+++ b/spec/bundler/gem_version_promoter_spec.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+require "spec_helper"
+
+describe Bundler::GemVersionPromoter do
+ context "conservative resolver" do
+ def versions(result)
+ result.flatten.map(&:version).map(&:to_s)
+ end
+
+ def make_instance(*args)
+ @gvp = Bundler::GemVersionPromoter.new(*args).tap do |gvp|
+ gvp.class.class_eval { public :filter_dep_specs, :sort_dep_specs }
+ end
+ end
+
+ def unlocking(options)
+ make_instance(Bundler::SpecSet.new([]), ["foo"]).tap do |p|
+ p.level = options[:level] if options[:level]
+ p.strict = options[:strict] if options[:strict]
+ end
+ end
+
+ def keep_locked(options)
+ make_instance(Bundler::SpecSet.new([]), ["bar"]).tap do |p|
+ p.level = options[:level] if options[:level]
+ p.strict = options[:strict] if options[:strict]
+ end
+ end
+
+ def build_spec_group(name, version)
+ Bundler::Resolver::SpecGroup.new(build_spec(name, version))
+ end
+
+ # Rightmost (highest array index) in result is most preferred.
+ # Leftmost (lowest array index) in result is least preferred.
+ # `build_spec_group` has all version of gem in index.
+ # `build_spec` is the version currently in the .lock file.
+ #
+ # In default (not strict) mode, all versions in the index will
+ # be returned, allowing Bundler the best chance to resolve all
+ # dependencies, but sometimes resulting in upgrades that some
+ # would not consider conservative.
+ context "filter specs (strict) level patch" do
+ it "when keeping build_spec, keep current, next release" do
+ keep_locked(:level => :patch)
+ res = @gvp.filter_dep_specs(
+ build_spec_group("foo", %w(1.7.8 1.7.9 1.8.0)),
+ build_spec("foo", "1.7.8").first)
+ expect(versions(res)).to eq %w(1.7.9 1.7.8)
+ end
+
+ it "when unlocking prefer next release first" do
+ unlocking(:level => :patch)
+ res = @gvp.filter_dep_specs(
+ build_spec_group("foo", %w(1.7.8 1.7.9 1.8.0)),
+ build_spec("foo", "1.7.8").first)
+ expect(versions(res)).to eq %w(1.7.8 1.7.9)
+ end
+
+ it "when unlocking keep current when already at latest release" do
+ unlocking(:level => :patch)
+ res = @gvp.filter_dep_specs(
+ build_spec_group("foo", %w(1.7.9 1.8.0 2.0.0)),
+ build_spec("foo", "1.7.9").first)
+ expect(versions(res)).to eq %w(1.7.9)
+ end
+ end
+
+ context "filter specs (strict) level minor" do
+ it "when unlocking favor next releases, remove minor and major increases" do
+ unlocking(:level => :minor)
+ res = @gvp.filter_dep_specs(
+ build_spec_group("foo", %w(0.2.0 0.3.0 0.3.1 0.9.0 1.0.0 2.0.0 2.0.1)),
+ build_spec("foo", "0.2.0").first)
+ expect(versions(res)).to eq %w(0.2.0 0.3.0 0.3.1 0.9.0)
+ end
+
+ it "when keep locked, keep current, then favor next release, remove minor and major increases" do
+ keep_locked(:level => :minor)
+ res = @gvp.filter_dep_specs(
+ build_spec_group("foo", %w(0.2.0 0.3.0 0.3.1 0.9.0 1.0.0 2.0.0 2.0.1)),
+ build_spec("foo", "0.2.0").first)
+ expect(versions(res)).to eq %w(0.3.0 0.3.1 0.9.0 0.2.0)
+ end
+ end
+
+ context "sort specs (not strict) level patch" do
+ it "when not unlocking, same order but make sure build_spec version is most preferred to stay put" do
+ keep_locked(:level => :patch)
+ res = @gvp.sort_dep_specs(
+ build_spec_group("foo", %w(1.5.4 1.6.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 2.0.0 2.0.1)),
+ build_spec("foo", "1.7.7").first)
+ expect(versions(res)).to eq %w(1.5.4 1.6.5 1.7.6 2.0.0 2.0.1 1.8.0 1.8.1 1.7.8 1.7.9 1.7.7)
+ end
+
+ it "when unlocking favor next release, then current over minor increase" do
+ unlocking(:level => :patch)
+ res = @gvp.sort_dep_specs(
+ build_spec_group("foo", %w(1.7.7 1.7.8 1.7.9 1.8.0)),
+ build_spec("foo", "1.7.8").first)
+ expect(versions(res)).to eq %w(1.7.7 1.8.0 1.7.8 1.7.9)
+ end
+
+ it "when unlocking do proper integer comparison, not string" do
+ unlocking(:level => :patch)
+ res = @gvp.sort_dep_specs(
+ build_spec_group("foo", %w(1.7.7 1.7.8 1.7.9 1.7.15 1.8.0)),
+ build_spec("foo", "1.7.8").first)
+ expect(versions(res)).to eq %w(1.7.7 1.8.0 1.7.8 1.7.9 1.7.15)
+ end
+
+ it "leave current when unlocking but already at latest release" do
+ unlocking(:level => :patch)
+ res = @gvp.sort_dep_specs(
+ build_spec_group("foo", %w(1.7.9 1.8.0 2.0.0)),
+ build_spec("foo", "1.7.9").first)
+ expect(versions(res)).to eq %w(2.0.0 1.8.0 1.7.9)
+ end
+ end
+
+ context "sort specs (not strict) level minor" do
+ it "when unlocking favor next release, then minor increase over current" do
+ unlocking(:level => :minor)
+ res = @gvp.sort_dep_specs(
+ build_spec_group("foo", %w(0.2.0 0.3.0 0.3.1 0.9.0 1.0.0 2.0.0 2.0.1)),
+ build_spec("foo", "0.2.0").first)
+ expect(versions(res)).to eq %w(2.0.0 2.0.1 1.0.0 0.2.0 0.3.0 0.3.1 0.9.0)
+ end
+ end
+
+ context "level error handling" do
+ subject { Bundler::GemVersionPromoter.new }
+
+ it "should raise if not major, minor or patch is passed" do
+ expect { subject.level = :minjor }.to raise_error ArgumentError
+ end
+
+ it "should raise if invalid classes passed" do
+ [123, nil].each do |value|
+ expect { subject.level = value }.to raise_error ArgumentError
+ end
+ end
+
+ it "should accept major, minor patch symbols" do
+ [:major, :minor, :patch].each do |value|
+ subject.level = value
+ expect(subject.level).to eq value
+ end
+ end
+
+ it "should accept major, minor patch strings" do
+ %w(major minor patch).each do |value|
+ subject.level = value
+ expect(subject.level).to eq value.to_sym
+ end
+ end
+ end
+
+ context "debug output" do
+ it "should not kerblooie on its own debug output" do
+ gvp = unlocking(:level => :patch)
+ dep = Bundler::DepProxy.new(dep("foo", "1.2.0").first, "ruby")
+ result = gvp.send(:debug_format_result, dep, [build_spec_group("foo", "1.2.0"),
+ build_spec_group("foo", "1.3.0")])
+ expect(result.class).to eq Array
+ end
+ end
+ end
+end
diff --git a/spec/commands/update_spec.rb b/spec/commands/update_spec.rb
index 31d5a21975..90a611ddeb 100644
--- a/spec/commands/update_spec.rb
+++ b/spec/commands/update_spec.rb
@@ -425,3 +425,94 @@ describe "bundle update --ruby" do
end
end
end
+
+# these specs are slow and focus on integration and therefore are not exhaustive. unit specs elsewhere handle that.
+describe "bundle update conservative" do
+ before do
+ build_repo4 do
+ build_gem "foo", %w(1.4.3 1.4.4) do |s|
+ s.add_dependency "bar", "~> 2.0"
+ end
+ build_gem "foo", %w(1.4.5 1.5.0) do |s|
+ s.add_dependency "bar", "~> 2.1"
+ end
+ build_gem "foo", %w(1.5.1) do |s|
+ s.add_dependency "bar", "~> 3.0"
+ end
+ build_gem "bar", %w(2.0.3 2.0.4 2.0.5 2.1.0 2.1.1 3.0.0)
+ build_gem "qux", %w(1.0.0 1.0.1 1.1.0 2.0.0)
+ end
+
+ # establish a lockfile set to 1.4.3
+ install_gemfile <<-G
+ source "file://#{gem_repo4}"
+ gem 'foo', '1.4.3'
+ gem 'bar', '2.0.3'
+ gem 'qux', '1.0.0'
+ G
+
+ # remove 1.4.3 requirement and bar altogether
+ # to setup update specs below
+ gemfile <<-G
+ source "file://#{gem_repo4}"
+ gem 'foo'
+ gem 'qux'
+ G
+ end
+
+ context "patch preferred" do
+ it "single gem updates dependent gem to minor" do
+ bundle "update --patch foo"
+
+ should_be_installed "foo 1.4.5", "bar 2.1.1", "qux 1.0.0"
+ end
+
+ it "update all" do
+ bundle "update --patch"
+
+ should_be_installed "foo 1.4.5", "bar 2.1.1", "qux 1.0.1"
+ end
+
+ it "warns on minor or major increment elsewhere" ## include in prior test
+ end
+
+ context "minor preferred" do
+ it "single gem updates dependent gem to major" do
+ bundle "update --minor foo"
+
+ should_be_installed "foo 1.5.1", "bar 3.0.0", "qux 1.0.0"
+ end
+
+ it "warns on major increment elsewhere" ## include in prior test
+
+ it "warns when something unlocked doesn't update at all"
+ end
+
+ context "strict" do
+ it "patch preferred" do
+ bundle "update --patch foo bar --strict"
+
+ should_be_installed "foo 1.4.4", "bar 2.0.5", "qux 1.0.0"
+ end
+
+ it "minor preferred" do
+ bundle "update --minor --strict"
+
+ should_be_installed "foo 1.5.0", "bar 2.1.1", "qux 1.1.0"
+ end
+ end
+
+ context "error handling" do
+ it "raises if too many flags are provided" do
+ bundle "update --patch --minor"
+
+ expect(out).to eq "Provide only one of the following options: minor, patch"
+ end
+ end
+
+ context "other commands" do
+ it "Installer could support --dry-run flag for install and update"
+
+ it "outdated should conform its flags to the resolver flags"
+ end
+end
diff --git a/spec/plugins/install_spec.rb b/spec/plugins/install_spec.rb
index 070a234a4a..eaf6427577 100644
--- a/spec/plugins/install_spec.rb
+++ b/spec/plugins/install_spec.rb
@@ -9,7 +9,7 @@ describe "bundler plugin install" do
end
end
- it "shows propper message when gem in not found in the source" do
+ it "shows proper message when gem in not found in the source" do
bundle "plugin install no-foo --source file://#{gem_repo1}"
expect(out).to include("Could not find")
diff --git a/spec/resolver/basic_spec.rb b/spec/resolver/basic_spec.rb
index cb5bc45597..48224ae7af 100644
--- a/spec/resolver/basic_spec.rb
+++ b/spec/resolver/basic_spec.rb
@@ -100,8 +100,131 @@ describe "Resolving" do
deps << Bundler::DepProxy.new(d, "ruby")
end
- got = Bundler::Resolver.resolve(deps, @index, {}, [], Bundler::RubyVersion.new("1.8.7", nil, nil, nil))
- got = got.map(&:full_name).sort
- expect(got).to eq(%w(foo-1.0.0 bar-1.0.0).sort)
+ should_resolve_and_include %w(foo-1.0.0 bar-1.0.0), [{}, [], Bundler::RubyVersion.new("1.8.7", nil, nil, nil)]
+ end
+
+ context "conservative" do
+ before :each do
+ @index = build_index do
+ gem("foo", "1.3.7") { dep "bar", "~> 2.0" }
+ gem("foo", "1.3.8") { dep "bar", "~> 2.0" }
+ gem("foo", "1.4.3") { dep "bar", "~> 2.0" }
+ gem("foo", "1.4.4") { dep "bar", "~> 2.0" }
+ gem("foo", "1.4.5") { dep "bar", "~> 2.1" }
+ gem("foo", "1.5.0") { dep "bar", "~> 2.1" }
+ gem("foo", "1.5.1") { dep "bar", "~> 3.0" }
+ gem("foo", "2.0.0") { dep "bar", "~> 3.0" }
+ gem "bar", %w(2.0.3 2.0.4 2.0.5 2.1.0 2.1.1 3.0.0)
+ end
+ dep "foo"
+
+ # base represents declared dependencies in the Gemfile that are still satisfied by the lockfile
+ @base = Bundler::SpecSet.new([])
+
+ # locked represents versions in lockfile
+ @locked = locked(%w(foo 1.4.3), %w(bar 2.0.3))
+ end
+
+ it "resolves all gems to latest patch" do
+ # strict is not set, so bar goes up a minor version due to dependency from foo 1.4.5
+ should_conservative_resolve_and_include :patch, [], %w(foo-1.4.5 bar-2.1.1)
+ end
+
+ it "resolves all gems to latest patch strict" do
+ # strict is set, so foo can only go up to 1.4.4 to avoid bar going up a minor version, and bar can go up to 2.0.5
+ should_conservative_resolve_and_include [:patch, :strict], [], %w(foo-1.4.4 bar-2.0.5)
+ end
+
+ it "resolves foo only to latest patch - same dependency case" do
+ @locked = locked(%w(foo 1.3.7), %w(bar 2.0.3))
+ # bar is locked, and the lock holds here because the dependency on bar doesn't change on the matching foo version.
+ should_conservative_resolve_and_include :patch, ["foo"], %w(foo-1.3.8 bar-2.0.3)
+ end
+
+ it "resolves foo only to latest patch - changing dependency not declared case" do
+ # foo is the only gem being requested for update, therefore bar is locked, but bar is NOT
+ # declared as a dependency in the Gemfile. In this case, locks don't apply to _changing_
+ # dependencies and since the dependency of the selected foo gem changes, the latest matching
+ # dependency of "bar", "~> 2.1" -- bar-2.1.1 -- is selected. This is not a bug and follows
+ # the long-standing documented Conservative Updating behavior of bundle install.
+ # http://bundler.io/v1.12/man/bundle-install.1.html#CONSERVATIVE-UPDATING
+ should_conservative_resolve_and_include :patch, ["foo"], %w(foo-1.4.5 bar-2.1.1)
+ end
+
+ it "resolves foo only to latest patch - changing dependency declared case" do
+ # bar is locked AND a declared dependency in the Gemfile, so it will not move, and therefore
+ # foo can only move up to 1.4.4.
+ @base << build_spec("bar", "2.0.3").first
+ should_conservative_resolve_and_include :patch, ["foo"], %w(foo-1.4.4 bar-2.0.3)
+ end
+
+ it "resolves foo only to latest patch strict" do
+ # adding strict helps solve the possibly unexpected behavior of bar changing in the prior test case,
+ # because no versions will be returned for bar ~> 2.1, so the engine falls back to ~> 2.0 (turn on
+ # debugging to see this happen).
+ should_conservative_resolve_and_include [:patch, :strict], ["foo"], %w(foo-1.4.4 bar-2.0.3)
+ end
+
+ it "resolves bar only to latest patch" do
+ # bar is locked, so foo can only go up to 1.4.4
+ should_conservative_resolve_and_include :patch, ["bar"], %w(foo-1.4.3 bar-2.0.5)
+ end
+
+ it "resolves all gems to latest minor" do
+ # strict is not set, so bar goes up a major version due to dependency from foo 1.4.5
+ should_conservative_resolve_and_include :minor, [], %w(foo-1.5.1 bar-3.0.0)
+ end
+
+ it "resolves all gems to latest minor strict" do
+ # strict is set, so foo can only go up to 1.5.0 to avoid bar going up a major version
+ should_conservative_resolve_and_include [:minor, :strict], [], %w(foo-1.5.0 bar-2.1.1)
+ end
+
+ it "resolves all gems to latest major" do
+ should_conservative_resolve_and_include :major, [], %w(foo-2.0.0 bar-3.0.0)
+ end
+
+ it "resolves all gems to latest major strict" do
+ should_conservative_resolve_and_include [:major, :strict], [], %w(foo-2.0.0 bar-3.0.0)
+ end
+
+ # Why would this happen in real life? If bar 2.2 has a bug that the author of foo wants to bypass
+ # by reverting the dependency, the author of foo could release a new gem with an older requirement.
+ context "revert to previous" do
+ before :each do
+ @index = build_index do
+ gem("foo", "1.4.3") { dep "bar", "~> 2.2" }
+ gem("foo", "1.4.4") { dep "bar", "~> 2.1.0" }
+ gem("foo", "1.5.0") { dep "bar", "~> 2.0.0" }
+ gem "bar", %w(2.0.5 2.1.1 2.2.3)
+ end
+ dep "foo"
+
+ # base represents declared dependencies in the Gemfile that are still satisfied by the lockfile
+ @base = Bundler::SpecSet.new([])
+
+ # locked represents versions in lockfile
+ @locked = locked(%w(foo 1.4.3), %w(bar 2.2.3))
+ end
+
+ it "could revert to a previous version level patch" do
+ should_conservative_resolve_and_include :patch, [], %w(foo-1.4.4 bar-2.1.1)
+ end
+
+ it "will not revert to a previous version in strict mode level patch" do
+ pending "possible issue with molinillo - needs further research"
+ ENV["DEBUG_RESOLVER"] = "true"
+ should_conservative_resolve_and_include [:patch, :strict], [], %w(foo-1.4.3 bar-2.1.1)
+ end
+
+ it "could revert to a previous version level minor" do
+ should_conservative_resolve_and_include :minor, [], %w(foo-1.5.0 bar-2.0.5)
+ end
+
+ it "will not revert to a previous version in strict mode level minor" do
+ pending "possible issue with molinillo - needs further research"
+ should_conservative_resolve_and_include [:minor, :strict], [], %w(foo-1.4.3 bar-2.1.1)
+ end
+ end
end
end
diff --git a/spec/support/indexes.rb b/spec/support/indexes.rb
index acace96886..9a7879bc74 100644
--- a/spec/support/indexes.rb
+++ b/spec/support/indexes.rb
@@ -13,7 +13,7 @@ module Spec
alias_method :platforms, :platform
- def resolve
+ def resolve(args = [])
@platforms ||= ["ruby"]
deps = []
@deps.each do |d|
@@ -21,7 +21,7 @@ module Spec
deps << Bundler::DepProxy.new(d, p)
end
end
- Bundler::Resolver.resolve(deps, @index)
+ Bundler::Resolver.resolve(deps, @index, *args)
end
def should_resolve_as(specs)
@@ -30,8 +30,8 @@ module Spec
expect(got).to eq(specs.sort)
end
- def should_resolve_and_include(specs)
- got = resolve
+ def should_resolve_and_include(specs, args = [])
+ got = resolve(args)
got = got.map(&:full_name).sort
specs.each do |s|
expect(got).to include(s)
@@ -49,6 +49,22 @@ module Spec
build_spec(*args, &blk).first
end
+ def locked(*args)
+ Bundler::SpecSet.new(args.map do |name, version|
+ gem(name, version)
+ end)
+ end
+
+ def should_conservative_resolve_and_include(opts, unlock, specs)
+ # empty unlock means unlock all
+ opts = Array(opts)
+ search = Bundler::GemVersionPromoter.new(@locked, unlock).tap do |s|
+ s.level = opts.first
+ s.strict = opts.include?(:strict)
+ end
+ should_resolve_and_include specs, [{}, @base, nil, search]
+ end
+
def an_awesome_index
build_index do
gem "rack", %w(0.8 0.9 0.9.1 0.9.2 1.0 1.1)