Skip to content
Snippets Groups Projects
update-report.rb 15 KiB
Newer Older
# frozen_string_literal: true

require "formula_versions"
require "migrator"
require "formulary"
require "descriptions"
require "cleanup"
require "description_cache_store"
require "cli/parser"
  module_function

Rylan Polster's avatar
Rylan Polster committed
  def update_preinstall_header(args:)
    @update_preinstall_header ||= begin
      ohai "Auto-updated Homebrew!" if args.preinstall?
  def update_report_args
    Homebrew::CLI::Parser.new do
      usage_banner <<~EOS
        `update-report`

        The Ruby implementation of `brew update`. Never called manually.
      EOS
      switch "--preinstall",
             description: "Run in 'auto-update' mode (faster, less output)."
      switch "-f", "--force",
             description: "Treat installed and updated formulae as if they are from "\
                          "the same taps and migrate them anyway."
      hide_from_man_page!
    end
  end

  def update_report
    if !Utils::Analytics.messages_displayed? &&
       !Utils::Analytics.disabled? &&
       !Utils::Analytics.no_message_output?
      ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1"
      # Use the shell's audible bell.
      print "\a"
      # Use an extra newline and bold to avoid this being missed.
Dustin Rodrigues's avatar
Dustin Rodrigues committed
      ohai "Homebrew has enabled anonymous aggregate formula and cask analytics."
      puts <<~EOS
        #{Tty.bold}Read the analytics documentation (and how to opt-out) here:
          #{Formatter.url("https://docs.brew.sh/Analytics")}#{Tty.reset}
        No analytics have been recorded yet (or will be during this `brew` run).

      EOS

      # Consider the messages possibly missed if not a TTY.
      Utils::Analytics.messages_displayed! if $stdout.tty?
    end

    HOMEBREW_REPOSITORY.cd do
      donation_message_displayed =
        Utils.popen_read("git", "config", "--get", "homebrew.donationmessage").chomp == "true"
      unless donation_message_displayed
        ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:"
        puts "  #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n"

        # Consider the message possibly missed if not a TTY.
        safe_system "git", "config", "--replace-all", "homebrew.donationmessage", "true" if $stdout.tty?
Xu Cheng's avatar
Xu Cheng committed
    install_core_tap_if_necessary
    initial_revision = ENV["HOMEBREW_UPDATE_BEFORE"].to_s
    current_revision = ENV["HOMEBREW_UPDATE_AFTER"].to_s
    odie "update-report should not be called directly!" if initial_revision.empty? || current_revision.empty?
    if initial_revision != current_revision
Rylan Polster's avatar
Rylan Polster committed
      update_preinstall_header args: args
      puts "Updated Homebrew from #{shorten_revision(initial_revision)} to #{shorten_revision(current_revision)}."
      updated = true
    Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"]
    return if ENV["HOMEBREW_DISABLE_LOAD_FORMULA"]

    hub = ReporterHub.new

    updated_taps = []
    Tap.each do |tap|
      next unless tap.git?
Xu Cheng's avatar
Xu Cheng committed
      begin
        reporter = Reporter.new(tap)
      rescue Reporter::ReporterRevisionUnsetError => e
Mike McQuaid's avatar
Mike McQuaid committed
        onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer?
Xu Cheng's avatar
Xu Cheng committed
        next
      end
      if reporter.updated?
        updated_taps << tap.name
        hub.add(reporter, preinstall: args.preinstall?)
    unless updated_taps.empty?
Rylan Polster's avatar
Rylan Polster committed
      update_preinstall_header args: args
      puts "Updated #{updated_taps.count} #{"tap".pluralize(updated_taps.count)} (#{updated_taps.to_sentence})."
      puts "Already up-to-date." if !args.preinstall? && !ENV["HOMEBREW_UPDATE_FAILED"]
Xu Cheng's avatar
Xu Cheng committed
    else
      if hub.empty?
        puts "No changes to formulae."
      else
        hub.dump(updated_formula_report: !args.preinstall?)
        hub.reporters.each(&:migrate_tap_migration)
        hub.reporters.each { |r| r.migrate_formula_rename(force: args.force?, verbose: args.verbose?) }
        CacheStoreDatabase.use(:descriptions) do |db|
          DescriptionCacheStore.new(db)
                               .update_from_report!(hub)
        end
      puts if args.preinstall?
    Commands.rebuild_commands_completion_list
    link_completions_manpages_and_docs
    Tap.each(&:link_completions_and_manpages)
  end

  def shorten_revision(revision)
Xu Cheng's avatar
Xu Cheng committed
    Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp
Xu Cheng's avatar
Xu Cheng committed

  def install_core_tap_if_necessary
    return if ENV["HOMEBREW_UPDATE_TEST"]
Xu Cheng's avatar
Xu Cheng committed
    core_tap = CoreTap.instance
    return if core_tap.installed?
    CoreTap.ensure_installed!
Xu Cheng's avatar
Xu Cheng committed
    revision = core_tap.git_head
    ENV["HOMEBREW_UPDATE_BEFORE_HOMEBREW_HOMEBREW_CORE"] = revision
    ENV["HOMEBREW_UPDATE_AFTER_HOMEBREW_HOMEBREW_CORE"] = revision
  end
  def link_completions_manpages_and_docs(repository = HOMEBREW_REPOSITORY)
    command = "brew update"
    Utils::Link.link_completions(repository, command)
    Utils::Link.link_manpages(repository, command)
    Utils::Link.link_docs(repository, command)
    ofail <<~EOS
      Failed to link all completions, docs and manpages:
        #{e}
    EOS
  class ReporterRevisionUnsetError < RuntimeError
    def initialize(var_name)
      super "#{var_name} is unset!"
Xu Cheng's avatar
Xu Cheng committed
    end
  attr_reader :tap, :initial_revision, :current_revision

  def initialize(tap)
    @tap = tap
    initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{tap.repo_var}"
    @initial_revision = ENV[initial_revision_var].to_s
    raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty?
    current_revision_var = "HOMEBREW_UPDATE_AFTER#{tap.repo_var}"
    @current_revision = ENV[current_revision_var].to_s
    raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty?
  def report(preinstall: false)
    return @report if @report

    @report = Hash.new { |h, k| h[k] = [] }
    return @report unless updated?

    diff.each_line do |line|
      status, *paths = line.split
      src = Pathname.new paths.first
      dst = Pathname.new paths.last

      next unless dst.extname == ".rb"

      if paths.any? { |p| tap.cask_file?(p) }
Jonathan Chang's avatar
Jonathan Chang committed
        case status
        when "A"
          # Have a dedicated report array for new casks.
          @report[:AC] << tap.formula_file_to_name(src)
Jonathan Chang's avatar
Jonathan Chang committed
        when "D"
          # Have a dedicated report array for deleted casks.
          @report[:DC] << tap.formula_file_to_name(src)
Jonathan Chang's avatar
Jonathan Chang committed
        when "M"
          # Report updated casks
          @report[:MC] << tap.formula_file_to_name(src)
        end
      end

      next unless paths.any? { |p| tap.formula_file?(p) }

      case status
      when "A", "D"
        full_name = tap.formula_file_to_name(src)
        name = full_name.split("/").last
        new_tap = tap.tap_migrations[name]
        @report[status.to_sym] << full_name unless new_tap
        name = tap.formula_file_to_name(src)

        # Skip reporting updated formulae to speed up automatic updates.
          @report[:M] << name
          next
        end

        begin
          formula = Formulary.factory(tap.path/src)
          new_version = formula.pkg_version
          old_version = FormulaVersions.new(formula).formula_at_revision(@initial_revision, &:pkg_version)
          next if new_version == old_version
        rescue FormulaUnavailableError
          # Don't care if the formula isn't available right now.
          nil
        rescue Exception => e # rubocop:disable Lint/RescueException
Mike McQuaid's avatar
Mike McQuaid committed
          onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer?

        @report[:M] << name
      when /^R\d{0,3}/
        src_full_name = tap.formula_file_to_name(src)
        dst_full_name = tap.formula_file_to_name(dst)
        # Don't report formulae that are moved within a tap but not renamed
        next if src_full_name == dst_full_name
        @report[:D] << src_full_name
        @report[:A] << dst_full_name
    renamed_formulae = Set.new
    @report[:D].each do |old_full_name|
      old_name = old_full_name.split("/").last
      new_name = tap.formula_renames[old_name]
      next unless new_name

Mike McQuaid's avatar
Mike McQuaid committed
      new_full_name = if tap.core_tap?
        new_name
Mike McQuaid's avatar
Mike McQuaid committed
        "#{tap}/#{new_name}"

      renamed_formulae << [old_full_name, new_full_name] if @report[:A].include? new_full_name
    end

    @report[:A].each do |new_full_name|
      new_name = new_full_name.split("/").last
      old_name = tap.formula_renames.key(new_name)
      next unless old_name

Mike McQuaid's avatar
Mike McQuaid committed
      old_full_name = if tap.core_tap?
        old_name
Mike McQuaid's avatar
Mike McQuaid committed
        "#{tap}/#{old_name}"
      end

      renamed_formulae << [old_full_name, new_full_name]
    end

    unless renamed_formulae.empty?
      @report[:A] -= renamed_formulae.map(&:last)
      @report[:D] -= renamed_formulae.map(&:first)
      @report[:R] = renamed_formulae.to_a
    initial_revision != current_revision
  def migrate_tap_migration
    (report[:D] + report[:DC]).each do |full_name|
      name = full_name.split("/").last
      new_tap_name = tap.tap_migrations[name]
      next if new_tap_name.nil? # skip if not in tap_migrations list.

      new_tap_user, new_tap_repo, new_tap_new_name = new_tap_name.split("/")
      new_name = if new_tap_new_name
        new_full_name = new_tap_new_name
        new_tap_name = "#{new_tap_user}/#{new_tap_repo}"
        new_tap_new_name
      else
        new_full_name = "#{new_tap_name}/#{name}"
        name
      end

      # This means it is a Cask
      if report[:DC].include? full_name
        next unless (HOMEBREW_PREFIX/"Caskroom"/new_name).exist?
        new_tap = Tap.fetch(new_tap_name)
        new_tap.install unless new_tap.installed?
        ohai "#{name} has been moved to Homebrew.", <<~EOS
          To uninstall the cask run:
            brew cask uninstall --force #{name}
        EOS
        next if (HOMEBREW_CELLAR/new_name.split("/").last).directory?
        ohai "Installing #{new_name}..."
        system HOMEBREW_BREW_FILE, "install", new_full_name
        begin
          unless Formulary.factory(new_full_name).keg_only?
            system HOMEBREW_BREW_FILE, "link", new_full_name, "--overwrite"
        rescue Exception => e # rubocop:disable Lint/RescueException
Mike McQuaid's avatar
Mike McQuaid committed
          onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer?
      next unless (dir = HOMEBREW_CELLAR/name).exist? # skip if formula is not installed.
      tabs = dir.subdirs.map { |d| Tab.for_keg(Keg.new(d)) }
      next unless tabs.first.tap == tap # skip if installed formula is not from this tap.
      new_tap = Tap.fetch(new_tap_name)
      # For formulae migrated to cask: Auto-install cask or provide install instructions.
      if new_tap_name.start_with?("homebrew/cask")
        if new_tap.installed? && (HOMEBREW_PREFIX/"Caskroom").directory?
          ohai "#{name} has been moved to Homebrew Cask."
          ohai "brew unlink #{name}"
          system HOMEBREW_BREW_FILE, "unlink", name
          ohai "brew cleanup"
          system HOMEBREW_BREW_FILE, "cleanup"
          ohai "brew cask install #{new_name}"
          system HOMEBREW_BREW_FILE, "cask", "install", new_name
          ohai <<~EOS
            #{name} has been moved to Homebrew Cask.
            The existing keg has been unlinked.
            Please uninstall the formula when convenient by running:
              brew uninstall --force #{name}
          EOS
          ohai "#{name} has been moved to Homebrew Cask.", <<~EOS
            To uninstall the formula and install the cask run:
              brew uninstall --force #{name}
              brew tap #{new_tap_name}
              brew cask install #{new_name}
          EOS
        end
      else
        new_tap.install unless new_tap.installed?
        # update tap for each Tab
        tabs.each { |tab| tab.tap = new_tap }
        tabs.each(&:write)
      end
  def migrate_formula_rename(force:, verbose:)
    Formula.installed.each do |formula|
      next unless Migrator.needs_migration?(formula)
      oldname = formula.oldname
      oldname_rack = HOMEBREW_CELLAR/oldname

      if oldname_rack.subdirs.empty?
        oldname_rack.rmdir_if_possible
        next
      end

      new_name = tap.formula_renames[oldname]
      next unless new_name

      new_full_name = "#{tap}/#{new_name}"

      begin
        f = Formulary.factory(new_full_name)
      rescue Exception => e # rubocop:disable Lint/RescueException
Mike McQuaid's avatar
Mike McQuaid committed
        onoe "#{e.message}\n#{e.backtrace.join "\n"}" if Homebrew::EnvConfig.developer?
      Migrator.migrate_if_needed(f, force: force)
  def diff
    Utils.popen_read(
      "git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR",
      "-M85%", initial_revision, current_revision
    )
  end
end

class ReporterHub
  attr_reader :reporters

  def initialize
    @hash = {}
    @reporters = []
  def select_formula(key)
    @hash.fetch(key, [])
  def add(reporter, preinstall: false)
    @reporters << reporter
    report = reporter.report(preinstall: preinstall).delete_if { |_k, v| v.empty? }
    @hash.update(report) { |_key, oldval, newval| oldval.concat(newval) }
Markus Reiter's avatar
Markus Reiter committed
  delegate empty?: :@hash
  def dump(updated_formula_report: true)
    # Key Legend: Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R)

    dump_formula_report :A, "New Formulae"
    if updated_formula_report
      dump_formula_report :M, "Updated Formulae"
    else
      updated = select_formula(:M).count
      if updated.positive?
        ohai "Updated Formulae"
        puts "Updated #{updated} #{"formula".pluralize(updated)}."
      end
    end
    dump_formula_report :R, "Renamed Formulae"
    dump_formula_report :D, "Deleted Formulae"
    dump_formula_report :AC, "New Casks"
    dump_formula_report :MC, "Updated Casks"
    dump_formula_report :DC, "Deleted Casks"

  def dump_formula_report(key, title)
    only_installed = Homebrew::EnvConfig.update_report_only_installed?

    formulae = select_formula(key).sort.map do |name, new_name|
      # Format list items of renamed formulae
        name = pretty_installed(name) if installed?(name)
        new_name = pretty_installed(new_name) if installed?(new_name)
        "#{name} -> #{new_name}" unless only_installed
        name if !installed?(name) && !only_installed
        name.split("/").last if !cask_installed?(name) && !only_installed
      when :MC, :DC
        name = name.split("/").last
        if cask_installed?(name)
          pretty_installed(name)
        elsif !only_installed
          name
        end
        if installed?(name)
          pretty_installed(name)
        elsif !only_installed
          name
        end
Markus Reiter's avatar
Markus Reiter committed
    return if formulae.empty?
Markus Reiter's avatar
Markus Reiter committed
    # Dump formula list.
    ohai title
    puts Formatter.columns(formulae.sort)
  end

  def installed?(formula)
    (HOMEBREW_CELLAR/formula.split("/").last).directory?
  end

  def cask_installed?(cask)
    (Cask::Caskroom.path/cask).directory?
  end