├── .git-blame-ignore-revs
├── .github
├── dependabot.yml
├── release-drafter.yml
└── workflows
│ ├── ci.yml
│ └── release-drafter.yml
├── .gitignore
├── .kodiak.toml
├── .overcommit.yml
├── .prettierignore
├── .rubocop.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
├── console
├── mt
└── setup
├── bundle_update_interactive.gemspec
├── exe
├── bundler-ui
└── bundler-update-interactive
├── images
├── conservative.png
├── held-back.png
├── security.png
├── semver.png
├── update-interactive.png
└── version-highlight.png
├── lib
├── bundle_update_interactive.rb
└── bundle_update_interactive
│ ├── bundler_commands.rb
│ ├── changelog_locator.rb
│ ├── cli.rb
│ ├── cli
│ ├── multi_select.rb
│ ├── options.rb
│ ├── row.rb
│ └── table.rb
│ ├── error.rb
│ ├── gemfile.rb
│ ├── git_committer.rb
│ ├── http.rb
│ ├── latest
│ ├── gem_requirement.rb
│ ├── gemfile_editor.rb
│ └── updater.rb
│ ├── lockfile.rb
│ ├── lockfile_entry.rb
│ ├── outdated_gem.rb
│ ├── report.rb
│ ├── semver_change.rb
│ ├── string_helper.rb
│ ├── thread_pool.rb
│ ├── updater.rb
│ └── version.rb
└── test
├── bundle_update_interactive
├── bundler_commands_test.rb
├── changelog_locator_test.rb
├── cli
│ ├── multi_select_test.rb
│ ├── options_test.rb
│ └── row_test.rb
├── cli_test.rb
├── git_committer_test.rb
├── latest
│ ├── gem_requirement_test.rb
│ ├── gemfile_editor_test.rb
│ └── updater_test.rb
├── lockfile_test.rb
├── outdated_gem_test.rb
├── semver_change_test.rb
└── updater_test.rb
├── bundle_update_interactive_test.rb
├── cassettes
└── changelog_requests.yml
├── factories
└── outdated_gems.rb
├── fixtures
├── Gemfile
├── Gemfile.lock
├── Gemfile.lock.development-test-updated
├── Gemfile.lock.updated
└── integration
│ ├── Gemfile
│ ├── Gemfile.lock
│ └── with_indirect
│ ├── Gemfile
│ └── Gemfile.lock
├── integration
└── cli_integration_test.rb
├── snapshots
└── bundleupdateinteractive_updatertest
│ ├── test_generates_a_report_of_updatable_gems_for_development_and_test_groups__1.snap.yaml
│ ├── test_generates_a_report_of_updatable_gems_that_can_be_rendered_as_a_table__1.snap.yaml
│ └── test_generates_a_report_of_withheld_gems_based_on_pins_that_excludes_updatable_gems__1.snap.yaml
├── support
├── bundler_audit_test_helpers.rb
├── capture_io_helpers.rb
├── factory_bot.rb
├── mocha.rb
├── vcr.rb
└── webmock.rb
└── test_helper.rb
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Standardize on nested modules syntax and reindent
2 | fc84d51930d3ed880b6bb895c106b1630a90a7f8
3 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: bundler
4 | directory: "/"
5 | schedule:
6 | interval: monthly
7 | time: "16:00"
8 | timezone: America/Los_Angeles
9 | open-pull-requests-limit: 10
10 | labels:
11 | - "🏠 Housekeeping"
12 | - package-ecosystem: github-actions
13 | directory: "/"
14 | schedule:
15 | interval: monthly
16 | time: "16:00"
17 | timezone: America/Los_Angeles
18 | open-pull-requests-limit: 10
19 | labels:
20 | - "🏠 Housekeeping"
21 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: "$RESOLVED_VERSION"
2 | tag-template: "v$RESOLVED_VERSION"
3 | categories:
4 | - title: "⚠️ Breaking Changes"
5 | label: "⚠️ Breaking"
6 | - title: "✨ New Features"
7 | label: "✨ Feature"
8 | - title: "🐛 Bug Fixes"
9 | label: "🐛 Bug Fix"
10 | - title: "📚 Documentation"
11 | label: "📚 Docs"
12 | - title: "🏠 Housekeeping"
13 | label: "🏠 Housekeeping"
14 | version-resolver:
15 | minor:
16 | labels:
17 | - "⚠️ Breaking"
18 | - "✨ Feature"
19 | default: patch
20 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR"
21 | no-changes-template: "- No changes"
22 | template: |
23 | $CHANGES
24 |
25 | **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
26 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 | jobs:
8 | rubocop:
9 | name: "Rubocop"
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: ruby/setup-ruby@v1
14 | with:
15 | ruby-version: "ruby"
16 | bundler-cache: true
17 | - run: bundle exec rubocop
18 | test:
19 | name: "Test / Ruby ${{ matrix.ruby }}"
20 | runs-on: ubuntu-latest
21 | strategy:
22 | matrix:
23 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "head"]
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: ruby/setup-ruby@v1
27 | with:
28 | ruby-version: ${{ matrix.ruby }}
29 | bundler-cache: true
30 | bundler: latest
31 | - run: git config --global user.name 'github-actions[bot]'
32 | - run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
33 | - run: bundle exec rake test
34 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: write
10 | pull-requests: read
11 |
12 | jobs:
13 | update_release_draft:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: release-drafter/release-drafter@v6
17 | env:
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /site/
8 | /spec/reports/
9 | /tmp/
10 | /Gemfile.lock
11 |
--------------------------------------------------------------------------------
/.kodiak.toml:
--------------------------------------------------------------------------------
1 | # .kodiak.toml
2 | # Minimal config. version is the only required field.
3 | version = 1
4 |
5 | [merge.automerge_dependencies]
6 | # auto merge all PRs opened by "dependabot" that are "minor" or "patch" version upgrades. "major" version upgrades will be ignored.
7 | versions = ["minor", "patch"]
8 | usernames = ["dependabot"]
9 |
10 | # if using `update.always`, add dependabot to `update.ignore_usernames` to allow
11 | # dependabot to update and close stale dependency upgrades.
12 | [update]
13 | ignored_usernames = ["dependabot"]
14 |
--------------------------------------------------------------------------------
/.overcommit.yml:
--------------------------------------------------------------------------------
1 | # Overcommit hooks run automatically on certain git operations, like "git commit".
2 | # For a complete list of options that you can use to customize hooks, see:
3 | # https://github.com/sds/overcommit
4 |
5 | gemfile: false
6 | verify_signatures: false
7 |
8 | PreCommit:
9 | BundleCheck:
10 | enabled: true
11 |
12 | FixMe:
13 | enabled: true
14 | keywords: ["FIXME"]
15 | exclude:
16 | - .overcommit.yml
17 |
18 | LocalPathsInGemfile:
19 | enabled: true
20 |
21 | RuboCop:
22 | enabled: true
23 | required_executable: bundle
24 | command: ["bundle", "exec", "rubocop"]
25 | on_warn: fail
26 |
27 | YamlSyntax:
28 | enabled: true
29 |
30 | PostCheckout:
31 | ALL:
32 | quiet: true
33 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /CODE_OF_CONDUCT.md
2 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | plugins:
2 | - rubocop-minitest
3 | - rubocop-factory_bot
4 | - rubocop-packaging
5 | - rubocop-performance
6 | - rubocop-rake
7 |
8 | AllCops:
9 | DisplayCopNames: true
10 | DisplayStyleGuide: true
11 | NewCops: enable
12 | TargetRubyVersion: 2.7
13 | Exclude:
14 | - "bin/*"
15 | - "test/fixtures/**/*"
16 | - "tmp/**/*"
17 | - "vendor/**/*"
18 |
19 | Layout/FirstArrayElementIndentation:
20 | EnforcedStyle: consistent
21 |
22 | Layout/FirstArrayElementLineBreak:
23 | Enabled: true
24 |
25 | Layout/FirstHashElementLineBreak:
26 | Enabled: true
27 |
28 | Layout/FirstMethodArgumentLineBreak:
29 | Enabled: true
30 |
31 | Layout/HashAlignment:
32 | EnforcedColonStyle:
33 | - table
34 | - key
35 | EnforcedHashRocketStyle:
36 | - table
37 | - key
38 |
39 | Layout/MultilineArrayLineBreaks:
40 | Enabled: true
41 |
42 | Layout/MultilineHashKeyLineBreaks:
43 | Enabled: true
44 |
45 | Layout/MultilineMethodArgumentLineBreaks:
46 | Enabled: true
47 |
48 | Layout/MultilineMethodCallIndentation:
49 | EnforcedStyle: indented
50 |
51 | Layout/SpaceAroundEqualsInParameterDefault:
52 | EnforcedStyle: no_space
53 |
54 | Metrics/AbcSize:
55 | Max: 20
56 | Exclude:
57 | - "test/**/*"
58 |
59 | Metrics/BlockLength:
60 | Exclude:
61 | - "*.gemspec"
62 | - "Rakefile"
63 | - "test/**/*"
64 |
65 | Metrics/ClassLength:
66 | Exclude:
67 | - "test/**/*"
68 |
69 | Metrics/MethodLength:
70 | Max: 18
71 | Exclude:
72 | - "test/**/*"
73 |
74 | Metrics/ParameterLists:
75 | Max: 6
76 |
77 | Minitest/EmptyLineBeforeAssertionMethods:
78 | Enabled: false
79 |
80 | Minitest/MultipleAssertions:
81 | Max: 6
82 |
83 | Naming/MemoizedInstanceVariableName:
84 | Enabled: false
85 |
86 | Naming/VariableNumber:
87 | Enabled: false
88 |
89 | Rake/Desc:
90 | Enabled: false
91 |
92 | Style/BarePercentLiterals:
93 | EnforcedStyle: percent_q
94 |
95 | Style/Documentation:
96 | Enabled: false
97 |
98 | Style/DoubleNegation:
99 | Enabled: false
100 |
101 | Style/EmptyMethod:
102 | Enabled: false
103 |
104 | Style/NumericPredicate:
105 | Enabled: false
106 |
107 | Style/StderrPuts:
108 | Enabled: false
109 |
110 | Style/StringLiterals:
111 | EnforcedStyle: double_quotes
112 |
113 | Style/TrivialAccessors:
114 | AllowPredicates: true
115 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Release notes for this project are kept here: https://github.com/mattbrictson/bundle_update_interactive/releases
2 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | * The use of sexualized language or imagery, and sexual attention or
22 | advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email
26 | address, without their explicit permission
27 | * Other conduct which could reasonably be considered inappropriate in a
28 | professional setting
29 |
30 | ## Enforcement Responsibilities
31 |
32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33 |
34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35 |
36 | ## Scope
37 |
38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39 |
40 | ## Enforcement
41 |
42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at opensource@mattbrictson.com. All complaints will be reviewed and investigated promptly and fairly.
43 |
44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45 |
46 | ## Enforcement Guidelines
47 |
48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49 |
50 | ### 1. Correction
51 |
52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53 |
54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55 |
56 | ### 2. Warning
57 |
58 | **Community Impact**: A violation through a single incident or series of actions.
59 |
60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61 |
62 | ### 3. Temporary Ban
63 |
64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65 |
66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67 |
68 | ### 4. Permanent Ban
69 |
70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71 |
72 | **Consequence**: A permanent ban from any sort of public interaction within the community.
73 |
74 | ## Attribution
75 |
76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78 |
79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80 |
81 | [homepage]: https://www.contributor-covenant.org
82 |
83 | For answers to common questions about this code of conduct, see the FAQ at
84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
85 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 | gemspec
5 |
6 | gem "activesupport", "~> 7.1.3" # Needed for factory_bot 6.3
7 | gem "cgi" # Needed for vcr on Ruby 3.5+
8 | gem "factory_bot", "~> 6.3.0"
9 | gem "minitest", "~> 5.11"
10 | gem "minitest-snapshots", "~> 1.1"
11 | gem "mocha", "~> 2.4"
12 | gem "observer"
13 | gem "rake", "~> 13.0"
14 | gem "vcr", "~> 6.2"
15 | gem "webmock", "~> 3.23"
16 |
17 | if RUBY_VERSION >= "3.3"
18 | gem "mighty_test", "~> 0.3"
19 | gem "rubocop", "1.75.8"
20 | gem "rubocop-factory_bot", "2.27.1"
21 | gem "rubocop-minitest", "0.38.1"
22 | gem "rubocop-packaging", "0.6.0"
23 | gem "rubocop-performance", "1.25.0"
24 | gem "rubocop-rake", "0.7.1"
25 | end
26 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2025 Matt Brictson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # bundle_update_interactive
2 |
3 | [](https://rubygems.org/gems/bundle_update_interactive)
4 | [](https://www.ruby-toolbox.com/projects/bundle_update_interactive)
5 | [](https://github.com/mattbrictson/bundle_update_interactive/actions/workflows/ci.yml)
6 | [](https://codeclimate.com/github/mattbrictson/bundle_update_interactive)
7 |
8 | **This gem adds an `update-interactive` command to [Bundler](https://bundler.io).** Run it to see what gems can be updated, then pick and choose which ones to update. If you've used `yarn upgrade-interactive`, the interface should be very familiar.
9 |
10 |
11 |
12 | ---
13 |
14 | - [Quick start](#quick-start)
15 | - [Options](#options)
16 | - [Features](#features)
17 | - [Prior art](#prior-art)
18 | - [Support](#support)
19 | - [License](#license)
20 | - [Code of conduct](#code-of-conduct)
21 | - [Contribution guide](#contribution-guide)
22 |
23 | ## Quick start
24 |
25 | Install the gem:
26 |
27 | ```
28 | gem install bundle_update_interactive
29 | ```
30 |
31 | Now you can use:
32 |
33 | ```
34 | bundle update-interactive
35 | ```
36 |
37 | Or the shorthand:
38 |
39 | ```
40 | bundle ui
41 | ```
42 |
43 | ## Options
44 |
45 | - `--commit` [applies each gem update in a discrete git commit](#git-commits)
46 | - `--latest` [modifies the Gemfile if necessary to allow the latest gem versions](#allow-latest-versions)
47 | - `--only-explicit` [updates Gemfile gems only (excluding indirect dependencies)](#exclude-indirect-dependencies)
48 | - `-D` / `--exclusively=GROUP` [limits updatable gems by Gemfile groups](#limit-impact-by-gemfile-groups)
49 |
50 | ## Features
51 |
52 | ### Semver highlighting
53 |
54 | `bundle update-interactive` highlights each gem according the severity of its version upgrade.
55 |
56 |
57 |
58 | Gems sourced from Git repositories are highlighted in cyan, regardless of the semver change, due to the fact that new commits pulled from the Git repo may not yet be officially released. In this case the semver information is unknown.
59 |
60 | `bundle update-interactive` also highlights the exact portion of the version number that has changed, so you can quickly scan gem versions for important differences.
61 |
62 |
63 |
64 | ### Security vulnerabilities
65 |
66 | `bundle update-interactive` uses [bundler-audit](https://github.com/rubysec/bundler-audit) internally to search for outdated gems that have known security vulnerabilities. These gems are highlighted prominently with white text on a red background.
67 |
68 |
69 |
70 | Some gems, notably `rails`, are composed of smaller gems like `actionpack`, `activesupport`, `railties`, etc. Because of how these component gem versions are constrained, you cannot update just one of them; they all must be updated together.
71 |
72 | 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.
73 |
74 | ### Git commits
75 |
76 | Sometimes, updating gems can lead to bugs or regressions. To facilitate troubleshooting, `update-interactive` offers the ability to commit each selected gem update in its own git commit, complete with a descriptive commit message. You can then make use of tools like `git bisect` to more easily find the update that introduced the problem.
77 |
78 | To enable this behavior, pass the `--commit` option:
79 |
80 | ```
81 | bundle update-interactive --commit
82 | ```
83 |
84 | The gems you select to be updated will be applied in separate commits, like this:
85 |
86 | ```
87 | * c9801382 Update activeadmin 3.2.2 → 3.2.3
88 | * 9957254b Update rexml 3.3.5 → 3.3.6
89 | * 4a4f2072 Update sass 1.77.6 → 1.77.8
90 | ```
91 |
92 | > [!NOTE]
93 | > In rare cases, Bundler may not be able to update a gem separately, due to interdependencies between gem versions. If this happens, you will see a message like "attempted to update [GEM] but its version stayed the same."
94 |
95 | ### Held back gems
96 |
97 | 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.
98 |
99 |
100 |
101 | To allow updates for gems that would normally be held back, use the `--latest` option (explained in the next section).
102 |
103 | ### Allow latest versions
104 |
105 | 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:
106 |
107 | ```ruby
108 | gem "rails", "~> 7.1.0"
109 | ```
110 |
111 | 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:
112 |
113 | ```
114 | bundle update-interactive --latest
115 | ```
116 |
117 | 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:
118 |
119 | ```ruby
120 | gem "rails", "~> 7.2.0"
121 | ```
122 |
123 | In other words, it works similarly to `yarn upgrade-interactive --latest`.
124 |
125 | ### Changelogs
126 |
127 | `bundle update-interactive` will do its best to find an appropriate changelog for each gem.
128 |
129 | It prefers the `changelog_uri` [metadata](https://guides.rubygems.org/specification-reference/#metadata) published in the gem itself. However, this metadata field is optional, and many gem authors do not provide it.
130 |
131 | As a fallback, `bundle update-interactive` will check if the gem's source code is hosted on GitHub, and scans the GitHub repo for obvious changelog files like `CHANGELOG.md`, `NEWS`, etc. Finally, if the project is actively documenting versions using GitHub Releases, the Releases URL will be used.
132 |
133 | If you discover a gem that is missing a changelog in `bundle update-interactive`, [log an issue](https://github.com/mattbrictson/bundle_update_interactive/issues) and I'll see if the algorithm can be improved.
134 |
135 | ### Git diffs
136 |
137 | If your `Gemfile` sources a gem from a Git repo like this:
138 |
139 | ```ruby
140 | gem "rails", github: "rails/rails", branch: "7-1-stable"
141 | ```
142 |
143 | Then `bundle update-interactive` will show a diff link instead of a changelog, so you can see exactly what changed when the gem is updated. For example:
144 |
145 | https://github.com/rails/rails/compare/5a8d894...77dfa65
146 |
147 | This feature currently works for GitHub, GitLab, and Bitbucket repos.
148 |
149 | ### Exclude indirect dependencies
150 |
151 | Just like with `bundle outdated`, you can pass `--only-explicit` to limit updates to only gems that are explicitly listed in the Gemfile.
152 |
153 | ```sh
154 | bundle update-interactive --only-explicit
155 | ```
156 |
157 | This will omit indirect dependencies from the list of gems that can be updated.
158 |
159 | ### Limit impact by Gemfile groups
160 |
161 | The effects of `bundle update-interactive` can be limited to one or more Gemfile groups using the `--exclusively` option:
162 |
163 | ```sh
164 | bundle update-interactive --exclusively=group1,group2
165 | ```
166 |
167 | 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`:
168 |
169 | ```sh
170 | # Update non-production dependencies.
171 | # This is equivalent to `bundle update-interactive --exclusively=development,test`
172 | bundle update-interactive -D
173 | ```
174 |
175 | 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.
176 |
177 | For example, given this Gemfile:
178 |
179 | ```ruby
180 | gem "rails"
181 |
182 | group :test do
183 | gem "capybara"
184 | end
185 | ```
186 |
187 | 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.
188 |
189 | ### Conservative updates
190 |
191 | `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.
192 |
193 |
194 |
195 | An exception is made for "meta gems" like `rails` that are composed of dependencies locked at exact versions. For example, if you chose to upgrade `rails`, the actual command issued to Bundler will be:
196 |
197 | ```
198 | bundle update --conservative \
199 | rails \
200 | actioncable \
201 | actionmailbox \
202 | actionmailer \
203 | actionpack \
204 | actiontext \
205 | actionview \
206 | activejob \
207 | activemodel \
208 | activerecord \
209 | activestorage \
210 | activesupport \
211 | railties
212 | ```
213 |
214 | ## Prior art
215 |
216 | This project was inspired by [yarn upgrade-interactive](https://classic.yarnpkg.com/lang/en/docs/cli/upgrade-interactive/), and borrows many of its interface ideas.
217 |
218 | Before creating `bundle update-interactive`, I published [bundleup](https://github.com/mattbrictson/bundleup), a gem that serves a similar purpose but with a simpler, non-interactive approach.
219 |
220 | ## Support
221 |
222 | If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/bundle_update_interactive/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
223 |
224 | ## License
225 |
226 | The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
227 |
228 | ## Code of conduct
229 |
230 | Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
231 |
232 | ## Contribution guide
233 |
234 | Pull requests are welcome!
235 |
236 | To test your locally cloned version of `bundle update-interactive`, run `rake install`. This will install the gem and its executable so that you can try it out on other local projects.
237 |
238 | Before submitting a PR, make sure to run `rake` to see if there are any RuboCop or test failures.
239 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 | require "rake/testtask"
5 |
6 | Rake::TestTask.new(:test) do |t|
7 | t.libs << "test"
8 | t.libs << "lib"
9 | t.test_files = FileList["test/**/*_test.rb"]
10 | end
11 |
12 | if Gem.loaded_specs.key?("rubocop")
13 | require "rubocop/rake_task"
14 | RuboCop::RakeTask.new
15 | task default: %i[test rubocop]
16 | else
17 | task default: :test # rubocop:disable Rake/DuplicateTask
18 | end
19 |
20 | # == "rake release" enhancements ==============================================
21 |
22 | Rake::Task["release"].enhance do
23 | puts "Don't forget to publish the release on GitHub!"
24 | system "open https://github.com/mattbrictson/bundle_update_interactive/releases"
25 | end
26 |
27 | task :disable_overcommit do
28 | ENV["OVERCOMMIT_DISABLE"] = "1"
29 | end
30 |
31 | Rake::Task[:build].enhance [:disable_overcommit]
32 |
33 | task :verify_gemspec_files do
34 | git_files = `git ls-files -z`.split("\x0")
35 | gemspec_files = Gem::Specification.load("bundle_update_interactive.gemspec").files.sort
36 | ignored_by_git = gemspec_files - git_files
37 | next if ignored_by_git.empty?
38 |
39 | raise <<~ERROR
40 |
41 | The `spec.files` specified in bundle_update_interactive.gemspec include the following files
42 | that are being ignored by git. Did you forget to add them to the repo? If
43 | not, you may need to delete these files or modify the gemspec to ensure
44 | that they are not included in the gem by mistake:
45 |
46 | #{ignored_by_git.join("\n").gsub(/^/, ' ')}
47 |
48 | ERROR
49 | end
50 |
51 | Rake::Task[:build].enhance [:verify_gemspec_files]
52 |
53 | # == "rake bump" tasks ========================================================
54 |
55 | task bump: %w[bump:bundler bump:ruby bump:year]
56 |
57 | namespace :bump do
58 | task :bundler do
59 | sh "bundle update --bundler"
60 | end
61 |
62 | task :ruby do
63 | replace_in_file "bundle_update_interactive.gemspec", /ruby_version = .*">= (.*)"/ => RubyVersions.lowest
64 | replace_in_file ".rubocop.yml", /TargetRubyVersion: (.*)/ => RubyVersions.lowest
65 | replace_in_file ".github/workflows/ci.yml", /ruby: (\[.+\])/ => RubyVersions.all.inspect
66 | end
67 |
68 | task :year do
69 | replace_in_file "LICENSE.txt", /\(c\) (\d+)/ => Date.today.year.to_s
70 | end
71 | end
72 |
73 | require "json"
74 | require "open-uri"
75 |
76 | def replace_in_file(path, replacements)
77 | contents = File.read(path)
78 | orig_contents = contents.dup
79 | replacements.each do |regexp, text|
80 | raise "Can't find #{regexp} in #{path}" unless regexp.match?(contents)
81 |
82 | contents.gsub!(regexp) do |match|
83 | match[regexp, 1] = text
84 | match
85 | end
86 | end
87 | File.write(path, contents) if contents != orig_contents
88 | end
89 |
90 | module RubyVersions
91 | class << self
92 | def lowest
93 | all.first
94 | end
95 |
96 | def all
97 | minor_versions = versions.filter_map { |v| v["cycle"] if v["releaseDate"] >= "2019-12-25" }
98 | [*minor_versions.sort, "head"]
99 | end
100 |
101 | private
102 |
103 | def versions
104 | @_versions ||= begin
105 | json = URI.open("https://endoflife.date/api/ruby.json").read
106 | JSON.parse(json)
107 | end
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "bundle_update_interactive"
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | # (If you use this, don't forget to add pry to your Gemfile!)
11 | # require "pry"
12 | # Pry.start
13 |
14 | require "irb"
15 | IRB.start(__FILE__)
16 |
--------------------------------------------------------------------------------
/bin/mt:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'mt' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12 |
13 | bundle_binstub = File.expand_path("bundle", __dir__)
14 |
15 | if File.file?(bundle_binstub)
16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17 | load(bundle_binstub)
18 | else
19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21 | end
22 | end
23 |
24 | require "rubygems"
25 | require "bundler/setup"
26 |
27 | load Gem.bin_path("mighty_test", "mt")
28 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | which overcommit > /dev/null 2>&1 && overcommit --install
7 | bundle install
8 |
9 | # Do any other automated setup that you need to do here
10 |
--------------------------------------------------------------------------------
/bundle_update_interactive.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/bundle_update_interactive/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "bundle_update_interactive"
7 | spec.version = BundleUpdateInteractive::VERSION
8 | spec.authors = ["Matt Brictson"]
9 | spec.email = ["opensource@mattbrictson.com"]
10 |
11 | spec.summary = "Adds an update-interactive command to Bundler"
12 | spec.homepage = "https://github.com/mattbrictson/bundle_update_interactive"
13 | spec.license = "MIT"
14 | spec.required_ruby_version = ">= 2.7"
15 |
16 | spec.metadata = {
17 | "bug_tracker_uri" => "https://github.com/mattbrictson/bundle_update_interactive/issues",
18 | "changelog_uri" => "https://github.com/mattbrictson/bundle_update_interactive/releases",
19 | "source_code_uri" => "https://github.com/mattbrictson/bundle_update_interactive",
20 | "homepage_uri" => spec.homepage,
21 | "rubygems_mfa_required" => "true"
22 | }
23 |
24 | # Specify which files should be added to the gem when it is released.
25 | spec.files = Dir.glob(%w[LICENSE.txt README.md {exe,lib}/**/*]).reject { |f| File.directory?(f) }
26 | spec.bindir = "exe"
27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28 | spec.require_paths = ["lib"]
29 |
30 | # Runtime dependencies
31 | spec.add_dependency "bundler", "~> 2.0"
32 | spec.add_dependency "bundler-audit", ">= 0.9.1"
33 | spec.add_dependency "concurrent-ruby", ">= 1.3.4"
34 | spec.add_dependency "launchy", ">= 2.5.0"
35 | spec.add_dependency "pastel", ">= 0.8.0"
36 | spec.add_dependency "tty-prompt", ">= 0.23.1"
37 | spec.add_dependency "tty-screen", ">= 0.8.2"
38 | spec.add_dependency "zeitwerk", "~> 2.6"
39 | end
40 |
--------------------------------------------------------------------------------
/exe/bundler-ui:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundle_update_interactive"
5 |
6 | BundleUpdateInteractive::CLI.new.run
7 |
--------------------------------------------------------------------------------
/exe/bundler-update-interactive:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundle_update_interactive"
5 |
6 | BundleUpdateInteractive::CLI.new.run
7 |
--------------------------------------------------------------------------------
/images/conservative.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/conservative.png
--------------------------------------------------------------------------------
/images/held-back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/held-back.png
--------------------------------------------------------------------------------
/images/security.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/security.png
--------------------------------------------------------------------------------
/images/semver.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/semver.png
--------------------------------------------------------------------------------
/images/update-interactive.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/update-interactive.png
--------------------------------------------------------------------------------
/images/version-highlight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/bundle_update_interactive/a4a21d7fbc15e68291b622469bb439c1d686e740/images/version-highlight.png
--------------------------------------------------------------------------------
/lib/bundle_update_interactive.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pastel"
4 |
5 | require "zeitwerk"
6 | loader = Zeitwerk::Loader.for_gem
7 | loader.inflector.inflect "cli" => "CLI"
8 | loader.inflector.inflect "http" => "HTTP"
9 | loader.setup
10 |
11 | module BundleUpdateInteractive
12 | class << self
13 | attr_accessor :pastel
14 | end
15 |
16 | self.pastel = Pastel.new
17 | end
18 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/bundler_commands.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 | require "shellwords"
5 |
6 | module BundleUpdateInteractive
7 | module BundlerCommands
8 | class << self
9 | def update_gems_conservatively(*gems)
10 | system "#{bundle_bin.shellescape} update --conservative #{gems.flatten.map(&:shellescape).join(' ')}"
11 | end
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 |
20 | def read_updated_lockfile(*gems)
21 | command = ["#{bundle_bin.shellescape} lock --print"]
22 | command << "--conservative" if gems.any?
23 | command << "--update"
24 | command.push(*gems.flatten.map(&:shellescape))
25 |
26 | `#{command.join(" ")}`.tap { raise "bundle lock command failed" unless Process.last_status.success? }
27 | end
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 |
37 | private
38 |
39 | def bundle_bin
40 | Gem.bin_path("bundler", "bundle", Bundler::VERSION)
41 | rescue Gem::GemNotFoundException
42 | "bundle"
43 | end
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/changelog_locator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "json"
4 |
5 | module BundleUpdateInteractive
6 | class ChangelogLocator
7 | GITHUB_PATTERN = %r{^(?:https?://)?github\.com/([^/]+/[^/]+)(?:\.git)?/?}.freeze
8 | URI_KEYS = %w[source_code_uri homepage_uri bug_tracker_uri wiki_uri].freeze
9 | FILE_PATTERN = /changelog|changes|history|news|release/i.freeze
10 | EXT_PATTERN = /md|txt|rdoc/i.freeze
11 |
12 | class GitHubRepo
13 | def self.from_uris(*uris)
14 | uris.flatten.each do |uri|
15 | return new(Regexp.last_match(1)) if uri&.match(GITHUB_PATTERN)
16 | end
17 | nil
18 | end
19 |
20 | attr_reader :path
21 |
22 | def initialize(path)
23 | @path = path
24 | end
25 |
26 | def discover_changelog_uri(version)
27 | repo_html = fetch_repo_html(follow_redirect: true)
28 | return if repo_html.nil?
29 |
30 | changelog_path = repo_html[%r{/(#{path}/blob/[^/]+/#{FILE_PATTERN}(?:\.#{EXT_PATTERN})?)"}i, 1]
31 | return "https://github.com/#{changelog_path}" if changelog_path
32 |
33 | releases_url = "https://github.com/#{path}/releases"
34 | response = HTTP.get(releases_url)
35 | releases_url if response.success? && response.body.include?("v#{version}")
36 | end
37 |
38 | private
39 |
40 | def fetch_repo_html(follow_redirect:)
41 | response = HTTP.get("https://github.com/#{path}")
42 |
43 | if response.code == "301" && follow_redirect
44 | @path = response["Location"][GITHUB_PATTERN, 1]
45 | return fetch_repo_html(follow_redirect: false)
46 | end
47 |
48 | response.success? ? response.body : nil
49 | end
50 | end
51 |
52 | def find_changelog_uri(name:, version: nil)
53 | data = fetch_rubygems_data(name, version)
54 | return if data.nil?
55 |
56 | if (rubygems_changelog_uri = data["changelog_uri"])
57 | rubygems_changelog_uri
58 | elsif (github_repo = GitHubRepo.from_uris(data.values_at(*URI_KEYS)))
59 | github_repo.discover_changelog_uri(data["version"])
60 | end
61 | end
62 |
63 | private
64 |
65 | def fetch_rubygems_data(name, version)
66 | api_url = if version.nil?
67 | "https://rubygems.org/api/v1/gems/#{name}.json"
68 | else
69 | "https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json"
70 | end
71 |
72 | response = HTTP.get(api_url)
73 |
74 | # Try again without the version in case the version does not exist at rubygems for some reason.
75 | # This can happen when using a pre-release Ruby that has a bundled gem newer than the published version.
76 | return fetch_rubygems_data(name, nil) if !response.success? && !version.nil?
77 |
78 | response.success? ? JSON.parse(response.body) : nil
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/cli.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 |
5 | module BundleUpdateInteractive
6 | class CLI
7 | def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
8 | options = Options.parse(argv)
9 | report, updater = generate_report(options)
10 |
11 | puts_legend_and_withheld_gems(report) unless report.empty?
12 | puts("No gems to update.").then { return } if report.updatable_gems.empty?
13 |
14 | selected_gems = MultiSelect.prompt_for_gems_to_update(report.updatable_gems)
15 | puts("No gems to update.").then { return } if selected_gems.empty?
16 |
17 | puts "Updating the following gems."
18 | puts Table.updatable(selected_gems).render
19 | puts
20 |
21 | if options.commit?
22 | GitCommitter.new(updater).apply_updates_as_individual_commits(*selected_gems.keys)
23 | else
24 | updater.apply_updates(*selected_gems.keys)
25 | end
26 |
27 | puts_gemfile_modified_notice if updater.modified_gemfile?
28 | rescue Exception => e # rubocop:disable Lint/RescueException
29 | handle_exception(e)
30 | end
31 |
32 | private
33 |
34 | def puts_gemfile_modified_notice
35 | puts BundleUpdateInteractive.pastel.yellow("Your Gemfile was changed to accommodate the latest gem versions.")
36 | end
37 |
38 | def puts_legend_and_withheld_gems(report)
39 | puts
40 | puts legend
41 | puts
42 | return if report.withheld_gems.empty?
43 |
44 | puts "The following gems are being held back and cannot be updated."
45 | puts Table.withheld(report.withheld_gems).render
46 | puts
47 | end
48 |
49 | def legend
50 | pastel = BundleUpdateInteractive.pastel
51 | <<~LEGEND
52 | Color legend:
53 | #{pastel.white.on_red('')} Known security vulnerability
54 | #{pastel.red('')} Major update; likely to have breaking changes, high risk
55 | #{pastel.yellow('')} Minor update; changes and additions, moderate risk
56 | #{pastel.green('')} Patch update; bug fixes, low risk
57 | #{pastel.cyan('')} Possibly unreleased git commits; unknown risk
58 | LEGEND
59 | end
60 |
61 | def generate_report(options)
62 | whisper "Resolving latest gem versions..."
63 | updater_class = options.latest? ? Latest::Updater : Updater
64 | updater = updater_class.new(groups: options.exclusively, only_explicit: options.only_explicit?)
65 |
66 | report = updater.generate_report
67 | populate_vulnerabilities_and_changelogs_concurrently(report) unless report.empty?
68 |
69 | [report, updater]
70 | end
71 |
72 | def populate_vulnerabilities_and_changelogs_concurrently(report)
73 | pool = ThreadPool.new(max_threads: 25)
74 | whisper "Checking for security vulnerabilities..."
75 | scan_promise = pool.future(report, &:scan_for_vulnerabilities!)
76 | changelog_promises = report.all_gems.map do |_, outdated_gem|
77 | pool.future(outdated_gem, &:changelog_uri)
78 | end
79 | progress "Finding changelogs", changelog_promises, &:value!
80 | scan_promise.value!
81 | end
82 |
83 | def whisper(message)
84 | $stderr.puts(message)
85 | end
86 |
87 | def progress(message, items, &block)
88 | $stderr.print(message)
89 | items.each_slice([1, items.length / 12].max) do |slice|
90 | slice.each(&block)
91 | $stderr.print(".")
92 | end
93 | $stderr.print("\n")
94 | end
95 |
96 | def handle_exception(error)
97 | case error
98 | when Errno::EPIPE
99 | # Ignore
100 | when BundleUpdateInteractive::Error, OptionParser::ParseError, Interrupt, Bundler::Dsl::DSLError
101 | $stderr.puts BundleUpdateInteractive.pastel.red(error.message)
102 | exit false
103 | else
104 | raise
105 | end
106 | end
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/cli/multi_select.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "launchy"
4 | require "pastel"
5 | require "tty/prompt"
6 | require "tty/screen"
7 |
8 | module BundleUpdateInteractive
9 | class CLI
10 | class MultiSelect
11 | extend BundleUpdateInteractive::StringHelper
12 |
13 | class List < TTY::Prompt::MultiList
14 | def initialize(prompt, **options)
15 | @opener = options.delete(:opener)
16 | defaults = {
17 | cycle: true,
18 | help_color: :itself.to_proc,
19 | per_page: [TTY::Prompt::Paginator::DEFAULT_PAGE_SIZE, TTY::Screen.height.to_i - 3].max,
20 | quiet: true,
21 | show_help: :always
22 | }
23 | super(prompt, **defaults.merge(options))
24 | end
25 |
26 | def selected_names
27 | ""
28 | end
29 |
30 | # Unregister tty-prompt's default ctrl-a and ctrl-r bindings
31 | alias select_all keyctrl_a
32 | alias reverse_selection keyctrl_r
33 | def keyctrl_a(*); end
34 | def keyctrl_r(*); end
35 |
36 | def keypress(event)
37 | case event.value
38 | when "k", "p" then keyup
39 | when "j", "n" then keydown
40 | when "a" then select_all
41 | when "r" then reverse_selection
42 | when "o" then opener&.call(choices[@active - 1].value)
43 | end
44 | end
45 |
46 | private
47 |
48 | attr_reader :opener
49 | end
50 |
51 | def self.prompt_for_gems_to_update(outdated_gems, prompt: nil)
52 | table = Table.updatable(outdated_gems)
53 | title = "#{pluralize(outdated_gems.length, 'gem')} can be updated."
54 | opener = lambda do |gem|
55 | url = outdated_gems[gem].changelog_uri
56 | Launchy.open(url) unless url.nil?
57 | end
58 | chosen = new(title: title, table: table, prompt: prompt, opener: opener).prompt
59 | outdated_gems.slice(*chosen)
60 | end
61 |
62 | def initialize(title:, table:, opener: nil, prompt: nil)
63 | @title = title
64 | @table = table
65 | @opener = opener
66 | @tty_prompt = prompt || TTY::Prompt.new(
67 | interrupt: lambda {
68 | puts
69 | exit(130)
70 | }
71 | )
72 | @pastel = BundleUpdateInteractive.pastel
73 | end
74 |
75 | def prompt
76 | choices = table.gem_names.to_h { |name| [table.render_gem(name), name] }
77 | tty_prompt.invoke_select(List, title, choices, help: help, opener: opener)
78 | end
79 |
80 | private
81 |
82 | attr_reader :pastel, :table, :opener, :tty_prompt, :title
83 |
84 | def help
85 | [
86 | pastel.dim("\nPress to select, ↑/↓ move, all, reverse, open url, to finish."),
87 | "\n ",
88 | table.render_header
89 | ].join
90 | end
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/cli/options.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "optparse"
4 |
5 | module BundleUpdateInteractive
6 | class CLI
7 | class Options
8 | class << self
9 | def parse(argv=ARGV)
10 | options = new
11 | remain = build_parser(options).parse!(argv.dup)
12 | raise Error, "update-interactive does not accept arguments. See --help for available options." if remain.any?
13 |
14 | options.freeze
15 | end
16 |
17 | def summary
18 | build_parser(new).summarize.join.gsub(/^\s+-.*? /, pastel.yellow('\0'))
19 | end
20 |
21 | def help # rubocop:disable Metrics/AbcSize
22 | <<~HELP
23 | Provides an easy way to update gems to their latest versions.
24 |
25 | #{pastel.bold.underline('USAGE')}
26 | #{pastel.green('bundle update-interactive')} #{pastel.yellow('[options]')}
27 | #{pastel.green('bundle ui')} #{pastel.yellow('[options]')}
28 |
29 | #{pastel.bold.underline('OPTIONS')}
30 | #{summary}
31 | #{pastel.bold.underline('DESCRIPTION')}
32 | Displays the list of gems that would be updated by `bundle update`, allowing you
33 | to navigate them by keyboard and pick which ones to update. A changelog URL,
34 | when available, is displayed alongside each update. Gems with known security
35 | vulnerabilities are also highlighted.
36 |
37 | Your Gemfile.lock will be updated conservatively based on the gems you select.
38 | Transitive dependencies are not affected.
39 |
40 | More information: #{pastel.blue('https://github.com/mattbrictson/bundle_update_interactive')}
41 |
42 | #{pastel.bold.underline('EXAMPLES')}
43 | Show all gems that can be updated.
44 | #{pastel.green('bundle update-interactive')}
45 |
46 | The "ui" command alias can also be used.
47 | #{pastel.green('bundle ui')}
48 |
49 | Show updates for development and test gems only, leaving production gems untouched.
50 | #{pastel.green('bundle update-interactive')} #{pastel.yellow('-D')}
51 |
52 | Allow the latest gem versions, ignoring Gemfile pins. May modify the Gemfile.
53 | #{pastel.green('bundle update-interactive')} #{pastel.yellow('--latest')}
54 |
55 | HELP
56 | end
57 |
58 | private
59 |
60 | def pastel
61 | BundleUpdateInteractive.pastel
62 | end
63 |
64 | def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
65 | OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength
66 | parser.summary_indent = " "
67 | parser.summary_width = 24
68 | parser.on("--commit", "Create a git commit for each selected gem update") do
69 | options.commit = true
70 | end
71 | parser.on("--latest", "Modify the Gemfile to allow the latest gem versions") do
72 | options.latest = true
73 | end
74 | parser.on("--only-explicit", "Update Gemfile gems only (no indirect dependencies)") do
75 | options.only_explicit = true
76 | end
77 | parser.on(
78 | "--exclusively=GROUP",
79 | "Update gems exclusively belonging to the specified Gemfile GROUP(s)"
80 | ) do |value|
81 | options.exclusively = value.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
82 | end
83 | parser.on("-D", "Shorthand for --exclusively=development,test") do
84 | options.exclusively = %i[development test]
85 | end
86 | parser.on("-v", "--version", "Display version") do
87 | require "bundler"
88 | puts "bundle_update_interactive/#{VERSION} bundler/#{Bundler::VERSION} #{RUBY_DESCRIPTION}"
89 | exit
90 | end
91 | parser.on("-h", "--help", "Show this help") do
92 | puts help
93 | exit
94 | end
95 | end
96 | end
97 | end
98 |
99 | attr_accessor :exclusively
100 | attr_writer :commit, :latest, :only_explicit
101 |
102 | def initialize
103 | @exclusively = []
104 | @commit = false
105 | @latest = false
106 | @only_explicit = false
107 | end
108 |
109 | def commit?
110 | @commit
111 | end
112 |
113 | def latest?
114 | @latest
115 | end
116 |
117 | def only_explicit?
118 | @only_explicit
119 | end
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/cli/row.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "delegate"
4 | require "pastel"
5 |
6 | module BundleUpdateInteractive
7 | class CLI
8 | class Row < SimpleDelegator
9 | SEMVER_COLORS = {
10 | major: :red,
11 | minor: :yellow,
12 | patch: :green
13 | }.freeze
14 |
15 | def initialize(outdated_gem)
16 | super
17 | @pastel = BundleUpdateInteractive.pastel
18 | end
19 |
20 | def formatted_gem_name
21 | vulnerable? ? pastel.white.on_red(name) : apply_semver_highlight(name)
22 | end
23 |
24 | def formatted_current_version
25 | [current_version.to_s, current_git_version].compact.join(" ")
26 | end
27 |
28 | def formatted_updated_version
29 | version = semver_change.format { |part| apply_semver_highlight(part) }
30 | git_version = apply_semver_highlight(updated_git_version)
31 |
32 | [version, git_version].compact.join(" ")
33 | end
34 |
35 | def formatted_gemfile_groups
36 | gemfile_groups&.map(&:inspect)&.join(", ")
37 | end
38 |
39 | def formatted_gemfile_requirement
40 | gemfile_requirement.to_s == ">= 0" ? "" : gemfile_requirement.to_s
41 | end
42 |
43 | def formatted_changelog_uri
44 | pastel.blue(changelog_uri)
45 | end
46 |
47 | def apply_semver_highlight(value)
48 | color = git_version_changed? ? :cyan : SEMVER_COLORS.fetch(semver_change.severity)
49 | pastel.decorate(value, color)
50 | end
51 |
52 | private
53 |
54 | attr_reader :pastel
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/cli/table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pastel"
4 |
5 | module BundleUpdateInteractive
6 | class CLI
7 | class Table
8 | class << self
9 | def withheld(gems)
10 | columns = [
11 | ["name", :formatted_gem_name],
12 | ["requirement", :formatted_gemfile_requirement],
13 | ["current", :formatted_current_version],
14 | ["latest", :formatted_updated_version],
15 | ["group", :formatted_gemfile_groups],
16 | ["url", :formatted_changelog_uri]
17 | ]
18 | new(gems, columns)
19 | end
20 |
21 | def updatable(gems)
22 | columns = [
23 | ["name", :formatted_gem_name],
24 | ["from", :formatted_current_version],
25 | [nil, "→"],
26 | ["to", :formatted_updated_version],
27 | ["group", :formatted_gemfile_groups],
28 | ["url", :formatted_changelog_uri]
29 | ]
30 | new(gems, columns)
31 | end
32 | end
33 |
34 | def initialize(gems, columns)
35 | @pastel = BundleUpdateInteractive.pastel
36 | @headers = columns.map { |header, _| pastel.dim.underline(header) }
37 | @rows = gems.transform_values do |gem|
38 | row = Row.new(gem)
39 | columns.map do |_, col|
40 | case col
41 | when Symbol then row.public_send(col).to_s
42 | when String then col
43 | end
44 | end
45 | end
46 | @column_widths = calculate_column_widths
47 | end
48 |
49 | def gem_names
50 | rows.keys.sort
51 | end
52 |
53 | def render_header
54 | render_row(headers)
55 | end
56 |
57 | def render_gem(name)
58 | row = rows.fetch(name)
59 | render_row(row)
60 | end
61 |
62 | def render
63 | lines = [render_header]
64 | gem_names.each { |name| lines << render_gem(name) }
65 | lines.join("\n")
66 | end
67 |
68 | private
69 |
70 | attr_reader :column_widths, :pastel, :rows, :headers
71 |
72 | def render_row(row)
73 | row.zip(column_widths).map do |value, width|
74 | padding = width && (" " * (width - pastel.strip(value).length))
75 | "#{value}#{padding}"
76 | end.join(" ")
77 | end
78 |
79 | def calculate_column_widths
80 | rows_with_header = [headers, *rows.values]
81 | Array.new(headers.length - 1) do |i|
82 | rows_with_header.map { |values| pastel.strip(values[i]).length }.max
83 | end
84 | end
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/error.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | class Error < StandardError
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/gemfile.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 |
5 | module BundleUpdateInteractive
6 | class Gemfile
7 | def self.parse(path="Gemfile")
8 | dsl = Bundler::Dsl.new
9 | dsl.eval_gemfile(path)
10 | dependencies = dsl.dependencies.to_h { |d| [d.name, d] }
11 | new(dependencies)
12 | end
13 |
14 | def initialize(dependencies)
15 | @dependencies = dependencies.freeze
16 | end
17 |
18 | def [](name)
19 | @dependencies[name]
20 | end
21 |
22 | def dependencies
23 | @dependencies.values
24 | end
25 |
26 | def gem_names
27 | dependencies.map(&:name)
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/git_committer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "shellwords"
4 |
5 | module BundleUpdateInteractive
6 | class GitCommitter
7 | def initialize(updater)
8 | @updater = updater
9 | end
10 |
11 | def apply_updates_as_individual_commits(*gem_names)
12 | assert_git_executable!
13 | assert_working_directory_clean!
14 |
15 | gem_names.flatten.each do |name|
16 | updates = updater.apply_updates(name)
17 | updated_gem = updates[name] || updates.values.first
18 | next if updated_gem.nil?
19 |
20 | commit_message = format_commit_message(updated_gem)
21 | system "git add Gemfile Gemfile.lock", exception: true
22 | system "git commit -m #{commit_message.shellescape}", exception: true
23 | end
24 | end
25 |
26 | def format_commit_message(outdated_gem)
27 | [
28 | "Update",
29 | outdated_gem.name,
30 | outdated_gem.current_version.to_s,
31 | outdated_gem.current_git_version,
32 | "→",
33 | outdated_gem.updated_version.to_s,
34 | outdated_gem.updated_git_version
35 | ].compact.join(" ")
36 | end
37 |
38 | private
39 |
40 | attr_reader :updater
41 |
42 | def assert_git_executable!
43 | success = begin
44 | `git --version`
45 | Process.last_status.success?
46 | rescue SystemCallError
47 | false
48 | end
49 | raise Error, "git could not be executed" unless success
50 | end
51 |
52 | def assert_working_directory_clean!
53 | status = `git status --untracked-files=no --porcelain`.strip
54 | return if status.empty?
55 |
56 | raise Error, "`git status` reports uncommitted changes; please commit or stash them them first!\n#{status}"
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/http.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "net/http"
4 | require "uri"
5 |
6 | module BundleUpdateInteractive
7 | module HTTP
8 | module Success
9 | def success?
10 | code.start_with?("2")
11 | end
12 | end
13 |
14 | class << self
15 | def get(url)
16 | http(:get, url)
17 | end
18 |
19 | def head(url)
20 | http(:head, url)
21 | end
22 |
23 | private
24 |
25 | def http(method, url_string)
26 | uri = URI(url_string)
27 | response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme.end_with?("s")) do |http|
28 | http.public_send(method, uri.request_uri)
29 | end
30 | response.extend(Success)
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/latest/gem_requirement.rb:
--------------------------------------------------------------------------------
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
63 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/latest/gemfile_editor.rb:
--------------------------------------------------------------------------------
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
69 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/latest/updater.rb:
--------------------------------------------------------------------------------
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
42 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/lockfile.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 | require "set"
5 |
6 | module BundleUpdateInteractive
7 | class Lockfile
8 | def self.parse(lockfile_contents=File.read("Gemfile.lock"))
9 | parser = Bundler::LockfileParser.new(lockfile_contents)
10 | new(parser.specs)
11 | end
12 |
13 | def initialize(specs)
14 | @specs_by_name = {}
15 | required_exactly = Set.new
16 |
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
21 |
22 | @entries_by_name = specs_by_name.transform_values do |spec|
23 | build_entry(spec, required_exactly.include?(spec.name))
24 | end
25 | end
26 |
27 | def entries
28 | entries_by_name.values
29 | end
30 |
31 | def [](gem_name)
32 | entries_by_name[gem_name]
33 | end
34 |
35 | def gems_exclusively_installed_by(gemfile:, groups:)
36 | return [] if groups.empty?
37 |
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 |
42 | entries_by_name.keys - gems_installed_by_other_groups
43 | end
44 |
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)
52 | end
53 |
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
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/lockfile_entry.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | class LockfileEntry
5 | attr_reader :spec, :exact_dependencies
6 |
7 | def initialize(spec, exact_dependencies, exact_requirement)
8 | @spec = spec
9 | @exact_dependencies = exact_dependencies
10 | @exact_requirement = exact_requirement
11 | end
12 |
13 | def name
14 | spec.name
15 | end
16 |
17 | def version
18 | spec.version
19 | end
20 |
21 | def older_than?(updated_entry)
22 | return false if updated_entry.nil?
23 |
24 | if git_source? && updated_entry.git_source?
25 | version <= updated_entry.version && git_version != updated_entry.git_version
26 | else
27 | version < updated_entry.version
28 | end
29 | end
30 |
31 | def exact_requirement?
32 | @exact_requirement
33 | end
34 |
35 | def git_version
36 | spec.git_version&.strip
37 | end
38 |
39 | def git_source_uri
40 | spec.source.uri if git_source?
41 | end
42 |
43 | def git_source?
44 | !!git_version
45 | end
46 |
47 | def rubygems_source?
48 | return false if git_source?
49 |
50 | source = spec.source
51 | source.respond_to?(:remotes) && source.remotes.map(&:to_s).include?("https://rubygems.org/")
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/outdated_gem.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | class OutdatedGem
5 | attr_accessor :name,
6 | :gemfile_groups,
7 | :gemfile_requirement,
8 | :git_source_uri,
9 | :current_version,
10 | :current_git_version,
11 | :updated_version,
12 | :updated_git_version
13 |
14 | attr_writer :changelog_uri, :rubygems_source, :vulnerable
15 |
16 | def initialize(**attrs)
17 | @vulnerable = nil
18 | @changelog_locator = ChangelogLocator.new
19 |
20 | attrs.each { |name, value| public_send(:"#{name}=", value) }
21 | end
22 |
23 | def semver_change
24 | @semver_change ||= SemverChange.new(current_version, updated_version)
25 | end
26 |
27 | def vulnerable?
28 | @vulnerable
29 | end
30 |
31 | def rubygems_source?
32 | @rubygems_source
33 | end
34 |
35 | def changelog_uri
36 | return @changelog_uri if defined?(@changelog_uri)
37 |
38 | @changelog_uri =
39 | if (diff_url = build_git_diff_url)
40 | diff_url
41 | elsif (found_uri = rubygems_source? && locate_changelog_uri)
42 | found_uri
43 | else
44 | begin
45 | Gem::Specification.find_by_name(name)&.homepage
46 | rescue Gem::MissingSpecError
47 | nil
48 | end
49 | end
50 | end
51 |
52 | def git_version_changed?
53 | current_git_version && updated_git_version && current_git_version != updated_git_version
54 | end
55 |
56 | private
57 |
58 | attr_reader :changelog_locator
59 |
60 | def locate_changelog_uri
61 | changelog_locator.find_changelog_uri(name: name, version: updated_version.to_s)
62 | end
63 |
64 | def build_git_diff_url
65 | return nil unless git_version_changed?
66 |
67 | if github_repo
68 | "https://github.com/#{github_repo}/compare/#{current_git_version}...#{updated_git_version}"
69 | elsif gitlab_repo
70 | "https://gitlab.com/os85/httpx/-/compare/#{current_git_version}...#{updated_git_version}"
71 | elsif bitbucket_cloud_repo
72 | "https://bitbucket.org/#{bitbucket_cloud_repo}/branches/compare/#{updated_git_version}..#{current_git_version}"
73 | end
74 | end
75 |
76 | def github_repo
77 | return nil unless updated_git_version
78 |
79 | git_source_uri.to_s[%r{^(?:git@github.com:|https://github.com/)([^/]+/[^/]+?)(:?\.git)?(?:$|/)}i, 1]
80 | end
81 |
82 | def gitlab_repo
83 | return nil unless updated_git_version
84 |
85 | git_source_uri.to_s[%r{^(?:git@gitlab.com:|https://gitlab.com/)([^/]+/[^/]+?)(:?\.git)?(?:$|/)}i, 1]
86 | end
87 |
88 | def bitbucket_cloud_repo
89 | return nil unless updated_git_version
90 |
91 | git_source_uri.to_s[%r{(?:@|://)bitbucket.org[:/]([^/]+/[^/]+?)(:?\.git)?(?:$|/)}i, 1]
92 | end
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/report.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 | require "bundler/audit"
5 | require "bundler/audit/scanner"
6 | require "set"
7 |
8 | module BundleUpdateInteractive
9 | class Report
10 | attr_reader :withheld_gems, :updatable_gems
11 |
12 | def initialize(current_lockfile:, withheld_gems:, updatable_gems:)
13 | @current_lockfile = current_lockfile
14 | @withheld_gems = withheld_gems.freeze
15 | @updatable_gems = updatable_gems.freeze
16 | end
17 |
18 | def empty?
19 | withheld_gems.empty? && updatable_gems.empty?
20 | end
21 |
22 | def all_gems
23 | @all_gems ||= withheld_gems.merge(updatable_gems)
24 | end
25 |
26 | def scan_for_vulnerabilities!
27 | return false if all_gems.empty?
28 |
29 | Bundler::Audit::Database.update!(quiet: true)
30 | audit_report = Bundler::Audit::Scanner.new.report
31 | vulnerable_gem_names = Set.new(audit_report.vulnerable_gems.map(&:name))
32 |
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?
36 | end
37 | true
38 | end
39 |
40 | private
41 |
42 | attr_reader :current_lockfile
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/semver_change.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | class SemverChange
5 | SEVERITIES = %i[major minor patch].freeze
6 |
7 | def initialize(old_version, new_version)
8 | old_segments = old_version.to_s.split(".")
9 | new_segments = new_version.to_s.split(".")
10 |
11 | @same_segments = new_segments.take_while.with_index { |seg, i| seg == old_segments[i] }
12 | @diff_segments = new_segments[same_segments.length..]
13 |
14 | @changed = diff_segments.any? || old_segments.length != new_segments.length
15 | end
16 |
17 | def severity
18 | return nil unless @changed
19 |
20 | SEVERITIES[same_segments.length] || :patch
21 | end
22 |
23 | SEVERITIES.each do |level|
24 | define_method(:"#{level}?") { severity == level }
25 | end
26 |
27 | def none?
28 | severity.nil?
29 | end
30 |
31 | def any?
32 | !!severity
33 | end
34 |
35 | def format
36 | parts = []
37 | parts << same_segments.join(".") if same_segments.any?
38 | parts << yield(diff_segments.join(".")) if diff_segments.any?
39 | parts.join(".")
40 | end
41 |
42 | private
43 |
44 | attr_reader :same_segments, :diff_segments
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/string_helper.rb:
--------------------------------------------------------------------------------
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
13 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/thread_pool.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "concurrent"
4 |
5 | module BundleUpdateInteractive
6 | class ThreadPool
7 | include Concurrent::Promises::FactoryMethods
8 |
9 | def initialize(max_threads:)
10 | @executor = Concurrent::ThreadPoolExecutor.new(
11 | min_threads: 0,
12 | max_threads: max_threads,
13 | max_queue: 0
14 | )
15 | end
16 |
17 | def default_executor
18 | @executor
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/updater.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | class Updater
5 | def initialize(groups: [], only_explicit: false)
6 | @only_explicit = only_explicit
7 | @gemfile = Gemfile.parse
8 | @current_lockfile = Lockfile.parse
9 | @candidate_gems = current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) if groups.any?
10 | end
11 |
12 | def generate_report
13 | updatable_gems = find_updatable_gems
14 | withheld_gems = find_withheld_gems(exclude: updatable_gems.keys)
15 |
16 | Report.new(current_lockfile: current_lockfile, updatable_gems: updatable_gems, withheld_gems: withheld_gems)
17 | end
18 |
19 | def apply_updates(*gem_names)
20 | expanded_names = expand_gems_with_exact_dependencies(*gem_names)
21 | BundlerCommands.update_gems_conservatively(*expanded_names)
22 |
23 | # Return the gems that were actually updated based on observed changes to the lock file
24 | updated_gems = build_outdated_gems(File.read("Gemfile.lock"))
25 | @current_lockfile = Lockfile.parse
26 | updated_gems
27 | end
28 |
29 | # Overridden by Latest::Updater subclass
30 | def modified_gemfile?
31 | false
32 | end
33 |
34 | private
35 |
36 | attr_reader :gemfile, :current_lockfile, :candidate_gems, :only_explicit
37 |
38 | def find_updatable_gems
39 | return {} if candidate_gems && candidate_gems.empty?
40 |
41 | updatable = build_outdated_gems(BundlerCommands.read_updated_lockfile(*Array(candidate_gems)))
42 | updatable = updatable.slice(*gemfile.gem_names) if only_explicit
43 | updatable
44 | end
45 |
46 | def build_outdated_gems(lockfile_contents)
47 | updated_lockfile = Lockfile.parse(lockfile_contents)
48 | current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
49 | name = current_lockfile_entry.name
50 | updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
51 | next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
52 | next if current_lockfile_entry.exact_requirement?
53 |
54 | hash[name] = build_outdated_gem(name, updated_lockfile_entry.version, updated_lockfile_entry.git_version)
55 | end
56 | end
57 |
58 | def find_withheld_gems(exclude: [])
59 | possibly_withheld = gemfile.dependencies.filter_map do |dep|
60 | dep.name if dep.should_include? && !dep.requirement.none? # rubocop:disable Style/InverseMethods
61 | end
62 | possibly_withheld -= exclude
63 | possibly_withheld &= candidate_gems unless candidate_gems.nil?
64 |
65 | return {} if possibly_withheld.empty?
66 |
67 | BundlerCommands.parse_outdated(*possibly_withheld).to_h do |name, newest|
68 | [name, build_outdated_gem(name, newest, nil)]
69 | end
70 | end
71 |
72 | def build_outdated_gem(name, updated_version, updated_git_version)
73 | current_lockfile_entry = current_lockfile[name]
74 |
75 | OutdatedGem.new(
76 | name: name,
77 | gemfile_groups: gemfile[name]&.groups,
78 | gemfile_requirement: gemfile[name]&.requirement&.to_s,
79 | rubygems_source: current_lockfile_entry.rubygems_source?,
80 | git_source_uri: current_lockfile_entry.git_source_uri&.to_s,
81 | current_version: current_lockfile_entry.version.to_s,
82 | current_git_version: current_lockfile_entry.git_version&.strip,
83 | updated_version: updated_version.to_s,
84 | updated_git_version: updated_git_version&.strip
85 | )
86 | end
87 |
88 | def expand_gems_with_exact_dependencies(*gem_names)
89 | gem_names.flatten!
90 | gem_names.flat_map { |name| [name, *current_lockfile[name].exact_dependencies] }.uniq
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/bundle_update_interactive/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module BundleUpdateInteractive
4 | VERSION = "0.11.1"
5 | end
6 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/bundler_commands_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "bundler"
5 |
6 | module BundleUpdateInteractive
7 | class BundlerCommandsTest < Minitest::Test
8 | def setup
9 | Gem.stubs(:bin_path).with("bundler", "bundle", Bundler::VERSION).returns("/exe/bundle")
10 | end
11 |
12 | def test_lock_executes_bundle_lock
13 | BundlerCommands.expects(:system).with("/exe/bundle lock").returns(true)
14 |
15 | assert BundlerCommands.lock
16 | end
17 |
18 | def test_lock_raises_if_bundle_lock_fails
19 | BundlerCommands.expects(:system).with("/exe/bundle lock").returns(false)
20 |
21 | error = assert_raises(RuntimeError) { BundlerCommands.lock }
22 | assert_match(/bundle lock command failed/i, error.message)
23 | end
24 |
25 | def test_read_updated_lockfile_runs_bundle_lock_and_captures_output
26 | expect_backticks("/exe/bundle lock --print --update", captures: "bundler output")
27 | result = BundlerCommands.read_updated_lockfile
28 |
29 | assert_equal "bundler output", result
30 | end
31 |
32 | def test_read_updated_lockfile_runs_bundle_lock_with_specified_gems_conservatively
33 | expect_backticks(
34 | "/exe/bundle lock --print --conservative --update actionpack railties",
35 | captures: "bundler output"
36 | )
37 | result = BundlerCommands.read_updated_lockfile("actionpack", "railties")
38 |
39 | assert_equal "bundler output", result
40 | end
41 |
42 | def test_read_updated_lockfile_raises_if_bundler_fails_to_run
43 | expect_backticks("/exe/bundle lock --print --update", success: false)
44 |
45 | error = assert_raises(RuntimeError) { BundlerCommands.read_updated_lockfile }
46 | assert_match(/bundle lock command failed/i, error.message)
47 | end
48 |
49 | def test_parse_outdated_returns_hash_of_gem_name_to_newest_version
50 | expect_backticks("/exe/bundle outdated --parseable sqlite3 redis-client", captures: <<~STDOUT, success: true)
51 |
52 | redis-client (newest 0.22.2, installed 0.22.1)
53 | sqlite3 (newest 2.0.3, installed 1.7.3, requested ~> 1.7)
54 | STDOUT
55 |
56 | result = BundlerCommands.parse_outdated("sqlite3", "redis-client")
57 | assert_equal(
58 | {
59 | "redis-client" => "0.22.2",
60 | "sqlite3" => "2.0.3"
61 | },
62 | result
63 | )
64 | end
65 |
66 | def test_parse_outdated_returns_empty_hash_if_nothing_outdated
67 | expect_backticks("/exe/bundle outdated --parseable", captures: "\n", success: true)
68 |
69 | result = BundlerCommands.parse_outdated
70 | assert_empty result
71 | end
72 |
73 | def test_parse_outdated_raises_if_bundle_command_fails_with_no_output
74 | expect_backticks("/exe/bundle outdated --parseable", captures: "", success: false)
75 |
76 | error = assert_raises(RuntimeError) { BundlerCommands.parse_outdated }
77 | assert_match(/bundle outdated command failed/i, error.message)
78 | end
79 |
80 | private
81 |
82 | def expect_backticks(command, captures: "", success: true)
83 | status = stub(success?: success)
84 | Process.stubs(:last_status).returns(status)
85 | BundlerCommands.expects(:`).with(command).returns(captures)
86 | end
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/changelog_locator_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class ChangelogLocatorTest < Minitest::Test
7 | def test_fetches_changelog_uri_from_rubygems
8 | VCR.use_cassette("changelog_requests") do
9 | uri = ChangelogLocator.new.find_changelog_uri(name: "nokogiri", version: "1.16.6")
10 |
11 | assert_equal "https://nokogiri.org/CHANGELOG.html", uri
12 | end
13 | end
14 |
15 | def test_falls_back_to_top_level_rubygems_data_when_version_does_not_exist
16 | VCR.use_cassette("changelog_requests") do
17 | uri = ChangelogLocator.new.find_changelog_uri(name: "nokogiri", version: "0.123.456")
18 |
19 | assert_equal "https://nokogiri.org/CHANGELOG.html", uri
20 | end
21 | end
22 |
23 | def test_discovers_changelog_file_on_github
24 | VCR.use_cassette("changelog_requests") do
25 | # This gem doesn't publish changelog_uri metadata, but does have a GitHub URL for its homepage.
26 | # We should crawl the GitHub repo and discover that it has a CHANGELOG.md file.
27 | uri = ChangelogLocator.new.find_changelog_uri(name: "ransack", version: "4.2.0")
28 |
29 | assert_equal "https://github.com/activerecord-hackery/ransack/blob/main/CHANGELOG.md", uri
30 | end
31 | end
32 |
33 | def test_discovers_changelog_file_on_github_after_following_redirect
34 | VCR.use_cassette("changelog_requests") do
35 | # This gem doesn't publish changelog_uri metadata, but does have a GitHub URL for its homepage.
36 | # However the URL in the metadata in this case still points to seattlerb/minitest, when the
37 | # repo now lives at minitest/minitest. We should follow the redirect to get the correct URL.
38 | uri = ChangelogLocator.new.find_changelog_uri(name: "minitest", version: "5.16.0")
39 |
40 | assert_equal "https://github.com/minitest/minitest/blob/master/History.rdoc", uri
41 | end
42 | end
43 |
44 | def test_discovers_github_releases_url
45 | VCR.use_cassette("changelog_requests") do
46 | # This gem doesn't publish changelog_uri metadata, but does have a GitHub URL for its homepage.
47 | # The repo doesn't have a CHANGELOG.md file, so we should fall back to GitHub Releases.
48 | uri = ChangelogLocator.new.find_changelog_uri(name: "web-console", version: "4.2.1")
49 |
50 | assert_equal "https://github.com/rails/web-console/releases", uri
51 | end
52 | end
53 |
54 | def test_returns_nil_when_changelog_cannot_be_discovered
55 | VCR.use_cassette("changelog_requests") do
56 | # This gem doesn't publish changelog_uri metadata, and isn't hosted on GitHub,
57 | # so we don't have a way to discover its changelog.
58 | uri = ChangelogLocator.new.find_changelog_uri(name: "atlassian-jwt", version: "0.2.1")
59 |
60 | assert_nil uri
61 | end
62 | end
63 |
64 | def test_returns_nil_when_project_is_on_github_but_is_not_using_releases
65 | VCR.use_cassette("changelog_requests") do
66 | # This gem doesn't publish changelog_uri metadata, it *is* on GitHub, but there is no
67 | # CHANGELOG, etc. file, and the GitHub Releases page doesn't seem to have any data,
68 | # so we don't have a way to discover its changelog.
69 | uri = ChangelogLocator.new.find_changelog_uri(name: "parallel", version: "1.26.3")
70 |
71 | assert_nil uri
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/cli/multi_select_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "launchy"
5 | require "tty/prompt/test"
6 |
7 | module BundleUpdateInteractive
8 | class CLI
9 | class MultiSelectTest < Minitest::Test
10 | ARROW_UP = "\e[A"
11 | ARROW_DOWN = "\e[B"
12 | CTRL_A = "\u0001"
13 | CTRL_R = "\u0012"
14 |
15 | def setup
16 | @outdated_gems = {
17 | "a" => build(:outdated_gem, name: "a", changelog_uri: nil),
18 | "b" => build(:outdated_gem, name: "b", changelog_uri: "https://b.example.com/"),
19 | "c" => build(:outdated_gem, name: "c", changelog_uri: "https://c.example.com/")
20 | }
21 | end
22 |
23 | def test_renders_choices_in_alphabetical_order
24 | @outdated_gems = @outdated_gems.to_a.reverse.to_h
25 |
26 | stdout, _stderr, _status = capture_io_and_exit_status(stdin_data: "\n") do
27 | MultiSelect.prompt_for_gems_to_update(@outdated_gems)
28 | end
29 | stdout = BundleUpdateInteractive.pastel.strip(stdout)
30 |
31 | assert_equal ["⬡ a", "⬡ b", "⬡ c"], stdout.scan(/⬡ [a-z]/)
32 | end
33 |
34 | def test_pressing_a_selects_all_rows
35 | selected = use_menu_with_keypress "a"
36 |
37 | assert_equal %w[a b c], selected
38 | end
39 |
40 | def test_pressing_space_then_r_selects_all_but_the_first_row
41 | selected = use_menu_with_keypress " ", "r"
42 |
43 | assert_equal %w[b c], selected
44 | end
45 |
46 | def test_pressing_down_then_space_selects_the_second_row
47 | selected = use_menu_with_keypress ARROW_DOWN, " "
48 |
49 | assert_equal %w[b], selected
50 | end
51 |
52 | def test_pressing_j_then_space_selects_the_second_row
53 | selected = use_menu_with_keypress "j", " "
54 |
55 | assert_equal %w[b], selected
56 | end
57 |
58 | def test_pressing_k_then_space_selects_the_last_row
59 | selected = use_menu_with_keypress "k", " "
60 |
61 | assert_equal %w[c], selected
62 | end
63 |
64 | def test_pressing_up_then_space_selects_the_last_row
65 | selected = use_menu_with_keypress ARROW_UP, " "
66 |
67 | assert_equal %w[c], selected
68 | end
69 |
70 | def test_pressing_ctrl_a_has_no_effect
71 | selected = use_menu_with_keypress CTRL_A
72 |
73 | assert_empty selected
74 | end
75 |
76 | def test_pressing_ctrl_r_has_no_effect
77 | selected = use_menu_with_keypress CTRL_R
78 |
79 | assert_empty selected
80 | end
81 |
82 | def test_pressing_down_then_o_opens_changelog_uri_of_second_gem_in_browser
83 | Launchy.expects(:open).with("https://b.example.com/").once
84 |
85 | use_menu_with_keypress ARROW_DOWN, "o"
86 | end
87 |
88 | def test_pressing_o_for_gem_with_no_changelog_does_nothing
89 | Launchy.expects(:open).never
90 |
91 | use_menu_with_keypress "o"
92 | end
93 |
94 | private
95 |
96 | def use_menu_with_keypress(*keys)
97 | prompt = TTY::Prompt::Test.new
98 | prompt.input << keys.join
99 | prompt.input << "\n"
100 | prompt.input.rewind
101 |
102 | selected = MultiSelect.prompt_for_gems_to_update(@outdated_gems, prompt: prompt)
103 | selected.keys
104 | end
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/cli/options_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class CLI
7 | class OptionsTest < Minitest::Test
8 | def test_prints_help_and_exits_when_given_dash_h
9 | stdout, _stderr, status = capture_io_and_exit_status do
10 | Options.parse(%w[-h])
11 | end
12 |
13 | assert_match(/usage/i, stdout)
14 | assert_equal(0, status)
15 | end
16 |
17 | def test_prints_help_and_exits_when_given_dash_dash_help
18 | stdout, _stderr, status = capture_io_and_exit_status do
19 | Options.parse(%w[--help])
20 | end
21 |
22 | assert_match(/usage/i, stdout)
23 | assert_equal(0, status)
24 | end
25 |
26 | def test_prints_version_and_exits_when_given_dash_v
27 | stdout, _stderr, status = capture_io_and_exit_status do
28 | Options.parse(%w[-v])
29 | end
30 |
31 | assert_match(%r{^bundle_update_interactive/#{Regexp.quote(VERSION)}}, stdout)
32 | assert_equal(0, status)
33 | end
34 |
35 | def test_prints_version_and_exits_when_given_dash_dash_version
36 | stdout, _stderr, status = capture_io_and_exit_status do
37 | Options.parse(%w[-v])
38 | end
39 |
40 | assert_match(%r{^bundle_update_interactive/#{Regexp.quote(VERSION)}}, stdout)
41 | assert_equal(0, status)
42 | end
43 |
44 | def test_defaults
45 | options = Options.parse([])
46 |
47 | assert_empty options.exclusively
48 | refute_predicate options, :latest?
49 | refute_predicate options, :commit?
50 | refute_predicate options, :only_explicit?
51 | end
52 |
53 | def test_allows_exclusive_groups_to_be_specified_as_comma_separated
54 | options = Options.parse(%w[--exclusively=development,test])
55 | assert_equal %i[development test], options.exclusively
56 | end
57 |
58 | def test_dash_capital_d_is_a_shortcut_for_exclusively_development_test
59 | options = Options.parse(%w[-D])
60 | assert_equal %i[development test], options.exclusively
61 | end
62 |
63 | def test_commit_can_be_enabled
64 | options = Options.parse(["--commit"])
65 |
66 | assert_predicate options, :commit?
67 | end
68 |
69 | def test_latest_can_be_enabled
70 | options = Options.parse(["--latest"])
71 |
72 | assert_predicate options, :latest?
73 | end
74 |
75 | def test_only_explicit_can_be_enabled
76 | options = Options.parse(["--only_explicit"])
77 |
78 | assert_predicate options, :only_explicit?
79 | end
80 |
81 | def test_raises_exception_when_given_a_positional_argment
82 | error = assert_raises(BundleUpdateInteractive::Error) do
83 | Options.parse(%w[hello])
84 | end
85 |
86 | assert_match(/update-interactive does not accept arguments/i, error.message)
87 | end
88 |
89 | def test_raises_exception_when_given_an_unrecognized_option
90 | error = assert_raises(OptionParser::ParseError) do
91 | Options.parse(%w[--fast])
92 | end
93 |
94 | assert_match(/invalid option/i, error.message)
95 | end
96 |
97 | def test_does_not_modify_argv
98 | argv = %w[--version]
99 | capture_io_and_exit_status { Options.parse(argv) }
100 |
101 | assert_equal %w[--version], argv
102 | end
103 |
104 | def test_parse_returns_an_instance_of_cli_options
105 | options = Options.parse([])
106 |
107 | assert_instance_of Options, options
108 | end
109 | end
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/cli/row_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class CLI
7 | class RowTest < Minitest::Test
8 | def test_formatted_gem_name_for_vulnerable_gem_is_red_on_white
9 | outdated_gem = build(:outdated_gem, name: "rails", vulnerable: true)
10 | row = Row.new(outdated_gem)
11 |
12 | assert_equal "\e[37;41mrails\e[0m", row.formatted_gem_name
13 | end
14 |
15 | def test_formatted_updated_version_highlights_diff_in_cyan_regardless_of_semver_change
16 | outdated_gem = build(
17 | :outdated_gem,
18 | current_version: "7.0.5",
19 | updated_version: "7.1.2",
20 | current_git_version: "a1a1207",
21 | updated_git_version: "0e5bafe"
22 | )
23 | row = Row.new(outdated_gem)
24 |
25 | assert_equal "7.\e[36m1.2\e[0m \e[36m0e5bafe\e[0m", row.formatted_updated_version
26 | end
27 |
28 | def test_name_and_version_red_if_major_semver_change
29 | outdated_gem = build(:outdated_gem, name: "rails", current_version: "6.1.2", updated_version: "7.0.3")
30 | row = Row.new(outdated_gem)
31 |
32 | assert_equal "\e[31mrails\e[0m", row.formatted_gem_name
33 | assert_equal "\e[31m7.0.3\e[0m", row.formatted_updated_version
34 | end
35 |
36 | def test_name_and_version_yellow_if_minor_semver_change
37 | outdated_gem = build(:outdated_gem, name: "rails", current_version: "7.0.3", updated_version: "7.1.0")
38 | row = Row.new(outdated_gem)
39 |
40 | assert_equal "\e[33mrails\e[0m", row.formatted_gem_name
41 | assert_equal "7.\e[33m1.0\e[0m", row.formatted_updated_version
42 | end
43 |
44 | def test_name_and_version_green_if_patch_semver_change
45 | outdated_gem = build(:outdated_gem, name: "rails", current_version: "7.0.3", updated_version: "7.0.4")
46 | row = Row.new(outdated_gem)
47 |
48 | assert_equal "\e[32mrails\e[0m", row.formatted_gem_name
49 | assert_equal "7.0.\e[32m4\e[0m", row.formatted_updated_version
50 | end
51 |
52 | def test_formatted_gemfile_groups_handles_nil_groups
53 | outdated_gem = build(:outdated_gem, gemfile_groups: nil)
54 | row = Row.new(outdated_gem)
55 |
56 | assert_nil row.formatted_gemfile_groups
57 | end
58 |
59 | def test_formatted_gemfile_groups_returns_comma_separated_symbols
60 | outdated_gem = build(:outdated_gem, gemfile_groups: %i[development test])
61 | row = Row.new(outdated_gem)
62 |
63 | assert_equal ":development, :test", row.formatted_gemfile_groups
64 | end
65 |
66 | def test_formatted_gemfile_requirement_treats_trivial_requirement_as_nil
67 | outdated_gem = build(:outdated_gem, gemfile_requirement: ">= 0")
68 | row = Row.new(outdated_gem)
69 |
70 | assert_equal "", row.formatted_gemfile_requirement
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/cli_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class CLIIest < Minitest::Test
7 | def test_shows_help_and_exits
8 | stdout, stderr, status = capture_io_and_exit_status do
9 | CLI.new.run(argv: %w[--help])
10 | end
11 |
12 | assert_match(/usage/i, stdout)
13 | assert_empty(stderr)
14 | assert_equal(0, status)
15 | end
16 |
17 | def test_prints_error_in_red_to_stderr_and_exits_with_failure_status
18 | Updater.expects(:new).raises(Error, "something went wrong")
19 |
20 | stdout, stderr, status = capture_io_and_exit_status do
21 | CLI.new.run(argv: [])
22 | end
23 |
24 | assert_empty(stdout)
25 | assert_equal("Resolving latest gem versions...\n\e[31msomething went wrong\e[0m\n", stderr)
26 | assert_equal(1, status)
27 | end
28 |
29 | def test_returns_if_no_gems_to_update_and_nothing_withheld
30 | stub_report(updatable_gems: {}, withheld_gems: {})
31 |
32 | stdout, stderr = capture_io do
33 | CLI.new.run(argv: [])
34 | end
35 |
36 | assert_equal("Resolving latest gem versions...\n", stderr)
37 | assert_equal("No gems to update.\n", stdout)
38 | end
39 |
40 | def test_prints_withheld_gems_and_returns_if_nothing_to_update
41 | report = stub_report(
42 | updatable_gems: {},
43 | withheld_gems: {
44 | "sqlite3" => build(:outdated_gem, name: "sqlite3", updated_version: "2.0.3", changelog_uri: nil)
45 | }
46 | )
47 | report.expects(:scan_for_vulnerabilities!)
48 |
49 | stdout, stderr, status = capture_io_and_exit_status do
50 | CLI.new.run(argv: [])
51 | end
52 |
53 | assert_equal(<<~EXPECTED_STDERR, stderr)
54 | Resolving latest gem versions...
55 | Checking for security vulnerabilities...
56 | Finding changelogs.
57 | EXPECTED_STDERR
58 |
59 | assert_match(/The following gems are being held back and cannot be updated/, stdout)
60 | assert_match(/sqlite3.*2\.0\.3/, stdout)
61 | assert_match(/^No gems to update.\n\z/, stdout)
62 | assert_nil(status)
63 | end
64 |
65 | def test_uses_correct_grammar_when_only_one_gem_can_be_updated
66 | report = stub_report(
67 | updatable_gems: {
68 | "sqlite3" => build(
69 | :outdated_gem,
70 | name: "sqlite3",
71 | current_version: "1.7.3",
72 | updated_version: "2.0.3",
73 | changelog_uri: nil
74 | )
75 | }
76 | )
77 |
78 | report.expects(:scan_for_vulnerabilities!)
79 |
80 | stdout, _stderr, _status = capture_io_and_exit_status(stdin_data: "\n") do
81 | CLI.new.run(argv: [])
82 | end
83 |
84 | assert_includes stdout, "1 gem can be updated"
85 | end
86 |
87 | private
88 |
89 | def stub_report(withheld_gems: {}, updatable_gems: {})
90 | report = Report.new(
91 | current_lockfile: nil,
92 | withheld_gems: withheld_gems,
93 | updatable_gems: updatable_gems
94 | )
95 |
96 | updater = Updater.new
97 | updater.stubs(:generate_report).returns(report)
98 | Updater.stubs(:new).returns(updater)
99 | report
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/git_committer_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class GitCommitterTest < Minitest::Test
7 | def setup
8 | @git_committer = GitCommitter.new(nil)
9 | end
10 |
11 | def test_format_commit_message
12 | gem = build(:outdated_gem, name: "activeadmin", current_version: "3.2.2", updated_version: "3.2.3")
13 |
14 | assert_equal "Update activeadmin 3.2.2 → 3.2.3", @git_committer.format_commit_message(gem)
15 | end
16 |
17 | def test_format_commit_message_with_git_version
18 | gem = build(
19 | :outdated_gem,
20 | name: "rails",
21 | current_version: "7.2.1",
22 | current_git_version: "5a8d894",
23 | updated_version: "7.2.1",
24 | updated_git_version: "77dfa65"
25 | )
26 |
27 | assert_equal "Update rails 7.2.1 5a8d894 → 7.2.1 77dfa65", @git_committer.format_commit_message(gem)
28 | end
29 |
30 | def test_apply_updates_as_individual_commits_raises_if_git_raises
31 | @git_committer.stubs(:`).with("git --version").raises(Errno::ENOENT)
32 |
33 | error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
34 | assert_equal "git could not be executed", error.message
35 | end
36 |
37 | def test_apply_updates_as_individual_commits_raises_if_git_does_not_succeed
38 | @git_committer.stubs(:`).with("git --version").returns("")
39 | Process.stubs(:last_status).returns(stub(success?: false))
40 |
41 | error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
42 | assert_equal "git could not be executed", error.message
43 | end
44 |
45 | def test_apply_updates_as_individual_commits_raises_if_there_are_uncommitted_files
46 | @git_committer.stubs(:`).with("git --version").returns("")
47 | @git_committer.stubs(:`).with("git status --untracked-files=no --porcelain").returns("M Gemfile.lock")
48 | Process.stubs(:last_status).returns(stub(success?: true))
49 |
50 | error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
51 | assert_equal <<~MESSAGE.strip, error.message
52 | `git status` reports uncommitted changes; please commit or stash them them first!
53 | M Gemfile.lock
54 | MESSAGE
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/latest/gem_requirement_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | module Latest
7 | class GemRequirementTest < Minitest::Test
8 | def test_relax_doesnt_affect_greater_than_equal_requirements
9 | assert_equal(">= 1.0.1", parse(">= 1.0.1").relax.to_s)
10 | end
11 |
12 | def test_relax_doesnt_affect_not_equal_requirements
13 | assert_equal("!= 5.2.0", parse("!= 5.2.0").relax.to_s)
14 | end
15 |
16 | def test_relax_doesnt_affect_greater_than_requirements
17 | assert_equal("> 1.0.1", parse("> 1.0.1").relax.to_s)
18 | end
19 |
20 | def test_relax_changes_approximate_requirements_to_greater_than_equal
21 | assert_equal(">= 5", parse("~> 5").relax.to_s)
22 | assert_equal(">= 5.2", parse("~> 5.2").relax.to_s)
23 | assert_equal(">= 5.2.0", parse("~> 5.2.0").relax.to_s)
24 | end
25 |
26 | def test_relax_changes_exact_requirements_to_greater_than_equal
27 | assert_equal(">= 0.89.0", parse("0.89.0").relax.to_s)
28 | end
29 |
30 | def test_relax_changes_less_than_requirements_to_greater_than_equal_zero
31 | assert_equal(">= 0", parse("< 1.9.5").relax.to_s)
32 | end
33 |
34 | def test_relax_changes_less_than_equal_requirements_to_greater_than_equal_zero
35 | assert_equal(">= 0", parse("<= 1.9.5").relax.to_s)
36 | end
37 |
38 | def test_shift_doesnt_affect_greater_than_equal_requirements
39 | assert_equal(">= 1.0.1", parse(">= 1.0.1").shift("2.3.5").to_s)
40 | end
41 |
42 | def test_shift_doesnt_affect_not_equal_requirements
43 | assert_equal("!= 5.2.0", parse("!= 5.2.0").shift("6.1.2").to_s)
44 | end
45 |
46 | def test_shift_doesnt_affect_greater_than_requirements
47 | assert_equal("> 1.0.1", parse("> 1.0.1").shift("2.3.5").to_s)
48 | end
49 |
50 | def test_shift_doesnt_affect_approximate_requirements_if_new_version_is_compatible
51 | assert_equal("~> 5.2.0", parse("~> 5.2.0").shift("5.2.6").to_s)
52 | assert_equal("~> 5.2", parse("~> 5.2").shift("5.4.9").to_s)
53 | end
54 |
55 | def test_shift_doesnt_affect_formatting_when_keeping_requirement_the_same
56 | assert_equal("~>5.2.0 ", parse("~>5.2.0 ").shift("5.2.6").to_s)
57 | end
58 |
59 | def test_shift_changes_exact_spec_to_new_version
60 | assert_equal("0.90.0", parse("0.89.0").shift("0.90.0").to_s)
61 | end
62 |
63 | def test_shift_changes_approximate_requirements_to_accomodate_new_version
64 | assert_equal("~> 6", parse("~> 5").shift("6.1.2").to_s)
65 | assert_equal("~> 6.1", parse("~> 5.2").shift("6.1.2").to_s)
66 | assert_equal("~> 6.1.2", parse("~> 5.2.0").shift("6.1.2").to_s)
67 | end
68 |
69 | def test_shift_changes_less_than_requirements_to_less_than_equal
70 | assert_equal("<= 2.1.4", parse("< 1.9.5").shift("2.1.4").to_s)
71 | end
72 |
73 | def test_shift_changes_less_than_equal_requirements_to_accomodate_new_veresion
74 | assert_equal("<= 2.1.4", parse("<= 1.9.5").shift("2.1.4").to_s)
75 | end
76 |
77 | private
78 |
79 | def parse(req)
80 | GemRequirement.parse(req)
81 | end
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/latest/gemfile_editor_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "fileutils"
5 | require "tmpdir"
6 |
7 | module BundleUpdateInteractive
8 | module Latest
9 | class GemfileEditorTest < Minitest::Test
10 | def setup
11 | @original_dir = Dir.pwd
12 | @temp_dir = Dir.mktmpdir
13 | Dir.chdir(Dir.mktmpdir)
14 | end
15 |
16 | def teardown
17 | Dir.chdir(@original_dir) if @original_dir
18 | FileUtils.rm_rf(@temp_dir) if @temp_dir
19 | end
20 |
21 | def test_with_relaxed_gemfile_doesnt_modify_gemfile_if_gemfile_lacks_specific_requirements
22 | original_gemfile = <<~GEMFILE
23 | source "https://rubygems.org"
24 | gem "sqlite3"
25 | GEMFILE
26 | File.write("Gemfile", original_gemfile)
27 |
28 | result = GemfileEditor.new.with_relaxed_gemfile do
29 | assert_equal original_gemfile, File.read("Gemfile")
30 | :done
31 | end
32 |
33 | assert_equal :done, result
34 | end
35 |
36 | def test_with_relaxed_gemfile_restores_original_gemfile_when_an_exception_is_raised
37 | original_gemfile = <<~GEMFILE
38 | source "https://rubygems.org"
39 | gem "sqlite3", "~> 1.7"
40 | GEMFILE
41 |
42 | File.write("Gemfile", original_gemfile)
43 |
44 | assert_raises(Interrupt) do
45 | GemfileEditor.new.with_relaxed_gemfile { raise Interrupt }
46 | end
47 |
48 | assert_equal original_gemfile, File.read("Gemfile")
49 | end
50 |
51 | def test_with_relaxed_gemfile_modifies_gemfile_then_restores_it_after_block_is_executed
52 | original_gemfile = <<~GEMFILE
53 | source "https://rubygems.org"
54 | gem "sqlite3", "~> 1.7"
55 | GEMFILE
56 |
57 | File.write("Gemfile", original_gemfile)
58 |
59 | result = GemfileEditor.new.with_relaxed_gemfile do
60 | assert_equal <<~GEMFILE, File.read("Gemfile")
61 | source "https://rubygems.org"
62 | gem "sqlite3", ">= 1.7"
63 | GEMFILE
64 | :done
65 | end
66 |
67 | assert_equal :done, result
68 | assert_equal original_gemfile, File.read("Gemfile")
69 | end
70 |
71 | def test_shift_gemfile_modifies_gemfile_based_on_versions_in_lock_file
72 | File.write("Gemfile", <<~GEMFILE)
73 | source "https://rubygems.org"
74 | gem "minitest", "~> 5.24.0"
75 | GEMFILE
76 | File.write("Gemfile.lock", <<~LOCK)
77 | GEM
78 | remote: https://rubygems.org/
79 | specs:
80 | minitest (5.25.1)
81 |
82 | PLATFORMS
83 | ruby
84 |
85 | DEPENDENCIES
86 | minitest (>= 5.24.0)
87 |
88 | BUNDLED WITH
89 | 2.5.17
90 | LOCK
91 |
92 | gemfile_modified = GemfileEditor.new.shift_gemfile
93 | assert gemfile_modified
94 | assert_equal(<<~GEMFILE, File.read("Gemfile"))
95 | source "https://rubygems.org"
96 | gem "minitest", "~> 5.25.1"
97 | GEMFILE
98 | end
99 |
100 | def test_shift_gemfile_does_not_modify_gemfile_if_it_already_matches_lock_file
101 | original_gemfile = <<~GEMFILE
102 | source "https://rubygems.org"
103 | gem "minitest", "~> 5.25.0"
104 | GEMFILE
105 | File.write("Gemfile", original_gemfile)
106 | File.write("Gemfile.lock", <<~LOCK)
107 | GEM
108 | remote: https://rubygems.org/
109 | specs:
110 | minitest (5.25.1)
111 |
112 | PLATFORMS
113 | ruby
114 |
115 | DEPENDENCIES
116 | minitest (>= 5.24.0)
117 |
118 | BUNDLED WITH
119 | 2.5.17
120 | LOCK
121 |
122 | gemfile_modified = GemfileEditor.new.shift_gemfile
123 | refute gemfile_modified
124 | assert_equal(original_gemfile, File.read("Gemfile"))
125 | end
126 | end
127 | end
128 | end
129 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/latest/updater_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "bundler"
5 |
6 | module BundleUpdateInteractive
7 | module Latest
8 | class UpdaterTest < Minitest::Test
9 | def test_generate_report_doesnt_run_bundle_outdated_and_always_returns_no_withheld_gems
10 | Dir.chdir(File.expand_path("../../fixtures", __dir__)) do
11 | updated_lockfile = File.read("Gemfile.lock.updated")
12 | BundlerCommands.expects(:read_updated_lockfile).with.returns(updated_lockfile)
13 | BundlerCommands.expects(:parse_outdated).never
14 |
15 | report = Updater.new.generate_report
16 |
17 | assert_empty report.withheld_gems
18 | end
19 | end
20 |
21 | def test_generate_report_relaxes_gemfile_and_restores_it
22 | Dir.chdir(File.expand_path("../../fixtures", __dir__)) do
23 | updatable_gems = { "sqlite3" => build(:outdated_gem, name: "sqlite3") }
24 | editor = GemfileEditor.new
25 | editor.expects(:with_relaxed_gemfile).returns(updatable_gems)
26 |
27 | report = Updater.new(editor: editor).generate_report
28 |
29 | assert_equal updatable_gems, report.updatable_gems
30 | end
31 | end
32 |
33 | def test_apply_updates_modifies_gemfile_and_runs_bundle_lock
34 | editor = GemfileEditor.new
35 | editor.expects(:with_relaxed_gemfile)
36 | editor.expects(:shift_gemfile).returns(true)
37 | BundlerCommands.expects(:lock)
38 |
39 | updater = Updater.new(editor: editor)
40 | updater.apply_updates("rails")
41 |
42 | assert_predicate updater, :modified_gemfile?
43 | end
44 |
45 | def test_apply_updates_doesnt_modify_gemfile_if_lockfile_is_unchanged
46 | editor = GemfileEditor.new
47 | editor.expects(:with_relaxed_gemfile)
48 | editor.expects(:shift_gemfile).returns(false)
49 | BundlerCommands.expects(:lock)
50 |
51 | updater = Updater.new(editor: editor)
52 | updater.apply_updates("rails")
53 |
54 | refute_predicate updater, :modified_gemfile?
55 | end
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/lockfile_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class LockfileTest < Minitest::Test
7 | def test_parses_a_lockfile_into_entries_by_name
8 | lockfile = Lockfile.parse(File.read(File.expand_path("../fixtures/Gemfile.lock", __dir__)))
9 |
10 | assert_equal 83, lockfile.entries.size
11 | refute_nil(lockfile.entries.find { |entry| entry.name == "rails" })
12 |
13 | rails_entry = lockfile["rails"]
14 | assert_equal "rails", rails_entry.name
15 | assert_instance_of LockfileEntry, rails_entry
16 | end
17 |
18 | def test_finds_exact_dependencies
19 | lockfile = Lockfile.parse(File.read(File.expand_path("../fixtures/Gemfile.lock", __dir__)))
20 |
21 | assert_equal(
22 | %w[
23 | actioncable
24 | actionmailbox
25 | actionmailer
26 | actionpack
27 | actiontext
28 | actionview
29 | activejob
30 | activemodel
31 | activerecord
32 | activestorage
33 | activesupport
34 | railties
35 | ],
36 | lockfile["rails"].exact_dependencies.sort
37 | )
38 |
39 | assert_empty lockfile["activesupport"].exact_dependencies
40 | end
41 |
42 | def test_denotes_entries_that_are_locked_by_exact_dependency_requirements
43 | lockfile = Lockfile.parse(File.read(File.expand_path("../fixtures/Gemfile.lock", __dir__)))
44 |
45 | assert_predicate lockfile["activesupport"], :exact_requirement?
46 | assert_predicate lockfile["railties"], :exact_requirement?
47 | refute_predicate lockfile["rails"], :exact_requirement?
48 | refute_predicate lockfile["nokogiri"], :exact_requirement?
49 | end
50 |
51 | def test_gems_exclusively_installed_by_development_and_test_groups
52 | gemfile = Gemfile.parse(File.expand_path("../fixtures/Gemfile", __dir__))
53 | lockfile = Lockfile.parse(File.read(File.expand_path("../fixtures/Gemfile.lock", __dir__)))
54 | exclusively_installed = lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: %i[development test])
55 |
56 | assert_equal(
57 | %w[
58 | addressable
59 | bindex
60 | capybara
61 | debug
62 | matrix
63 | public_suffix
64 | regexp_parser
65 | rexml
66 | rubyzip
67 | selenium-webdriver
68 | web-console
69 | websocket
70 | xpath
71 | ],
72 | exclusively_installed.sort
73 | )
74 | end
75 |
76 | def test_gems_exclusively_installed_by_no_groups_is_empty_array
77 | gemfile = Gemfile.parse(File.expand_path("../fixtures/Gemfile", __dir__))
78 | lockfile = Lockfile.parse(File.read(File.expand_path("../fixtures/Gemfile.lock", __dir__)))
79 | exclusively_installed = lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: [])
80 |
81 | assert_empty exclusively_installed
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/outdated_gem_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class OutdatedGemTest < Minitest::Test
7 | def test_changelog_uri_delegates_to_changelog_locator_for_rubygems_source
8 | changelog_locator = mock
9 | ChangelogLocator.expects(:new).returns(changelog_locator)
10 | changelog_locator.expects(:find_changelog_uri).with(name: "rails", version: "7.1.3.4")
11 | .returns("https://github.com/rails/rails/releases/tag/v7.1.3.4")
12 |
13 | outdated_gem = build(
14 | :outdated_gem,
15 | rubygems_source: true,
16 | name: "rails",
17 | updated_version: "7.1.3.4"
18 | )
19 |
20 | assert_equal "https://github.com/rails/rails/releases/tag/v7.1.3.4", outdated_gem.changelog_uri
21 | end
22 |
23 | def test_changelog_uri_for_rubygems_source_falls_back_to_gem_spec_homepage_if_locator_fails
24 | Gem::Specification
25 | .expects(:find_by_name)
26 | .with("gem_without_changelog")
27 | .returns(Gem::Specification.new("gem_without_changelog") { |spec| spec.homepage = "https://example/" })
28 |
29 | changelog_locator = mock
30 | ChangelogLocator.expects(:new).returns(changelog_locator)
31 | changelog_locator.expects(:find_changelog_uri).with(name: "gem_without_changelog", version: "1.0.0").returns(nil)
32 |
33 | outdated_gem = build(
34 | :outdated_gem,
35 | rubygems_source: true,
36 | name: "gem_without_changelog",
37 | updated_version: "1.0.0"
38 | )
39 |
40 | assert_equal "https://example/", outdated_gem.changelog_uri
41 | end
42 |
43 | def test_changelog_uri_diff_url_for_github_repo
44 | outdated_gem = build(
45 | :outdated_gem,
46 | rubygems_source: false,
47 | name: "mighty_test",
48 | git_source_uri: "https://github.com/mattbrictson/mighty_test.git",
49 | current_git_version: "302ad5c",
50 | updated_git_version: "e27ab73"
51 | )
52 |
53 | assert_equal "https://github.com/mattbrictson/mighty_test/compare/302ad5c...e27ab73", outdated_gem.changelog_uri
54 | end
55 |
56 | def test_changelog_uri_builds_diff_url_for_bitbucket_cloud_repo
57 | outdated_gem = build(
58 | :outdated_gem,
59 | rubygems_source: false,
60 | name: "atlassian-jwt",
61 | git_source_uri: "https://bitbucket.org/atlassian/atlassian-jwt-ruby.git",
62 | current_git_version: "7c06fd5",
63 | updated_git_version: "e8b7a92"
64 | )
65 |
66 | assert_equal(
67 | "https://bitbucket.org/atlassian/atlassian-jwt-ruby/branches/compare/e8b7a92..7c06fd5",
68 | outdated_gem.changelog_uri
69 | )
70 | end
71 |
72 | def test_changelog_uri_builds_diff_url_for_gitlab_repo
73 | outdated_gem = build(
74 | :outdated_gem,
75 | rubygems_source: false,
76 | name: "httpx",
77 | git_source_uri: "https://gitlab.com/os85/httpx.git",
78 | current_git_version: "e250ea5",
79 | updated_git_version: "7278647"
80 | )
81 |
82 | assert_equal "https://gitlab.com/os85/httpx/-/compare/e250ea5...7278647", outdated_gem.changelog_uri
83 | end
84 |
85 | def test_changelog_uri_falls_back_to_gem_spec_homepage_if_unsupported_git_repo
86 | Gem::Specification
87 | .expects(:find_by_name)
88 | .with("some_private_gem")
89 | .returns(Gem::Specification.new("some_private_gem") { |spec| spec.homepage = "https://example/" })
90 |
91 | outdated_gem = build(
92 | :outdated_gem,
93 | rubygems_source: false,
94 | name: "some_private_gem",
95 | git_source_uri: "git@private.example.com/repo.git",
96 | current_git_version: "bedf6a5",
97 | updated_git_version: "9c80944"
98 | )
99 |
100 | assert_equal "https://example/", outdated_gem.changelog_uri
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/semver_change_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | module BundleUpdateInteractive
6 | class SemverChangeTest < Minitest::Test
7 | def test_prerelease_is_considered_patch
8 | change = SemverChange.new("7.2.0.beta2", "7.2.0.beta3")
9 |
10 | assert_equal :patch, change.severity
11 | assert_predicate change, :patch?
12 |
13 | refute_predicate change, :minor?
14 | refute_predicate change, :major?
15 | end
16 |
17 | def test_prerelease_to_final_is_considered_patch
18 | change = SemverChange.new("7.2.0.rc1", "7.2.0")
19 |
20 | assert_equal :patch, change.severity
21 | assert_predicate change, :patch?
22 |
23 | refute_predicate change, :minor?
24 | refute_predicate change, :major?
25 | end
26 |
27 | def test_change_in_fourth_segment_is_considered_patch
28 | change = SemverChange.new("7.1.3.3", "7.1.3.4")
29 |
30 | assert_equal :patch, change.severity
31 | assert_predicate change, :patch?
32 |
33 | refute_predicate change, :minor?
34 | refute_predicate change, :major?
35 | end
36 |
37 | def test_change_in_first_segment_is_major
38 | change = SemverChange.new("6.1.7", "7.0.0")
39 |
40 | assert_equal :major, change.severity
41 | assert_predicate change, :major?
42 |
43 | refute_predicate change, :patch?
44 | refute_predicate change, :minor?
45 | end
46 |
47 | def test_change_in_second_segment_is_minor
48 | change = SemverChange.new("7.0.8", "7.1.0")
49 |
50 | assert_equal :minor, change.severity
51 | assert_predicate change, :minor?
52 |
53 | refute_predicate change, :patch?
54 | refute_predicate change, :major?
55 | end
56 |
57 | def test_change_in_third_segment_is_patch
58 | change = SemverChange.new("7.1.0", "7.1.1")
59 |
60 | assert_equal :patch, change.severity
61 | assert_predicate change, :patch?
62 |
63 | refute_predicate change, :minor?
64 | refute_predicate change, :major?
65 | end
66 |
67 | def test_format_applies_to_all_segments_starting_with_changed_one
68 | formatter = ->(str) { "<#{str}>" }
69 |
70 | assert_equal "<2.1.6>", SemverChange.new("1.2.9", "2.1.6").format(&formatter)
71 | assert_equal "2.<1.6>", SemverChange.new("2.0.9", "2.1.6").format(&formatter)
72 | assert_equal "2.1.<6>", SemverChange.new("2.1.5", "2.1.6").format(&formatter)
73 | assert_equal "2.1.6.<1>", SemverChange.new("2.1.6", "2.1.6.1").format(&formatter)
74 | end
75 |
76 | def test_format_doesnt_apply_to_final_release
77 | formatter = ->(str) { "<#{str}>" }
78 |
79 | assert_equal "7.2.0", SemverChange.new("7.2.0.rc1", "7.2.0").format(&formatter)
80 | end
81 |
82 | def test_none_is_true_when_versions_are_identical
83 | change = SemverChange.new("1.0.3", "1.0.3")
84 |
85 | assert_predicate change, :none?
86 | refute_predicate change, :any?
87 | end
88 |
89 | def test_none_is_false_when_versions_are_different
90 | change = SemverChange.new("1.0.3", "1.0.4")
91 |
92 | refute_predicate change, :none?
93 | assert_predicate change, :any?
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive/updater_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "bundler"
5 | require "bundler/audit"
6 | require "bundler/audit/scanner"
7 |
8 | module BundleUpdateInteractive
9 | class UpdaterTest < Minitest::Test
10 | def test_generates_a_report_of_updatable_gems_that_can_be_rendered_as_a_table
11 | VCR.use_cassette("changelog_requests") do
12 | Dir.chdir(File.expand_path("../fixtures", __dir__)) do
13 | updated_lockfile = File.read("Gemfile.lock.updated")
14 | BundlerCommands.expects(:parse_outdated).returns({})
15 | BundlerCommands.expects(:read_updated_lockfile).with.returns(updated_lockfile)
16 | mock_vulnerable_gems("actionpack", "rexml", "devise")
17 |
18 | report = Updater.new.generate_report
19 | report.scan_for_vulnerabilities!
20 |
21 | gem_update_table = CLI::Table.updatable(report.updatable_gems).render
22 | assert_matches_snapshot(gem_update_table)
23 | end
24 | end
25 | end
26 |
27 | def test_generates_a_report_of_withheld_gems_based_on_pins_that_excludes_updatable_gems
28 | VCR.use_cassette("changelog_requests") do
29 | Dir.chdir(File.expand_path("../fixtures", __dir__)) do
30 | updated_lockfile = File.read("Gemfile.lock.updated")
31 | BundlerCommands.expects(:read_updated_lockfile).with.returns(updated_lockfile)
32 |
33 | # Although sqlite3 is a pinned gem, it is updatable and thus excluded from the outdated check.
34 | # Therefore puma is the only pinned gem to check.
35 | BundlerCommands.expects(:parse_outdated).with("puma").returns({ "puma" => "7.0.1" })
36 |
37 | report = Updater.new.generate_report
38 |
39 | withheld_table = CLI::Table.withheld(report.withheld_gems).render
40 | assert_matches_snapshot(withheld_table)
41 | end
42 | end
43 | end
44 |
45 | def test_generates_a_report_of_updatable_gems_for_development_and_test_groups
46 | VCR.use_cassette("changelog_requests") do
47 | Dir.chdir(File.expand_path("../fixtures", __dir__)) do
48 | updated_lockfile = File.read("Gemfile.lock.development-test-updated")
49 | BundlerCommands.expects(:read_updated_lockfile).with(
50 | *%w[
51 | addressable
52 | bindex
53 | capybara
54 | debug
55 | matrix
56 | public_suffix
57 | regexp_parser
58 | rexml
59 | rubyzip
60 | selenium-webdriver
61 | web-console
62 | websocket
63 | xpath
64 | ]
65 | ).returns(updated_lockfile)
66 | mock_vulnerable_gems("actionpack", "rexml", "devise")
67 |
68 | # The development and test groups don't contain pinned gems, so the outdated check is skipped.
69 | BundlerCommands.expects(:parse_outdated).never
70 |
71 | report = Updater.new(groups: %i[development test]).generate_report
72 | report.scan_for_vulnerabilities!
73 |
74 | gem_update_table = CLI::Table.updatable(report.updatable_gems).render
75 | assert_matches_snapshot(gem_update_table)
76 | end
77 | end
78 | end
79 |
80 | def test_generates_empty_report_when_given_non_existent_group
81 | Dir.chdir(File.expand_path("../fixtures", __dir__)) do
82 | report = Updater.new(groups: [:assets]).generate_report
83 |
84 | assert_empty report
85 | assert_empty report.updatable_gems
86 | assert_empty report.withheld_gems
87 | end
88 | end
89 |
90 | def test_when_updating_rails_it_also_updates_actionpack_etc
91 | Dir.chdir(File.expand_path("../fixtures", __dir__)) do
92 | BundlerCommands.expects(:update_gems_conservatively).with(
93 | *%w[
94 | rails
95 | actioncable
96 | actionmailbox
97 | actionmailer
98 | actionpack
99 | actiontext
100 | actionview
101 | activejob
102 | activemodel
103 | activerecord
104 | activestorage
105 | activesupport
106 | railties
107 | ]
108 | )
109 |
110 | updater = Updater.new
111 | updater.apply_updates("rails")
112 | end
113 | end
114 | end
115 | end
116 |
--------------------------------------------------------------------------------
/test/bundle_update_interactive_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class BundleUpdateInteractiveTest < Minitest::Test
6 | def test_that_it_has_a_version_number
7 | refute_nil ::BundleUpdateInteractive::VERSION
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/factories/outdated_gems.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | FactoryBot.define do
4 | factory :outdated_gem, class: "BundleUpdateInteractive::OutdatedGem" do
5 | current_git_version { nil }
6 | current_version { "0.0.1" }
7 | git_source_uri { nil }
8 | name { "rails" }
9 | rubygems_source { true }
10 | updated_git_version { nil }
11 | updated_version { "7.1.0" }
12 | vulnerable { false }
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/fixtures/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
5 | gem "rails"
6 |
7 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
8 | gem "sprockets-rails"
9 |
10 | # Use sqlite3 as the database for Active Record
11 | gem "sqlite3", "~> 1.7"
12 |
13 | # Use the Puma web server [https://github.com/puma/puma]
14 | gem "puma", "~> 6.4"
15 |
16 | # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
17 | gem "importmap-rails"
18 |
19 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
20 | gem "turbo-rails"
21 |
22 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
23 | gem "stimulus-rails"
24 |
25 | # Build JSON APIs with ease [https://github.com/rails/jbuilder]
26 | gem "jbuilder"
27 |
28 | # Use Redis adapter to run Action Cable in production
29 | gem "redis", "~> 5.0"
30 |
31 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
32 | # gem "kredis"
33 |
34 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
35 | # gem "bcrypt", "~> 3.1.7"
36 |
37 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
38 | gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
39 |
40 | # Reduces boot times through caching; required in config/boot.rb
41 | gem "bootsnap", require: false
42 |
43 | # Use Sass to process CSS
44 | # gem "sassc-rails"
45 |
46 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
47 | # gem "image_processing", "~> 1.2"
48 |
49 | group :development, :test do
50 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
51 | gem "debug", platforms: %i[ mri mingw x64_mingw ]
52 | end
53 |
54 | group :development do
55 | # Use console on exceptions pages [https://github.com/rails/web-console]
56 | gem "web-console"
57 |
58 | # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
59 | # gem "rack-mini-profiler"
60 |
61 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring]
62 | # gem "spring"
63 | end
64 |
65 | group :test do
66 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
67 | gem "capybara"
68 | gem "selenium-webdriver"
69 | end
70 |
--------------------------------------------------------------------------------
/test/fixtures/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (7.1.3)
5 | actionpack (= 7.1.3)
6 | activesupport (= 7.1.3)
7 | nio4r (~> 2.0)
8 | websocket-driver (>= 0.6.1)
9 | zeitwerk (~> 2.6)
10 | actionmailbox (7.1.3)
11 | actionpack (= 7.1.3)
12 | activejob (= 7.1.3)
13 | activerecord (= 7.1.3)
14 | activestorage (= 7.1.3)
15 | activesupport (= 7.1.3)
16 | mail (>= 2.7.1)
17 | net-imap
18 | net-pop
19 | net-smtp
20 | actionmailer (7.1.3)
21 | actionpack (= 7.1.3)
22 | actionview (= 7.1.3)
23 | activejob (= 7.1.3)
24 | activesupport (= 7.1.3)
25 | mail (~> 2.5, >= 2.5.4)
26 | net-imap
27 | net-pop
28 | net-smtp
29 | rails-dom-testing (~> 2.2)
30 | actionpack (7.1.3)
31 | actionview (= 7.1.3)
32 | activesupport (= 7.1.3)
33 | nokogiri (>= 1.8.5)
34 | racc
35 | rack (>= 2.2.4)
36 | rack-session (>= 1.0.1)
37 | rack-test (>= 0.6.3)
38 | rails-dom-testing (~> 2.2)
39 | rails-html-sanitizer (~> 1.6)
40 | actiontext (7.1.3)
41 | actionpack (= 7.1.3)
42 | activerecord (= 7.1.3)
43 | activestorage (= 7.1.3)
44 | activesupport (= 7.1.3)
45 | globalid (>= 0.6.0)
46 | nokogiri (>= 1.8.5)
47 | actionview (7.1.3)
48 | activesupport (= 7.1.3)
49 | builder (~> 3.1)
50 | erubi (~> 1.11)
51 | rails-dom-testing (~> 2.2)
52 | rails-html-sanitizer (~> 1.6)
53 | activejob (7.1.3)
54 | activesupport (= 7.1.3)
55 | globalid (>= 0.3.6)
56 | activemodel (7.1.3)
57 | activesupport (= 7.1.3)
58 | activerecord (7.1.3)
59 | activemodel (= 7.1.3)
60 | activesupport (= 7.1.3)
61 | timeout (>= 0.4.0)
62 | activestorage (7.1.3)
63 | actionpack (= 7.1.3)
64 | activejob (= 7.1.3)
65 | activerecord (= 7.1.3)
66 | activesupport (= 7.1.3)
67 | marcel (~> 1.0)
68 | activesupport (7.1.3)
69 | base64
70 | bigdecimal
71 | concurrent-ruby (~> 1.0, >= 1.0.2)
72 | connection_pool (>= 2.2.5)
73 | drb
74 | i18n (>= 1.6, < 2)
75 | minitest (>= 5.1)
76 | mutex_m
77 | tzinfo (~> 2.0)
78 | addressable (2.8.6)
79 | public_suffix (>= 2.0.2, < 6.0)
80 | base64 (0.2.0)
81 | bigdecimal (3.1.6)
82 | bindex (0.8.1)
83 | bootsnap (1.18.3)
84 | msgpack (~> 1.2)
85 | builder (3.2.4)
86 | capybara (3.40.0)
87 | addressable
88 | matrix
89 | mini_mime (>= 0.1.3)
90 | nokogiri (~> 1.11)
91 | rack (>= 1.6.0)
92 | rack-test (>= 0.6.3)
93 | regexp_parser (>= 1.5, < 3.0)
94 | xpath (~> 3.2)
95 | concurrent-ruby (1.2.3)
96 | connection_pool (2.4.1)
97 | crass (1.0.6)
98 | date (3.3.4)
99 | debug (1.9.1)
100 | irb (~> 1.10)
101 | reline (>= 0.3.8)
102 | drb (2.2.0)
103 | ruby2_keywords
104 | erubi (1.12.0)
105 | globalid (1.2.1)
106 | activesupport (>= 6.1)
107 | i18n (1.14.1)
108 | concurrent-ruby (~> 1.0)
109 | importmap-rails (2.0.1)
110 | actionpack (>= 6.0.0)
111 | activesupport (>= 6.0.0)
112 | railties (>= 6.0.0)
113 | io-console (0.7.2)
114 | irb (1.11.1)
115 | rdoc
116 | reline (>= 0.4.2)
117 | jbuilder (2.11.5)
118 | actionview (>= 5.0.0)
119 | activesupport (>= 5.0.0)
120 | loofah (2.22.0)
121 | crass (~> 1.0.2)
122 | nokogiri (>= 1.12.0)
123 | mail (2.8.1)
124 | mini_mime (>= 0.1.1)
125 | net-imap
126 | net-pop
127 | net-smtp
128 | marcel (1.0.2)
129 | matrix (0.4.2)
130 | mini_mime (1.1.5)
131 | minitest (5.21.2)
132 | msgpack (1.7.2)
133 | mutex_m (0.2.0)
134 | net-imap (0.4.9.1)
135 | date
136 | net-protocol
137 | net-pop (0.1.2)
138 | net-protocol
139 | net-protocol (0.2.2)
140 | timeout
141 | net-smtp (0.4.0.1)
142 | net-protocol
143 | nio4r (2.7.0)
144 | nokogiri (1.16.2-aarch64-linux)
145 | racc (~> 1.4)
146 | nokogiri (1.16.2-arm64-darwin)
147 | racc (~> 1.4)
148 | nokogiri (1.16.2-x86_64-darwin)
149 | racc (~> 1.4)
150 | nokogiri (1.16.2-x86_64-linux)
151 | racc (~> 1.4)
152 | psych (5.1.2)
153 | stringio
154 | public_suffix (5.0.4)
155 | puma (6.4.2)
156 | nio4r (~> 2.0)
157 | racc (1.7.3)
158 | rack (3.0.8)
159 | rack-session (2.0.0)
160 | rack (>= 3.0.0)
161 | rack-test (2.1.0)
162 | rack (>= 1.3)
163 | rackup (2.1.0)
164 | rack (>= 3)
165 | webrick (~> 1.8)
166 | rails (7.1.3)
167 | actioncable (= 7.1.3)
168 | actionmailbox (= 7.1.3)
169 | actionmailer (= 7.1.3)
170 | actionpack (= 7.1.3)
171 | actiontext (= 7.1.3)
172 | actionview (= 7.1.3)
173 | activejob (= 7.1.3)
174 | activemodel (= 7.1.3)
175 | activerecord (= 7.1.3)
176 | activestorage (= 7.1.3)
177 | activesupport (= 7.1.3)
178 | bundler (>= 1.15.0)
179 | railties (= 7.1.3)
180 | rails-dom-testing (2.2.0)
181 | activesupport (>= 5.0.0)
182 | minitest
183 | nokogiri (>= 1.6)
184 | rails-html-sanitizer (1.6.0)
185 | loofah (~> 2.21)
186 | nokogiri (~> 1.14)
187 | railties (7.1.3)
188 | actionpack (= 7.1.3)
189 | activesupport (= 7.1.3)
190 | irb
191 | rackup (>= 1.0.0)
192 | rake (>= 12.2)
193 | thor (~> 1.0, >= 1.2.2)
194 | zeitwerk (~> 2.6)
195 | rake (13.1.0)
196 | rdoc (6.6.2)
197 | psych (>= 4.0.0)
198 | redis (5.0.8)
199 | redis-client (>= 0.17.0)
200 | redis-client (0.18.0)
201 | connection_pool
202 | regexp_parser (2.9.0)
203 | reline (0.4.2)
204 | io-console (~> 0.5)
205 | rexml (3.2.6)
206 | ruby2_keywords (0.0.5)
207 | rubyzip (2.3.2)
208 | selenium-webdriver (4.17.0)
209 | base64 (~> 0.2)
210 | rexml (~> 3.2, >= 3.2.5)
211 | rubyzip (>= 1.2.2, < 3.0)
212 | websocket (~> 1.0)
213 | sprockets (4.2.1)
214 | concurrent-ruby (~> 1.0)
215 | rack (>= 2.2.4, < 4)
216 | sprockets-rails (3.4.2)
217 | actionpack (>= 5.2)
218 | activesupport (>= 5.2)
219 | sprockets (>= 3.0.0)
220 | sqlite3 (1.7.2-aarch64-linux)
221 | sqlite3 (1.7.2-arm64-darwin)
222 | sqlite3 (1.7.2-x86_64-darwin)
223 | sqlite3 (1.7.2-x86_64-linux)
224 | stimulus-rails (1.3.3)
225 | railties (>= 6.0.0)
226 | stringio (3.1.0)
227 | thor (1.3.0)
228 | timeout (0.4.1)
229 | turbo-rails (1.5.0)
230 | actionpack (>= 6.0.0)
231 | activejob (>= 6.0.0)
232 | railties (>= 6.0.0)
233 | tzinfo (2.0.6)
234 | concurrent-ruby (~> 1.0)
235 | web-console (4.2.1)
236 | actionview (>= 6.0.0)
237 | activemodel (>= 6.0.0)
238 | bindex (>= 0.4.0)
239 | railties (>= 6.0.0)
240 | webrick (1.8.1)
241 | websocket (1.2.10)
242 | websocket-driver (0.7.6)
243 | websocket-extensions (>= 0.1.0)
244 | websocket-extensions (0.1.5)
245 | xpath (3.2.0)
246 | nokogiri (~> 1.8)
247 | zeitwerk (2.6.12)
248 |
249 | PLATFORMS
250 | aarch64-linux
251 | arm64-darwin-21
252 | arm64-darwin-22
253 | x86_64-darwin-20
254 | x86_64-darwin-21
255 | x86_64-darwin-22
256 | x86_64-darwin-23
257 | x86_64-linux
258 |
259 | DEPENDENCIES
260 | bootsnap
261 | capybara
262 | debug
263 | importmap-rails
264 | jbuilder
265 | puma (~> 6.4)
266 | rails
267 | redis (~> 5.0)
268 | selenium-webdriver
269 | sprockets-rails
270 | sqlite3 (~> 1.7)
271 | stimulus-rails
272 | turbo-rails
273 | tzinfo-data
274 | web-console
275 |
276 | RUBY VERSION
277 | ruby 3.3.0p0
278 |
279 | BUNDLED WITH
280 | 2.5.3
281 |
--------------------------------------------------------------------------------
/test/fixtures/Gemfile.lock.development-test-updated:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (7.1.3)
5 | actionpack (= 7.1.3)
6 | activesupport (= 7.1.3)
7 | nio4r (~> 2.0)
8 | websocket-driver (>= 0.6.1)
9 | zeitwerk (~> 2.6)
10 | actionmailbox (7.1.3)
11 | actionpack (= 7.1.3)
12 | activejob (= 7.1.3)
13 | activerecord (= 7.1.3)
14 | activestorage (= 7.1.3)
15 | activesupport (= 7.1.3)
16 | mail (>= 2.7.1)
17 | net-imap
18 | net-pop
19 | net-smtp
20 | actionmailer (7.1.3)
21 | actionpack (= 7.1.3)
22 | actionview (= 7.1.3)
23 | activejob (= 7.1.3)
24 | activesupport (= 7.1.3)
25 | mail (~> 2.5, >= 2.5.4)
26 | net-imap
27 | net-pop
28 | net-smtp
29 | rails-dom-testing (~> 2.2)
30 | actionpack (7.1.3)
31 | actionview (= 7.1.3)
32 | activesupport (= 7.1.3)
33 | nokogiri (>= 1.8.5)
34 | racc
35 | rack (>= 2.2.4)
36 | rack-session (>= 1.0.1)
37 | rack-test (>= 0.6.3)
38 | rails-dom-testing (~> 2.2)
39 | rails-html-sanitizer (~> 1.6)
40 | actiontext (7.1.3)
41 | actionpack (= 7.1.3)
42 | activerecord (= 7.1.3)
43 | activestorage (= 7.1.3)
44 | activesupport (= 7.1.3)
45 | globalid (>= 0.6.0)
46 | nokogiri (>= 1.8.5)
47 | actionview (7.1.3)
48 | activesupport (= 7.1.3)
49 | builder (~> 3.1)
50 | erubi (~> 1.11)
51 | rails-dom-testing (~> 2.2)
52 | rails-html-sanitizer (~> 1.6)
53 | activejob (7.1.3)
54 | activesupport (= 7.1.3)
55 | globalid (>= 0.3.6)
56 | activemodel (7.1.3)
57 | activesupport (= 7.1.3)
58 | activerecord (7.1.3)
59 | activemodel (= 7.1.3)
60 | activesupport (= 7.1.3)
61 | timeout (>= 0.4.0)
62 | activestorage (7.1.3)
63 | actionpack (= 7.1.3)
64 | activejob (= 7.1.3)
65 | activerecord (= 7.1.3)
66 | activesupport (= 7.1.3)
67 | marcel (~> 1.0)
68 | activesupport (7.1.3)
69 | base64
70 | bigdecimal
71 | concurrent-ruby (~> 1.0, >= 1.0.2)
72 | connection_pool (>= 2.2.5)
73 | drb
74 | i18n (>= 1.6, < 2)
75 | minitest (>= 5.1)
76 | mutex_m
77 | tzinfo (~> 2.0)
78 | addressable (2.8.7)
79 | public_suffix (>= 2.0.2, < 7.0)
80 | base64 (0.2.0)
81 | bigdecimal (3.1.6)
82 | bindex (0.8.1)
83 | bootsnap (1.18.3)
84 | msgpack (~> 1.2)
85 | builder (3.2.4)
86 | capybara (3.40.0)
87 | addressable
88 | matrix
89 | mini_mime (>= 0.1.3)
90 | nokogiri (~> 1.11)
91 | rack (>= 1.6.0)
92 | rack-test (>= 0.6.3)
93 | regexp_parser (>= 1.5, < 3.0)
94 | xpath (~> 3.2)
95 | concurrent-ruby (1.2.3)
96 | connection_pool (2.4.1)
97 | crass (1.0.6)
98 | date (3.3.4)
99 | debug (1.9.2)
100 | irb (~> 1.10)
101 | reline (>= 0.3.8)
102 | drb (2.2.0)
103 | ruby2_keywords
104 | erubi (1.12.0)
105 | globalid (1.2.1)
106 | activesupport (>= 6.1)
107 | i18n (1.14.1)
108 | concurrent-ruby (~> 1.0)
109 | importmap-rails (2.0.1)
110 | actionpack (>= 6.0.0)
111 | activesupport (>= 6.0.0)
112 | railties (>= 6.0.0)
113 | io-console (0.7.2)
114 | irb (1.11.1)
115 | rdoc
116 | reline (>= 0.4.2)
117 | jbuilder (2.11.5)
118 | actionview (>= 5.0.0)
119 | activesupport (>= 5.0.0)
120 | logger (1.6.0)
121 | loofah (2.22.0)
122 | crass (~> 1.0.2)
123 | nokogiri (>= 1.12.0)
124 | mail (2.8.1)
125 | mini_mime (>= 0.1.1)
126 | net-imap
127 | net-pop
128 | net-smtp
129 | marcel (1.0.2)
130 | matrix (0.4.2)
131 | mini_mime (1.1.5)
132 | minitest (5.21.2)
133 | msgpack (1.7.2)
134 | mutex_m (0.2.0)
135 | net-imap (0.4.9.1)
136 | date
137 | net-protocol
138 | net-pop (0.1.2)
139 | net-protocol
140 | net-protocol (0.2.2)
141 | timeout
142 | net-smtp (0.4.0.1)
143 | net-protocol
144 | nio4r (2.7.0)
145 | nokogiri (1.16.2-aarch64-linux)
146 | racc (~> 1.4)
147 | nokogiri (1.16.2-arm64-darwin)
148 | racc (~> 1.4)
149 | nokogiri (1.16.2-x86_64-darwin)
150 | racc (~> 1.4)
151 | nokogiri (1.16.2-x86_64-linux)
152 | racc (~> 1.4)
153 | psych (5.1.2)
154 | stringio
155 | public_suffix (6.0.1)
156 | puma (6.4.2)
157 | nio4r (~> 2.0)
158 | racc (1.7.3)
159 | rack (3.0.8)
160 | rack-session (2.0.0)
161 | rack (>= 3.0.0)
162 | rack-test (2.1.0)
163 | rack (>= 1.3)
164 | rackup (2.1.0)
165 | rack (>= 3)
166 | webrick (~> 1.8)
167 | rails (7.1.3)
168 | actioncable (= 7.1.3)
169 | actionmailbox (= 7.1.3)
170 | actionmailer (= 7.1.3)
171 | actionpack (= 7.1.3)
172 | actiontext (= 7.1.3)
173 | actionview (= 7.1.3)
174 | activejob (= 7.1.3)
175 | activemodel (= 7.1.3)
176 | activerecord (= 7.1.3)
177 | activestorage (= 7.1.3)
178 | activesupport (= 7.1.3)
179 | bundler (>= 1.15.0)
180 | railties (= 7.1.3)
181 | rails-dom-testing (2.2.0)
182 | activesupport (>= 5.0.0)
183 | minitest
184 | nokogiri (>= 1.6)
185 | rails-html-sanitizer (1.6.0)
186 | loofah (~> 2.21)
187 | nokogiri (~> 1.14)
188 | railties (7.1.3)
189 | actionpack (= 7.1.3)
190 | activesupport (= 7.1.3)
191 | irb
192 | rackup (>= 1.0.0)
193 | rake (>= 12.2)
194 | thor (~> 1.0, >= 1.2.2)
195 | zeitwerk (~> 2.6)
196 | rake (13.1.0)
197 | rdoc (6.6.2)
198 | psych (>= 4.0.0)
199 | redis (5.0.8)
200 | redis-client (>= 0.17.0)
201 | redis-client (0.18.0)
202 | connection_pool
203 | regexp_parser (2.9.2)
204 | reline (0.4.2)
205 | io-console (~> 0.5)
206 | rexml (3.3.2)
207 | strscan
208 | ruby2_keywords (0.0.5)
209 | rubyzip (2.3.2)
210 | selenium-webdriver (4.23.0)
211 | base64 (~> 0.2)
212 | logger (~> 1.4)
213 | rexml (~> 3.2, >= 3.2.5)
214 | rubyzip (>= 1.2.2, < 3.0)
215 | websocket (~> 1.0)
216 | sprockets (4.2.1)
217 | concurrent-ruby (~> 1.0)
218 | rack (>= 2.2.4, < 4)
219 | sprockets-rails (3.4.2)
220 | actionpack (>= 5.2)
221 | activesupport (>= 5.2)
222 | sprockets (>= 3.0.0)
223 | sqlite3 (1.7.2-aarch64-linux)
224 | sqlite3 (1.7.2-arm64-darwin)
225 | sqlite3 (1.7.2-x86_64-darwin)
226 | sqlite3 (1.7.2-x86_64-linux)
227 | stimulus-rails (1.3.3)
228 | railties (>= 6.0.0)
229 | stringio (3.1.0)
230 | strscan (3.1.0)
231 | thor (1.3.0)
232 | timeout (0.4.1)
233 | turbo-rails (1.5.0)
234 | actionpack (>= 6.0.0)
235 | activejob (>= 6.0.0)
236 | railties (>= 6.0.0)
237 | tzinfo (2.0.6)
238 | concurrent-ruby (~> 1.0)
239 | web-console (4.2.1)
240 | actionview (>= 6.0.0)
241 | activemodel (>= 6.0.0)
242 | bindex (>= 0.4.0)
243 | railties (>= 6.0.0)
244 | webrick (1.8.1)
245 | websocket (1.2.11)
246 | websocket-driver (0.7.6)
247 | websocket-extensions (>= 0.1.0)
248 | websocket-extensions (0.1.5)
249 | xpath (3.2.0)
250 | nokogiri (~> 1.8)
251 | zeitwerk (2.6.12)
252 |
253 | PLATFORMS
254 | aarch64-linux
255 | arm64-darwin-21
256 | arm64-darwin-22
257 | x86_64-darwin-20
258 | x86_64-darwin-21
259 | x86_64-darwin-22
260 | x86_64-darwin-23
261 | x86_64-linux
262 |
263 | DEPENDENCIES
264 | bootsnap
265 | capybara
266 | debug
267 | importmap-rails
268 | jbuilder
269 | puma (~> 6.4)
270 | rails
271 | redis (~> 5.0)
272 | selenium-webdriver
273 | sprockets-rails
274 | sqlite3 (~> 1.7)
275 | stimulus-rails
276 | turbo-rails
277 | tzinfo-data
278 | web-console
279 |
280 | BUNDLED WITH
281 | 2.5.15
282 |
--------------------------------------------------------------------------------
/test/fixtures/Gemfile.lock.updated:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (7.1.3.4)
5 | actionpack (= 7.1.3.4)
6 | activesupport (= 7.1.3.4)
7 | nio4r (~> 2.0)
8 | websocket-driver (>= 0.6.1)
9 | zeitwerk (~> 2.6)
10 | actionmailbox (7.1.3.4)
11 | actionpack (= 7.1.3.4)
12 | activejob (= 7.1.3.4)
13 | activerecord (= 7.1.3.4)
14 | activestorage (= 7.1.3.4)
15 | activesupport (= 7.1.3.4)
16 | mail (>= 2.7.1)
17 | net-imap
18 | net-pop
19 | net-smtp
20 | actionmailer (7.1.3.4)
21 | actionpack (= 7.1.3.4)
22 | actionview (= 7.1.3.4)
23 | activejob (= 7.1.3.4)
24 | activesupport (= 7.1.3.4)
25 | mail (~> 2.5, >= 2.5.4)
26 | net-imap
27 | net-pop
28 | net-smtp
29 | rails-dom-testing (~> 2.2)
30 | actionpack (7.1.3.4)
31 | actionview (= 7.1.3.4)
32 | activesupport (= 7.1.3.4)
33 | nokogiri (>= 1.8.5)
34 | racc
35 | rack (>= 2.2.4)
36 | rack-session (>= 1.0.1)
37 | rack-test (>= 0.6.3)
38 | rails-dom-testing (~> 2.2)
39 | rails-html-sanitizer (~> 1.6)
40 | actiontext (7.1.3.4)
41 | actionpack (= 7.1.3.4)
42 | activerecord (= 7.1.3.4)
43 | activestorage (= 7.1.3.4)
44 | activesupport (= 7.1.3.4)
45 | globalid (>= 0.6.0)
46 | nokogiri (>= 1.8.5)
47 | actionview (7.1.3.4)
48 | activesupport (= 7.1.3.4)
49 | builder (~> 3.1)
50 | erubi (~> 1.11)
51 | rails-dom-testing (~> 2.2)
52 | rails-html-sanitizer (~> 1.6)
53 | activejob (7.1.3.4)
54 | activesupport (= 7.1.3.4)
55 | globalid (>= 0.3.6)
56 | activemodel (7.1.3.4)
57 | activesupport (= 7.1.3.4)
58 | activerecord (7.1.3.4)
59 | activemodel (= 7.1.3.4)
60 | activesupport (= 7.1.3.4)
61 | timeout (>= 0.4.0)
62 | activestorage (7.1.3.4)
63 | actionpack (= 7.1.3.4)
64 | activejob (= 7.1.3.4)
65 | activerecord (= 7.1.3.4)
66 | activesupport (= 7.1.3.4)
67 | marcel (~> 1.0)
68 | activesupport (7.1.3.4)
69 | base64
70 | bigdecimal
71 | concurrent-ruby (~> 1.0, >= 1.0.2)
72 | connection_pool (>= 2.2.5)
73 | drb
74 | i18n (>= 1.6, < 2)
75 | minitest (>= 5.1)
76 | mutex_m
77 | tzinfo (~> 2.0)
78 | addressable (2.8.7)
79 | public_suffix (>= 2.0.2, < 7.0)
80 | base64 (0.2.0)
81 | bigdecimal (3.1.8)
82 | bindex (0.8.1)
83 | bootsnap (1.18.3)
84 | msgpack (~> 1.2)
85 | builder (3.3.0)
86 | capybara (3.40.0)
87 | addressable
88 | matrix
89 | mini_mime (>= 0.1.3)
90 | nokogiri (~> 1.11)
91 | rack (>= 1.6.0)
92 | rack-test (>= 0.6.3)
93 | regexp_parser (>= 1.5, < 3.0)
94 | xpath (~> 3.2)
95 | concurrent-ruby (1.3.3)
96 | connection_pool (2.4.1)
97 | crass (1.0.6)
98 | date (3.3.4)
99 | debug (1.9.2)
100 | irb (~> 1.10)
101 | reline (>= 0.3.8)
102 | drb (2.2.1)
103 | erubi (1.13.0)
104 | globalid (1.2.1)
105 | activesupport (>= 6.1)
106 | i18n (1.14.5)
107 | concurrent-ruby (~> 1.0)
108 | importmap-rails (2.0.1)
109 | actionpack (>= 6.0.0)
110 | activesupport (>= 6.0.0)
111 | railties (>= 6.0.0)
112 | io-console (0.7.2)
113 | irb (1.14.0)
114 | rdoc (>= 4.0.0)
115 | reline (>= 0.4.2)
116 | jbuilder (2.12.0)
117 | actionview (>= 5.0.0)
118 | activesupport (>= 5.0.0)
119 | logger (1.6.0)
120 | loofah (2.22.0)
121 | crass (~> 1.0.2)
122 | nokogiri (>= 1.12.0)
123 | mail (2.8.1)
124 | mini_mime (>= 0.1.1)
125 | net-imap
126 | net-pop
127 | net-smtp
128 | marcel (1.0.4)
129 | matrix (0.4.2)
130 | mini_mime (1.1.5)
131 | minitest (5.24.1)
132 | msgpack (1.7.2)
133 | mutex_m (0.2.0)
134 | net-imap (0.4.14)
135 | date
136 | net-protocol
137 | net-pop (0.1.2)
138 | net-protocol
139 | net-protocol (0.2.2)
140 | timeout
141 | net-smtp (0.5.0)
142 | net-protocol
143 | nio4r (2.7.3)
144 | nokogiri (1.16.6-aarch64-linux)
145 | racc (~> 1.4)
146 | nokogiri (1.16.6-arm64-darwin)
147 | racc (~> 1.4)
148 | nokogiri (1.16.6-x86_64-darwin)
149 | racc (~> 1.4)
150 | nokogiri (1.16.6-x86_64-linux)
151 | racc (~> 1.4)
152 | psych (5.1.2)
153 | stringio
154 | public_suffix (6.0.0)
155 | puma (6.4.2)
156 | nio4r (~> 2.0)
157 | racc (1.8.0)
158 | rack (3.1.7)
159 | rack-session (2.0.0)
160 | rack (>= 3.0.0)
161 | rack-test (2.1.0)
162 | rack (>= 1.3)
163 | rackup (2.1.0)
164 | rack (>= 3)
165 | webrick (~> 1.8)
166 | rails (7.1.3.4)
167 | actioncable (= 7.1.3.4)
168 | actionmailbox (= 7.1.3.4)
169 | actionmailer (= 7.1.3.4)
170 | actionpack (= 7.1.3.4)
171 | actiontext (= 7.1.3.4)
172 | actionview (= 7.1.3.4)
173 | activejob (= 7.1.3.4)
174 | activemodel (= 7.1.3.4)
175 | activerecord (= 7.1.3.4)
176 | activestorage (= 7.1.3.4)
177 | activesupport (= 7.1.3.4)
178 | bundler (>= 1.15.0)
179 | railties (= 7.1.3.4)
180 | rails-dom-testing (2.2.0)
181 | activesupport (>= 5.0.0)
182 | minitest
183 | nokogiri (>= 1.6)
184 | rails-html-sanitizer (1.6.0)
185 | loofah (~> 2.21)
186 | nokogiri (~> 1.14)
187 | railties (7.1.3.4)
188 | actionpack (= 7.1.3.4)
189 | activesupport (= 7.1.3.4)
190 | irb
191 | rackup (>= 1.0.0)
192 | rake (>= 12.2)
193 | thor (~> 1.0, >= 1.2.2)
194 | zeitwerk (~> 2.6)
195 | rake (13.2.1)
196 | rdoc (6.7.0)
197 | psych (>= 4.0.0)
198 | redis (5.2.0)
199 | redis-client (>= 0.22.0)
200 | redis-client (0.22.2)
201 | connection_pool
202 | regexp_parser (2.9.2)
203 | reline (0.5.9)
204 | io-console (~> 0.5)
205 | rexml (3.3.1)
206 | strscan
207 | rubyzip (2.3.2)
208 | selenium-webdriver (4.22.0)
209 | base64 (~> 0.2)
210 | logger (~> 1.4)
211 | rexml (~> 3.2, >= 3.2.5)
212 | rubyzip (>= 1.2.2, < 3.0)
213 | websocket (~> 1.0)
214 | sprockets (4.2.1)
215 | concurrent-ruby (~> 1.0)
216 | rack (>= 2.2.4, < 4)
217 | sprockets-rails (3.5.1)
218 | actionpack (>= 6.1)
219 | activesupport (>= 6.1)
220 | sprockets (>= 3.0.0)
221 | sqlite3 (1.7.3-aarch64-linux)
222 | sqlite3 (1.7.3-arm64-darwin)
223 | sqlite3 (1.7.3-x86_64-darwin)
224 | sqlite3 (1.7.3-x86_64-linux)
225 | stimulus-rails (1.3.3)
226 | railties (>= 6.0.0)
227 | stringio (3.1.1)
228 | strscan (3.1.0)
229 | thor (1.3.1)
230 | timeout (0.4.1)
231 | turbo-rails (2.0.5)
232 | actionpack (>= 6.0.0)
233 | activejob (>= 6.0.0)
234 | railties (>= 6.0.0)
235 | tzinfo (2.0.6)
236 | concurrent-ruby (~> 1.0)
237 | web-console (4.2.1)
238 | actionview (>= 6.0.0)
239 | activemodel (>= 6.0.0)
240 | bindex (>= 0.4.0)
241 | railties (>= 6.0.0)
242 | webrick (1.8.1)
243 | websocket (1.2.11)
244 | websocket-driver (0.7.6)
245 | websocket-extensions (>= 0.1.0)
246 | websocket-extensions (0.1.5)
247 | xpath (3.2.0)
248 | nokogiri (~> 1.8)
249 | zeitwerk (2.6.16)
250 |
251 | PLATFORMS
252 | aarch64-linux
253 | arm64-darwin-21
254 | arm64-darwin-22
255 | x86_64-darwin-20
256 | x86_64-darwin-21
257 | x86_64-darwin-22
258 | x86_64-darwin-23
259 | x86_64-linux
260 |
261 | DEPENDENCIES
262 | bootsnap
263 | capybara
264 | debug
265 | importmap-rails
266 | jbuilder
267 | puma (~> 6.4)
268 | rails
269 | redis (~> 5.0)
270 | selenium-webdriver
271 | sprockets-rails
272 | sqlite3 (~> 1.7)
273 | stimulus-rails
274 | turbo-rails
275 | tzinfo-data
276 | web-console
277 |
278 | RUBY VERSION
279 | ruby 3.3.0p0
280 |
281 | BUNDLED WITH
282 | 2.5.3
283 |
--------------------------------------------------------------------------------
/test/fixtures/integration/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 | gem "bigdecimal"
5 | gem "minitest", "~> 5.0.0"
6 | gem "rake"
7 |
--------------------------------------------------------------------------------
/test/fixtures/integration/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | bigdecimal (3.1.7)
5 | minitest (5.0.0)
6 | rake (12.3.3)
7 |
8 | PLATFORMS
9 | arm64-darwin-23
10 | ruby
11 |
12 | DEPENDENCIES
13 | bigdecimal
14 | minitest (~> 5.0.0)
15 | rake
16 |
17 | BUNDLED WITH
18 | 2.5.17
19 |
--------------------------------------------------------------------------------
/test/fixtures/integration/with_indirect/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 | gem "mail"
5 |
--------------------------------------------------------------------------------
/test/fixtures/integration/with_indirect/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | date (3.4.0)
5 | mail (2.8.0)
6 | mini_mime (>= 0.1.1)
7 | net-imap
8 | net-pop
9 | net-smtp
10 | mini_mime (1.1.4)
11 | net-imap (0.5.1)
12 | date
13 | net-protocol
14 | net-pop (0.1.2)
15 | net-protocol
16 | net-protocol (0.2.2)
17 | timeout
18 | net-smtp (0.5.0)
19 | net-protocol
20 | timeout (0.4.2)
21 |
22 | PLATFORMS
23 | arm64-darwin-24
24 | ruby
25 |
26 | DEPENDENCIES
27 | mail
28 |
29 | BUNDLED WITH
30 | 2.5.23
31 |
--------------------------------------------------------------------------------
/test/integration/cli_integration_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require "json"
5 | require "open3"
6 | require "tmpdir"
7 |
8 | module BundleUpdateInteractive
9 | class CLIIntegrationIest < Minitest::Test
10 | def test_updates_lock_file_based_on_selected_gem_while_honoring_gemfile_requirement
11 | out, _gemfile, lockfile = within_fixture_copy("integration") do
12 | run_bundle_update_interactive(argv: [], key_presses: "j \n")
13 | end
14 |
15 | assert_includes out, "Color legend:"
16 |
17 | assert_includes out, "3 gems can be updated."
18 | assert_includes out, "‣ ⬡ bigdecimal 3.1.7 →"
19 | assert_includes out, " ⬡ minitest 5.0.0 → 5.0.8"
20 | assert_includes out, " ⬡ rake 12.3.3 →"
21 |
22 | assert_includes out, "‣ ⬢ minitest 5.0.0 → 5.0.8"
23 |
24 | assert_includes out, "Updating the following gems."
25 | assert_includes out, "minitest 5.0.0 → 5.0.8 :default"
26 |
27 | assert_includes out, "Bundle updated!"
28 |
29 | assert_includes lockfile, <<~LOCK
30 | GEM
31 | remote: https://rubygems.org/
32 | specs:
33 | bigdecimal (3.1.7)
34 | minitest (5.0.8)
35 | LOCK
36 | assert_includes lockfile, <<~LOCK
37 | DEPENDENCIES
38 | bigdecimal
39 | minitest (~> 5.0.0)
40 | LOCK
41 | end
42 |
43 | def test_omits_indirect_gems_when_only_explicit_option_is_passed
44 | out, _gemfile, _lockfile = within_fixture_copy("integration/with_indirect") do
45 | run_bundle_update_interactive(argv: ["--only-explicit"], key_presses: "\n")
46 | end
47 |
48 | assert_includes out, "1 gem can be updated."
49 | assert_includes out, "‣ ⬡ mail"
50 | end
51 |
52 | def test_updates_lock_file_and_gemfile_to_accommodate_latest_version_when_latest_option_is_specified
53 | latest_minitest_version = fetch_latest_gem_version_from_rubygems_api("minitest")
54 |
55 | out, gemfile, lockfile = within_fixture_copy("integration") do
56 | run_bundle_update_interactive(argv: ["--latest"], key_presses: "j \n")
57 | end
58 |
59 | assert_includes out, "Color legend:"
60 |
61 | assert_includes out, "3 gems can be updated."
62 | assert_includes out, "‣ ⬡ bigdecimal 3.1.7 →"
63 | assert_includes out, " ⬡ minitest 5.0.0 → #{latest_minitest_version}"
64 | assert_includes out, " ⬡ rake 12.3.3 →"
65 |
66 | assert_includes out, "‣ ⬢ minitest 5.0.0 → #{latest_minitest_version}"
67 |
68 | assert_includes out, "Updating the following gems."
69 | assert_includes out, "minitest 5.0.0 → #{latest_minitest_version} :default"
70 |
71 | assert_includes out, "Bundle updated!"
72 | assert_includes out, "Your Gemfile was changed"
73 |
74 | assert_includes gemfile, <<~GEMFILE
75 | gem "minitest", "~> #{latest_minitest_version}"
76 | GEMFILE
77 |
78 | assert_includes lockfile, <<~LOCK
79 | GEM
80 | remote: https://rubygems.org/
81 | specs:
82 | bigdecimal (3.1.7)
83 | minitest (#{latest_minitest_version})
84 | LOCK
85 | assert_includes lockfile, <<~LOCK
86 | DEPENDENCIES
87 | bigdecimal
88 | minitest (~> #{latest_minitest_version})
89 | LOCK
90 | end
91 |
92 | def test_updates_each_selected_gem_with_a_git_commit
93 | out, _gemfile, _lockfile = within_fixture_copy("integration") do
94 | system "git init", out: File::NULL, exception: true
95 | system "git add .", out: File::NULL, exception: true
96 | system "git commit -m init", out: File::NULL, exception: true
97 | run_bundle_update_interactive(argv: ["--commit"], key_presses: " j \n")
98 | end
99 |
100 | assert_match(/^\[(main|master) \h+\] Update bigdecimal 3\.1\.7 →/, out)
101 | assert_match(/^\[(main|master) \h+\] Update minitest 5\.0\.0 →/, out)
102 | end
103 |
104 | private
105 |
106 | def run_bundle_update_interactive(argv:, key_presses: "\n")
107 | command = [
108 | { "GEM_HOME" => ENV.fetch("GEM_HOME", nil) },
109 | Gem.ruby,
110 | "-I",
111 | File.expand_path("../../lib", __dir__),
112 | File.expand_path("../../exe/bundler-update-interactive", __dir__),
113 | *argv
114 | ]
115 | Bundler.with_unbundled_env do
116 | out, err, status = Open3.capture3(*command, stdin_data: key_presses)
117 | raise "Command failed: #{[out, err].join}" unless status.success?
118 |
119 | [out, File.read("Gemfile"), File.read("Gemfile.lock")]
120 | end
121 | end
122 |
123 | def within_fixture_copy(fixture, &block)
124 | fixture_path = File.join(File.expand_path("../fixtures", __dir__), fixture)
125 | Dir.mktmpdir do |tmp|
126 | FileUtils.cp_r(fixture_path, tmp)
127 | Dir.chdir(File.join(tmp, File.basename(fixture_path)), &block)
128 | end
129 | end
130 |
131 | def fetch_latest_gem_version_from_rubygems_api(name)
132 | WebMock.allow_net_connect!
133 | VCR.turned_off do
134 | response = HTTP.get("https://rubygems.org/api/v1/gems/#{name}.json")
135 | raise unless response.success?
136 |
137 | JSON.parse(response.body)["version"]
138 | end
139 | ensure
140 | WebMock.disable_net_connect!
141 | end
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/test/snapshots/bundleupdateinteractive_updatertest/test_generates_a_report_of_updatable_gems_for_development_and_test_groups__1.snap.yaml:
--------------------------------------------------------------------------------
1 | --- "\e[2;4mname\e[0m \e[2;4mfrom\e[0m \e[2;4mto\e[0m \e[2;4mgroup\e[0m
2 | \ \e[2;4murl\e[0m\n\e[32maddressable\e[0m 2.8.6 → 2.8.\e[32m7\e[0m
3 | \ \e[34mhttps://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md#v2.8.7\e[0m\n\e[32mdebug\e[0m
4 | \ 1.9.1 → 1.9.\e[32m2\e[0m :development, :test \e[34mhttps://github.com/ruby/debug/releases\e[0m\n\e[31mpublic_suffix\e[0m
5 | \ 5.0.4 → \e[31m6.0.1\e[0m \e[34mhttps://github.com/weppos/publicsuffix-ruby/blob/master/CHANGELOG.md\e[0m\n\e[32mregexp_parser\e[0m
6 | \ 2.9.0 → 2.9.\e[32m2\e[0m \e[34mhttps://github.com/ammar/regexp_parser/blob/master/CHANGELOG.md\e[0m\n\e[37;41mrexml\e[0m
7 | \ 3.2.6 → 3.\e[33m3.2\e[0m \e[34mhttps://github.com/ruby/rexml/releases/tag/v3.3.2\e[0m\n\e[33mselenium-webdriver\e[0m
8 | \ 4.17.0 → 4.\e[33m23.0\e[0m :test \e[34mhttps://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES\e[0m\n\e[32mwebsocket\e[0m
9 | \ 1.2.10 → 1.2.\e[32m11\e[0m \e[34mhttps://github.com/imanel/websocket-ruby/blob/master/CHANGELOG.md\e[0m"
10 |
--------------------------------------------------------------------------------
/test/snapshots/bundleupdateinteractive_updatertest/test_generates_a_report_of_updatable_gems_that_can_be_rendered_as_a_table__1.snap.yaml:
--------------------------------------------------------------------------------
1 | --- "\e[2;4mname\e[0m \e[2;4mfrom\e[0m \e[2;4mto\e[0m \e[2;4mgroup\e[0m
2 | \ \e[2;4murl\e[0m\n\e[32maddressable\e[0m 2.8.6 → 2.8.\e[32m7\e[0m
3 | \ \e[34mhttps://github.com/sporkmonger/addressable/blob/main/CHANGELOG.md#v2.8.7\e[0m\n\e[32mbigdecimal\e[0m
4 | \ 3.1.6 → 3.1.\e[32m8\e[0m \e[34mhttps://github.com/ruby/bigdecimal/blob/master/CHANGES.md\e[0m\n\e[33mbuilder\e[0m
5 | \ 3.2.4 → 3.\e[33m3.0\e[0m \e[34mhttps://github.com/rails/builder/blob/master/CHANGES\e[0m\n\e[33mconcurrent-ruby\e[0m
6 | \ 1.2.3 → 1.\e[33m3.3\e[0m \e[34mhttps://github.com/ruby-concurrency/concurrent-ruby/blob/master/CHANGELOG.md\e[0m\n\e[32mdebug\e[0m
7 | \ 1.9.1 → 1.9.\e[32m2\e[0m :development, :test \e[34mhttps://github.com/ruby/debug/releases\e[0m\n\e[32mdrb\e[0m
8 | \ 2.2.0 → 2.2.\e[32m1\e[0m \e[34mhttps://github.com/ruby/drb/releases\e[0m\n\e[33merubi\e[0m
9 | \ 1.12.0 → 1.\e[33m13.0\e[0m \e[34mhttps://github.com/jeremyevans/erubi/blob/master/CHANGELOG\e[0m\n\e[32mi18n\e[0m
10 | \ 1.14.1 → 1.14.\e[32m5\e[0m \e[34mhttps://github.com/ruby-i18n/i18n/releases\e[0m\n\e[33mirb\e[0m
11 | \ 1.11.1 → 1.\e[33m14.0\e[0m \e[34mhttps://github.com/ruby/irb/releases\e[0m\n\e[33mjbuilder\e[0m
12 | \ 2.11.5 → 2.\e[33m12.0\e[0m :default \e[34mhttps://github.com/rails/jbuilder/releases/tag/v2.12.0\e[0m\n\e[32mmarcel\e[0m
13 | \ 1.0.2 → 1.0.\e[32m4\e[0m \e[34mhttps://github.com/rails/marcel/releases\e[0m\n\e[33mminitest\e[0m
14 | \ 5.21.2 → 5.\e[33m24.1\e[0m \e[34mhttps://github.com/minitest/minitest/blob/master/History.rdoc\e[0m\n\e[32mnet-imap\e[0m
15 | \ 0.4.9.1 → 0.4.\e[32m14\e[0m \e[34mhttps://github.com/ruby/net-imap/releases\e[0m\n\e[33mnet-smtp\e[0m
16 | \ 0.4.0.1 → 0.\e[33m5.0\e[0m \e[34mhttps://github.com/ruby/net-smtp/blob/master/NEWS.md\e[0m\n\e[32mnio4r\e[0m
17 | \ 2.7.0 → 2.7.\e[32m3\e[0m \e[34mhttps://github.com/socketry/nio4r/blob/main/changes.md\e[0m\n\e[32mnokogiri\e[0m
18 | \ 1.16.2 → 1.16.\e[32m6\e[0m \e[34mhttps://nokogiri.org/CHANGELOG.html\e[0m\n\e[31mpublic_suffix\e[0m
19 | \ 5.0.4 → \e[31m6.0.0\e[0m \e[34mhttps://github.com/weppos/publicsuffix-ruby/blob/master/CHANGELOG.md\e[0m\n\e[33mracc\e[0m
20 | \ 1.7.3 → 1.\e[33m8.0\e[0m \e[34mhttps://github.com/ruby/racc/blob/master/ChangeLog\e[0m\n\e[33mrack\e[0m
21 | \ 3.0.8 → 3.\e[33m1.7\e[0m \e[34mhttps://github.com/rack/rack/blob/main/CHANGELOG.md\e[0m\n\e[37;41mrails\e[0m
22 | \ 7.1.3 → 7.1.3.\e[32m4\e[0m :default \e[34mhttps://github.com/rails/rails/releases/tag/v7.1.3.4\e[0m\n\e[33mrake\e[0m
23 | \ 13.1.0 → 13.\e[33m2.1\e[0m \e[34mhttps://github.com/ruby/rake/blob/v13.2.1/History.rdoc\e[0m\n\e[33mrdoc\e[0m
24 | \ 6.6.2 → 6.\e[33m7.0\e[0m \e[34mhttps://github.com/ruby/rdoc/releases\e[0m\n\e[33mredis\e[0m
25 | \ 5.0.8 → 5.\e[33m2.0\e[0m :default \e[34mhttps://github.com/redis/redis-rb/blob/master/CHANGELOG.md\e[0m\n\e[33mredis-client\e[0m
26 | \ 0.18.0 → 0.\e[33m22.2\e[0m \e[34mhttps://github.com/redis-rb/redis-client/blob/master/CHANGELOG.md\e[0m\n\e[32mregexp_parser\e[0m
27 | \ 2.9.0 → 2.9.\e[32m2\e[0m \e[34mhttps://github.com/ammar/regexp_parser/blob/master/CHANGELOG.md\e[0m\n\e[33mreline\e[0m
28 | \ 0.4.2 → 0.\e[33m5.9\e[0m \e[34mhttps://github.com/ruby/reline/releases\e[0m\n\e[37;41mrexml\e[0m
29 | \ 3.2.6 → 3.\e[33m3.1\e[0m \e[34mhttps://github.com/ruby/rexml/releases/tag/v3.3.1\e[0m\n\e[33mselenium-webdriver\e[0m
30 | \ 4.17.0 → 4.\e[33m22.0\e[0m :test \e[34mhttps://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES\e[0m\n\e[33msprockets-rails\e[0m
31 | \ 3.4.2 → 3.\e[33m5.1\e[0m :default \e[34mhttps://github.com/rails/sprockets-rails/releases\e[0m\n\e[32msqlite3\e[0m
32 | \ 1.7.2 → 1.7.\e[32m3\e[0m :default \e[34mhttps://github.com/sparklemotion/sqlite3-ruby/blob/master/CHANGELOG.md\e[0m\n\e[32mstringio\e[0m
33 | \ 3.1.0 → 3.1.\e[32m1\e[0m \e[34mhttps://github.com/ruby/stringio/blob/master/NEWS.md\e[0m\n\e[32mthor\e[0m
34 | \ 1.3.0 → 1.3.\e[32m1\e[0m \e[34mhttps://github.com/rails/thor/releases/tag/v1.3.1\e[0m\n\e[31mturbo-rails\e[0m
35 | \ 1.5.0 → \e[31m2.0.5\e[0m :default \e[34mhttps://github.com/hotwired/turbo-rails/releases\e[0m\n\e[32mwebsocket\e[0m
36 | \ 1.2.10 → 1.2.\e[32m11\e[0m \e[34mhttps://github.com/imanel/websocket-ruby/blob/master/CHANGELOG.md\e[0m\n\e[32mzeitwerk\e[0m
37 | \ 2.6.12 → 2.6.\e[32m16\e[0m \e[34mhttps://github.com/fxn/zeitwerk/blob/master/CHANGELOG.md\e[0m"
38 |
--------------------------------------------------------------------------------
/test/snapshots/bundleupdateinteractive_updatertest/test_generates_a_report_of_withheld_gems_based_on_pins_that_excludes_updatable_gems__1.snap.yaml:
--------------------------------------------------------------------------------
1 | --- "\e[2;4mname\e[0m \e[2;4mrequirement\e[0m \e[2;4mcurrent\e[0m \e[2;4mlatest\e[0m
2 | \ \e[2;4mgroup\e[0m \e[2;4murl\e[0m\n\e[31mpuma\e[0m ~> 6.4 6.4.2 \e[31m7.0.1\e[0m
3 | \ :default \e[34mhttps://github.com/puma/puma/blob/master/History.md\e[0m"
4 |
--------------------------------------------------------------------------------
/test/support/bundler_audit_test_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler"
4 | require "bundler/audit"
5 | require "bundler/audit/scanner"
6 |
7 | module BundlerAuditTestHelpers
8 | private
9 |
10 | def mock_vulnerable_gems(*gem_names)
11 | vulnerable_gems = gem_names.flatten.map { |name| Gem::Specification.new(name) }
12 | audit_report = mock(vulnerable_gems: vulnerable_gems)
13 | scanner = mock(report: audit_report)
14 |
15 | Bundler::Audit::Database.expects(:update!)
16 | Bundler::Audit::Scanner.expects(:new).returns(scanner)
17 | end
18 | end
19 |
20 | Minitest::Test.include(BundlerAuditTestHelpers)
21 |
--------------------------------------------------------------------------------
/test/support/capture_io_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "stringio"
4 | require "tty/prompt/test"
5 |
6 | module CaptureIOHelpers
7 | private
8 |
9 | # Patch Minitest's capture_io to make it compatible with TTY::Prompt
10 | def capture_io
11 | super do
12 | $stdout.extend(TTY::Prompt::StringIOExtensions) if $stdout.is_a?(StringIO)
13 | yield
14 | end
15 | end
16 |
17 | def capture_io_and_exit_status(stdin_data: "")
18 | orig_stdin = $stdin
19 | $stdin = StringIO.new(stdin_data)
20 |
21 | exit_status = nil
22 | stdout = +""
23 | stderr = +""
24 |
25 | out, err = capture_io do
26 | yield
27 | rescue SystemExit => e
28 | exit_status = e.status
29 | end
30 |
31 | stdout << out
32 | stderr << err
33 |
34 | [stdout, stderr, exit_status]
35 | ensure
36 | $stdin = orig_stdin
37 | end
38 | end
39 |
40 | Minitest::Test.prepend(CaptureIOHelpers)
41 |
--------------------------------------------------------------------------------
/test/support/factory_bot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "factory_bot"
4 |
5 | FactoryBot.find_definitions
6 |
7 | module Minitest
8 | class Test
9 | include FactoryBot::Syntax::Methods
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/support/mocha.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "mocha/minitest"
4 |
5 | Mocha.configure do |config|
6 | config.stubbing_non_existent_method = :prevent
7 | end
8 |
--------------------------------------------------------------------------------
/test/support/vcr.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "vcr"
4 |
5 | VCR.configure do |config|
6 | config.allow_http_connections_when_no_cassette = false
7 | config.cassette_library_dir = File.expand_path("../cassettes", __dir__)
8 | config.hook_into :webmock
9 | config.default_cassette_options = {
10 | match_requests_on: %i[method uri body_as_json],
11 | record: :once,
12 | record_on_error: false
13 | }
14 | end
15 |
--------------------------------------------------------------------------------
/test/support/webmock.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "webmock/minitest"
4 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4 | require "bundle_update_interactive"
5 | require "minitest/autorun"
6 |
7 | BundleUpdateInteractive.pastel = Pastel.new(enabled: true)
8 |
9 | Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |rb| require(rb) }
10 |
--------------------------------------------------------------------------------