From e5aad2726c5cbb6d79a9d779b0d55dd2fcbecd50 Mon Sep 17 00:00:00 2001 From: Dylan Clark Date: Sat, 3 Aug 2024 22:57:37 -0700 Subject: [PATCH] WIP begin minor/patch option impl --- README.md | 16 +++++++++++++++ .../bundler_commands.rb | 12 ++++++++--- lib/bundle_update_interactive/cli.rb | 2 +- lib/bundle_update_interactive/cli/options.rb | 14 +++++++++++-- lib/bundle_update_interactive/report.rb | 20 ++++++++++++++----- .../bundler_commands_test.rb | 14 +++++++++++++ .../cli/options_test.rb | 18 +++++++++++++++++ test/bundle_update_interactive/cli_test.rb | 2 +- test/bundle_update_interactive/report_test.rb | 7 ++++--- 9 files changed, 90 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 48effce..b6ac281 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,22 @@ https://github.com/rails/rails/compare/5a8d894...77dfa65 This feature currently works for GitHub, GitLab, and Bitbucket repos. +### Limit updates by version type + +In order to reduce the risks of an update, `bundle update-interactive` can be limited to either patch or minor level updates with the `--patch` and `--minor` options. + +```sh +# Limit updates to patch version changes (exclude minor and major updates) +bundle update-interactive --patch + +# Limit updates to patch and minor version changes (exclude major updates) +bundle update-interactive --minor +``` + +For example, consider a lock file that currently has rack 2.0.3. The latest version of rack is 3.1.7, but updating by a major version is risky. Instead, we'd like to update to the latest 2.0.x version first, which is 2.0.9. We can accomplish this with the `--patch` option. + +Once this update is successful, we might wish to update rack from 2.0.9 to the last 2.x version, 2.2.9, before going to version 3. We can accomplish this with the `--minor` option. + ### Limit impact by Gemfile groups The effects of `bundle update-interactive` can be limited to one or more Gemfile groups using the `--exclusively` option: diff --git a/lib/bundle_update_interactive/bundler_commands.rb b/lib/bundle_update_interactive/bundler_commands.rb index ec18297..5d1f927 100644 --- a/lib/bundle_update_interactive/bundler_commands.rb +++ b/lib/bundle_update_interactive/bundler_commands.rb @@ -6,13 +6,19 @@ module BundleUpdateInteractive module BundlerCommands class << self - def update_gems_conservatively(*gems) - system "#{bundle_bin.shellescape} update --conservative #{gems.flatten.map(&:shellescape).join(' ')}" + def update_gems_conservatively(*gems, level: nil) + command = ["#{bundle_bin.shellescape} update"] + command << "--minor" if level == :minor + command << "--patch" if level == :patch + command.push("--conservative #{gems.flatten.map(&:shellescape).join(' ')}") + system command.join(" ") end - def read_updated_lockfile(*gems) + def read_updated_lockfile(*gems, level: nil) command = ["#{bundle_bin.shellescape} lock --print"] command << "--conservative" if gems.any? + command << "--minor" if level == :minor + command << "--patch" if level == :patch command << "--update" command.push(*gems.flatten.map(&:shellescape)) diff --git a/lib/bundle_update_interactive/cli.rb b/lib/bundle_update_interactive/cli.rb index 9d39963..1a5bd6d 100644 --- a/lib/bundle_update_interactive/cli.rb +++ b/lib/bundle_update_interactive/cli.rb @@ -46,7 +46,7 @@ def legend def generate_report(options) whisper "Resolving latest gem versions..." - report = Report.generate(groups: options.exclusively) + report = Report.generate(groups: options.exclusively, level: options.level) updateable_gems = report.updateable_gems return report if updateable_gems.empty? diff --git a/lib/bundle_update_interactive/cli/options.rb b/lib/bundle_update_interactive/cli/options.rb index bc01062..6c4f947 100644 --- a/lib/bundle_update_interactive/cli/options.rb +++ b/lib/bundle_update_interactive/cli/options.rb @@ -58,7 +58,7 @@ def pastel end def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - OptionParser.new do |parser| + OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength parser.summary_indent = " " parser.summary_width = 24 parser.on( @@ -70,6 +70,16 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt parser.on("-D", "Shorthand for --exclusively=development,test") do options.exclusively = %i[development test] end + parser.on("-m", "--minor", "Only update to the latest minor version") do + raise Error, "Please specify EITHER --patch or --minor option, not both" unless options.level.nil? + + options.level = :minor + end + parser.on("-p", "--patch", "Only update to the latest patch version") do + raise Error, "Please specify EITHER --patch or --minor option, not both" unless options.level.nil? + + options.level = :patch + end parser.on("-v", "--version", "Display version") do require "bundler" puts "bundle_update_interactive/#{VERSION} bundler/#{Bundler::VERSION} #{RUBY_DESCRIPTION}" @@ -83,7 +93,7 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt end end - attr_accessor :exclusively + attr_accessor :exclusively, :level def initialize @exclusively = [] diff --git a/lib/bundle_update_interactive/report.rb b/lib/bundle_update_interactive/report.rb index ba0e174..08d1d6b 100644 --- a/lib/bundle_update_interactive/report.rb +++ b/lib/bundle_update_interactive/report.rb @@ -8,19 +8,28 @@ module BundleUpdateInteractive class Report class << self - def generate(groups: []) + def generate(groups: [], level: nil) gemfile = Gemfile.parse current_lockfile = Lockfile.parse gems = groups.any? ? current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) : nil - updated_lockfile = gems&.none? ? nil : Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(gems))) - new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile) + updated_lockfile = if gems&.none? + nil + else + Lockfile.parse( + BundlerCommands.read_updated_lockfile( + *Array(gems), + level: level + ) + ) + end + new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile, level: level) end end attr_reader :outdated_gems - def initialize(gemfile:, current_lockfile:, updated_lockfile:) + def initialize(gemfile:, current_lockfile:, updated_lockfile:, level:) @current_lockfile = current_lockfile @outdated_gems = current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash| name = current_lockfile_entry.name @@ -29,6 +38,7 @@ def initialize(gemfile:, current_lockfile:, updated_lockfile:) hash[name] = build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile[name]&.groups) end.freeze + @level = level end def [](gem_name) @@ -61,7 +71,7 @@ def scan_for_vulnerabilities! def bundle_update!(*gem_names) expanded_names = expand_gems_with_exact_dependencies(*gem_names) - BundlerCommands.update_gems_conservatively(*expanded_names) + BundlerCommands.update_gems_conservatively(*expanded_names, level: @level) end private diff --git a/test/bundle_update_interactive/bundler_commands_test.rb b/test/bundle_update_interactive/bundler_commands_test.rb index 261a689..50a6e0e 100644 --- a/test/bundle_update_interactive/bundler_commands_test.rb +++ b/test/bundle_update_interactive/bundler_commands_test.rb @@ -33,6 +33,20 @@ def test_read_updated_lockfile_raises_if_bundler_fails_to_run assert_match(/bundle lock command failed/i, error.message) end + def test_read_updated_lockfile_runs_bundle_lock_with_patch_option + expect_backticks("/exe/bundle lock --print --patch --update", captures: "bundler output") + result = BundlerCommands.read_updated_lockfile(level: :patch) + + assert_equal "bundler output", result + end + + def test_read_updated_lockfile_runs_bundle_lock_with_minor_option + expect_backticks("/exe/bundle lock --print --minor --update", captures: "bundler output") + result = BundlerCommands.read_updated_lockfile(level: :minor) + + assert_equal "bundler output", result + end + private def expect_backticks(command, captures: "", success: true) diff --git a/test/bundle_update_interactive/cli/options_test.rb b/test/bundle_update_interactive/cli/options_test.rb index 8fb26f0..9426d19 100644 --- a/test/bundle_update_interactive/cli/options_test.rb +++ b/test/bundle_update_interactive/cli/options_test.rb @@ -40,6 +40,24 @@ def test_prints_version_and_exits_when_given_dash_dash_version assert_equal(0, status) end + def test_allows_patch_option_to_be_specified + options = CLI::Options.parse(%w[--patch]) + assert_equal :patch, options.level + end + + def test_allows_minor_option_to_be_specified + options = CLI::Options.parse(%w[--patch]) + assert_equal :patch, options.level + end + + def test_raises_if_both_minor_and_patch_options_are_specified + error = assert_raises(BundleUpdateInteractive::Error) do + CLI::Options.parse(%w[--patch --minor]) + end + + assert_match(/specify EITHER --patch or --minor option/i, error.message) + end + def test_exclusively_is_empty_array_by_default options = CLI::Options.parse([]) assert_empty options.exclusively diff --git a/test/bundle_update_interactive/cli_test.rb b/test/bundle_update_interactive/cli_test.rb index 7cd9fcf..634a1aa 100644 --- a/test/bundle_update_interactive/cli_test.rb +++ b/test/bundle_update_interactive/cli_test.rb @@ -44,7 +44,7 @@ def test_shows_interactive_list_of_gems_and_updates_the_selected_ones VCR.use_cassette("changelog_requests") do updated_lockfile = File.read("Gemfile.lock.updated") BundlerCommands.expects(:read_updated_lockfile).returns(updated_lockfile) - BundlerCommands.expects(:update_gems_conservatively).with("addressable", "bigdecimal", "builder") + BundlerCommands.expects(:update_gems_conservatively).with("addressable", "bigdecimal", "builder", level: nil) mock_vulnerable_gems([]) stdin_data = " j j \n" # SPACE,DOWN,SPACE,DOWN,SPACE,ENTER selects first three gems to update diff --git a/test/bundle_update_interactive/report_test.rb b/test/bundle_update_interactive/report_test.rb index 1f0f448..a2f10b8 100644 --- a/test/bundle_update_interactive/report_test.rb +++ b/test/bundle_update_interactive/report_test.rb @@ -11,7 +11,7 @@ def test_generate_creates_a_report_of_updatable_gems_that_can_be_rendered_as_a_t VCR.use_cassette("changelog_requests") do Dir.chdir(File.expand_path("../fixtures", __dir__)) do updated_lockfile = File.read("Gemfile.lock.updated") - BundlerCommands.expects(:read_updated_lockfile).with.returns(updated_lockfile) + BundlerCommands.expects(:read_updated_lockfile).with(level: nil).returns(updated_lockfile) mock_vulnerable_gems("actionpack", "rexml", "devise") report = Report.generate @@ -24,7 +24,7 @@ def test_generate_creates_a_report_of_updatable_gems_that_can_be_rendered_as_a_t end def test_generate_creates_a_report_of_updatable_gems_for_development_and_test_groups - VCR.use_cassette("changelog_requests") do + VCR.use_cassette("changelog_requests") do # rubocop:disable Metrics/BlockLength Dir.chdir(File.expand_path("../fixtures", __dir__)) do updated_lockfile = File.read("Gemfile.lock.development-test-updated") BundlerCommands.expects(:read_updated_lockfile).with( @@ -42,7 +42,8 @@ def test_generate_creates_a_report_of_updatable_gems_for_development_and_test_gr web-console websocket xpath - ] + ], + level: nil ).returns(updated_lockfile) mock_vulnerable_gems("actionpack", "rexml", "devise")