Skip to content

Commit

Permalink
Create a git commit for each selected gem update when --commit is s…
Browse files Browse the repository at this point in the history
…pecified (#53)

Sometimes, updating gems can lead to bugs or regressions. To facilitate
troubleshooting, this PR introduces 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.

To enable this behavior, pass the `--commit` option:

```
bundle update-interactive --commit
```

The gems you select to be updated will be applied in separate commits,
like this:

```
* c9801382 Update activeadmin 3.2.2 → 3.2.3
* 9957254b Update rexml 3.3.5 → 3.3.6
* 4a4f2072 Update sass 1.77.6 → 1.77.8
```

Supersedes #50 and #51. Closes #49.
  • Loading branch information
mattbrictson authored Sep 19, 2024
1 parent 5fd57b6 commit 035a979
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 21 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ jobs:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
bundler: latest
- run: git config --global user.name 'github-actions[bot]'
- run: git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- run: bundle exec rake test
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ bundle ui

## Options

- `--commit` [applies each gem update in a discrete git commit](#git-commits)
- `--latest` [modifies the Gemfile if necessary to allow the latest gem versions](#allow-latest-versions)
- `-D` / `--exclusively=GROUP` [limits updatable gems by Gemfile groups](#limit-impact-by-gemfile-groups)

Expand Down Expand Up @@ -69,6 +70,27 @@ Some gems, notably `rails`, are composed of smaller gems like `actionpack`, `act

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.

### Git commits

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.

To enable this behavior, pass the `--commit` option:

```
bundle update-interactive --commit
```

The gems you select to be updated will be applied in separate commits, like this:

```
* c9801382 Update activeadmin 3.2.2 → 3.2.3
* 9957254b Update rexml 3.3.5 → 3.3.6
* 4a4f2072 Update sass 1.77.6 → 1.77.8
```

> [!NOTE]
> 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."
### Held back gems

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.
Expand Down
8 changes: 7 additions & 1 deletion lib/bundle_update_interactive/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
puts "Updating the following gems."
puts Table.updatable(selected_gems).render
puts
updater.apply_updates(*selected_gems.keys)

if options.commit?
GitCommitter.new(updater).apply_updates_as_individual_commits(*selected_gems.keys)
else
updater.apply_updates(*selected_gems.keys)
end

puts_gemfile_modified_notice if updater.modified_gemfile?
rescue Exception => e # rubocop:disable Lint/RescueException
handle_exception(e)
Expand Down
12 changes: 10 additions & 2 deletions lib/bundle_update_interactive/cli/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ def pastel
end

def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
OptionParser.new do |parser|
OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength
parser.summary_indent = " "
parser.summary_width = 24
parser.on("--commit", "Create a git commit for each selected gem update") do
options.commit = true
end
parser.on("--latest", "Modify the Gemfile to allow the latest gem versions") do
options.latest = true
end
Expand All @@ -90,13 +93,18 @@ def build_parser(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt
end

attr_accessor :exclusively
attr_writer :latest
attr_writer :commit, :latest

def initialize
@exclusively = []
@commit = false
@latest = false
end

def commit?
@commit
end

def latest?
@latest
end
Expand Down
59 changes: 59 additions & 0 deletions lib/bundle_update_interactive/git_committer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require "shellwords"

module BundleUpdateInteractive
class GitCommitter
def initialize(updater)
@updater = updater
end

def apply_updates_as_individual_commits(*gem_names)
assert_git_executable!
assert_working_directory_clean!

gem_names.flatten.each do |name|
updates = updater.apply_updates(name)
updated_gem = updates[name] || updates.values.first
next if updated_gem.nil?

commit_message = format_commit_message(updated_gem)
system "git add Gemfile Gemfile.lock", exception: true
system "git commit -m #{commit_message.shellescape}", exception: true
end
end

def format_commit_message(outdated_gem)
[
"Update",
outdated_gem.name,
outdated_gem.current_version.to_s,
outdated_gem.current_git_version,
"→",
outdated_gem.updated_version.to_s,
outdated_gem.updated_git_version
].compact.join(" ")
end

private

attr_reader :updater

def assert_git_executable!
success = begin
`git --version`
Process.last_status.success?
rescue SystemCallError
false
end
raise Error, "git could not be executed" unless success
end

def assert_working_directory_clean!
status = `git status --untracked-files=no --porcelain`.strip
return if status.empty?

raise Error, "`git status` reports uncommitted changes; please commit or stash them them first!\n#{status}"
end
end
end
11 changes: 10 additions & 1 deletion lib/bundle_update_interactive/updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def generate_report
def apply_updates(*gem_names)
expanded_names = expand_gems_with_exact_dependencies(*gem_names)
BundlerCommands.update_gems_conservatively(*expanded_names)

# Return the gems that were actually updated based on observed changes to the lock file
updated_gems = build_outdated_gems(File.read("Gemfile.lock"))
@current_lockfile = Lockfile.parse
updated_gems
end

# Overridden by Latest::Updater subclass
Expand All @@ -32,7 +37,11 @@ def modified_gemfile?
def find_updatable_gems
return {} if candidate_gems && candidate_gems.empty?

updated_lockfile = Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(candidate_gems)))
build_outdated_gems(BundlerCommands.read_updated_lockfile(*Array(candidate_gems)))
end

def build_outdated_gems(lockfile_contents)
updated_lockfile = Lockfile.parse(lockfile_contents)
current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
name = current_lockfile_entry.name
updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
Expand Down
7 changes: 7 additions & 0 deletions test/bundle_update_interactive/cli/options_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def test_defaults

assert_empty options.exclusively
refute_predicate options, :latest?
refute_predicate options, :commit?
end

def test_allows_exclusive_groups_to_be_specified_as_comma_separated
Expand All @@ -57,6 +58,12 @@ def test_dash_capital_d_is_a_shortcut_for_exclusively_development_test
assert_equal %i[development test], options.exclusively
end

def test_commit_can_be_enabled
options = CLI::Options.parse(["--commit"])

assert_predicate options, :commit?
end

def test_latest_can_be_enabled
options = CLI::Options.parse(["--latest"])

Expand Down
57 changes: 57 additions & 0 deletions test/bundle_update_interactive/git_committer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "test_helper"

module BundleUpdateInteractive
class GitCommitterTest < Minitest::Test
def setup
@git_committer = GitCommitter.new(nil)
end

def test_format_commit_message
gem = build(:outdated_gem, name: "activeadmin", current_version: "3.2.2", updated_version: "3.2.3")

assert_equal "Update activeadmin 3.2.2 → 3.2.3", @git_committer.format_commit_message(gem)
end

def test_format_commit_message_with_git_version
gem = build(
:outdated_gem,
name: "rails",
current_version: "7.2.1",
current_git_version: "5a8d894",
updated_version: "7.2.1",
updated_git_version: "77dfa65"
)

assert_equal "Update rails 7.2.1 5a8d894 → 7.2.1 77dfa65", @git_committer.format_commit_message(gem)
end

def test_apply_updates_as_individual_commits_raises_if_git_raises
@git_committer.stubs(:`).with("git --version").raises(Errno::ENOENT)

error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
assert_equal "git could not be executed", error.message
end

def test_apply_updates_as_individual_commits_raises_if_git_does_not_succeed
@git_committer.stubs(:`).with("git --version").returns("")
Process.stubs(:last_status).returns(stub(success?: false))

error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
assert_equal "git could not be executed", error.message
end

def test_apply_updates_as_individual_commits_raises_if_there_are_uncommitted_files
@git_committer.stubs(:`).with("git --version").returns("")
@git_committer.stubs(:`).with("git status --untracked-files=no --porcelain").returns("M Gemfile.lock")
Process.stubs(:last_status).returns(stub(success?: true))

error = assert_raises(Error) { @git_committer.apply_updates_as_individual_commits }
assert_equal <<~MESSAGE.strip, error.message
`git status` reports uncommitted changes; please commit or stash them them first!
M Gemfile.lock
MESSAGE
end
end
end
40 changes: 23 additions & 17 deletions test/integration/cli_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
module BundleUpdateInteractive
class CLIIntegrationIest < Minitest::Test
def test_updates_lock_file_based_on_selected_gem_while_honoring_gemfile_requirement
out, _gemfile, lockfile = run_bundle_update_interactive(
fixture: "integration",
argv: [],
key_presses: "j \n"
)
out, _gemfile, lockfile = within_fixture_copy("integration") do
run_bundle_update_interactive(argv: [], key_presses: "j \n")
end

assert_includes out, "Color legend:"

Expand Down Expand Up @@ -45,11 +43,9 @@ def test_updates_lock_file_based_on_selected_gem_while_honoring_gemfile_requirem
def test_updates_lock_file_and_gemfile_to_accommodate_latest_version_when_latest_option_is_specified
latest_minitest_version = fetch_latest_gem_version_from_rubygems_api("minitest")

out, gemfile, lockfile = run_bundle_update_interactive(
fixture: "integration",
argv: ["--latest"],
key_presses: "j \n"
)
out, gemfile, lockfile = within_fixture_copy("integration") do
run_bundle_update_interactive(argv: ["--latest"], key_presses: "j \n")
end

assert_includes out, "Color legend:"

Expand Down Expand Up @@ -84,9 +80,21 @@ def test_updates_lock_file_and_gemfile_to_accommodate_latest_version_when_latest
LOCK
end

def test_updates_each_selected_gem_with_a_git_commit
out, _gemfile, _lockfile = within_fixture_copy("integration") do
system "git init", out: File::NULL, exception: true
system "git add .", out: File::NULL, exception: true
system "git commit -m init", out: File::NULL, exception: true
run_bundle_update_interactive(argv: ["--commit"], key_presses: " j \n")
end

assert_match(/^\[(main|master) \h+\] Update bigdecimal 3\.1\.7 →/, out)
assert_match(/^\[(main|master) \h+\] Update minitest 5\.0\.0 →/, out)
end

private

def run_bundle_update_interactive(fixture:, argv:, key_presses: "\n")
def run_bundle_update_interactive(argv:, key_presses: "\n")
command = [
{ "GEM_HOME" => ENV.fetch("GEM_HOME", nil) },
Gem.ruby,
Expand All @@ -95,13 +103,11 @@ def run_bundle_update_interactive(fixture:, argv:, key_presses: "\n")
File.expand_path("../../exe/bundler-update-interactive", __dir__),
*argv
]
within_fixture_copy(fixture) do
Bundler.with_unbundled_env do
out, err, status = Open3.capture3(*command, stdin_data: key_presses)
raise "Command failed: #{[out, err].join}" unless status.success?
Bundler.with_unbundled_env do
out, err, status = Open3.capture3(*command, stdin_data: key_presses)
raise "Command failed: #{[out, err].join}" unless status.success?

[out, File.read("Gemfile"), File.read("Gemfile.lock")]
end
[out, File.read("Gemfile"), File.read("Gemfile.lock")]
end
end

Expand Down

0 comments on commit 035a979

Please sign in to comment.