# 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.
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

  if File.exist?(SAFETY_FILE)
    # Remove references to unused files in code_safety.yml
    system('rake audit:tidy_code_safety_file')
  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]

  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")
  files_to_git_add = `git status Gemfile Gemfile.lock 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 = "#{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