├── .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 | [![Gem Version](https://img.shields.io/gem/v/bundle_update_interactive)](https://rubygems.org/gems/bundle_update_interactive) 4 | [![Gem Downloads](https://img.shields.io/gem/dt/bundle_update_interactive)](https://www.ruby-toolbox.com/projects/bundle_update_interactive) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/bundle_update_interactive/ci.yml)](https://github.com/mattbrictson/bundle_update_interactive/actions/workflows/ci.yml) 6 | [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/mattbrictson/bundle_update_interactive)](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 | Screenshot of update-interactive UI 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 | Severities are in red, yellow, and green 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 | Screenshot of highlighted version numbers 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 | Screenshot of security vulnerability highlighted in red 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 | Screenshot of rails and selenium-webdriver gems held back due to Gemfile requirements 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 | Screenshot of gems being updated 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 | --------------------------------------------------------------------------------