bundler-interactive 0.1.2.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cb83b26ff4dea18f9dcfb7c8aa94ca8b1a90ed6e87f6cf3350686e75d828dee
4
- data.tar.gz: ca98b4c3abd8b9654341d960f19af1867f29fa87da2d3cb72620527d1fa9308d
3
+ metadata.gz: 6209ba5bd75ad342ff569bcbc4a134c55b290f1e31b6073e2d8d1e9a8378ace0
4
+ data.tar.gz: 5065c826088d8c4123c69876d233767c136a7c24a75807d6b1fba8fd78599edc
5
5
  SHA512:
6
- metadata.gz: 618be7c402a08da7a0e4a46098b2aed18ec20dad968e3d34be05ed0192bbb90acbe90fd364bc6394ab5e2fe891d98917110c919195a9a147b45e63420c43168b
7
- data.tar.gz: d4aa3d4202a94a0a555d315f46c34e44969c886d753864ae2097953f19c052c556c749df1cc221af737ab0e445a574ceec8a931e05fda51fe362e4ec097adb0b
6
+ metadata.gz: 1d18e351b8b0293f2737718e053b26d7eda3f72a76e888ddbb5761cf92d3127db4bcdb02d0b4601733f28efd1da5d04e6552c5f74f972ad6808ce0d2c03db486
7
+ data.tar.gz: dcb27e24c6b779ddc9d0a3eceecc6d1e5cb1ae759d7768068a53c1894183531134227a1c1d841c98aa4f3594c97c8eaff85ea393ccdc703228982f3a480ca5a0
@@ -0,0 +1,37 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches:
5
+ - master
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: Ruby ${{ matrix.ruby }} / Bundler ${{ matrix.bundler }}
11
+ runs-on: ubuntu-latest
12
+ timeout-minutes: 10
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ # Bundler 4.x requires Ruby >= 3.2.
17
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
18
+ # This is a Bundler plugin, so the Bundler version is the axis that
19
+ # matters most: "default" exercises the Bundler that ships with each
20
+ # Ruby, and "latest" guards against API drift and covers the cooldown
21
+ # feature (Bundler >= 4.0.13).
22
+ bundler: [default, latest]
23
+
24
+ steps:
25
+ - name: Checkout code
26
+ uses: actions/checkout@v5
27
+
28
+ - name: Set up Ruby and bundle install
29
+ uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: ${{ matrix.ruby }}
32
+ bundler: ${{ matrix.bundler }}
33
+ bundler-cache: true
34
+ rubygems: latest
35
+
36
+ - name: Run tests
37
+ run: bundle exec rspec
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Bundler::Interactive
2
2
 
3
- This is a plugin to Bundler which provides an interactive interface to manage updating outdated gems within your project similar to [`yarn upgrade-interactive`]
3
+ A Bundler plugin that turns updating your gems into a quick, interactive pass
4
+ think [`yarn upgrade-interactive`], but for your Gemfile. Instead of guessing
5
+ which gems are behind and editing version numbers by hand, you get a list you
6
+ can walk through and pick exactly what to bump.
4
7
 
5
8
  ## Installation
6
9
 
@@ -8,8 +11,57 @@ This is a plugin to Bundler which provides an interactive interface to manage up
8
11
 
9
12
  ## Usage
10
13
 
14
+ From a project with a Gemfile, run:
15
+
11
16
  $ bundle update-interactive
12
17
 
18
+ (`bundle upgrade-interactive` works too — it's an alias, for muscle memory
19
+ either way.)
20
+
21
+ You'll see one row per outdated gem, showing its group (development, test, …)
22
+ and the versions you can move to:
23
+
24
+ ```
25
+ Select gems and target versions to update:
26
+ * = newer version held by cooldown
27
+ ❯ rake (default) 12.0.0 [12.3.3] 13.4.2
28
+ rexml (development,test) 3.4.0 [3.4.1] 3.4.4*
29
+ ```
30
+
31
+ Each row offers up to three targets:
32
+
33
+ - **current** — what's installed now
34
+ - **in-range** — the newest version your Gemfile constraint already allows
35
+ - **latest** — the newest version published, even if it's beyond your constraint
36
+
37
+ ### Keys
38
+
39
+ | Key | Action |
40
+ | -------------- | ------------------------------------------------- |
41
+ | `↑` / `↓` | move between gems |
42
+ | `←` / `→` | choose a target version for the highlighted gem |
43
+ | `space` | skip a gem (or restore it) |
44
+ | `enter` | apply your selections |
45
+ | `q` / `Ctrl-C` | cancel without changing anything |
46
+
47
+ The selected version is shown in `[brackets]`. By default each gem is set to its
48
+ in-range latest; gems where the only newer option is **beyond** your constraint
49
+ start out skipped, so pressing `enter` never changes your Gemfile unless you ask
50
+ it to.
51
+
52
+ When you hit `enter`, Bundler updates the lockfile for everything you picked. If
53
+ you chose a version that's outside the current constraint, the matching line in
54
+ your Gemfile is rewritten to allow it first.
55
+
56
+ ### Cooldown
57
+
58
+ If you've enabled Bundler's [cooldown] setting (Bundler 4.0.13+), which holds
59
+ back gem versions for a few days after release as a supply-chain safeguard,
60
+ `bundle update-interactive` respects it: the versions you can pick are the ones
61
+ Bundler will actually install. A version that's newer but still inside the
62
+ cooldown window is shown with an asterisk (`*`) so you know an update is coming
63
+ once it clears.
64
+
13
65
  ## Development
14
66
 
15
67
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -22,3 +74,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/wesric
22
74
 
23
75
 
24
76
  [`yarn upgrade-interactive`]: https://yarnpkg.com/lang/en/docs/cli/upgrade-interactive/
77
+ [cooldown]: https://blog.rubygems.org/2026/06/03/cooldown-let-new-gems-be-vetted.html
@@ -39,7 +39,7 @@ Gem::Specification.new do |spec|
39
39
  spec.required_ruby_version = '>= 2.3.0'
40
40
 
41
41
  spec.add_development_dependency 'bundler', '>= 1.16'
42
- spec.add_development_dependency 'rake', '~> 10.0'
42
+ spec.add_development_dependency 'rake', '>= 10.0'
43
43
  spec.add_development_dependency 'rspec', '~> 3.0'
44
44
 
45
45
  spec.add_dependency 'tty-prompt'
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Interactive
5
+ # Rewrites a gem's version requirement in the Gemfile when the user picks a
6
+ # version beyond the declared constraint.
7
+ #
8
+ # Bundler's own Injector can only append or remove entries, so this performs
9
+ # a targeted, in-place edit of the existing `gem "name", ...` line, keeping
10
+ # any groups, options, and trailing comments intact. When no editable line
11
+ # is found it warns and returns false rather than risk corrupting the file.
12
+ class GemfileEditor
13
+ def initialize(name, target_version, gemfile_path: Bundler.default_gemfile)
14
+ @name = name
15
+ @target = Gem::Version.new(target_version.to_s)
16
+ @gemfile_path = gemfile_path
17
+ end
18
+
19
+ def rewrite!
20
+ content = File.read(@gemfile_path)
21
+ updated = replace_requirement(content)
22
+
23
+ if updated.nil?
24
+ warn_unfound
25
+ return false
26
+ end
27
+
28
+ File.write(@gemfile_path, updated)
29
+ true
30
+ rescue StandardError => e
31
+ warn_failure(e)
32
+ false
33
+ end
34
+
35
+ # A pessimistic requirement that admits the chosen version, pinned to its
36
+ # major.minor (e.g. 8.0.1 -> "~> 8.0").
37
+ def new_requirement
38
+ "~> #{@target.segments.first(2).join('.')}"
39
+ end
40
+
41
+ private
42
+
43
+ def replace_requirement(content)
44
+ pattern = /^(?<indent>[ \t]*)gem\s+(?<q>['"])#{Regexp.escape(@name)}\k<q>(?<rest>[^\n]*)$/
45
+ match = content.match(pattern)
46
+ return nil unless match
47
+
48
+ content.sub(pattern) do
49
+ "#{match[:indent]}gem #{match[:q]}#{@name}#{match[:q]}#{rebuild_rest(match[:rest])}"
50
+ end
51
+ end
52
+
53
+ # Strips any existing version-requirement string literals that follow the
54
+ # gem name and inserts the new requirement, preserving option pairs and a
55
+ # trailing comment.
56
+ def rebuild_rest(rest)
57
+ code, comment = split_comment(rest)
58
+ remainder = strip_leading_versions(code.strip.sub(/\A,\s*/, ''))
59
+
60
+ parts = ["\"#{new_requirement}\""]
61
+ parts << remainder unless remainder.empty?
62
+
63
+ result = ", #{parts.join(', ')}"
64
+ comment.empty? ? result : "#{result} #{comment}"
65
+ end
66
+
67
+ def split_comment(rest)
68
+ if (index = comment_index(rest))
69
+ [rest[0...index], rest[index..-1].strip]
70
+ else
71
+ [rest, '']
72
+ end
73
+ end
74
+
75
+ # Index of a `#` that begins a comment (i.e. one not inside a string).
76
+ def comment_index(rest)
77
+ in_string = nil
78
+ rest.each_char.with_index do |char, index|
79
+ if in_string
80
+ in_string = nil if char == in_string
81
+ elsif char == '"' || char == "'"
82
+ in_string = char
83
+ elsif char == '#'
84
+ return index
85
+ end
86
+ end
87
+ nil
88
+ end
89
+
90
+ def strip_leading_versions(code)
91
+ loop do
92
+ match = code.match(/\A(?<q>['"])(?<req>[^'"]*)\k<q>\s*,?\s*/)
93
+ break unless match && version_requirement?(match[:req])
94
+
95
+ code = code[match[0].length..-1]
96
+ end
97
+ code.strip
98
+ end
99
+
100
+ def version_requirement?(string)
101
+ Gem::Requirement::PATTERN.match?(string.strip)
102
+ end
103
+
104
+ def warn_unfound
105
+ Bundler.ui.warn(
106
+ "bundler-interactive: could not find an editable `gem \"#{@name}\"` line in " \
107
+ "#{@gemfile_path}; updating #{@name} within its existing constraint instead."
108
+ )
109
+ end
110
+
111
+ def warn_failure(error)
112
+ Bundler.ui.warn(
113
+ "bundler-interactive: failed to rewrite the Gemfile for #{@name} " \
114
+ "(#{error.message}); updating within its existing constraint instead."
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
@@ -2,54 +2,165 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
+ # View-model for a single outdated gem row in the interactive table.
6
+ #
7
+ # It exposes an ordered, de-duplicated list of selectable version +choices+
8
+ # (current -> in-range latest -> absolute latest) and tracks which one the
9
+ # user has highlighted. The renderer cycles the selection with
10
+ # +cycle_left+/+cycle_right+ and reads +selected_version+ when applying.
5
11
  class OutdatedGem
6
- VERSION_NAMES = %w[MAJOR MINOR PATCH].freeze
12
+ Choice = Struct.new(:version, :role, :held, :git, :ref, keyword_init: true)
7
13
 
8
- def initialize(current_spec, active_spec, dependency)
14
+ def initialize(current_spec:, resolved_spec:, dependency:,
15
+ latest_version: nil, pending_version: nil,
16
+ cooldown_active: false)
9
17
  @current_spec = current_spec
10
- @active_spec = active_spec
18
+ @resolved_spec = resolved_spec
11
19
  @dependency = dependency
20
+ @latest_version = latest_version || resolved_spec.version
21
+ @pending_version = pending_version
22
+ @cooldown_active = cooldown_active
23
+ @selected_index = default_index
12
24
  end
13
25
 
26
+ attr_reader :dependency, :latest_version, :pending_version, :selected_index
27
+
14
28
  def name(padding = 0)
15
29
  current_spec.name.ljust(padding)
16
30
  end
17
31
 
32
+ def groups
33
+ dependency ? Array(dependency.groups) : []
34
+ end
35
+
36
+ # Only the noteworthy groups; the implicit :default group renders as blank.
37
+ def group_label
38
+ (groups.map(&:to_s) - ['default']).join(',')
39
+ end
40
+
18
41
  def current_version
19
42
  current_spec.version
20
43
  end
21
44
 
22
- def active_version
23
- active_spec.version
45
+ def resolved_version
46
+ resolved_spec.version
47
+ end
48
+
49
+ def choices
50
+ @choices ||= build_choices
51
+ end
52
+
53
+ # True when there is anything to pick beyond the installed version.
54
+ def upgradable?
55
+ choices.size > 1
56
+ end
57
+
58
+ def selected
59
+ choices[@selected_index]
60
+ end
61
+
62
+ def selected_version
63
+ selected.version
64
+ end
65
+
66
+ def cycle_left
67
+ @selected_index = (@selected_index - 1) % choices.size
68
+ end
69
+
70
+ def cycle_right
71
+ @selected_index = (@selected_index + 1) % choices.size
72
+ end
73
+
74
+ def select_current!
75
+ @selected_index = choices.index { |choice| choice.role == :current } || 0
76
+ end
77
+
78
+ # Jump to the best upgrade target (in-range latest, else absolute latest).
79
+ def select_upgrade!
80
+ index = choices.index { |choice| choice.role == :in_range } ||
81
+ choices.index { |choice| choice.role == :latest }
82
+ @selected_index = index if index
83
+ end
84
+
85
+ # A row is "skipped" (left untouched) when the highlighted choice is the
86
+ # currently installed version.
87
+ def skip?
88
+ selected.role == :current
89
+ end
90
+
91
+ def git_changed?
92
+ current_git != resolved_git && !(current_git.nil? && resolved_git.nil?)
24
93
  end
25
94
 
26
- def label
27
- "#{version_label.ljust(10)} " \
28
- "newest #{active_version.to_s.ljust(10)} " \
29
- "installed #{current_version.to_s.ljust(10)} " \
30
- "#{dependency_text.ljust(20)}"
95
+ # The selected target is newer than the Gemfile constraint allows, so the
96
+ # Gemfile requirement must be rewritten before updating.
97
+ def cross_constraint?
98
+ return false unless dependency
99
+ return false if git_changed?
100
+
101
+ !dependency.requirement.satisfied_by?(selected_version)
102
+ end
103
+
104
+ # Informational note for an in-range update that is being withheld by the
105
+ # cooldown window but is not otherwise visible as a selectable choice.
106
+ def cooldown_note
107
+ return nil unless @cooldown_active
108
+ return nil unless pending_version
109
+ return nil if choices.any?(&:held)
110
+
111
+ "#{pending_version} held by cooldown"
31
112
  end
32
113
 
33
114
  private
34
115
 
35
- attr_reader :current_spec, :active_spec, :dependency
116
+ attr_reader :current_spec, :resolved_spec
117
+
118
+ def build_choices
119
+ list = [Choice.new(version: current_version, role: :current, held: false, git: false, ref: short_ref(current_spec))]
120
+
121
+ if git_changed?
122
+ list << Choice.new(version: resolved_version, role: :in_range, held: false, git: true, ref: short_ref(resolved_spec))
123
+ elsif resolved_version > current_version
124
+ list << Choice.new(version: resolved_version, role: :in_range, held: held?(resolved_version), git: false, ref: nil)
125
+ end
126
+
127
+ if latest_version > resolved_version && latest_version > current_version
128
+ list << Choice.new(version: latest_version, role: :latest, held: held?(latest_version), git: false, ref: nil)
129
+ end
130
+
131
+ list.uniq { |choice| [choice.version, choice.git] }
132
+ end
133
+
134
+ # The short git revision for a git-sourced spec (e.g. "380c0bc"), or nil.
135
+ def short_ref(spec)
136
+ return nil unless spec.respond_to?(:git_version)
137
+
138
+ ref = spec.git_version
139
+ ref && !ref.strip.empty? ? ref.strip : nil
140
+ end
141
+
142
+ # Default to the current version (skip), matching `yarn
143
+ # upgrade-interactive` — nothing is touched until you opt a gem in.
144
+ def default_index
145
+ choices.index { |choice| choice.role == :current } || 0
146
+ end
147
+
148
+ # A version is held by cooldown when it satisfies the Gemfile constraint
149
+ # (so it is not merely blocked by the declared range) yet sits above the
150
+ # version resolution was willing to pick.
151
+ def held?(version)
152
+ return false unless @cooldown_active
153
+ return false unless dependency
36
154
 
37
- def dependency_text
38
- dependency ? "requested #{dependency.requirement}" : ''
155
+ version > resolved_version && dependency.requirement.satisfied_by?(version)
39
156
  end
40
157
 
41
- def version_label
42
- index = version_diff_index
43
- index ? VERSION_NAMES[index] : 'GIT'
158
+ def current_git
159
+ current_spec.respond_to?(:git_version) ? current_spec.git_version : nil
44
160
  end
45
161
 
46
- def version_diff_index
47
- active_version
48
- .segments
49
- .zip(current_version.segments)
50
- .index do |active_segment, current_segment|
51
- active_segment.to_i > current_segment.to_i
52
- end
162
+ def resolved_git
163
+ resolved_spec.respond_to?(:git_version) ? resolved_spec.git_version : nil
53
164
  end
54
165
  end
55
166
  end
@@ -2,18 +2,49 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
+ # Applies the version selections the user made in the interactive table.
6
+ #
7
+ # Selections whose target exceeds the Gemfile constraint have their
8
+ # requirement rewritten first (via GemfileEditor); then every selected gem
9
+ # is unlocked and re-resolved through a fresh Bundler definition so the
10
+ # lockfile is updated in one pass.
5
11
  class Update
6
- attr_reader :gems
7
-
8
- def initialize(gems)
9
- @gems = gems
12
+ def initialize(selections)
13
+ @selections = Array(selections)
10
14
  end
11
15
 
12
16
  def update!
13
- puts "Updating #{gems.join(', ')}..."
14
- # Update Gemfile?
15
- definition = Bundler.definition(gems: gems)
17
+ return if selections.empty?
18
+
19
+ rewrite_gemfile!
20
+ names = selections.map(&:name)
21
+
22
+ Bundler.ui.info("Updating #{names.join(', ')}...")
23
+ definition = Bundler.definition(gems: names)
24
+ definition.resolve_remotely!
16
25
  Bundler::Installer.install(Bundler.root, definition)
26
+ definition.lock
27
+
28
+ print_summary
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :selections
34
+
35
+ def rewrite_gemfile!
36
+ selections.select(&:cross_constraint?).each do |selection|
37
+ GemfileEditor.new(selection.name, selection.selected_version).rewrite!
38
+ end
39
+ end
40
+
41
+ def print_summary
42
+ Bundler.ui.confirm('Updated:')
43
+ selections.each do |selection|
44
+ Bundler.ui.confirm(
45
+ " #{selection.name} #{selection.current_version} -> #{selection.selected_version}"
46
+ )
47
+ end
17
48
  end
18
49
  end
19
50
  end
@@ -1,60 +1,322 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
4
+
3
5
  begin
4
- require 'tty-prompt'
6
+ require 'tty-reader'
5
7
  rescue LoadError
6
- # tty-prompt probably wasn't loaded when we tried to install the plugin
7
- # for now, this is fine
8
+ # tty-reader (shipped with tty-prompt) probably wasn't loaded when we tried to
9
+ # install the plugin. For now, this is fine.
8
10
  nil
9
11
  end
10
12
 
11
13
  module Bundler
12
14
  module Interactive
15
+ # The `bundle update-interactive` command.
16
+ #
17
+ # Renders a navigable table of outdated gems: up/down move between gems,
18
+ # left/right cycle a gem's target version (current / in-range latest /
19
+ # absolute latest), space skips or restores a row, enter applies the
20
+ # selection, and q (or Ctrl-C) cancels.
13
21
  class UpdateInteractive < Bundler::Plugin::API
22
+ KEY_UP = ["\e[A", "\eOA"].freeze
23
+ KEY_DOWN = ["\e[B", "\eOB"].freeze
24
+ KEY_RIGHT = ["\e[C", "\eOC"].freeze
25
+ KEY_LEFT = ["\e[D", "\eOD"].freeze
26
+ KEY_RETURN = ["\r", "\n"].freeze
27
+ KEY_SPACE = [' '].freeze
28
+ KEY_QUIT = ['q', "\e"].freeze
29
+
30
+ # Fixed version columns, mirroring `yarn upgrade-interactive`.
31
+ ROLE_ORDER = %i[current in_range latest].freeze
32
+ COLUMN_LABELS = { current: 'Current', in_range: 'Range', latest: 'Latest' }.freeze
33
+
34
+ # Semver bump levels, coloured like `yarn upgrade-interactive`.
35
+ LEVEL_COLOR = { major: '31', minor: '33', patch: '32' }.freeze # red / yellow / green
36
+
14
37
  def exec(_command, _args)
15
- response = prompt.multi_select(
16
- 'Select one or more gems to update:', outdated_gem_list,
17
- echo: false, per_page: 20, filter: true, cycle: true
18
- )
19
- Update.new(response).update! if response.any?
38
+ if outdated_gems.empty?
39
+ Bundler.ui.info('Bundle up to date!')
40
+ return
41
+ end
42
+
43
+ selection = interactive_select
44
+ return Bundler.ui.info('Canceling...') if selection.nil?
45
+
46
+ chosen = selection.reject(&:skip?)
47
+ return Bundler.ui.info('No gems selected.') if chosen.empty?
48
+
49
+ Update.new(chosen).update!
20
50
  rescue TTY::Reader::InputInterrupt
21
- prompt.say('Canceling...')
51
+ show_cursor
52
+ Bundler.ui.info("\nCanceling...")
22
53
  end
23
54
 
24
- def outdated_gem_list
25
- outdated_gems.map do |gem|
26
- {
27
- name: "#{gem.name(name_padding)} #{gem.label}",
28
- value: gem.name
29
- }
30
- end
55
+ def outdated_gems
56
+ @outdated_gems ||= VersionResolver.new(
57
+ current_specs: current_gems,
58
+ resolved_definition: definition,
59
+ dependencies: dependencies,
60
+ cooldown_days: cooldown_days
61
+ ).outdated_gems
31
62
  end
32
63
 
33
64
  private
34
65
 
35
- def prompt
36
- @prompt ||= TTY::Prompt.new
66
+ # --- interaction loop ----------------------------------------------------
67
+
68
+ def interactive_select
69
+ @gems = outdated_gems
70
+ reader = TTY::Reader.new(interrupt: :error)
71
+ @cursor = 0
72
+ @offset = 0
73
+
74
+ hide_cursor
75
+ render(first: true)
76
+
77
+ loop do
78
+ case reader.read_keypress
79
+ when *KEY_UP then @cursor = (@cursor - 1) % @gems.size
80
+ when *KEY_DOWN then @cursor = (@cursor + 1) % @gems.size
81
+ when *KEY_LEFT then @gems[@cursor].cycle_left
82
+ when *KEY_RIGHT then @gems[@cursor].cycle_right
83
+ when *KEY_SPACE then toggle_skip(@gems[@cursor])
84
+ when *KEY_RETURN then return @gems
85
+ when *KEY_QUIT then return nil
86
+ end
87
+ render
88
+ end
89
+ ensure
90
+ show_cursor
37
91
  end
38
92
 
39
- def outdated_gems
40
- @outdated_gems ||= begin
41
- current_gems
42
- .sort_by(&:name)
43
- .each_with_object([]) do |current_spec, outdated|
44
- active_spec = definition.find_resolved_spec(current_spec)
45
- next unless gem_outdated?(current_spec, active_spec)
46
-
47
- outdated << OutdatedGem.new(current_spec, active_spec, dependencies[current_spec.name])
48
- end
93
+ def toggle_skip(gem)
94
+ gem.skip? ? gem.select_upgrade! : gem.select_current!
95
+ end
96
+
97
+ # --- rendering -----------------------------------------------------------
98
+
99
+ # Renders the header, a scrolling window of gem rows sized to the
100
+ # terminal, and a footer. Keeping the block within the terminal height is
101
+ # what lets the in-place `cursor_up` redraw work instead of scrolling the
102
+ # whole list off-screen on every keypress.
103
+ def render(first: false)
104
+ total = @gems.size
105
+ page = [page_size, total].min
106
+ adjust_viewport(total, page)
107
+
108
+ lines = header_lines
109
+ lines << column_header_line
110
+ (@offset...(@offset + page)).each { |i| lines << row_line(@gems[i], i == @cursor) }
111
+ lines << ''
112
+ lines << footer_line(total, page)
113
+
114
+ output = +''
115
+ output << cursor_up(@line_count) if !first && @line_count
116
+ lines.each { |line| output << "\e[2K" << line.rstrip << "\n" }
117
+ @line_count = lines.size
118
+
119
+ $stdout.print(output)
120
+ $stdout.flush
121
+ end
122
+
123
+ def adjust_viewport(total, page)
124
+ @offset = @cursor if @cursor < @offset
125
+ @offset = @cursor - page + 1 if @cursor > @offset + page - 1
126
+ max_offset = [total - page, 0].max
127
+ @offset = [[@offset, max_offset].min, 0].max
128
+ end
129
+
130
+ def page_size
131
+ @page_size ||= [terminal_rows - header_lines.size - 4, 3].max
132
+ end
133
+
134
+ def terminal_rows
135
+ (IO.console && IO.console.winsize.first) || 24
136
+ rescue StandardError
137
+ 24
138
+ end
139
+
140
+ def header_lines
141
+ lines = [
142
+ colorize('Select gems and target versions to update:', '1'),
143
+ dim('↑/↓ move · ←/→ choose version · space skip/restore · enter apply · q cancel'),
144
+ legend_line
145
+ ]
146
+ lines << dim('* = newer version held by cooldown') if cooldown_present?
147
+ lines << ''
148
+ lines
149
+ end
150
+
151
+ def legend_line
152
+ levels = %i[major minor patch].map { |level| color_for_level(level.to_s, level) }
153
+ dim('diff: ') + levels.join(dim(' · '))
154
+ end
155
+
156
+ def cooldown_present?
157
+ return @cooldown_present if defined?(@cooldown_present)
158
+
159
+ @cooldown_present = outdated_gems.any? do |gem|
160
+ gem.cooldown_note || gem.choices.any?(&:held)
161
+ end
162
+ end
163
+
164
+ def column_header_line
165
+ name_blank = ' ' * (2 + name_padding)
166
+ cells = present_roles.map { |role| bold(COLUMN_LABELS[role].ljust(column_width(role))) }
167
+ cells << bold('Group') if show_group?
168
+ "#{name_blank} #{cells.join(' ')}".rstrip
169
+ end
170
+
171
+ def row_line(gem, active)
172
+ pointer = active ? '❯ ' : ' '
173
+ cells = present_roles.map do |role|
174
+ choice = cell_for(gem, role)
175
+ version_cell(choice, choice && gem.selected.equal?(choice), column_width(role), gem.current_version)
49
176
  end
177
+ cells << gem.group_label if show_group?
178
+ note = gem.cooldown_note ? " #{dim("← #{gem.cooldown_note}")}" : ''
179
+
180
+ "#{pointer}#{gem.name(name_padding)} #{cells.join(' ')}#{note}"
181
+ end
182
+
183
+ # Show the Group column only when at least one gem belongs to a group
184
+ # other than the implicit :default.
185
+ def show_group?
186
+ return @show_group if defined?(@show_group)
187
+
188
+ @show_group = outdated_gems.any? { |gem| !gem.group_label.empty? }
189
+ end
190
+
191
+ # Renders one version into a fixed-width column with a radio marker
192
+ # (● selected / ○ not), colouring the changed portion of the version by
193
+ # bump level so the table shows patch/minor/major at a glance. Padding is
194
+ # computed from the visible (uncoloured) text so columns stay aligned.
195
+ def version_cell(choice, selected, width, current)
196
+ return ' ' * width if choice.nil?
197
+
198
+ marker = selected ? '●' : '○'
199
+ ref = choice.ref ? " @#{choice.ref}" : ''
200
+ held = choice.held ? '*' : ''
201
+ visible = "#{marker} #{version_text(choice)}"
202
+ marker_out = selected ? color_for_level(marker, diff_level(choice.version, current)) : dim(marker)
203
+
204
+ "#{marker_out} #{colorize_diff(choice.version, current)}#{dim(ref)}#{held}" +
205
+ (' ' * [width - visible.length, 0].max)
50
206
  end
51
207
 
208
+ # Returns the version string with the segments that differ from +current+
209
+ # (and everything after) coloured by bump level; the unchanged prefix is
210
+ # left plain. e.g. 8.1 -> 8.1.3 colours ".3" green, 7 -> 7.29.7 ".29.7"
211
+ # yellow, 4 -> 5.3.0 the whole thing red.
212
+ def colorize_diff(version, current)
213
+ index = diff_index(version, current)
214
+ return version.to_s if index.nil?
215
+
216
+ segments = version.segments
217
+ prefix = segments[0...index].join('.')
218
+ suffix = color_for_level(segments[index..-1].join('.'), diff_level(version, current))
219
+ prefix.empty? ? suffix : "#{prefix}.#{suffix}"
220
+ end
221
+
222
+ def diff_index(version, current)
223
+ current_segments = current.segments
224
+ version.segments.each_index.find { |i| version.segments[i] != current_segments[i] }
225
+ end
226
+
227
+ def diff_level(version, current)
228
+ case diff_index(version, current)
229
+ when nil then nil
230
+ when 0 then :major
231
+ when 1 then :minor
232
+ else :patch
233
+ end
234
+ end
235
+
236
+ def color_for_level(text, level)
237
+ level ? colorize(text, LEVEL_COLOR[level]) : text
238
+ end
239
+
240
+ def version_text(choice)
241
+ ref = choice.ref ? " @#{choice.ref}" : ''
242
+ "#{choice.version}#{ref}#{choice.held ? '*' : ''}"
243
+ end
244
+
245
+ def cell_for(gem, role)
246
+ gem.choices.find { |choice| choice.role == role }
247
+ end
248
+
249
+ def present_roles
250
+ @present_roles ||= ROLE_ORDER.select do |role|
251
+ outdated_gems.any? { |gem| cell_for(gem, role) }
252
+ end
253
+ end
254
+
255
+ def column_width(role)
256
+ @column_widths ||= {}
257
+ # +2 for the "● " radio marker that precedes each version.
258
+ @column_widths[role] ||= outdated_gems.map do |gem|
259
+ (choice = cell_for(gem, role)) ? version_text(choice).length + 2 : 0
260
+ end.push(COLUMN_LABELS[role].length).max
261
+ end
262
+
263
+ def footer_line(total, page)
264
+ count = @gems.count { |gem| !gem.skip? }
265
+ parts = ["#{count} of #{total} selected to update"]
266
+ parts << "showing #{@offset + 1}-#{@offset + page} of #{total}" if total > page
267
+ dim(parts.join(' · '))
268
+ end
269
+
270
+ def bold(text)
271
+ colorize(text, '1')
272
+ end
273
+
274
+ def cursor_up(count)
275
+ count.positive? ? "\e[#{count}A" : ''
276
+ end
277
+
278
+ def hide_cursor
279
+ $stdout.print("\e[?25l") if color?
280
+ end
281
+
282
+ def show_cursor
283
+ $stdout.print("\e[?25h") if color?
284
+ end
285
+
286
+ def colorize(text, code)
287
+ color? ? "\e[#{code}m#{text}\e[0m" : text
288
+ end
289
+
290
+ def dim(text)
291
+ colorize(text, '2')
292
+ end
293
+
294
+ def color?
295
+ $stdout.tty?
296
+ end
297
+
298
+ def name_padding
299
+ @name_padding ||= outdated_gems.map { |gem| gem.name.length }.max
300
+ end
301
+
302
+ # --- bundler data --------------------------------------------------------
303
+
52
304
  def current_gems
53
305
  @current_gems ||= Bundler.definition.tap(&:validate_runtime!).resolve
54
306
  end
55
307
 
308
+ # Adapts a resolved Bundler SpecSet to the lookup the resolver expects.
309
+ # (Bundler::Definition#find_resolved_spec was removed in Bundler 4.x.)
310
+ ResolvedSpecs = Struct.new(:spec_set) do
311
+ def find_resolved_spec(current_spec)
312
+ spec_set.find_by_name_and_platform(current_spec.name, current_spec.platform)
313
+ end
314
+ end
315
+
56
316
  def definition
57
- @definition ||= Bundler.definition(true).tap(&:resolve_remotely!)
317
+ @definition ||= ResolvedSpecs.new(
318
+ Bundler.definition(true).tap(&:resolve_remotely!).resolve
319
+ )
58
320
  end
59
321
 
60
322
  def dependencies
@@ -62,15 +324,18 @@ module Bundler
62
324
  Bundler.load.dependencies.map { |dep| [dep.name, dep] }.to_h
63
325
  end
64
326
 
65
- def gem_outdated?(current_spec, active_spec)
66
- active_spec.version > current_spec.version ||
67
- active_spec.git_version != current_spec.git_version
68
- rescue NoMethodError
69
- puts "Gem #{current_spec.name}, couldn't find an active_spec"
70
- end
327
+ # Cooldown is opt-in and only exists on Bundler >= 4.0.13, so read it
328
+ # defensively and treat anything unparseable as "off".
329
+ def cooldown_days
330
+ return @cooldown_days if defined?(@cooldown_days)
71
331
 
72
- def name_padding
73
- @name_padding ||= outdated_gems.map { |gem| gem.name.length }.max
332
+ @cooldown_days =
333
+ begin
334
+ value = Bundler.settings[:cooldown]
335
+ value.nil? ? 0 : value.to_i
336
+ rescue StandardError
337
+ 0
338
+ end
74
339
  end
75
340
  end
76
341
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
- VERSION = '0.1.2.1'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Interactive
5
+ # Gathers the candidate versions for every outdated gem.
6
+ #
7
+ # For each installed gem it computes:
8
+ # * the currently installed (locked) version,
9
+ # * the in-range latest that dependency resolution selected (which already
10
+ # respects the Gemfile constraint and, when configured, the cooldown
11
+ # window),
12
+ # * the absolute latest published version from the gem's source, and
13
+ # * the in-range version held back by cooldown, if any.
14
+ #
15
+ # Collaborators are injected so the resolver can be unit-tested without a
16
+ # live Bundler environment.
17
+ class VersionResolver
18
+ def initialize(current_specs:, resolved_definition:, dependencies:, cooldown_days: 0)
19
+ @current_specs = current_specs
20
+ @resolved_definition = resolved_definition
21
+ @dependencies = dependencies
22
+ @cooldown_days = cooldown_days.to_i
23
+ end
24
+
25
+ def outdated_gems
26
+ current_specs.sort_by(&:name).each_with_object([]) do |current_spec, list|
27
+ resolved = resolved_definition.find_resolved_spec(current_spec)
28
+ next unless resolved
29
+
30
+ gem = build(current_spec, resolved)
31
+ # A gem is worth listing when it offers any upgrade target — an
32
+ # in-range bump, a newer git revision, or a version beyond the
33
+ # declared constraint (which `bundle outdated` surfaces too).
34
+ list << gem if gem.upgradable?
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :current_specs, :resolved_definition, :dependencies, :cooldown_days
41
+
42
+ def build(current_spec, resolved)
43
+ dependency = dependencies[current_spec.name]
44
+ available = available_versions(current_spec, resolved)
45
+
46
+ OutdatedGem.new(
47
+ current_spec: current_spec,
48
+ resolved_spec: resolved,
49
+ dependency: dependency,
50
+ latest_version: available.max || resolved.version,
51
+ pending_version: pending_version(resolved, dependency, available),
52
+ cooldown_active: cooldown_active?
53
+ )
54
+ end
55
+
56
+ # All published versions for the gem, straight from the source index (this
57
+ # list is *not* cooldown-filtered, which is what lets us detect held
58
+ # versions). Prereleases are dropped unless the installed gem is itself a
59
+ # prerelease, mirroring `bundle outdated`.
60
+ def available_versions(current_spec, resolved)
61
+ specs = resolved.source.specs.search(resolved.name)
62
+ specs = specs.select { |spec| installable?(spec, current_spec) }
63
+ versions = specs.map(&:version)
64
+ versions = versions.reject(&:prerelease?) unless current_spec.version.prerelease?
65
+ versions.uniq.sort
66
+ rescue StandardError
67
+ []
68
+ end
69
+
70
+ def installable?(spec, current_spec)
71
+ return true unless spec.respond_to?(:installable_on_platform?)
72
+
73
+ spec.installable_on_platform?(current_spec.platform)
74
+ rescue StandardError
75
+ true
76
+ end
77
+
78
+ # The highest in-range version available in the source that resolution
79
+ # refused to pick — i.e. a version waiting out the cooldown window.
80
+ def pending_version(resolved, dependency, available)
81
+ return nil unless cooldown_active?
82
+ return nil unless dependency
83
+
84
+ candidate = available.select { |version| dependency.requirement.satisfied_by?(version) }.max
85
+ return nil unless candidate
86
+
87
+ candidate > resolved.version ? candidate : nil
88
+ end
89
+
90
+ def cooldown_active?
91
+ cooldown_days.positive?
92
+ end
93
+ end
94
+ end
95
+ end
data/plugins.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'bundler/interactive'
4
4
 
5
- Bundler::Plugin::API.command(
6
- 'update-interactive', Bundler::Interactive::UpdateInteractive
7
- )
5
+ # `update-interactive` matches Bundler's own verb (`bundle update`);
6
+ # `upgrade-interactive` is an alias for parity with `yarn upgrade-interactive`.
7
+ %w[update-interactive upgrade-interactive].each do |command|
8
+ Bundler::Plugin::API.command(command, Bundler::Interactive::UpdateInteractive)
9
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-interactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wes Rich
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2019-08-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -28,14 +27,14 @@ dependencies:
28
27
  name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
30
+ - - ">="
32
31
  - !ruby/object:Gem::Version
33
32
  version: '10.0'
34
33
  type: :development
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - "~>"
37
+ - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '10.0'
41
40
  - !ruby/object:Gem::Dependency
@@ -73,11 +72,10 @@ executables: []
73
72
  extensions: []
74
73
  extra_rdoc_files: []
75
74
  files:
75
+ - ".github/workflows/ci.yml"
76
76
  - ".gitignore"
77
77
  - ".rspec"
78
78
  - ".rubocop.yml"
79
- - ".semaphore/semaphore.yml"
80
- - ".travis.yml"
81
79
  - Gemfile
82
80
  - README.md
83
81
  - Rakefile
@@ -85,10 +83,12 @@ files:
85
83
  - bin/setup
86
84
  - bundler-interactive.gemspec
87
85
  - lib/bundler/interactive.rb
86
+ - lib/bundler/interactive/gemfile_editor.rb
88
87
  - lib/bundler/interactive/outdated_gem.rb
89
88
  - lib/bundler/interactive/update.rb
90
89
  - lib/bundler/interactive/update_interactive.rb
91
90
  - lib/bundler/interactive/version.rb
91
+ - lib/bundler/interactive/version_resolver.rb
92
92
  - plugins.rb
93
93
  homepage: https://github.com/wesrich/Bundler-Interactive
94
94
  licenses: []
@@ -97,7 +97,6 @@ metadata:
97
97
  homepage_uri: https://github.com/wesrich/Bundler-Interactive
98
98
  source_code_uri: https://github.com/wesrich/Bundler-Interactive
99
99
  changelog_uri: https://github.com/wesrich/Bundler-Interactive
100
- post_install_message:
101
100
  rdoc_options: []
102
101
  require_paths:
103
102
  - lib
@@ -112,8 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
111
  - !ruby/object:Gem::Version
113
112
  version: '0'
114
113
  requirements: []
115
- rubygems_version: 3.0.4
116
- signing_key:
114
+ rubygems_version: 4.0.10
117
115
  specification_version: 4
118
116
  summary: Interactive prompt for Bundler
119
117
  test_files: []
@@ -1,26 +0,0 @@
1
- version: v1.0
2
- name: Bundler::Interactive
3
- agent:
4
- machine:
5
- type: e1-standard-2
6
- os_image: ubuntu1804
7
-
8
- blocks:
9
- - name: Build
10
- execution_time_limit:
11
- minutes: 1
12
- task:
13
- jobs:
14
- - name: Tests
15
- matrix:
16
- - env_var: RUBY_VERSION
17
- values: ['2.3', '2.4', '2.5', '2.6.3']
18
- commands:
19
- - checkout
20
- - sem-version ruby $RUBY_VERSION
21
-
22
- # - cache restore gems-$RUBY_VERSION,gems-
23
- # - bundle check --path=vendor/bundle || bundle install
24
- # - cache store gems-$RUBY_VERSION vendor/bundle
25
- - bundle install
26
- - bundle exec rspec --format documentation
data/.travis.yml DELETED
@@ -1,9 +0,0 @@
1
- ---
2
- sudo: false
3
- language: ruby
4
- cache: bundler
5
- rvm:
6
- - 2.3
7
- - 2.4
8
- - 2.5
9
- - 2.6