bundle_update_interactive 0.3.0 → 0.4.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: 0bdc9b0322d61da7334ee24e12d415948f8f1117f6824ebb4afe5eec6eadd57e
4
- data.tar.gz: bdc4cf0e7a740c4f33f9211b269257cc854ba49f9d190f956dcdb2513cbfef6b
3
+ metadata.gz: 5eb0324c92b1a60c3611ce47993022fef9c3d69aeba735a752252cc2cffe8158
4
+ data.tar.gz: f68c74d7fc286abf743796b3323bed579736bf548ccc2a405b8a2433bf3eddf1
5
5
  SHA512:
6
- metadata.gz: 6120695a0dcd0f21cc652e3e37a7b3f2a1573bfc042aba435d97896522cfd92a169c7d66cc73f7bbd2b4e5d8244b37321acda4e42ca61092201f80c584c65552
7
- data.tar.gz: 37b171778494c7e5e8b89f16f6c9b0c6e47a22d2c3dc174c7d80e0324dac1a36260a485cc220aa7746d43db757917a8eb11df8dac312e6bee2048f5d0fb56371
6
+ metadata.gz: 874b63b22b4ff2ad9101f9a5a51b2947cf27009ff37096ae0ff3f00e3f5de5ba2ea32a4e2a2adae0d46bf4a523868deb5f8f7ec45599f988719a983e1bb9e16f
7
+ data.tar.gz: 7f134fec1b5304ed93921506a4e763a1f7ef7954f812b679cb63e271c99fc76bd291f68f1e9fd51a532a3d63a55e74ad01657767250924cd8a2a0d0d7baa69ce
data/README.md CHANGED
@@ -87,6 +87,36 @@ https://github.com/rails/rails/compare/5a8d894...77dfa65
87
87
 
88
88
  This feature currently works for GitHub, GitLab, and Bitbucket repos.
89
89
 
90
+ ### Limit impact by Gemfile groups
91
+
92
+ The effects of `bundle update-interactive` can be limited to one or more Gemfile groups using the `--exclusively` option:
93
+
94
+ ```sh
95
+ bundle update-interactive --exclusively=group1,group2
96
+ ```
97
+
98
+ This is especially useful when you want to safely update a subset of your lock file without introducing any risk to your application in production. The best way to do this is with `--exclusively=development,test`, which can be abbreviated to simply `-D`:
99
+
100
+ ```sh
101
+ # Update non-production dependencies.
102
+ # This is equivalent to `bundle update-interactive --exclusively=development,test`
103
+ bundle update-interactive -D
104
+ ```
105
+
106
+ The `--exclusively` and `-D` options will cause `update-interactive` to only consider gems that are used _exclusively_ by the specified Gemfile groups. Indirect dependencies that are shared with other Gemfile groups will not be updated.
107
+
108
+ For example, given this Gemfile:
109
+
110
+ ```ruby
111
+ gem "rails"
112
+
113
+ group :test do
114
+ gem "capybara"
115
+ end
116
+ ```
117
+
118
+ If `--exclusively=test` is used, `capybara` and its indirect dependency `xpath` are both exclusively used in test and can therefore be updated. However, capybara's `nokogiri` indirect dependency, which is also used in production via `rails` → `actionpack` → `nokogiri`, would not be allowed to update.
119
+
90
120
  ### Conservative updates
91
121
 
92
122
  `bundle update-interactive` updates the gems you select by running `bundle update --conservative [GEMS...]`. This means that only those specific gems will be updated. Indirect dependencies shared with other gems will not be affected.
data/exe/bundler-ui CHANGED
@@ -2,4 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundle_update_interactive"
5
- BundleUpdateInteractive::CLI.start(ARGV)
5
+
6
+ BundleUpdateInteractive::CLI.new.run
@@ -2,4 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundle_update_interactive"
5
- BundleUpdateInteractive::CLI.start(ARGV)
5
+
6
+ BundleUpdateInteractive::CLI.new.run
@@ -9,10 +9,13 @@ module BundleUpdateInteractive
9
9
  system "bundle update --conservative #{gems.flatten.map(&:shellescape).join(' ')}"
10
10
  end
11
11
 
12
- def read_updated_lockfile
13
- `bundle lock --print --update`.tap do
14
- raise "bundle lock command failed" unless Process.last_status.success?
15
- end
12
+ def read_updated_lockfile(*gems)
13
+ command = ["bundle lock --print"]
14
+ command << "--conservative" if gems.any?
15
+ command << "--update"
16
+ command.push(*gems.flatten.map(&:shellescape))
17
+
18
+ `#{command.join(" ")}`.tap { raise "bundle lock command failed" unless Process.last_status.success? }
16
19
  end
17
20
  end
18
21
  end
@@ -3,13 +3,13 @@
3
3
  require "faraday"
4
4
  require "json"
5
5
 
6
- GITHUB_PATTERN = %r{^(?:https?://)?github\.com/([^/]+/[^/]+)(?:\.git)?/?}.freeze
7
- URI_KEYS = %w[source_code_uri homepage_uri bug_tracker_uri wiki_uri].freeze
8
- FILE_PATTERN = /changelog|changes|history|news|release/i.freeze
9
- EXT_PATTERN = /md|txt|rdoc/i.freeze
10
-
11
6
  module BundleUpdateInteractive
12
7
  class ChangelogLocator
8
+ GITHUB_PATTERN = %r{^(?:https?://)?github\.com/([^/]+/[^/]+)(?:\.git)?/?}.freeze
9
+ URI_KEYS = %w[source_code_uri homepage_uri bug_tracker_uri wiki_uri].freeze
10
+ FILE_PATTERN = /changelog|changes|history|news|release/i.freeze
11
+ EXT_PATTERN = /md|txt|rdoc/i.freeze
12
+
13
13
  class GitHubRepo
14
14
  def self.from_uris(*uris)
15
15
  uris.flatten.each do |uri|
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module BundleUpdateInteractive
6
+ class CLI::Options
7
+ class << self
8
+ def parse(argv=ARGV)
9
+ options = new
10
+ remaining = build_parser(options).parse!(argv.dup)
11
+ raise Error, "update-interactive does not accept arguments. See --help for available options." if remaining.any?
12
+
13
+ options.freeze
14
+ end
15
+
16
+ private
17
+
18
+ def build_parser(options) # rubocop:disable Metrics/MethodLength
19
+ OptionParser.new do |parser|
20
+ parser.banner = "Usage: bundle update-interactive"
21
+ parser.on(
22
+ "--exclusively=GROUP",
23
+ "Update gems that exclusively belong to the specified Gemfile GROUP(s) (comma-separated)"
24
+ ) do |value|
25
+ options.exclusively = value.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
26
+ end
27
+ parser.on(
28
+ "-D",
29
+ "Update development and test gems only; short for --exclusively=development,test"
30
+ ) do
31
+ options.exclusively = %i[development test]
32
+ end
33
+ parser.on("-v", "--version", "Display bundle_update_interactive version") do
34
+ require "bundler"
35
+ puts "bundle_update_interactive/#{VERSION} bundler/#{Bundler::VERSION} #{RUBY_DESCRIPTION}"
36
+ exit
37
+ end
38
+ parser.on("-h", "--help", "Show this help") do
39
+ puts parser
40
+ exit
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ attr_accessor :exclusively
47
+
48
+ def initialize
49
+ @exclusively = []
50
+ end
51
+ end
52
+ end
@@ -1,40 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thor"
3
+ require "bundler"
4
4
 
5
5
  module BundleUpdateInteractive
6
- class CLI < Thor
6
+ class CLI
7
7
  autoload :MultiSelect, "bundle_update_interactive/cli/multi_select"
8
+ autoload :Options, "bundle_update_interactive/cli/options"
8
9
  autoload :Row, "bundle_update_interactive/cli/row"
9
10
  autoload :Table, "bundle_update_interactive/cli/table"
10
- autoload :ThorExt, "bundle_update_interactive/cli/thor_ext"
11
11
 
12
- extend ThorExt::Start
12
+ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
13
+ options = Options.parse(argv)
13
14
 
14
- default_command :ui
15
- map %w[-v --version] => "version"
15
+ report = generate_report(options)
16
+ puts("No gems to update.").then { return } if report.updateable_gems.empty?
16
17
 
17
- desc "version", "Display bundle_update_interactive version", hide: true
18
- def version
19
- say "bundle_update_interactive/#{VERSION} #{RUBY_DESCRIPTION}"
20
- end
21
-
22
- desc "ui", "Update Gemfile.lock interactively", hide: true
23
- def ui # rubocop:disable Metrics/AbcSize
24
- report = generate_report
25
- say("No gems to update.") && return if report.updateable_gems.empty?
26
-
27
- say
28
- say legend
29
- say
18
+ puts
19
+ puts legend
20
+ puts
30
21
  selected_gems = MultiSelect.prompt_for_gems_to_update(report.updateable_gems)
31
- say("No gems to update.") && return if selected_gems.empty?
22
+ puts("No gems to update.").then { return } if selected_gems.empty?
32
23
 
33
- say "\nUpdating the following gems."
34
- say
35
- say Table.new(selected_gems).render
36
- say
24
+ puts "\nUpdating the following gems."
25
+ puts
26
+ puts Table.new(selected_gems).render
27
+ puts
37
28
  report.bundle_update!(*selected_gems.keys)
29
+ rescue Exception => e # rubocop:disable Lint/RescueException
30
+ handle_exception(e)
38
31
  end
39
32
 
40
33
  private
@@ -51,9 +44,9 @@ module BundleUpdateInteractive
51
44
  LEGEND
52
45
  end
53
46
 
54
- def generate_report
47
+ def generate_report(options)
55
48
  whisper "Resolving latest gem versions..."
56
- report = Report.generate
49
+ report = Report.generate(groups: options.exclusively)
57
50
  updateable_gems = report.updateable_gems
58
51
  return report if updateable_gems.empty?
59
52
 
@@ -65,7 +58,7 @@ module BundleUpdateInteractive
65
58
  end
66
59
 
67
60
  def whisper(message)
68
- $stderr.puts(message) # rubocop:disable Style/StderrPuts
61
+ $stderr.puts(message)
69
62
  end
70
63
 
71
64
  def progress(message, items, &block)
@@ -76,5 +69,17 @@ module BundleUpdateInteractive
76
69
  end
77
70
  $stderr.print("\n")
78
71
  end
72
+
73
+ def handle_exception(error)
74
+ case error
75
+ when Errno::EPIPE
76
+ # Ignore
77
+ when BundleUpdateInteractive::Error, OptionParser::ParseError, Interrupt, Bundler::Dsl::DSLError
78
+ $stderr.puts BundleUpdateInteractive.pastel.red(error.message)
79
+ exit false
80
+ else
81
+ raise
82
+ end
83
+ end
79
84
  end
80
85
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -18,5 +18,9 @@ module BundleUpdateInteractive
18
18
  def [](name)
19
19
  @dependencies[name]
20
20
  end
21
+
22
+ def dependencies
23
+ @dependencies.values
24
+ end
21
25
  end
22
26
  end
@@ -1,54 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler"
4
+ require "set"
4
5
 
5
6
  module BundleUpdateInteractive
6
7
  class Lockfile
7
- # TODO: refactor
8
- def self.parse(lockfile_contents=File.read("Gemfile.lock")) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
8
+ def self.parse(lockfile_contents=File.read("Gemfile.lock"))
9
9
  parser = Bundler::LockfileParser.new(lockfile_contents)
10
- specs_by_name = {}
11
- exact = Set.new
12
- exact_children = {}
10
+ new(parser.specs)
11
+ end
13
12
 
14
- parser.specs.each do |spec|
15
- specs_by_name[spec.name] = spec
13
+ def initialize(specs)
14
+ @specs_by_name = {}
15
+ required_exactly = Set.new
16
16
 
17
- spec.dependencies.each do |dep|
18
- next unless dep.requirement.exact?
17
+ specs.each do |spec|
18
+ specs_by_name[spec.name] = spec
19
+ spec.dependencies.each { |dep| required_exactly << dep.name if dep.requirement.exact? }
20
+ end
19
21
 
20
- exact << dep.name
21
- (exact_children[spec.name] ||= []) << dep.name
22
- end
22
+ @entries_by_name = specs_by_name.transform_values do |spec|
23
+ build_entry(spec, required_exactly.include?(spec.name))
23
24
  end
25
+ end
24
26
 
25
- entries = specs_by_name.transform_values do |spec|
26
- exact_dependencies = Set.new
27
- traversal = exact_children[spec.name]&.dup || []
28
- until traversal.empty?
29
- name = traversal.pop
30
- next if exact_dependencies.include?(name)
27
+ def entries
28
+ entries_by_name.values
29
+ end
31
30
 
32
- exact_dependencies << name
33
- traversal.push(*exact_children.fetch(name, []))
34
- end
31
+ def [](gem_name)
32
+ entries_by_name[gem_name]
33
+ end
35
34
 
36
- LockfileEntry.new(spec, exact_dependencies, exact.include?(spec.name))
37
- end
35
+ def gems_exclusively_installed_by(gemfile:, groups:)
36
+ return [] if groups.empty?
38
37
 
39
- new(entries)
40
- end
38
+ other_group_gems = gemfile.dependencies.filter_map { |gem| gem.name unless (gem.groups & groups).any? }
39
+ other_group_gems &= entries_by_name.keys
40
+ gems_installed_by_other_groups = other_group_gems + traverse_transient_dependencies(*other_group_gems)
41
41
 
42
- def initialize(entries)
43
- @entries = entries.freeze
42
+ entries_by_name.keys - gems_installed_by_other_groups
44
43
  end
45
44
 
46
- def entries
47
- @entries.values
45
+ private
46
+
47
+ attr_reader :entries_by_name, :specs_by_name
48
+
49
+ def build_entry(spec, exact)
50
+ exact_dependencies = traverse_transient_dependencies(spec.name) { |dep| dep.requirement.exact? }
51
+ LockfileEntry.new(spec, exact_dependencies, exact)
48
52
  end
49
53
 
50
- def [](gem_name)
51
- @entries[gem_name]
54
+ def traverse_transient_dependencies(*gem_names) # rubocop:disable Metrics/AbcSize
55
+ traversal = Set.new
56
+ stack = gem_names.flatten
57
+ until stack.empty?
58
+ specs_by_name[stack.pop].dependencies.each do |dep|
59
+ next if traversal.include?(dep.name)
60
+ next unless specs_by_name.key?(dep.name)
61
+ next if block_given? && !yield(dep)
62
+
63
+ traversal << dep.name
64
+ stack << dep.name
65
+ end
66
+ end
67
+ traversal.to_a
52
68
  end
53
69
  end
54
70
  end
@@ -4,10 +4,10 @@ module BundleUpdateInteractive
4
4
  class LockfileEntry
5
5
  attr_reader :spec, :exact_dependencies
6
6
 
7
- def initialize(spec, exact_dependencies, exact_dependency)
7
+ def initialize(spec, exact_dependencies, exact_requirement)
8
8
  @spec = spec
9
9
  @exact_dependencies = exact_dependencies
10
- @exact_dependency = exact_dependency
10
+ @exact_requirement = exact_requirement
11
11
  end
12
12
 
13
13
  def name
@@ -28,8 +28,8 @@ module BundleUpdateInteractive
28
28
  end
29
29
  end
30
30
 
31
- def exact_dependency?
32
- @exact_dependency
31
+ def exact_requirement?
32
+ @exact_requirement
33
33
  end
34
34
 
35
35
  def git_version
@@ -8,11 +8,12 @@ require "set"
8
8
  module BundleUpdateInteractive
9
9
  class Report
10
10
  class << self
11
- def generate
11
+ def generate(groups: [])
12
12
  gemfile = Gemfile.parse
13
13
  current_lockfile = Lockfile.parse
14
- updated_lockfile = Lockfile.parse(BundlerCommands.read_updated_lockfile)
14
+ gems = groups.any? ? current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) : nil
15
15
 
16
+ updated_lockfile = gems&.none? ? nil : Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(gems)))
16
17
  new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile)
17
18
  end
18
19
  end
@@ -21,9 +22,9 @@ module BundleUpdateInteractive
21
22
 
22
23
  def initialize(gemfile:, current_lockfile:, updated_lockfile:)
23
24
  @current_lockfile = current_lockfile
24
- @outdated_gems ||= current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
25
+ @outdated_gems = current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
25
26
  name = current_lockfile_entry.name
26
- updated_lockfile_entry = updated_lockfile[name]
27
+ updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
27
28
  next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
28
29
 
29
30
  hash[name] = build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile[name]&.groups)
@@ -36,7 +37,7 @@ module BundleUpdateInteractive
36
37
 
37
38
  def updateable_gems
38
39
  @updateable_gems ||= outdated_gems.reject do |name, _|
39
- current_lockfile[name].exact_dependency?
40
+ current_lockfile[name].exact_requirement?
40
41
  end.freeze
41
42
  end
42
43
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUpdateInteractive
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -6,6 +6,7 @@ module BundleUpdateInteractive
6
6
  autoload :BundlerCommands, "bundle_update_interactive/bundler_commands"
7
7
  autoload :ChangelogLocator, "bundle_update_interactive/changelog_locator"
8
8
  autoload :CLI, "bundle_update_interactive/cli"
9
+ autoload :Error, "bundle_update_interactive/error"
9
10
  autoload :Gemfile, "bundle_update_interactive/gemfile"
10
11
  autoload :Lockfile, "bundle_update_interactive/lockfile"
11
12
  autoload :LockfileEntry, "bundle_update_interactive/lockfile_entry"
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.3.0
4
+ version: 0.4.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-07-19 00:00:00.000000000 Z
11
+ date: 2024-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,20 +66,6 @@ dependencies:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: 0.8.0
69
- - !ruby/object:Gem::Dependency
70
- name: thor
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - "~>"
74
- - !ruby/object:Gem::Version
75
- version: '1.2'
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - "~>"
81
- - !ruby/object:Gem::Version
82
- version: '1.2'
83
69
  - !ruby/object:Gem::Dependency
84
70
  name: tty-prompt
85
71
  requirement: !ruby/object:Gem::Requirement
@@ -126,9 +112,10 @@ files:
126
112
  - lib/bundle_update_interactive/changelog_locator.rb
127
113
  - lib/bundle_update_interactive/cli.rb
128
114
  - lib/bundle_update_interactive/cli/multi_select.rb
115
+ - lib/bundle_update_interactive/cli/options.rb
129
116
  - lib/bundle_update_interactive/cli/row.rb
130
117
  - lib/bundle_update_interactive/cli/table.rb
131
- - lib/bundle_update_interactive/cli/thor_ext.rb
118
+ - lib/bundle_update_interactive/error.rb
132
119
  - lib/bundle_update_interactive/gemfile.rb
133
120
  - lib/bundle_update_interactive/lockfile.rb
134
121
  - lib/bundle_update_interactive/lockfile_entry.rb
@@ -160,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
160
147
  - !ruby/object:Gem::Version
161
148
  version: '0'
162
149
  requirements: []
163
- rubygems_version: 3.5.11
150
+ rubygems_version: 3.5.16
164
151
  signing_key:
165
152
  specification_version: 4
166
153
  summary: Adds an update-interactive command to Bundler
@@ -1,76 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler"
4
-
5
- class BundleUpdateInteractive::CLI
6
- module ThorExt
7
- # Configures Thor to behave more like a typical CLI, with better help and error handling.
8
- #
9
- # - Passing -h or --help to a command will show help for that command.
10
- # - Unrecognized options will be treated as errors (instead of being silently ignored).
11
- # - Error messages will be printed in red to stderr, without stack trace.
12
- # - Full stack traces can be enabled by setting the VERBOSE environment variable.
13
- # - Errors will cause Thor to exit with a non-zero status.
14
- #
15
- # To take advantage of this behavior, your CLI should subclass Thor and extend this module.
16
- #
17
- # class CLI < Thor
18
- # extend ThorExt::Start
19
- # end
20
- #
21
- # Start your CLI with:
22
- #
23
- # CLI.start
24
- #
25
- # In tests, prevent Kernel.exit from being called when an error occurs, like this:
26
- #
27
- # CLI.start(args, exit_on_failure: false)
28
- #
29
- module Start
30
- def self.extended(base)
31
- super
32
- base.check_unknown_options!
33
- end
34
-
35
- def start(given_args=ARGV, config={})
36
- config[:shell] ||= Thor::Base.shell.new
37
- handle_help_switches(given_args) do |args|
38
- dispatch(nil, args, nil, config)
39
- end
40
- rescue Exception => e # rubocop:disable Lint/RescueException
41
- handle_exception_on_start(e, config)
42
- end
43
-
44
- private
45
-
46
- def handle_help_switches(given_args)
47
- yield(given_args.dup)
48
- rescue Thor::UnknownArgumentError => e
49
- retry_with_args = []
50
-
51
- if given_args.first == "help"
52
- retry_with_args = ["help"] if given_args.length > 1
53
- elsif (e.unknown & %w[-h --help]).any?
54
- retry_with_args = ["help", (given_args - e.unknown).first]
55
- end
56
- raise unless retry_with_args.any?
57
-
58
- yield(retry_with_args)
59
- end
60
-
61
- def handle_exception_on_start(error, config)
62
- case error
63
- when Errno::EPIPE
64
- # Ignore
65
- when Thor::Error, Interrupt, Bundler::Dsl::DSLError
66
- raise unless config.fetch(:exit_on_failure, true)
67
-
68
- config[:shell]&.say_error(error.message, :red)
69
- exit(false)
70
- else
71
- raise
72
- end
73
- end
74
- end
75
- end
76
- end