bundle_update_interactive 0.6.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e71278ee6c0ffbfb39537712a8d97e956c57062dad9723d358d97474cf63b69c
4
- data.tar.gz: 13491cad6f990d97fb3a2c918913fb4bc5eb5048829a18dd8c5400f6e5f9fadf
3
+ metadata.gz: 0cbe5e5a4fef836edfd7c1bc87939066c2f7460f4f1b883510ea065840747a54
4
+ data.tar.gz: 26bc0b61f7d3d2830c3fd687bd8e723486446cdbe6acf91455e6d02dfbb93df6
5
5
  SHA512:
6
- metadata.gz: 9bf5c295484ae3aa19d12d5891afd7883873204d0a9314babcf111b832cab60b89be13afcb6f33e8a8d2cbc7b702df9b0c800ca0639a2c8148e56adb6964cde4
7
- data.tar.gz: 0caebaf4d1bbc2a2d3cd6a5820ed031491a41a43286d7ba6f3e7bf419c28c5627ac58b8bed0407658d46efdc01d105f75857c66784b61b98eb581c00fe0ec575
6
+ metadata.gz: d351f14505560566df5b09fd27ca1a537bca621208bb1859bb6ba1a430333a5889a2d8f1dfd6ce5b72ede3cecb3429c148a1e48a3a795c3290effe0e37c207d0
7
+ data.tar.gz: c649e223608a24c65715f7c430abc64841b41a8d765612a57c64cc5c7c56f4cc1327389916521c5f19af0742e177b4f3e5d31267aacbda3048efa5b9b9b0cc78
data/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
  ---
13
13
 
14
14
  - [Quick start](#quick-start)
15
+ - [Options](#options)
15
16
  - [Features](#features)
16
17
  - [Prior art](#prior-art)
17
18
  - [Support](#support)
@@ -39,6 +40,11 @@ Or the shorthand:
39
40
  bundle ui
40
41
  ```
41
42
 
43
+ ## Options
44
+
45
+ - `--latest` [modifies the Gemfile if necessary to allow the latest gem versions](#allow-latest-versions)
46
+ - `-D` / `--exclusively=GROUP` [limits updatable gems by Gemfile groups](#limit-impact-by-gemfile-groups)
47
+
42
48
  ## Features
43
49
 
44
50
  ### Semver highlighting
@@ -63,6 +69,36 @@ Some gems, notably `rails`, are composed of smaller gems like `actionpack`, `act
63
69
 
64
70
  Therefore, if any Rails component has a security vulnerability, `bundle update-interactive` will automatically roll up that information into a single `rails` line item, so you can select it and upgrade all of its components in one shot.
65
71
 
72
+ ### Held back gems
73
+
74
+ When a newer version of a gem is available, but updating is not allowed due to a Gemfile requirement, `update-interactive` will report that the gem has been held back.
75
+
76
+ <img src="images/held-back.png" alt="Screenshot of rails and selenium-webdriver gems held back due to Gemfile requirements" width="717" />
77
+
78
+ To allow updates for gems that would normally be held back, use the `--latest` option (explained in the next section).
79
+
80
+ ### Allow latest versions
81
+
82
+ Normally `update-interactive` only makes changes to your Gemfile.lock. It honors the version restrictions ("pins") in your Gemfile and will not update your Gemfile.lock to have versions that are not allowed. However with the `--latest` flag, update-interactive can update the version pins in your Gemfile as well. Consider the following Gemfile:
83
+
84
+ ```ruby
85
+ gem "rails", "~> 7.1.0"
86
+ ```
87
+
88
+ Normally running `bundle update-interactive` will report that Rails is held back and therefore cannot be updated to the latest version. However, if you pass the `--latest` option like this:
89
+
90
+ ```
91
+ bundle update-interactive --latest
92
+ ```
93
+
94
+ Now Rails will be allowed to update. If you select to update Rails to the latest version (e.g. 7.2.0), `update-interactive` will modify the version requirement in your Gemfile to look like this:
95
+
96
+ ```ruby
97
+ gem "rails", "~> 7.2.0"
98
+ ```
99
+
100
+ In other words, it works similarly to `yarn upgrade-interactive --latest`.
101
+
66
102
  ### Changelogs
67
103
 
68
104
  `bundle update-interactive` will do its best to find an appropriate changelog for each gem.
@@ -10,6 +10,13 @@ module BundleUpdateInteractive
10
10
  system "#{bundle_bin.shellescape} update --conservative #{gems.flatten.map(&:shellescape).join(' ')}"
11
11
  end
12
12
 
13
+ def lock
14
+ success = system "#{bundle_bin.shellescape} lock"
15
+ raise "bundle lock command failed" unless success
16
+
17
+ true
18
+ end
19
+
13
20
  def read_updated_lockfile(*gems)
14
21
  command = ["#{bundle_bin.shellescape} lock --print"]
15
22
  command << "--conservative" if gems.any?
@@ -19,6 +26,14 @@ module BundleUpdateInteractive
19
26
  `#{command.join(" ")}`.tap { raise "bundle lock command failed" unless Process.last_status.success? }
20
27
  end
21
28
 
29
+ def parse_outdated(*gems)
30
+ command = ["#{bundle_bin.shellescape} outdated --parseable", *gems.flatten.map(&:shellescape)]
31
+ output = `#{command.join(" ")}`
32
+ raise "bundle outdated command failed" if output.empty? && !Process.last_status.success?
33
+
34
+ output.scan(/^(\S+) \(newest (\S+),/).to_h
35
+ end
36
+
22
37
  private
23
38
 
24
39
  def bundle_bin
@@ -7,6 +7,8 @@ require "tty/screen"
7
7
 
8
8
  class BundleUpdateInteractive::CLI
9
9
  class MultiSelect
10
+ extend BundleUpdateInteractive::StringHelper
11
+
10
12
  class List < TTY::Prompt::MultiList
11
13
  def initialize(prompt, **options)
12
14
  @opener = options.delete(:opener)
@@ -46,8 +48,8 @@ class BundleUpdateInteractive::CLI
46
48
  end
47
49
 
48
50
  def self.prompt_for_gems_to_update(outdated_gems, prompt: nil)
49
- table = Table.new(outdated_gems)
50
- title = "#{outdated_gems.length} gems can be updated."
51
+ table = Table.updatable(outdated_gems)
52
+ title = "#{pluralize(outdated_gems.length, 'gem')} can be updated."
51
53
  opener = lambda do |gem|
52
54
  url = outdated_gems[gem].changelog_uri
53
55
  Launchy.open(url) unless url.nil?
@@ -48,6 +48,9 @@ module BundleUpdateInteractive
48
48
  Show updates for development and test gems only, leaving production gems untouched.
49
49
  #{pastel.green('bundle update-interactive')} #{pastel.yellow('-D')}
50
50
 
51
+ Allow the latest gem versions, ignoring Gemfile pins. May modify the Gemfile.
52
+ #{pastel.green('bundle update-interactive')} #{pastel.yellow('--latest')}
53
+
51
54
  HELP
52
55
  end
53
56
 
@@ -61,6 +64,9 @@ module BundleUpdateInteractive
61
64
  OptionParser.new do |parser|
62
65
  parser.summary_indent = " "
63
66
  parser.summary_width = 24
67
+ parser.on("--latest", "Modify the Gemfile to allow the latest gem versions") do
68
+ options.latest = true
69
+ end
64
70
  parser.on(
65
71
  "--exclusively=GROUP",
66
72
  "Update gems exclusively belonging to the specified Gemfile GROUP(s)"
@@ -84,9 +90,15 @@ module BundleUpdateInteractive
84
90
  end
85
91
 
86
92
  attr_accessor :exclusively
93
+ attr_writer :latest
87
94
 
88
95
  def initialize
89
96
  @exclusively = []
97
+ @latest = false
98
+ end
99
+
100
+ def latest?
101
+ @latest
90
102
  end
91
103
  end
92
104
  end
@@ -16,17 +16,6 @@ class BundleUpdateInteractive::CLI
16
16
  @pastel = BundleUpdateInteractive.pastel
17
17
  end
18
18
 
19
- def to_a
20
- [
21
- formatted_gem_name,
22
- formatted_current_version,
23
- "→",
24
- formatted_updated_version,
25
- formatted_gemfile_groups,
26
- formatted_changelog_uri
27
- ]
28
- end
29
-
30
19
  def formatted_gem_name
31
20
  vulnerable? ? pastel.white.on_red(name) : apply_semver_highlight(name)
32
21
  end
@@ -46,6 +35,10 @@ class BundleUpdateInteractive::CLI
46
35
  gemfile_groups&.map(&:inspect)&.join(", ")
47
36
  end
48
37
 
38
+ def formatted_gemfile_requirement
39
+ gemfile_requirement.to_s == ">= 0" ? "" : gemfile_requirement.to_s
40
+ end
41
+
49
42
  def formatted_changelog_uri
50
43
  pastel.blue(changelog_uri)
51
44
  end
@@ -4,12 +4,44 @@ require "pastel"
4
4
 
5
5
  class BundleUpdateInteractive::CLI
6
6
  class Table
7
- HEADERS = ["name", "from", nil, "to", "group", "url"].freeze
7
+ class << self
8
+ def withheld(gems)
9
+ columns = [
10
+ ["name", :formatted_gem_name],
11
+ ["requirement", :formatted_gemfile_requirement],
12
+ ["current", :formatted_current_version],
13
+ ["latest", :formatted_updated_version],
14
+ ["group", :formatted_gemfile_groups],
15
+ ["url", :formatted_changelog_uri]
16
+ ]
17
+ new(gems, columns)
18
+ end
8
19
 
9
- def initialize(outdated_gems)
20
+ def updatable(gems)
21
+ columns = [
22
+ ["name", :formatted_gem_name],
23
+ ["from", :formatted_current_version],
24
+ [nil, "→"],
25
+ ["to", :formatted_updated_version],
26
+ ["group", :formatted_gemfile_groups],
27
+ ["url", :formatted_changelog_uri]
28
+ ]
29
+ new(gems, columns)
30
+ end
31
+ end
32
+
33
+ def initialize(gems, columns)
10
34
  @pastel = BundleUpdateInteractive.pastel
11
- @headers = HEADERS.map { |h| pastel.dim.underline(h) }
12
- @rows = outdated_gems.transform_values { |gem| Row.new(gem).to_a.map(&:to_s) }
35
+ @headers = columns.map { |header, _| pastel.dim.underline(header) }
36
+ @rows = gems.transform_values do |gem|
37
+ row = Row.new(gem)
38
+ columns.map do |_, col|
39
+ case col
40
+ when Symbol then row.public_send(col).to_s
41
+ when String then col
42
+ end
43
+ end
44
+ end
13
45
  @column_widths = calculate_column_widths
14
46
  end
15
47
 
@@ -4,34 +4,42 @@ require "bundler"
4
4
 
5
5
  module BundleUpdateInteractive
6
6
  class CLI
7
- autoload :MultiSelect, "bundle_update_interactive/cli/multi_select"
8
- autoload :Options, "bundle_update_interactive/cli/options"
9
- autoload :Row, "bundle_update_interactive/cli/row"
10
- autoload :Table, "bundle_update_interactive/cli/table"
11
-
12
7
  def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
13
8
  options = Options.parse(argv)
9
+ report, updater = generate_report(options)
14
10
 
15
- report = generate_report(options)
16
- puts("No gems to update.").then { return } if report.updateable_gems.empty?
11
+ puts_legend_and_withheld_gems(report) unless report.empty?
12
+ puts("No gems to update.").then { return } if report.updatable_gems.empty?
17
13
 
18
- puts
19
- puts legend
20
- puts
21
- selected_gems = MultiSelect.prompt_for_gems_to_update(report.updateable_gems)
14
+ selected_gems = MultiSelect.prompt_for_gems_to_update(report.updatable_gems)
22
15
  puts("No gems to update.").then { return } if selected_gems.empty?
23
16
 
24
- puts "\nUpdating the following gems."
17
+ puts "Updating the following gems."
18
+ puts Table.updatable(selected_gems).render
25
19
  puts
26
- puts Table.new(selected_gems).render
27
- puts
28
- report.bundle_update!(*selected_gems.keys)
20
+ updater.apply_updates(*selected_gems.keys)
21
+ puts_gemfile_modified_notice if updater.modified_gemfile?
29
22
  rescue Exception => e # rubocop:disable Lint/RescueException
30
23
  handle_exception(e)
31
24
  end
32
25
 
33
26
  private
34
27
 
28
+ def puts_gemfile_modified_notice
29
+ puts BundleUpdateInteractive.pastel.yellow("Your Gemfile was changed to accommodate the latest gem versions.")
30
+ end
31
+
32
+ def puts_legend_and_withheld_gems(report)
33
+ puts
34
+ puts legend
35
+ puts
36
+ return if report.withheld_gems.empty?
37
+
38
+ puts "The following gems are being held back and cannot be updated."
39
+ puts Table.withheld(report.withheld_gems).render
40
+ puts
41
+ end
42
+
35
43
  def legend
36
44
  pastel = BundleUpdateInteractive.pastel
37
45
  <<~LEGEND
@@ -46,15 +54,17 @@ module BundleUpdateInteractive
46
54
 
47
55
  def generate_report(options)
48
56
  whisper "Resolving latest gem versions..."
49
- report = Report.generate(groups: options.exclusively)
50
- updateable_gems = report.updateable_gems
51
- return report if updateable_gems.empty?
57
+ updater_class = options.latest? ? Latest::Updater : Updater
58
+ updater = updater_class.new(groups: options.exclusively)
52
59
 
53
- whisper "Checking for security vulnerabilities..."
54
- report.scan_for_vulnerabilities!
60
+ report = updater.generate_report
61
+ unless report.empty?
62
+ whisper "Checking for security vulnerabilities..."
63
+ report.scan_for_vulnerabilities!
64
+ progress "Finding changelogs", report.all_gems.values, &:changelog_uri
65
+ end
55
66
 
56
- progress "Finding changelogs", updateable_gems.values, &:changelog_uri
57
- report
67
+ [report, updater]
58
68
  end
59
69
 
60
70
  def whisper(message)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ module Latest
5
+ class GemRequirement
6
+ def self.parse(version)
7
+ return version if version.is_a?(GemRequirement)
8
+
9
+ _, operator, number = version.strip.match(/^([^\d\s]*)\s*(.+)/).to_a
10
+ operator = nil if operator.empty?
11
+
12
+ new(parts: number.split("."), operator: operator, parsed_from: version)
13
+ end
14
+
15
+ attr_reader :parts, :operator
16
+
17
+ def initialize(parts:, operator: nil, parsed_from: nil)
18
+ @parts = parts
19
+ @operator = operator
20
+ @parsed_from = parsed_from
21
+ end
22
+
23
+ def exact?
24
+ operator.nil?
25
+ end
26
+
27
+ def relax
28
+ return self if %w[!= > >=].include?(operator)
29
+ return self.class.parse(">= 0") if %w[< <=].include?(operator)
30
+
31
+ self.class.new(parts: parts, operator: ">=")
32
+ end
33
+
34
+ def shift(new_version) # rubocop:disable Metrics/AbcSize
35
+ return self.class.parse(new_version) if exact?
36
+ return self if Gem::Requirement.new(to_s).satisfied_by?(Gem::Version.new(new_version))
37
+ return self.class.new(parts: self.class.parse(new_version).parts, operator: "<=") if %w[< <=].include?(operator)
38
+
39
+ new_slice = self.class.parse(new_version).slice(parts.length)
40
+ self.class.new(parts: new_slice.parts, operator: "~>")
41
+ end
42
+
43
+ def slice(amount)
44
+ self.class.new(parts: parts[0, amount], operator: operator)
45
+ end
46
+
47
+ def to_s
48
+ parsed_from || [operator, parts.join(".")].compact.join(" ")
49
+ end
50
+
51
+ def ==(other)
52
+ return false unless other.is_a?(GemRequirement)
53
+
54
+ to_s == other.to_s
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :parsed_from
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ module Latest
5
+ class GemfileEditor
6
+ def initialize(gemfile_path: "Gemfile", lockfile_path: "Gemfile.lock")
7
+ @gemfile_path = gemfile_path
8
+ @lockfile_path = lockfile_path
9
+ end
10
+
11
+ def with_relaxed_gemfile
12
+ original, modified = modify_gemfile { |_, requirement| requirement.relax }
13
+ yield
14
+ ensure
15
+ File.write(gemfile_path, original) if original && original != modified
16
+ end
17
+
18
+ def shift_gemfile
19
+ lockfile = Lockfile.parse(File.read(lockfile_path))
20
+ original, modified = modify_gemfile do |name, requirement|
21
+ lockfile_entry = lockfile[name]
22
+ requirement.shift(lockfile_entry.version.to_s) if lockfile_entry
23
+ end
24
+ original != modified
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :gemfile_path, :lockfile_path
30
+
31
+ def modify_gemfile(&block)
32
+ original_contents = File.read(gemfile_path)
33
+ new_contents = original_contents.dup
34
+
35
+ find_rewritable_gem_names(original_contents).each do |name|
36
+ rewrite_contents(name, new_contents, &block)
37
+ end
38
+
39
+ File.write(gemfile_path, new_contents) unless new_contents == original_contents
40
+ [original_contents, new_contents]
41
+ end
42
+
43
+ def find_rewritable_gem_names(contents)
44
+ Gemfile.parse(gemfile_path).dependencies.filter_map do |dep|
45
+ gem_name = dep.name
46
+ gem_name if gem_declaration_with_requirement_re(gem_name).match?(contents)
47
+ end
48
+ end
49
+
50
+ def rewrite_contents(gem_name, contents)
51
+ found = contents.sub!(gem_declaration_with_requirement_re(gem_name)) do |match|
52
+ version = Regexp.last_match[1]
53
+ match[Regexp.last_match.regexp, 1] = yield(gem_name, GemRequirement.parse(version)).to_s
54
+ match
55
+ end
56
+ raise "Can't rewrite version for #{gem_name}" unless found
57
+ end
58
+
59
+ def gem_declaration_re(gem_name)
60
+ /^\s*gem\s+["']#{Regexp.escape(gem_name)}["']/
61
+ end
62
+
63
+ def gem_declaration_with_requirement_re(gem_name)
64
+ /#{gem_declaration_re(gem_name)},\s*["']([^'"]+)["']/
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extends the default Updater class to allow updating to the latest gem versions.
4
+ # Does this by using GemfileEditor to relax the Gemfile requirements before
5
+ # `find_updatable_gems` and `apply_updates` are called.
6
+ module BundleUpdateInteractive
7
+ module Latest
8
+ class Updater < BundleUpdateInteractive::Updater
9
+ def initialize(editor: GemfileEditor.new, **kwargs)
10
+ super(**kwargs)
11
+ @modified_gemfile = false
12
+ @editor = editor
13
+ end
14
+
15
+ def apply_updates(*, **)
16
+ result = editor.with_relaxed_gemfile { super }
17
+ @modified_gemfile = editor.shift_gemfile
18
+ BundlerCommands.lock
19
+ result
20
+ end
21
+
22
+ def modified_gemfile?
23
+ @modified_gemfile
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :editor
29
+
30
+ def find_updatable_gems
31
+ editor.with_relaxed_gemfile { super }
32
+ end
33
+
34
+ # Overrides the default Updater implementation.
35
+ # When updating the latest gems, by definition nothing is withheld, so we can skip this.
36
+ def find_withheld_gems(**)
37
+ {}
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,6 +4,7 @@ module BundleUpdateInteractive
4
4
  class OutdatedGem
5
5
  attr_accessor :name,
6
6
  :gemfile_groups,
7
+ :gemfile_requirement,
7
8
  :git_source_uri,
8
9
  :current_version,
9
10
  :current_git_version,
@@ -7,78 +7,38 @@ require "set"
7
7
 
8
8
  module BundleUpdateInteractive
9
9
  class Report
10
- class << self
11
- def generate(groups: [])
12
- gemfile = Gemfile.parse
13
- current_lockfile = Lockfile.parse
14
- gems = groups.any? ? current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) : nil
10
+ attr_reader :withheld_gems, :updatable_gems
15
11
 
16
- updated_lockfile = gems&.none? ? nil : Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(gems)))
17
- new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile)
18
- end
19
- end
20
-
21
- attr_reader :outdated_gems
22
-
23
- def initialize(gemfile:, current_lockfile:, updated_lockfile:)
12
+ def initialize(current_lockfile:, withheld_gems:, updatable_gems:)
24
13
  @current_lockfile = current_lockfile
25
- @outdated_gems = current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
26
- name = current_lockfile_entry.name
27
- updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
28
- next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
29
-
30
- hash[name] = build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile[name]&.groups)
31
- end.freeze
14
+ @withheld_gems = withheld_gems.freeze
15
+ @updatable_gems = updatable_gems.freeze
32
16
  end
33
17
 
34
- def [](gem_name)
35
- outdated_gems[gem_name]
18
+ def empty?
19
+ withheld_gems.empty? && updatable_gems.empty?
36
20
  end
37
21
 
38
- def updateable_gems
39
- @updateable_gems ||= outdated_gems.reject do |name, _|
40
- current_lockfile[name].exact_requirement?
41
- end.freeze
42
- end
43
-
44
- def expand_gems_with_exact_dependencies(*gem_names)
45
- gem_names.flatten!
46
- gem_names.flat_map { |name| [name, *current_lockfile[name].exact_dependencies] }.uniq
22
+ def all_gems
23
+ @all_gems ||= withheld_gems.merge(updatable_gems)
47
24
  end
48
25
 
49
26
  def scan_for_vulnerabilities!
50
- return false if outdated_gems.empty?
27
+ return false if all_gems.empty?
51
28
 
52
29
  Bundler::Audit::Database.update!(quiet: true)
53
30
  audit_report = Bundler::Audit::Scanner.new.report
54
31
  vulnerable_gem_names = Set.new(audit_report.vulnerable_gems.map(&:name))
55
32
 
56
- outdated_gems.each do |name, gem|
57
- gem.vulnerable = (vulnerable_gem_names & [name, *current_lockfile[name].exact_dependencies]).any?
33
+ all_gems.each do |name, gem|
34
+ exact_deps = current_lockfile && current_lockfile[name].exact_dependencies
35
+ gem.vulnerable = (vulnerable_gem_names & [name, *Array(exact_deps)]).any?
58
36
  end
59
37
  true
60
38
  end
61
39
 
62
- def bundle_update!(*gem_names)
63
- expanded_names = expand_gems_with_exact_dependencies(*gem_names)
64
- BundlerCommands.update_gems_conservatively(*expanded_names)
65
- end
66
-
67
40
  private
68
41
 
69
42
  attr_reader :current_lockfile
70
-
71
- def build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile_groups)
72
- OutdatedGem.new(
73
- name: current_lockfile_entry.name,
74
- gemfile_groups: gemfile_groups,
75
- rubygems_source: updated_lockfile_entry.rubygems_source?,
76
- git_source_uri: updated_lockfile_entry.git_source_uri&.to_s,
77
- current_version: current_lockfile_entry.version.to_s,
78
- current_git_version: current_lockfile_entry.git_version&.strip,
79
- updated_version: updated_lockfile_entry.version.to_s,
80
- updated_git_version: updated_lockfile_entry.git_version&.strip
81
- )
82
- end
83
43
  end
84
44
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/bundle_update_interactive/string_helper.rb
4
+ module BundleUpdateInteractive
5
+ module StringHelper
6
+ def pluralize(count, singular, plural=nil)
7
+ plural ||= "#{singular}s"
8
+ "#{count} #{count == 1 ? singular : plural}"
9
+ end
10
+ module_function :pluralize
11
+ end
12
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class Updater
5
+ def initialize(groups: [])
6
+ @gemfile = Gemfile.parse
7
+ @current_lockfile = Lockfile.parse
8
+ @candidate_gems = current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) if groups.any?
9
+ end
10
+
11
+ def generate_report
12
+ updatable_gems = find_updatable_gems
13
+ withheld_gems = find_withheld_gems(exclude: updatable_gems.keys)
14
+
15
+ Report.new(current_lockfile: current_lockfile, updatable_gems: updatable_gems, withheld_gems: withheld_gems)
16
+ end
17
+
18
+ def apply_updates(*gem_names)
19
+ expanded_names = expand_gems_with_exact_dependencies(*gem_names)
20
+ BundlerCommands.update_gems_conservatively(*expanded_names)
21
+ end
22
+
23
+ # Overridden by Latest::Updater subclass
24
+ def modified_gemfile?
25
+ false
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :gemfile, :current_lockfile, :candidate_gems
31
+
32
+ def find_updatable_gems
33
+ return {} if candidate_gems && candidate_gems.empty?
34
+
35
+ updated_lockfile = Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(candidate_gems)))
36
+ current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
37
+ name = current_lockfile_entry.name
38
+ updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
39
+ next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
40
+ next if current_lockfile_entry.exact_requirement?
41
+
42
+ hash[name] = build_outdated_gem(name, updated_lockfile_entry.version, updated_lockfile_entry.git_version)
43
+ end
44
+ end
45
+
46
+ def find_withheld_gems(exclude: [])
47
+ possibly_withheld = gemfile.dependencies.filter_map do |dep|
48
+ dep.name if dep.should_include? && !dep.requirement.none? # rubocop:disable Style/InverseMethods
49
+ end
50
+ possibly_withheld -= exclude
51
+ possibly_withheld &= candidate_gems unless candidate_gems.nil?
52
+
53
+ return {} if possibly_withheld.empty?
54
+
55
+ BundlerCommands.parse_outdated(*possibly_withheld).to_h do |name, newest|
56
+ [name, build_outdated_gem(name, newest, nil)]
57
+ end
58
+ end
59
+
60
+ def build_outdated_gem(name, updated_version, updated_git_version)
61
+ current_lockfile_entry = current_lockfile[name]
62
+
63
+ OutdatedGem.new(
64
+ name: name,
65
+ gemfile_groups: gemfile[name]&.groups,
66
+ gemfile_requirement: gemfile[name]&.requirement&.to_s,
67
+ rubygems_source: current_lockfile_entry.rubygems_source?,
68
+ git_source_uri: current_lockfile_entry.git_source_uri&.to_s,
69
+ current_version: current_lockfile_entry.version.to_s,
70
+ current_git_version: current_lockfile_entry.git_version&.strip,
71
+ updated_version: updated_version.to_s,
72
+ updated_git_version: updated_git_version&.strip
73
+ )
74
+ end
75
+
76
+ def expand_gems_with_exact_dependencies(*gem_names)
77
+ gem_names.flatten!
78
+ gem_names.flat_map { |name| [name, *current_lockfile[name].exact_dependencies] }.uniq
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUpdateInteractive
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -2,20 +2,13 @@
2
2
 
3
3
  require "pastel"
4
4
 
5
- module BundleUpdateInteractive
6
- autoload :BundlerCommands, "bundle_update_interactive/bundler_commands"
7
- autoload :ChangelogLocator, "bundle_update_interactive/changelog_locator"
8
- autoload :CLI, "bundle_update_interactive/cli"
9
- autoload :Error, "bundle_update_interactive/error"
10
- autoload :Gemfile, "bundle_update_interactive/gemfile"
11
- autoload :HTTP, "bundle_update_interactive/http"
12
- autoload :Lockfile, "bundle_update_interactive/lockfile"
13
- autoload :LockfileEntry, "bundle_update_interactive/lockfile_entry"
14
- autoload :OutdatedGem, "bundle_update_interactive/outdated_gem"
15
- autoload :Report, "bundle_update_interactive/report"
16
- autoload :SemverChange, "bundle_update_interactive/semver_change"
17
- autoload :VERSION, "bundle_update_interactive/version"
5
+ require "zeitwerk"
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.inflector.inflect "cli" => "CLI"
8
+ loader.inflector.inflect "http" => "HTTP"
9
+ loader.setup
18
10
 
11
+ module BundleUpdateInteractive
19
12
  class << self
20
13
  attr_accessor :pastel
21
14
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundle_update_interactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Brictson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-10 00:00:00.000000000 Z
11
+ date: 2024-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: 0.8.2
97
+ - !ruby/object:Gem::Dependency
98
+ name: zeitwerk
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.6'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2.6'
97
111
  description:
98
112
  email:
99
113
  - opensource@mattbrictson.com
@@ -118,11 +132,16 @@ files:
118
132
  - lib/bundle_update_interactive/error.rb
119
133
  - lib/bundle_update_interactive/gemfile.rb
120
134
  - lib/bundle_update_interactive/http.rb
135
+ - lib/bundle_update_interactive/latest/gem_requirement.rb
136
+ - lib/bundle_update_interactive/latest/gemfile_editor.rb
137
+ - lib/bundle_update_interactive/latest/updater.rb
121
138
  - lib/bundle_update_interactive/lockfile.rb
122
139
  - lib/bundle_update_interactive/lockfile_entry.rb
123
140
  - lib/bundle_update_interactive/outdated_gem.rb
124
141
  - lib/bundle_update_interactive/report.rb
125
142
  - lib/bundle_update_interactive/semver_change.rb
143
+ - lib/bundle_update_interactive/string_helper.rb
144
+ - lib/bundle_update_interactive/updater.rb
126
145
  - lib/bundle_update_interactive/version.rb
127
146
  homepage: https://github.com/mattbrictson/bundle_update_interactive
128
147
  licenses:
@@ -148,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
167
  - !ruby/object:Gem::Version
149
168
  version: '0'
150
169
  requirements: []
151
- rubygems_version: 3.5.16
170
+ rubygems_version: 3.5.11
152
171
  signing_key:
153
172
  specification_version: 4
154
173
  summary: Adds an update-interactive command to Bundler