diff --git a/CHANGELOG.md b/CHANGELOG.md index c1510f6..5ef91b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - [BUGFIX: example](https://github.com/fastruby/next_rails/pull/) - [FEATURE: Validate the DeprecationTracker mode at initialization, treating a blank mode as the default `save`](https://github.com/fastruby/next_rails/pull/186) +- [BUGFIX: `bundle_report outdated` no longer confuses a locally-sourced (`path:`) gem with a same-named public gem on rubygems; local gems are excluded from the out-of-date check and counted separately](https://github.com/fastruby/next_rails/pull/189) * Your changes/patches go here. diff --git a/lib/next_rails/bundle_report.rb b/lib/next_rails/bundle_report.rb index 1ec958f..26f09f2 100644 --- a/lib/next_rails/bundle_report.rb +++ b/lib/next_rails/bundle_report.rb @@ -63,22 +63,27 @@ def compatible_ruby_version(rails_version) def outdated(format = nil) gems = NextRails::GemInfo.all - out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) + sourced_locally = gems.select(&:sourced_locally?) sourced_from_git = gems.select(&:sourced_from_git?) + # Locally-sourced gems (e.g. `path:` engines) are excluded from the + # out-of-date check: looking them up by name on rubygems can match an + # unrelated public gem with the same name and report a bogus upgrade. + out_of_date_gems = (gems - sourced_locally).reject(&:up_to_date?).sort_by(&:created_at) + if format == 'json' - output_to_json(out_of_date_gems, gems.count, sourced_from_git.count) + output_to_json(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count) else - output_to_stdout(out_of_date_gems, gems.count, sourced_from_git.count) + output_to_stdout(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count) end end - def output_to_json(out_of_date_gems, total_gem_count, sourced_from_git_count) - obj = build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) + def output_to_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) + obj = build_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) puts JSON.pretty_generate(obj) end - def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) + def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) output = Hash.new { [] } out_of_date_gems.each do |gem| output[:outdated_gems] += [ @@ -95,12 +100,13 @@ def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) output.merge( { sourced_from_git_count: sourced_from_git_count, + sourced_locally_count: sourced_locally_count, total_gem_count: total_gem_count } ) end - def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count) + def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) out_of_date_gems.each do |gem| header = "#{gem.name} #{gem.version}" @@ -112,6 +118,7 @@ def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count) percentage_out_of_date = ((out_of_date_gems.count / total_gem_count.to_f) * 100).round footer = <<-MESSAGE #{NextRails::Tint(sourced_from_git_count.to_s).yellow} gems are sourced from git + #{NextRails::Tint(sourced_locally_count.to_s).yellow} gems are sourced from a local path #{NextRails::Tint(out_of_date_gems.count.to_s).red} of the #{total_gem_count} gems are out-of-date (#{percentage_out_of_date}%) MESSAGE diff --git a/lib/next_rails/gem_info.rb b/lib/next_rails/gem_info.rb index 1f9897f..b4728b8 100644 --- a/lib/next_rails/gem_info.rb +++ b/lib/next_rails/gem_info.rb @@ -66,6 +66,17 @@ def sourced_from_git? !!gem_specification.git_version end + def sourced_locally? + # Bundler defines Source::Path and patches Gem::Specification#source to return the + # gem's real source. Without Bundler loaded, #source is RubyGems' own and returns a + # Gem::Source::Installed, so no path source can exist; treat the gem as not local. + return false unless defined?(Bundler::Source::Path) + + source = gem_specification.source + # Git sources subclass Path, so exclude them; they are reported via #sourced_from_git?. + source.is_a?(Bundler::Source::Path) && !source.is_a?(Bundler::Source::Git) + end + def created_at @created_at ||= gem_specification.date end diff --git a/spec/next_rails/bundle_report_spec.rb b/spec/next_rails/bundle_report_spec.rb index d33b6dd..dfa530c 100644 --- a/spec/next_rails/bundle_report_spec.rb +++ b/spec/next_rails/bundle_report_spec.rb @@ -6,7 +6,7 @@ RSpec.describe NextRails::BundleReport do describe '.outdated' do let(:mock_version) { Struct.new(:version, :age) } - let(:mock_gem) { Struct.new(:name, :version, :age, :latest_version, :up_to_date?, :created_at, :sourced_from_git?) } + let(:mock_gem) { Struct.new(:name, :version, :age, :latest_version, :up_to_date?, :created_at, :sourced_from_git?, :sourced_locally?) } let(:format_str) { '%b %e, %Y' } let(:alpha_date) { Date.parse('2022-01-01') } let(:alpha_age) { alpha_date.strftime(format_str) } @@ -18,37 +18,29 @@ before do allow(NextRails::GemInfo).to receive(:all).and_return( [ - mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false), - mock_gem.new('bravo', '0.2.0', bravo_age, mock_version.new('0.2.2', charlie_age), false, bravo_date, true) + mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false, false), + mock_gem.new('bravo', '0.2.0', bravo_age, mock_version.new('0.2.2', charlie_age), false, bravo_date, true, false) ] ) end context 'when writing human-readable output' do - #subject { described_class.outdated } - - it 'invokes $stdout.puts properly', :aggregate_failures do - allow($stdout) - .to receive(:puts) - .with("#{NextRails::Tint('alpha 0.0.1').bold.white}: released #{alpha_age} (latest version, 0.0.2, released #{bravo_age})\n") - allow($stdout) - .to receive(:puts) - .with("#{NextRails::Tint('bravo 0.2.0').bold.white}: released #{bravo_age} (latest version, 0.2.2, released #{charlie_age})\n") - allow($stdout).to receive(:puts).with('') - allow($stdout).to receive(:puts).with(<<-EO_MULTLINE_STRING) - #{NextRails::Tint('1').yellow} gems are sourced from git - #{NextRails::Tint('2').red} of the 2 gems are out-of-date (100%) - EO_MULTLINE_STRING + it 'prints each out-of-date gem and a footer with the source counts', :aggregate_failures do + output = with_captured_stdout { described_class.outdated } + + expect(output).to include("#{NextRails::Tint('alpha 0.0.1').bold.white}: released #{alpha_age} (latest version, 0.0.2, released #{bravo_age})") + expect(output).to include("#{NextRails::Tint('bravo 0.2.0').bold.white}: released #{bravo_age} (latest version, 0.2.2, released #{charlie_age})") + expect(output).to include("#{NextRails::Tint('1').yellow} gems are sourced from git") + expect(output).to include("#{NextRails::Tint('0').yellow} gems are sourced from a local path") + expect(output).to include("#{NextRails::Tint('2').red} of the 2 gems are out-of-date (100%)") end end context 'when writing JSON output' do it 'JSON is correctly formatted' do - gems = NextRails::GemInfo.all - out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) - sourced_from_git = gems.select(&:sourced_from_git?) + output = with_captured_stdout { described_class.outdated('json') } - expect(NextRails::BundleReport.build_json(out_of_date_gems, gems.count, sourced_from_git.count)).to eq( + expect(JSON.parse(output, symbolize_names: true)).to eq( { outdated_gems: [ { name: 'alpha', installed_version: '0.0.1', installed_age: alpha_age, latest_version: '0.0.2', @@ -56,12 +48,36 @@ { name: 'bravo', installed_version: '0.2.0', installed_age: bravo_age, latest_version: '0.2.2', latest_age: charlie_age } ], - sourced_from_git_count: sourced_from_git.count, - total_gem_count: gems.count + sourced_from_git_count: 1, + sourced_locally_count: 0, + total_gem_count: 2 } ) end end + + context 'when a gem is sourced from a local path' do + let(:delta_date) { Date.parse('2022-04-04') } + let(:delta_age) { delta_date.strftime(format_str) } + + before do + allow(NextRails::GemInfo).to receive(:all).and_return( + [ + mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false, false), + # same name as a public gem, but sourced locally and out-of-date + mock_gem.new('delta', '0.1.0', delta_age, mock_version.new('0.1.2', charlie_age), false, delta_date, false, true) + ] + ) + end + + it 'excludes the local gem from the out-of-date list and counts it separately', :aggregate_failures do + output = with_captured_stdout { described_class.outdated('json') } + result = JSON.parse(output, symbolize_names: true) + + expect(result[:outdated_gems].map { |gem| gem[:name] }).to eq(['alpha']) + expect(result[:sourced_locally_count]).to eq(1) + end + end end describe ".rails_compatibility" do diff --git a/spec/next_rails/gem_info_spec.rb b/spec/next_rails/gem_info_spec.rb index 98a7191..caa422a 100644 --- a/spec/next_rails/gem_info_spec.rb +++ b/spec/next_rails/gem_info_spec.rb @@ -74,6 +74,41 @@ end end + describe "#sourced_locally?" do + let(:source) { nil } + let(:spec) do + Gem::Specification.new do |s| + s.date = release_date + s.version = "1.0.0" + s.source = source + end + end + + context "when the gem is sourced from a local path" do + let(:source) { Bundler::Source::Path.new("path" => "engines/foo") } + + it "is true" do + expect(subject.sourced_locally?).to be(true) + end + end + + context "when the gem is sourced from git" do + let(:source) { Bundler::Source::Git.new("uri" => "https://example.com/foo.git") } + + it "is false (git is reported separately)" do + expect(subject.sourced_locally?).to be(false) + end + end + + context "when the gem is sourced from rubygems" do + let(:source) { Bundler::Source::Rubygems.new } + + it "is false" do + expect(subject.sourced_locally?).to be(false) + end + end + end + describe "#find_latest_compatible" do let(:mock_gem) { Struct.new(:name, :version) }