diff --git a/lib/bundler/dsl.rb b/lib/bundler/dsl.rb index 4f9fbc55b1f2e7..0e36f52269abfa 100644 --- a/lib/bundler/dsl.rb +++ b/lib/bundler/dsl.rb @@ -290,7 +290,7 @@ def add_dependency(name, version = nil, options = {}) @dependencies.delete(current) elsif dep.gemspec_dev_dep? return - elsif current.source != dep.source + elsif current.source.to_s != dep.source.to_s raise GemfileError, "You cannot specify the same gem twice coming from different sources.\n" \ "You specified that #{name} (#{dep.requirement}) should come from " \ "#{current.source || "an unspecified source"} and #{dep.source}\n" diff --git a/lib/bundler/source/gemspec.rb b/lib/bundler/source/gemspec.rb index b59dce1d09055b..ed766dbe74b5f4 100644 --- a/lib/bundler/source/gemspec.rb +++ b/lib/bundler/source/gemspec.rb @@ -10,6 +10,10 @@ def initialize(options) super @gemspec = options["gemspec"] end + + def to_s + "gemspec at `#{@path}`" + end end end end diff --git a/lib/bundler/source/path.rb b/lib/bundler/source/path.rb index 7511def2306192..76bd1c66c19f08 100644 --- a/lib/bundler/source/path.rb +++ b/lib/bundler/source/path.rb @@ -53,6 +53,8 @@ def to_s "source at `#{@path}`" end + alias_method :identifier, :to_s + alias_method :to_gemfile, :path def hash diff --git a/lib/rubygems/commands/sources_command.rb b/lib/rubygems/commands/sources_command.rb index 9e2f156da374ce..7e5c2a2465e6f3 100644 --- a/lib/rubygems/commands/sources_command.rb +++ b/lib/rubygems/commands/sources_command.rb @@ -18,6 +18,14 @@ def initialize options[:add] = value end + add_option "--append SOURCE_URI", "Append source (can be used multiple times)" do |value, options| + options[:append] = value + end + + add_option "-p", "--prepend SOURCE_URI", "Prepend source (can be used multiple times)" do |value, options| + options[:prepend] = value + end + add_option "-l", "--list", "List sources" do |value, options| options[:list] = value end @@ -26,8 +34,7 @@ def initialize options[:remove] = value end - add_option "-c", "--clear-all", - "Remove all sources (clear the cache)" do |value, options| + add_option "-c", "--clear-all", "Remove all sources (clear the cache)" do |value, options| options[:clear_all] = value end @@ -68,6 +75,60 @@ def add_source(source_uri) # :nodoc: end end + def append_source(source_uri) # :nodoc: + check_rubygems_https source_uri + + source = Gem::Source.new source_uri + + check_typo_squatting(source) + + begin + source.load_specs :released + was_present = Gem.sources.include?(source) + Gem.sources.append source + Gem.configuration.write + + if was_present + say "#{source_uri} moved to end of sources" + else + say "#{source_uri} added to sources" + end + rescue Gem::URI::Error, ArgumentError + say "#{source_uri} is not a URI" + terminate_interaction 1 + rescue Gem::RemoteFetcher::FetchError => e + say "Error fetching #{Gem::Uri.redact(source.uri)}:\n\t#{e.message}" + terminate_interaction 1 + end + end + + def prepend_source(source_uri) # :nodoc: + check_rubygems_https source_uri + + source = Gem::Source.new source_uri + + check_typo_squatting(source) + + begin + source.load_specs :released + was_present = Gem.sources.include?(source) + Gem.sources.prepend source + Gem.configuration.write + + if was_present + say "#{source_uri} moved to top of sources" + else + say "#{source_uri} added to sources" + end + rescue Gem::URI::Error, ArgumentError + say "#{source_uri} is not a URI" + terminate_interaction 1 + rescue Gem::RemoteFetcher::FetchError => e + say "Error fetching #{Gem::Uri.redact(source.uri)}:\n\t#{e.message}" + terminate_interaction 1 + end + end + def check_typo_squatting(source) if source.typo_squatting?("rubygems.org") question = <<-QUESTION.chomp @@ -147,14 +208,20 @@ def description # :nodoc: of them in your list. https://rubygems.org is recommended as it brings the protections of an SSL connection to gem downloads. -To add a source use the --add argument: +To add a private gem source use the --prepend argument to insert it before +the default source. This is usually the best place for private gem sources: - $ gem sources --add https://my.private.source + $ gem sources --prepend https://my.private.source https://my.private.source added to sources RubyGems will check to see if gems can be installed from the source given before it is added. +To add or move a source after all other sources, use --append: + + $ gem sources --append https://rubygems.org + https://rubygems.org moved to end of sources + To remove a source use the --remove argument: $ gem sources --remove https://my.private.source/ @@ -182,6 +249,8 @@ def list # :nodoc: def list? # :nodoc: !(options[:add] || + options[:prepend] || + options[:append] || options[:clear_all] || options[:remove] || options[:update]) @@ -190,11 +259,13 @@ def list? # :nodoc: def execute clear_all if options[:clear_all] - source_uri = options[:add] - add_source source_uri if source_uri + add_source options[:add] if options[:add] + + prepend_source options[:prepend] if options[:prepend] + + append_source options[:append] if options[:append] - source_uri = options[:remove] - remove_source source_uri if source_uri + remove_source options[:remove] if options[:remove] update if options[:update] diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb index adf7ad6d7d05ac..362b09dcbb9949 100644 --- a/lib/rubygems/exceptions.rb +++ b/lib/rubygems/exceptions.rb @@ -23,15 +23,8 @@ def initialize(unknown_command) def self.attach_correctable return if defined?(@attached) - if defined?(DidYouMean::SPELL_CHECKERS) && defined?(DidYouMean::Correctable) - if DidYouMean.respond_to?(:correct_error) - DidYouMean.correct_error(Gem::UnknownCommandError, Gem::UnknownCommandSpellChecker) - else - DidYouMean::SPELL_CHECKERS["Gem::UnknownCommandError"] = - Gem::UnknownCommandSpellChecker - - prepend DidYouMean::Correctable - end + if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) + DidYouMean.correct_error(Gem::UnknownCommandError, Gem::UnknownCommandSpellChecker) end @attached = true diff --git a/lib/rubygems/source_list.rb b/lib/rubygems/source_list.rb index 33db64fbc1513d..19bf4595c4dc7d 100644 --- a/lib/rubygems/source_list.rb +++ b/lib/rubygems/source_list.rb @@ -59,6 +59,42 @@ def <<(obj) src end + ## + # Prepends +obj+ to the beginning of the source list which may be a Gem::Source, Gem::URI or URI + # Moves +obj+ to the beginning of the list if already present. + # String. + + def prepend(obj) + src = case obj + when Gem::Source + obj + else + Gem::Source.new(obj) + end + + @sources.delete(src) if @sources.include?(src) + @sources.unshift(src) + src + end + + ## + # Appends +obj+ to the end of the source list, moving it if already present. + # +obj+ may be a Gem::Source, Gem::URI or URI String. + # Moves +obj+ to the end of the list if already present. + + def append(obj) + src = case obj + when Gem::Source + obj + else + Gem::Source.new(obj) + end + + @sources.delete(src) if @sources.include?(src) + @sources << src + src + end + ## # Replaces this SourceList with the sources in +other+ See #<< for # acceptable items in +other+. diff --git a/spec/bundler/commands/install_spec.rb b/spec/bundler/commands/install_spec.rb index e7c1f5e456e2d2..77c7dff0ce38a8 100644 --- a/spec/bundler/commands/install_spec.rb +++ b/spec/bundler/commands/install_spec.rb @@ -615,6 +615,30 @@ expect(err).to include("Two gemspec development dependencies have conflicting requirements on the same gem: rubocop (~> 1.36.0) and rubocop (~> 2.0). Bundler cannot continue.") end + it "errors out if a gem is specified in a gemspec and in the Gemfile" do + gem = tmp("my-gem-1") + + build_lib "rubocop", path: gem do |s| + s.add_development_dependency "rubocop", "~> 1.0" + end + + build_repo4 do + build_gem "rubocop" + end + + gemfile <<~G + source "https://gem.repo4" + + gem "rubocop", :path => "#{gem}" + gemspec path: "#{gem}" + G + + bundle :install, raise_on_error: false + + expect(err).to include("There was an error parsing `Gemfile`: You cannot specify the same gem twice coming from different sources.") + expect(err).to include("You specified that rubocop (>= 0) should come from source at `#{gem}` and gemspec at `#{gem}`") + end + it "does not warn if a gem is added once in Gemfile and also inside a gemspec as a development dependency, with same requirements, and different sources" do build_lib "my-gem", path: bundled_app do |s| s.add_development_dependency "activesupport" diff --git a/test/rubygems/test_gem_command_manager.rb b/test/rubygems/test_gem_command_manager.rb index e1c3512b6f9d04..4c31eb3bc136fa 100644 --- a/test/rubygems/test_gem_command_manager.rb +++ b/test/rubygems/test_gem_command_manager.rb @@ -78,7 +78,7 @@ def test_find_command_unknown_suggestions message = "Unknown command pish".dup - if defined?(DidYouMean::SPELL_CHECKERS) && defined?(DidYouMean::Correctable) + if defined?(DidYouMean) message << "\nDid you mean? \"push\"" end diff --git a/test/rubygems/test_gem_commands_sources_command.rb b/test/rubygems/test_gem_commands_sources_command.rb index e004123c905ced..00eb9239940e81 100644 --- a/test/rubygems/test_gem_commands_sources_command.rb +++ b/test/rubygems/test_gem_commands_sources_command.rb @@ -60,6 +60,25 @@ def test_execute_add assert_equal "", @ui.error end + def test_execute_append + setup_fake_source(@new_repo) + + @cmd.handle_options %W[--append #{@new_repo}] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo, @new_repo], Gem.sources + + expected = <<-EOF +#{@new_repo} added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_allow_typo_squatting_source rubygems_org = "https://rubyems.org" @@ -82,6 +101,28 @@ def test_execute_add_allow_typo_squatting_source assert_empty ui.error end + def test_execute_append_allow_typo_squatting_source + rubygems_org = "https://rubyems.org" + + setup_fake_source(rubygems_org) + + @cmd.handle_options %W[--append #{rubygems_org}] + ui = Gem::MockGemUi.new("y") + + use_ui ui do + @cmd.execute + end + + expected = "https://rubyems.org is too similar to https://rubygems.org\n\nDo you want to add this source? [yn] https://rubyems.org added to sources\n" + + assert_equal expected, ui.output + + source = Gem::Source.new(rubygems_org) + assert Gem.sources.include?(source) + + assert_empty ui.error + end + def test_execute_add_allow_typo_squatting_source_forced rubygems_org = "https://rubyems.org" @@ -100,6 +141,24 @@ def test_execute_add_allow_typo_squatting_source_forced assert_empty ui.error end + def test_execute_append_allow_typo_squatting_source_forced + rubygems_org = "https://rubyems.org" + + setup_fake_source(rubygems_org) + + @cmd.handle_options %W[--force --append #{rubygems_org}] + + @cmd.execute + + expected = "https://rubyems.org added to sources\n" + assert_equal expected, ui.output + + source = Gem::Source.new(rubygems_org) + assert Gem.sources.include?(source) + + assert_empty ui.error + end + def test_execute_add_deny_typo_squatting_source rubygems_org = "https://rubyems.org" @@ -125,6 +184,31 @@ def test_execute_add_deny_typo_squatting_source assert_empty ui.error end + def test_execute_append_deny_typo_squatting_source + rubygems_org = "https://rubyems.org" + + setup_fake_source(rubygems_org) + + @cmd.handle_options %W[--append #{rubygems_org}] + + ui = Gem::MockGemUi.new("n") + + use_ui ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + expected = "https://rubyems.org is too similar to https://rubygems.org\n\nDo you want to add this source? [yn] " + + assert_equal expected, ui.output + + source = Gem::Source.new(rubygems_org) + refute Gem.sources.include?(source) + + assert_empty ui.error + end + def test_execute_add_nonexistent_source spec_fetcher @@ -150,6 +234,31 @@ def test_execute_add_nonexistent_source assert_equal "", @ui.error end + def test_execute_append_nonexistent_source + spec_fetcher + + uri = "http://beta-gems.example.com/specs.#{@marshal_version}.gz" + @fetcher.data[uri] = proc do + raise Gem::RemoteFetcher::FetchError.new("it died", uri) + end + + @cmd.handle_options %w[--append http://beta-gems.example.com] + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + expected = <<-EOF +Error fetching http://beta-gems.example.com: +\tit died (#{uri}) + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_existent_source_invalid_uri spec_fetcher @@ -175,6 +284,31 @@ def test_execute_add_existent_source_invalid_uri assert_equal "", @ui.error end + def test_execute_append_existent_source_invalid_uri + spec_fetcher + + uri = "https://u:p@example.com/specs.#{@marshal_version}.gz" + + @cmd.handle_options %w[--append https://u:p@example.com] + @fetcher.data[uri] = proc do + raise Gem::RemoteFetcher::FetchError.new("it died", uri) + end + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + expected = <<-EOF +Error fetching https://u:REDACTED@example.com: +\tit died (https://u:REDACTED@example.com/specs.#{@marshal_version}.gz) + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_existent_source_invalid_uri_with_error_by_chance_including_the_uri_password spec_fetcher @@ -200,6 +334,31 @@ def test_execute_add_existent_source_invalid_uri_with_error_by_chance_including_ assert_equal "", @ui.error end + def test_execute_append_existent_source_invalid_uri_with_error_by_chance_including_the_uri_password + spec_fetcher + + uri = "https://u:secret@example.com/specs.#{@marshal_version}.gz" + + @cmd.handle_options %w[--append https://u:secret@example.com] + @fetcher.data[uri] = proc do + raise Gem::RemoteFetcher::FetchError.new("it secretly died", uri) + end + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + expected = <<-EOF +Error fetching https://u:REDACTED@example.com: +\tit secretly died (https://u:REDACTED@example.com/specs.#{@marshal_version}.gz) + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_redundant_source spec_fetcher @@ -219,6 +378,25 @@ def test_execute_add_redundant_source assert_equal "", @ui.error end + def test_execute_append_redundant_source + spec_fetcher + + @cmd.handle_options %W[--append #{@gem_repo}] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo], Gem.sources + + expected = <<-EOF +#{@gem_repo} moved to end of sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_redundant_source_trailing_slash repo_with_slash = "http://sample.repo/" @@ -285,6 +463,30 @@ def test_execute_add_http_rubygems_org assert_empty @ui.error end + def test_execute_append_http_rubygems_org + http_rubygems_org = "http://rubygems.org/" + + setup_fake_source(http_rubygems_org) + + @cmd.handle_options %W[--append #{http_rubygems_org}] + + ui = Gem::MockGemUi.new "n" + + use_ui ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + assert_equal [@gem_repo], Gem.sources + + expected = <<-EXPECTED + EXPECTED + + assert_equal expected, @ui.output + assert_empty @ui.error + end + def test_execute_add_http_rubygems_org_forced rubygems_org = "http://rubygems.org" @@ -303,6 +505,24 @@ def test_execute_add_http_rubygems_org_forced assert_empty ui.error end + def test_execute_append_http_rubygems_org_forced + rubygems_org = "http://rubygems.org" + + setup_fake_source(rubygems_org) + + @cmd.handle_options %W[--force --append #{rubygems_org}] + + @cmd.execute + + expected = "http://rubygems.org added to sources\n" + assert_equal expected, ui.output + + source = Gem::Source.new(rubygems_org) + assert Gem.sources.include?(source) + + assert_empty ui.error + end + def test_execute_add_https_rubygems_org https_rubygems_org = "https://rubygems.org/" @@ -327,6 +547,30 @@ def test_execute_add_https_rubygems_org assert_empty @ui.error end + def test_execute_append_https_rubygems_org + https_rubygems_org = "https://rubygems.org/" + + setup_fake_source(https_rubygems_org) + + @cmd.handle_options %W[--append #{https_rubygems_org}] + + ui = Gem::MockGemUi.new "n" + + use_ui ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + assert_equal [@gem_repo], Gem.sources + + expected = <<-EXPECTED + EXPECTED + + assert_equal expected, @ui.output + assert_empty @ui.error + end + def test_execute_add_bad_uri @cmd.handle_options %w[--add beta-gems.example.com] @@ -346,6 +590,25 @@ def test_execute_add_bad_uri assert_equal "", @ui.error end + def test_execute_append_bad_uri + @cmd.handle_options %w[--append beta-gems.example.com] + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + assert_equal [@gem_repo], Gem.sources + + expected = <<-EOF +beta-gems.example.com is not a URI + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_clear_all @cmd.handle_options %w[--clear-all] @@ -530,6 +793,85 @@ def test_execute_update assert_equal "", @ui.error end + def test_execute_prepend_new_source + setup_fake_source(@new_repo) + + @cmd.handle_options %W[--prepend #{@new_repo}] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@new_repo, @gem_repo], Gem.sources + + expected = <<-EOF +#{@new_repo} added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + + def test_execute_prepend_existing_source + setup_fake_source(@new_repo) + + # Append the source normally first + @cmd.handle_options %W[--append #{@new_repo}] + use_ui @ui do + @cmd.execute + end + + # Initial state: [@gem_repo, @new_repo] + assert_equal [@gem_repo, @new_repo], Gem.sources + + # Now prepend the existing source + @cmd.handle_options %W[--prepend #{@new_repo}] + use_ui @ui do + @cmd.execute + end + + # Should be moved to front: [@new_repo, @gem_repo] + assert_equal [@new_repo, @gem_repo], Gem.sources + + expected = <<-EOF +#{@new_repo} added to sources +#{@new_repo} moved to top of sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + + def test_execute_append_existing_source + setup_fake_source(@new_repo) + + # Prepend the source first so it's at the beginning + @cmd.handle_options %W[--prepend #{@new_repo}] + use_ui @ui do + @cmd.execute + end + + # Initial state: [@new_repo, @gem_repo] (new_repo is first) + assert_equal [@new_repo, @gem_repo], Gem.sources + + # Now append the existing source + @cmd.handle_options %W[--append #{@new_repo}] + use_ui @ui do + @cmd.execute + end + + # Should be moved to end: [@gem_repo, @new_repo] + assert_equal [@gem_repo, @new_repo], Gem.sources + + expected = <<-EOF +#{@new_repo} added to sources +#{@new_repo} moved to end of sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + private def setup_fake_source(uri) diff --git a/test/rubygems/test_gem_source_list.rb b/test/rubygems/test_gem_source_list.rb index 64353f8f90df97..5327b14db8e17a 100644 --- a/test/rubygems/test_gem_source_list.rb +++ b/test/rubygems/test_gem_source_list.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require "rubygems" -require "rubygems/source_list" require_relative "helper" +require "rubygems/source_list" class TestGemSourceList < Gem::TestCase def setup @@ -116,4 +115,128 @@ def test_delete_a_source @sl.delete Gem::Source.new(@uri) assert_equal @sl.sources, [] end + + def test_prepend_new_source + uri2 = "http://example2" + source2 = Gem::Source.new(uri2) + + result = @sl.prepend(uri2) + + assert_kind_of Gem::Source, result + assert_kind_of Gem::URI, result.uri + assert_equal uri2, result.uri.to_s + assert_equal [source2, @source], @sl.sources + end + + def test_prepend_existing_source + uri2 = "http://example2" + source2 = Gem::Source.new(uri2) + @sl << uri2 + + assert_equal [@source, source2], @sl.sources + + result = @sl.prepend(uri2) + + assert_kind_of Gem::Source, result + assert_kind_of Gem::URI, result.uri + assert_equal uri2, result.uri.to_s + assert_equal [source2, @source], @sl.sources + end + + def test_prepend_alias_behaves_like_unshift + sl = Gem::SourceList.new + + uri1 = "http://one" + uri2 = "http://two" + + source1 = sl << uri1 + source2 = sl << uri2 + + # move existing to front + result = sl.prepend(uri2) + + assert_kind_of Gem::Source, result + assert_equal [source2, source1], sl.sources + + # and again with the other + result = sl.prepend(uri1) + assert_equal [source1, source2], sl.sources + end + + def test_append_method_new_source + sl = Gem::SourceList.new + + uri1 = "http://example1" + + result = sl.append(uri1) + + assert_kind_of Gem::Source, result + assert_kind_of Gem::URI, result.uri + assert_equal uri1, result.uri.to_s + assert_equal [result], sl.sources + end + + def test_append_method_existing_moves_to_end + sl = Gem::SourceList.new + + uri1 = "http://example1" + uri2 = "http://example2" + + s1 = sl << uri1 + s2 = sl << uri2 + + # list is [s1, s2]; appending s1 should move it to end => [s2, s1] + result = sl.append(uri1) + + assert_equal s1, result + assert_equal [s2, s1], sl.sources + end + + def test_prepend_with_gem_source_object + sl = Gem::SourceList.new + + uri1 = "http://example1" + uri2 = "http://example2" + source1 = Gem::Source.new(uri1) + source2 = Gem::Source.new(uri2) + + # Add first source + sl << source1 + + # Prepend with Gem::Source object + result = sl.prepend(source2) + + assert_equal source2, result + assert_equal [source2, source1], sl.sources + + # Prepend existing source - should move to front + result = sl.prepend(source1) + + assert_equal source1, result + assert_equal [source1, source2], sl.sources + end + + def test_append_with_gem_source_object + sl = Gem::SourceList.new + + uri1 = "http://example1" + uri2 = "http://example2" + source1 = Gem::Source.new(uri1) + source2 = Gem::Source.new(uri2) + + # Add first source + sl << source1 + + # Append with Gem::Source object + result = sl.append(source2) + + assert_equal source2, result + assert_equal [source1, source2], sl.sources + + # Append existing source - should move to end + result = sl.append(source1) + + assert_equal source1, result + assert_equal [source2, source1], sl.sources + end end