brew/Library/Homebrew/cmd/update-report.rb
Mike McQuaid 28753ef002
cmd/update-report: display descriptions for new formulae and casks.
This parses Homebrew's API JSON data to display descriptions for new
formulae and casks if available.

While we're here also add tests for ReporterHub.
2025-06-24 16:59:24 +01:00

993 lines
33 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "abstract_command"
require "migrator"
require "formulary"
require "cask/cask_loader"
require "cask/migrator"
require "descriptions"
require "cleanup"
require "description_cache_store"
require "settings"
require "linuxbrew-core-migration"
module Homebrew
module Cmd
class UpdateReport < AbstractCommand
cmd_args do
description <<~EOS
The Ruby implementation of `brew update`. Never called manually.
EOS
switch "--auto-update", "--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
sig { override.void }
def run
return output_update_report if $stdout.tty?
redirect_stdout($stderr) do
output_update_report
end
end
private
sig { void }
def auto_update_header
@auto_update_header ||= T.let(begin
ohai "Auto-updated Homebrew!" if args.auto_update?
true
end, T.nilable(T::Boolean))
end
sig { void }
def output_update_report
# Run `brew update` (again) if we've got a linuxbrew-core CoreTap
if CoreTap.instance.installed? && CoreTap.instance.linuxbrew_core? &&
ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"].blank?
ohai "Re-running `brew update` for linuxbrew-core migration"
if Homebrew::EnvConfig.core_git_remote != HOMEBREW_CORE_DEFAULT_GIT_REMOTE
opoo <<~EOS
HOMEBREW_CORE_GIT_REMOTE was set: #{Homebrew::EnvConfig.core_git_remote}.
It has been unset for the migration.
You may need to change this from a linuxbrew-core mirror to a homebrew-core one.
EOS
end
ENV.delete("HOMEBREW_CORE_GIT_REMOTE")
if Homebrew::EnvConfig.bottle_domain != HOMEBREW_BOTTLE_DEFAULT_DOMAIN
opoo <<~EOS
HOMEBREW_BOTTLE_DOMAIN was set: #{Homebrew::EnvConfig.bottle_domain}.
It has been unset for the migration.
You may need to change this from a Linuxbrew package mirror to a Homebrew one.
EOS
end
ENV.delete("HOMEBREW_BOTTLE_DOMAIN")
ENV["HOMEBREW_LINUXBREW_CORE_MIGRATION"] = "1"
FileUtils.rm_f HOMEBREW_LOCKS/"update"
update_args = []
update_args << "--auto-update" if args.auto_update?
update_args << "--force" if args.force?
exec HOMEBREW_BREW_FILE, "update", *update_args
end
if ENV["HOMEBREW_ADDITIONAL_GOOGLE_ANALYTICS_ID"].present?
opoo "HOMEBREW_ADDITIONAL_GOOGLE_ANALYTICS_ID is now a no-op so can be unset."
puts "All Homebrew Google Analytics code and data was destroyed."
end
if ENV["HOMEBREW_NO_GOOGLE_ANALYTICS"].present?
opoo "HOMEBREW_NO_GOOGLE_ANALYTICS is now a no-op so can be unset."
puts "All Homebrew Google Analytics code and data was destroyed."
end
unless args.quiet?
analytics_message
donation_message
install_from_api_message
end
tap_or_untap_core_taps_if_necessary
updated = false
new_tag = nil
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
auto_update_header
updated = true
old_tag = Settings.read "latesttag"
new_tag = Utils.popen_read(
"git", "-C", HOMEBREW_REPOSITORY, "tag", "--list", "--sort=-version:refname", "*.*"
).lines.first.chomp
Settings.write "latesttag", new_tag if new_tag != old_tag
if new_tag == old_tag
ohai "Updated Homebrew from #{shorten_revision(initial_revision)} " \
"to #{shorten_revision(current_revision)}."
elsif old_tag.blank?
ohai "Updated Homebrew from #{shorten_revision(initial_revision)} " \
"to #{new_tag} (#{shorten_revision(current_revision)})."
else
ohai "Updated Homebrew from #{old_tag} (#{shorten_revision(initial_revision)}) " \
"to #{new_tag} (#{shorten_revision(current_revision)})."
end
end
# Check if we can parse the JSON and do any Ruby-side follow-up.
unless Homebrew::EnvConfig.no_install_from_api?
Homebrew::API::Formula.write_names_and_aliases
Homebrew::API::Cask.write_names
end
Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"]
return if Homebrew::EnvConfig.disable_load_formula?
migrate_gcc_dependents_if_needed
hub = ReporterHub.new
updated_taps = []
Tap.installed.each do |tap|
next if !tap.git? || tap.git_repository.origin_url.nil?
next if (tap.core_tap? || tap.core_cask_tap?) && !Homebrew::EnvConfig.no_install_from_api?
if ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"].present? && tap.core_tap? &&
Settings.read("linuxbrewmigrated") != "true"
ohai "Migrating formulae from linuxbrew-core to homebrew-core"
LINUXBREW_CORE_MIGRATION_LIST.each do |name|
begin
formula = Formula[name]
rescue FormulaUnavailableError
next
end
next unless formula.any_version_installed?
keg = formula.installed_kegs.fetch(-1)
tab = keg.tab
# force a `brew upgrade` from the linuxbrew-core version to the homebrew-core version (even if lower)
tab.source["versions"]["version_scheme"] = -1
tab.write
end
Settings.write "linuxbrewmigrated", true
end
begin
reporter = Reporter.new(tap)
rescue Reporter::ReporterRevisionUnsetError => e
if Homebrew::EnvConfig.developer?
require "utils/backtrace"
onoe "#{e.message}\n#{Utils::Backtrace.clean(e)&.join("\n")}"
end
next
end
if reporter.updated?
updated_taps << tap.name
hub.add(reporter, auto_update: args.auto_update?)
end
end
# If we're installing from the API: we cannot use Git to check for #
# differences in packages so instead use {formula,cask}_names.txt to do so.
# The first time this runs: we won't yet have a base state
# ({formula,cask}_names.before.txt) to compare against so we don't output a
# anything and just copy the files for next time.
unless Homebrew::EnvConfig.no_install_from_api?
api_cache = Homebrew::API::HOMEBREW_CACHE_API
core_tap = CoreTap.instance
cask_tap = CoreCaskTap.instance
[
[:formula, core_tap, core_tap.formula_dir],
[:cask, cask_tap, cask_tap.cask_dir],
].each do |type, tap, dir|
names_txt = api_cache/"#{type}_names.txt"
next unless names_txt.exist?
names_before_txt = api_cache/"#{type}_names.before.txt"
if names_before_txt.exist?
reporter = Reporter.new(
tap,
api_names_txt: names_txt,
api_names_before_txt: names_before_txt,
api_dir_prefix: dir,
)
if reporter.updated?
updated_taps << tap.name
hub.add(reporter, auto_update: args.auto_update?)
end
else
FileUtils.cp names_txt, names_before_txt
end
end
end
unless updated_taps.empty?
auto_update_header
puts "Updated #{Utils.pluralize("tap", updated_taps.count,
include_count: true)} (#{updated_taps.to_sentence})."
updated = true
end
if updated
if hub.empty?
puts no_changes_message unless args.quiet?
else
if ENV.fetch("HOMEBREW_UPDATE_REPORT_ONLY_INSTALLED", false)
opoo "HOMEBREW_UPDATE_REPORT_ONLY_INSTALLED is now the default behaviour, " \
"so you can unset it from your environment."
end
hub.dump(auto_update: args.auto_update?) unless args.quiet?
hub.reporters.each(&:migrate_tap_migration)
hub.reporters.each(&:migrate_cask_rename)
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
CacheStoreDatabase.use(:cask_descriptions) do |db|
CaskDescriptionCacheStore.new(db)
.update_from_report!(hub)
end
end
puts if args.auto_update?
elsif !args.auto_update? && !ENV["HOMEBREW_UPDATE_FAILED"] && !ENV["HOMEBREW_MIGRATE_LINUXBREW_FORMULAE"]
puts "Already up-to-date." unless args.quiet?
end
Commands.rebuild_commands_completion_list
link_completions_manpages_and_docs
Tap.installed.each(&:link_completions_and_manpages)
failed_fetch_dirs = ENV["HOMEBREW_MISSING_REMOTE_REF_DIRS"]&.split("\n")
if failed_fetch_dirs.present?
failed_fetch_taps = failed_fetch_dirs.map { |dir| Tap.from_path(dir) }
ofail <<~EOS
Some taps failed to update!
The following taps can not read their remote branches:
#{failed_fetch_taps.join("\n ")}
This is happening because the remote branch was renamed or deleted.
Reset taps to point to the correct remote branches by running `brew tap --repair`
EOS
end
return if new_tag.blank? || new_tag == old_tag || args.quiet?
puts
new_major_version, new_minor_version, new_patch_version = new_tag.split(".").map(&:to_i)
old_major_version, old_minor_version = old_tag.split(".")[0, 2].map(&:to_i) if old_tag.present?
if old_tag.blank? || new_major_version > old_major_version || new_minor_version > old_minor_version
puts <<~EOS
The #{new_major_version}.#{new_minor_version}.0 release notes are available on the Homebrew Blog:
#{Formatter.url("https://brew.sh/blog/#{new_major_version}.#{new_minor_version}.0")}
EOS
end
return if new_patch_version.zero?
puts <<~EOS
The #{new_tag} changelog can be found at:
#{Formatter.url("https://github.com/Homebrew/brew/releases/tag/#{new_tag}")}
EOS
end
sig { returns(String) }
def no_changes_message
"No changes to formulae or casks."
end
sig { params(revision: String).returns(String) }
def shorten_revision(revision)
Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp
end
sig { void }
def tap_or_untap_core_taps_if_necessary
return if ENV["HOMEBREW_UPDATE_TEST"]
if Homebrew::EnvConfig.no_install_from_api?
return if Homebrew::EnvConfig.automatically_set_no_install_from_api?
core_tap = CoreTap.instance
return if core_tap.installed?
core_tap.ensure_installed!
revision = CoreTap.instance.git_head
ENV["HOMEBREW_UPDATE_BEFORE_HOMEBREW_HOMEBREW_CORE"] = revision
ENV["HOMEBREW_UPDATE_AFTER_HOMEBREW_HOMEBREW_CORE"] = revision
else
return if Homebrew::EnvConfig.developer? || ENV["HOMEBREW_DEV_CMD_RUN"]
return if ENV["HOMEBREW_GITHUB_HOSTED_RUNNER"] || ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"]
return if (HOMEBREW_PREFIX/".homebrewdocker").exist?
tap_output_header_printed = T.let(false, T::Boolean)
default_branches = %w[main master].freeze
[CoreTap.instance, CoreCaskTap.instance].each do |tap|
next unless tap.installed?
if default_branches.include?(tap.git_branch) &&
(Date.parse(T.must(tap.git_repository.last_commit_date)) <= Date.today.prev_month)
ohai "#{tap.name} is old and unneeded, untapping to save space..."
tap.uninstall
else
unless tap_output_header_printed
puts "Installing from the API is now the default behaviour!"
puts "You can save space and time by running:"
tap_output_header_printed = true
end
puts " brew untap #{tap.name}"
end
end
end
end
sig { params(repository: Pathname).void }
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)
rescue => e
ofail <<~EOS
Failed to link all completions, docs and manpages:
#{e}
EOS
end
sig { void }
def migrate_gcc_dependents_if_needed
# do nothing
end
sig { void }
def analytics_message
return if Utils::Analytics.messages_displayed?
return if Utils::Analytics.no_message_output?
if Utils::Analytics.disabled? && !Utils::Analytics.influx_message_displayed?
ohai "Homebrew's analytics have entirely moved to our InfluxDB instance in the EU."
puts "We gather less data than before and have destroyed all Google Analytics data:"
puts " #{Formatter.url("https://docs.brew.sh/Analytics")}#{Tty.reset}"
puts "Please reconsider re-enabling analytics to help our volunteer maintainers with:"
puts " brew analytics on"
elsif !Utils::Analytics.disabled?
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.
ohai "Homebrew collects anonymous 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 (nor will be during this `brew` run).
EOS
end
# Consider the messages possibly missed if not a TTY.
Utils::Analytics.messages_displayed! if $stdout.tty?
end
sig { void }
def donation_message
return if Settings.read("donationmessage") == "true"
ohai "Homebrew is run entirely by unpaid volunteers. Please consider donating:"
puts " #{Formatter.url("https://github.com/Homebrew/brew#donations")}\n\n"
# Consider the message possibly missed if not a TTY.
Settings.write "donationmessage", true if $stdout.tty?
end
sig { void }
def install_from_api_message
return if Settings.read("installfromapimessage") == "true"
no_install_from_api_set = Homebrew::EnvConfig.no_install_from_api? &&
!Homebrew::EnvConfig.automatically_set_no_install_from_api?
return unless no_install_from_api_set
ohai "You have HOMEBREW_NO_INSTALL_FROM_API set"
puts "Homebrew >=4.1.0 is dramatically faster and less error-prone when installing"
puts "from the JSON API. Please consider unsetting HOMEBREW_NO_INSTALL_FROM_API."
puts "This message will only be printed once."
puts "\n\n"
# Consider the message possibly missed if not a TTY.
Settings.write "installfromapimessage", true if $stdout.tty?
end
end
end
end
require "extend/os/cmd/update-report"
class Reporter
class ReporterRevisionUnsetError < RuntimeError
sig { params(var_name: String).void }
def initialize(var_name)
super "#{var_name} is unset!"
end
end
sig {
params(tap: Tap, api_names_txt: T.nilable(Pathname), api_names_before_txt: T.nilable(Pathname),
api_dir_prefix: T.nilable(Pathname)).void
}
def initialize(tap, api_names_txt: nil, api_names_before_txt: nil, api_dir_prefix: nil)
@tap = tap
# This is slightly involved/weird but all the #report logic is shared so it's worth it.
if installed_from_api?(api_names_txt, api_names_before_txt, api_dir_prefix)
@api_names_txt = T.let(api_names_txt, T.nilable(Pathname))
@api_names_before_txt = T.let(api_names_before_txt, T.nilable(Pathname))
@api_dir_prefix = T.let(api_dir_prefix, T.nilable(Pathname))
else
initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{tap.repository_var_suffix}"
@initial_revision = T.let(ENV[initial_revision_var].to_s, String)
raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty?
current_revision_var = "HOMEBREW_UPDATE_AFTER#{tap.repository_var_suffix}"
@current_revision = T.let(ENV[current_revision_var].to_s, String)
raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty?
end
@report = T.let(nil, T.nilable(T::Hash[Symbol, T::Array[String]]))
end
sig { params(auto_update: T::Boolean).returns(T::Hash[Symbol, T::Array[String]]) }
def report(auto_update: 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 if dst.extname != ".rb"
if paths.any? { |p| tap.cask_file?(p) }
case status
when "A"
# Have a dedicated report array for new casks.
@report[:AC] << tap.formula_file_to_name(src)
when "D"
# Have a dedicated report array for deleted casks.
@report[:DC] << tap.formula_file_to_name(src)
when "M"
# Report updated casks
@report[:MC] << tap.formula_file_to_name(src)
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[:DC] << src_full_name
@report[:AC] << dst_full_name
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 = T.must(full_name.split("/").last)
new_tap = tap.tap_migrations[name]
@report[T.must(status).to_sym] << full_name unless new_tap
when "M"
name = tap.formula_file_to_name(src)
@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
end
end
renamed_casks = Set.new
@report[:DC].each do |old_full_name|
old_name = old_full_name.split("/").last
new_name = tap.cask_renames[old_name]
next unless new_name
new_full_name = if tap.core_cask_tap?
new_name
else
"#{tap}/#{new_name}"
end
renamed_casks << [old_full_name, new_full_name] if @report[:AC].include?(new_full_name)
end
@report[:AC].each do |new_full_name|
new_name = new_full_name.split("/").last
old_name = tap.cask_renames.key(new_name)
next unless old_name
old_full_name = if tap.core_cask_tap?
old_name
else
"#{tap}/#{old_name}"
end
renamed_casks << [old_full_name, new_full_name]
end
if renamed_casks.any?
@report[:AC] -= renamed_casks.map(&:last)
@report[:DC] -= renamed_casks.map(&:first)
@report[:RC] = renamed_casks.to_a
end
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
new_full_name = if tap.core_tap?
new_name
else
"#{tap}/#{new_name}"
end
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
old_full_name = if tap.core_tap?
old_name
else
"#{tap}/#{old_name}"
end
renamed_formulae << [old_full_name, new_full_name]
end
if renamed_formulae.any?
@report[:A] -= renamed_formulae.map(&:last)
@report[:D] -= renamed_formulae.map(&:first)
@report[:R] = renamed_formulae.to_a
end
# If any formulae/casks are marked as added and deleted, remove them from
# the report as we've not detected things correctly.
if (added_and_deleted_formulae = (@report[:A] & @report[:D]).presence)
@report[:A] -= added_and_deleted_formulae
@report[:D] -= added_and_deleted_formulae
end
if (added_and_deleted_casks = (@report[:AC] & @report[:DC]).presence)
@report[:AC] -= added_and_deleted_casks
@report[:DC] -= added_and_deleted_casks
end
@report
end
sig { returns(T::Boolean) }
def updated?
if installed_from_api?
diff.present?
else
initial_revision != current_revision
end
end
sig { void }
def migrate_tap_migration
(Array(report[:D]) + Array(report[:DC])).each do |full_name|
name = T.must(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 Array(report[:DC]).include? full_name
next unless (HOMEBREW_PREFIX/"Caskroom"/new_name).exist?
new_tap = Tap.fetch(new_tap_name)
new_tap.ensure_installed!
ohai "#{name} has been moved to Homebrew.", <<~EOS
To uninstall the cask, run:
brew uninstall --cask --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"
end
# Rescue any possible exception types.
rescue Exception => e # rubocop:disable Lint/RescueException
if Homebrew::EnvConfig.developer?
require "utils/backtrace"
onoe "#{e.message}\n#{Utils::Backtrace.clean(e)&.join("\n")}"
end
end
next
end
next unless (dir = HOMEBREW_CELLAR/name).exist? # skip if formula is not installed.
tabs = dir.subdirs.map { |d| Keg.new(d).tab }
next if 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 install --cask #{new_name}"
system HOMEBREW_BREW_FILE, "install", "--cask", 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
else
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 install --cask #{new_name}
EOS
end
else
new_tap.ensure_installed!
# update tap for each Tab
tabs.each { |tab| tab.tap = new_tap }
tabs.each(&:write)
end
end
end
sig { void }
def migrate_cask_rename
Cask::Caskroom.casks.each do |cask|
Cask::Migrator.migrate_if_needed(cask)
end
end
sig { params(force: T::Boolean, verbose: T::Boolean).void }
def migrate_formula_rename(force:, verbose:)
Formula.installed.each do |formula|
next unless Migrator.needs_migration?(formula)
oldnames_to_migrate = formula.oldnames.select do |oldname|
oldname_rack = HOMEBREW_CELLAR/oldname
next false unless oldname_rack.exist?
if oldname_rack.subdirs.empty?
oldname_rack.rmdir_if_possible
next false
end
true
end
next if oldnames_to_migrate.empty?
Migrator.migrate_if_needed(formula, force:)
end
end
private
sig { returns(Tap) }
attr_reader :tap
sig { returns(String) }
attr_reader :initial_revision
sig { returns(String) }
attr_reader :current_revision
sig { returns(T.nilable(Pathname)) }
attr_reader :api_names_txt
sig { returns(T.nilable(Pathname)) }
attr_reader :api_names_before_txt
sig { returns(T.nilable(Pathname)) }
attr_reader :api_dir_prefix
sig {
params(api_names_txt: T.nilable(Pathname), api_names_before_txt: T.nilable(Pathname),
api_dir_prefix: T.nilable(Pathname)).returns(T::Boolean)
}
def installed_from_api?(api_names_txt = @api_names_txt, api_names_before_txt = @api_names_before_txt,
api_dir_prefix = @api_dir_prefix)
!api_names_txt.nil? && !api_names_before_txt.nil? && !api_dir_prefix.nil?
end
sig { returns(String) }
def diff
@diff ||= T.let(nil, T.nilable(String))
@diff ||= if installed_from_api?
# Hack `git diff` output with regexes to look like `git diff-tree` output.
# Yes, I know this is a bit filthy but it saves duplicating the #report logic.
diff_output = Utils.popen_read("git", "diff", "--no-ext-diff", api_names_before_txt, api_names_txt)
header_regex = /^(---|\+\+\+) /
add_delete_characters = ["+", "-"].freeze
api_dir_prefix_basename = T.must(api_dir_prefix).basename
diff_output.lines.filter_map do |line|
next if line.match?(header_regex)
next unless add_delete_characters.include?(line[0])
line.sub(/^\+/, "A #{api_dir_prefix_basename}/")
.sub(/^-/, "D #{api_dir_prefix_basename}/")
.sub(/$/, ".rb")
.chomp
end.join("\n")
else
Utils.popen_read(
"git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR",
"-M85%", initial_revision, current_revision
)
end
end
end
class ReporterHub
sig { returns(T::Array[Reporter]) }
attr_reader :reporters
sig { void }
def initialize
@hash = T.let({}, T::Hash[Symbol, T::Array[String]])
@reporters = T.let([], T::Array[Reporter])
end
sig { params(key: Symbol).returns(T::Array[String]) }
def select_formula_or_cask(key)
@hash.fetch(key, [])
end
sig { params(reporter: Reporter, auto_update: T::Boolean).void }
def add(reporter, auto_update: false)
@reporters << reporter
report = reporter.report(auto_update:).delete_if { |_k, v| v.empty? }
@hash.update(report) { |_key, oldval, newval| oldval.concat(newval) }
end
sig { returns(T::Boolean) }
def empty?
@hash.empty?
end
sig { params(auto_update: T::Boolean).void }
def dump(auto_update: false)
unless Homebrew::EnvConfig.no_update_report_new?
dump_new_formula_report
dump_new_cask_report
end
dump_deleted_formula_report
dump_deleted_cask_report
outdated_formulae = Formula.installed.select(&:outdated?).map(&:name)
outdated_casks = Cask::Caskroom.casks.select(&:outdated?).map(&:token)
unless auto_update
output_dump_formula_or_cask_report "Outdated Formulae", outdated_formulae
output_dump_formula_or_cask_report "Outdated Casks", outdated_casks
end
return if outdated_formulae.blank? && outdated_casks.blank?
outdated_formulae = outdated_formulae.count
outdated_casks = outdated_casks.count
update_pronoun = if (outdated_formulae + outdated_casks) == 1
"it"
else
"them"
end
msg = ""
if outdated_formulae.positive?
noun = Utils.pluralize("formula", outdated_formulae, plural: "e")
msg += "#{Tty.bold}#{outdated_formulae}#{Tty.reset} outdated #{noun}"
end
if outdated_casks.positive?
msg += " and " if msg.present?
msg += "#{Tty.bold}#{outdated_casks}#{Tty.reset} outdated #{Utils.pluralize("cask", outdated_casks)}"
end
return if msg.blank?
puts
puts "You have #{msg} installed."
# If we're auto-updating, don't need to suggest commands that we're perhaps
# already running.
return if auto_update
puts <<~EOS
You can upgrade #{update_pronoun} with #{Tty.bold}brew upgrade#{Tty.reset}
or list #{update_pronoun} with #{Tty.bold}brew outdated#{Tty.reset}.
EOS
end
private
sig { void }
def dump_new_formula_report
formulae = select_formula_or_cask(:A).sort.reject { |name| installed?(name) }
return if formulae.blank?
ohai "New Formulae"
formulae.each do |formula|
if (desc = description(formula))
puts "#{formula}: #{desc}"
else
puts formula
end
end
end
sig { void }
def dump_new_cask_report
return unless Cask::Caskroom.any_casks_installed?
casks = select_formula_or_cask(:AC).sort.filter_map do |name|
name.split("/").last unless cask_installed?(name)
end
return if casks.blank?
ohai "New Casks"
casks.each do |cask|
if (desc = cask_description(cask))
puts "#{cask}: #{desc}"
else
puts cask
end
end
end
sig { void }
def dump_deleted_formula_report
formulae = select_formula_or_cask(:D).sort.filter_map do |name|
pretty_uninstalled(name) if installed?(name)
end
output_dump_formula_or_cask_report "Deleted Installed Formulae", formulae
end
sig { void }
def dump_deleted_cask_report
return if Homebrew::SimulateSystem.simulating_or_running_on_linux?
casks = select_formula_or_cask(:DC).sort.filter_map do |name|
name = T.must(name.split("/").last)
pretty_uninstalled(name) if cask_installed?(name)
end
output_dump_formula_or_cask_report "Deleted Installed Casks", casks
end
sig { params(title: String, formulae_or_casks: T::Array[String]).void }
def output_dump_formula_or_cask_report(title, formulae_or_casks)
return if formulae_or_casks.blank?
ohai title, Formatter.columns(formulae_or_casks.sort)
end
sig { params(formula: String).returns(T::Boolean) }
def installed?(formula)
(HOMEBREW_CELLAR/formula.split("/").last).directory?
end
sig { params(formula: String).returns(T::Boolean) }
def outdated?(formula)
Formula[formula].outdated?
rescue FormulaUnavailableError
false
end
sig { params(cask: String).returns(T::Boolean) }
def cask_installed?(cask)
(Cask::Caskroom.path/cask).directory?
end
sig { params(cask: String).returns(T::Boolean) }
def cask_outdated?(cask)
Cask::CaskLoader.load(cask).outdated?
rescue Cask::CaskError
false
end
sig { returns(T::Array[T.untyped]) }
def all_formula_json
return @all_formula_json if @all_formula_json
@all_formula_json = T.let(nil, T.nilable(T::Array[T.untyped]))
all_formula_json, = Homebrew::API.fetch_json_api_file "formula.jws.json"
all_formula_json = T.cast(all_formula_json, T::Array[T.untyped])
@all_formula_json = all_formula_json
end
sig { returns(T::Array[T.untyped]) }
def all_cask_json
return @all_cask_json if @all_cask_json
@all_cask_json = T.let(nil, T.nilable(T::Array[T.untyped]))
all_cask_json, = Homebrew::API.fetch_json_api_file "cask.jws.json"
all_cask_json = T.cast(all_cask_json, T::Array[T.untyped])
@all_cask_json = all_cask_json
end
sig { params(formula: String).returns(T.nilable(String)) }
def description(formula)
return if Homebrew::EnvConfig.no_install_from_api?
all_formula_json.find { |f| f["name"] == formula }
&.fetch("desc", nil)
&.presence
end
sig { params(cask: String).returns(T.nilable(String)) }
def cask_description(cask)
return if Homebrew::EnvConfig.no_install_from_api?
all_cask_json.find { |f| f["token"] == cask }
&.fetch("desc", nil)
&.presence
end
end