# A rake task to update the bundled version of a gem

# Assumes constant SAFETY_FILE and function repository_type are defined in another rake task.

namespace :bundle do

desc <<~USAGE
  Update to a later version of a gem interactively.

  Usage: bundle:update gem=rails [version=6.0.4.7]

  Updates the bundled gem (e.g. rails) version to e.g. 6.0.4.7
  and provides instructions for committing changes.
  It will attempt to modify a hardcoded version in the Gemfile if necessary.

  If a secondary Gemfile is present in the same directory, e.g. Gemfile.monterey,
  and it defines constants such as BUNDLER_OVERRIDE_PUMA=true, then this task
  will attempt to update the secondary lock file, e.g. Gemfile.monterey.lock too.
USAGE
task(:update) do
  unless %w[git git-svn].include?(repository_type)
    warn 'Error: Requires a git working copy. Aborting.'
    exit 1
  end

  gem = ENV['gem']
  if gem.blank? || gem !~ /\A[a-zA-Z0-9_.-]+\z/
    warn "Error: missing or invalid required 'gem' parameter. Aborting.\n\n"
    system('rake -D bundle:update')
    exit 1
  end

  gem_list = Bundler.with_unbundled_env { `bundle exec gem list ^#{gem}$` }
  # Needs to match e.g. "nokogiri (1.12.5 x86_64-darwin)"
  old_gem_version = gem_list.match(/ \(([0-9.]+)( [a-z0-9_-]*)?\)$/).to_a[1]
  unless old_gem_version
    warn <<~MSG.chomp
      Cannot determine gem version for gem=#{gem}. Aborting. Output from bundle exec gem list:
      #{gem_list}
    MSG
    exit 1
  end
  puts "Old #{gem} version from bundle: #{old_gem_version}"

  new_gem_version = ENV['version'].presence
  if new_gem_version && new_gem_version !~ /\A[0-9.a-zA-Z-]+\z/
    warn "Error: invalid 'version' parameter. Aborting.\n\n"
    system('rake -D bundle:update')
    exit 1
  end

  unless Bundler.with_unbundled_env { system('bundle check 2> /dev/null') }
    warn('Error: bundle check fails before doing anything.')
    warn('Please clean up the Gemfile before running this. Aborting.')
    exit 1
  end

  if gem == 'rails'
    # If updating Rails and using activemodel-caution, prompt to put
    # activemodel-caution gem in place, unless it's already installed for this rails version.
    activemodel_caution = Bundler.
                          with_unbundled_env { `bundle exec gem list activemodel-caution` }.
                          match?(/^activemodel-caution \([0-9.]+\)$/)
    if activemodel_caution && new_gem_version
      file_pattern = "activemodel-caution-#{new_gem_version}*.gem"
      unless Dir.glob("vendor/cache/#{file_pattern}").any? ||
             Bundler.with_unbundled_env do
               `gem list ^activemodel-caution$ -i -v #{new_gem_version}`
             end.match?(/^true$/)
        warn("Error: missing #{file_pattern} file in vendor/cache")
        warn('Copy this file to vendor/cache, then run this command again.')
        exit 1
      end
    end
  end

  related_gems = if gem == 'rails'
                   gem_list2 = Bundler.with_unbundled_env do
                     `bundle exec gem list`
                   end
                   gem_list2.split("\n").
                     grep(/[ (]#{old_gem_version}(.0)*[,)]/).
                     collect { |row| row.split.first }
                 else
                   [gem]
                 end
  puts "Gems to update: #{related_gems.join(' ')}"

  if new_gem_version
    puts 'Tweaking Gemfile for new gem version'
    cmd = ['sed', '-i', '.bak', '-E']
    related_gems.each do |rgem|
      cmd += ['-e', "s/(gem '(#{rgem})', '(~> )?)#{old_gem_version}(')/\\1#{new_gem_version}\\4/"]
    end
    cmd += %w[Gemfile]
    system(*cmd)
    File.delete('Gemfile.bak')

    system('git diff Gemfile')
  end

  cmd = "bundle update --conservative --minor #{related_gems.join(' ')}"
  puts "Running: #{cmd}"
  Bundler.with_unbundled_env do
    system(cmd)
  end

  unless Bundler.with_unbundled_env { system('bundle check 2> /dev/null') }
    warn <<~MSG
      Error: bundle check fails after trying to update Rails version. Aborting.
      You will need to check your working copy, especially Gemfile, Gemfile.lock, vendor/cache
    MSG
    exit 1
  end

  gem_list = Bundler.with_unbundled_env { `bundle exec gem list ^#{gem}$` }
  new_gem_version2 = gem_list.match(/ \(([0-9.]+)( [a-z0-9_-]*)?\)$/).to_a[1]

  # Update secondary Gemfile.lock to keep vendored gems in sync
  secondary_gemfiles = `git ls-tree --name-only HEAD Gemfile.*`.split("\n").grep_v(/[.]lock$/)
  secondary_gemfiles.each do |secondary_gemfile|
    gem_re = /^BUNDLER_OVERRIDE_([^ =]*) *=/
    secondary_gems = File.readlines(secondary_gemfile).grep(gem_re).
                     collect { |s| gem_re.match(s)[1].downcase }
    if secondary_gems.empty?
      puts "Warning: cannot update #{secondary_gemfile}.lock - no BUNDLER_OVERRIDE_... entries"
      next
    end
    puts "Updating #{secondary_gemfile}.lock"
    FileUtils.cp('Gemfile.lock', "#{secondary_gemfile}.lock")
    Bundler.with_unbundled_env do
      system("BUNDLE_GEMFILE=#{secondary_gemfile} bundle update --quiet \
              --conservative --minor #{secondary_gems.join(' ')}")
    end
    system('git checkout -q vendor/cache/')
    system('git clean -q -f vendor/cache')
    Bundler.with_unbundled_env { system('bundle install --local --quiet 2> /dev/null') }
    puts "Finished updating #{secondary_gemfile}.lock"
  end

  # Retrieve binary gems for platforms listed in Gemfile.lock
  platforms = `bundle platform`.split("\n").grep(/^[*] x86_64-/).collect { |s| s[2..] }
  Dir.chdir('vendor/cache') do
    platforms.each do |platform|
      system("gem fetch #{gem} --version=#{new_gem_version2} --platform=#{platform}")
    end
  end if Dir.exist?('vendor/cache')

  if gem == 'webpacker'
    puts 'TODO: update package.json and yarn.lock with bin/rails webpacker:install'
    puts '      and git add / git remove files in vendor/npm-packages-offline-cache'
  end

  if File.exist?(SAFETY_FILE)
    # Remove references to unused files in code_safety.yml
    system('rake audit:tidy_code_safety_file')
  end

  if new_gem_version && new_gem_version != new_gem_version2
    puts <<~MSG
      Error: Tried to update gem #{gem} to version #{new_gem_version} but ended up at version #{new_gem_version2}. Aborting.
      You will need to check your working copy, especially Gemfile, Gemfile.lock, vendor/cache
      Try running:
         bundle exec rake bundle:update gem=#{gem} version=#{new_gem_version2}
    MSG
    exit 1
  end

  # At this point, we have successfully updated all the local files.
  # All that remains is to set up a branch, if necessary, and inform the user what to commit.

  puts "Looking for changed files using git status\n\n"
  files_to_git_rm = `git status vendor/cache/|grep 'deleted: ' | \
                     grep -o ': .*' | sed -e 's/^: *//'`.split("\n")
  secondary_lockfiles = secondary_gemfiles.collect { |s| "#{s}.lock" }
  files_to_git_add = `git status Gemfile Gemfile.lock #{secondary_gemfiles.join(' ')} \
                        #{secondary_lockfiles.join(' ')} code_safety.yml config/code_safety.yml| \
                      grep 'modified: ' | \
                      grep -o ': .*' | sed -e 's/^: *//'`.split("\n")
  files_to_git_add += `git status vendor/cache|expand|grep '^\s*vendor/cache' | \
                       sed -e 's/^ *//'`.split("\n")

  if files_to_git_rm.empty? && files_to_git_add.empty?
    puts <<~MSG
      No changes were made. Please manually update the Gemfile, run
        bundle update --conservative --minor #{related_gems.join(' ')}
    MSG
    puts '  rake audit:tidy_code_safety_file' if File.exist?(SAFETY_FILE)
    puts <<~MSG
      then run tests and git rm / git add any changes
      including vendor/cache Gemfile Gemfile.lock code_safety.yml
      then git commit
    MSG
    exit
  end

  if repository_type == 'git'
    # Check out a fresh branch, if a git working copy (but not git-svn)
    branch_name = "feature/#{gem}_#{new_gem_version2.gsub('.', '_')}"
    system('git', 'checkout', '-b', branch_name) # Create a new git branch
  end

  puts <<~MSG
    Gemfile updated. Please use "git status" and "git diff" to check the local changes,
    manually add any additional platform-specific gems required (e.g. for nokogiri),
    re-run tests locally, then run the following to commit the changes:

    $ ( git rm #{files_to_git_rm.join(' ')}
        git add #{files_to_git_add.join(' ')}
        git commit -m '# Bump #{gem} to #{new_gem_version2}'
      )
  MSG
end

end