diff --git a/Library/Homebrew/abstract_command.rb b/Library/Homebrew/abstract_command.rb index 0831c05702455..e7a571375573f 100644 --- a/Library/Homebrew/abstract_command.rb +++ b/Library/Homebrew/abstract_command.rb @@ -16,21 +16,21 @@ class AbstractCommand abstract! class << self - sig { returns(T.nilable(CLI::Parser)) } - attr_reader :parser - sig { returns(String) } - def command_name = T.must(name).split("::").fetch(-1).downcase + def command_name = Utils.underscore(T.must(name).split("::").fetch(-1)).tr("_", "-") # @return the AbstractCommand subclass associated with the brew CLI command name. sig { params(name: String).returns(T.nilable(T.class_of(AbstractCommand))) } def command(name) = subclasses.find { _1.command_name == name } + sig { returns(CLI::Parser) } + def parser = CLI::Parser.new(self, &@parser_block) + private sig { params(block: T.proc.bind(CLI::Parser).void).void } def cmd_args(&block) - @parser = T.let(CLI::Parser.new(&block), T.nilable(CLI::Parser)) + @parser_block = T.let(block, T.nilable(T.proc.void)) end end @@ -39,10 +39,7 @@ def cmd_args(&block) sig { params(argv: T::Array[String]).void } def initialize(argv = ARGV.freeze) - parser = self.class.parser - raise "Commands must include a `cmd_args` block" if parser.nil? - - @args = T.let(parser.parse(argv), CLI::Args) + @args = T.let(self.class.parser.parse(argv), CLI::Args) end sig { abstract.void } diff --git a/Library/Homebrew/cli/parser.rb b/Library/Homebrew/cli/parser.rb index 30d093884383d..0f042094b89b8 100644 --- a/Library/Homebrew/cli/parser.rb +++ b/Library/Homebrew/cli/parser.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "env_config" require "cask/config" require "cli/args" @@ -19,9 +20,18 @@ class Parser def self.from_cmd_path(cmd_path) cmd_args_method_name = Commands.args_method_name(cmd_path) + cmd_name = cmd_args_method_name.to_s.delete_suffix("_args").tr("_", "-") begin - Homebrew.send(cmd_args_method_name) if require?(cmd_path) + if require?(cmd_path) + cmd = Homebrew::AbstractCommand.command(cmd_name) + if cmd + cmd.parser + else + # FIXME: remove once commands are all subclasses of `AbstractCommand`: + Homebrew.send(cmd_args_method_name) + end + end rescue NoMethodError => e raise if e.name.to_sym != cmd_args_method_name @@ -109,8 +119,10 @@ def self.global_options ] end - sig { params(block: T.nilable(T.proc.bind(Parser).void)).void } - def initialize(&block) + sig { + params(cmd: T.nilable(T.class_of(Homebrew::AbstractCommand)), block: T.nilable(T.proc.bind(Parser).void)).void + } + def initialize(cmd = nil, &block) @parser = OptionParser.new @parser.summary_indent = " " * 2 @@ -123,12 +135,18 @@ def initialize(&block) @args = Homebrew::CLI::Args.new - # Filter out Sorbet runtime type checking method calls. - cmd_location = T.must(caller_locations).select do |location| - T.must(location.path).exclude?("/gems/sorbet-runtime-") - end.fetch(1) - @command_name = T.must(cmd_location.label).chomp("_args").tr("_", "-") - @is_dev_cmd = T.must(cmd_location.absolute_path).start_with?(Commands::HOMEBREW_DEV_CMD_PATH) + if cmd + @command_name = cmd.command_name + @is_dev_cmd = cmd.name&.start_with?("Homebrew::DevCmd") + else + # FIXME: remove once commands are all subclasses of `AbstractCommand`: + # Filter out Sorbet runtime type checking method calls. + cmd_location = T.must(caller_locations).select do |location| + T.must(location.path).exclude?("/gems/sorbet-runtime-") + end.fetch(1) + @command_name = T.must(cmd_location.label).chomp("_args").tr("_", "-") + @is_dev_cmd = T.must(cmd_location.absolute_path).start_with?(Commands::HOMEBREW_DEV_CMD_PATH) + end @constraints = [] @conflicts = [] diff --git a/Library/Homebrew/dev-cmd/audit.rb b/Library/Homebrew/dev-cmd/audit.rb index 9c065dd43db18..6e29e4fa37b91 100644 --- a/Library/Homebrew/dev-cmd/audit.rb +++ b/Library/Homebrew/dev-cmd/audit.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "formula" require "formula_versions" require "utils/curl" @@ -20,341 +21,345 @@ require "tap_auditor" module Homebrew - sig { returns(CLI::Parser) } - def self.audit_args - Homebrew::CLI::Parser.new do - description <<~EOS - Check for Homebrew coding style violations. This should be run before - submitting a new formula or cask. If no | are provided, check all - locally available formulae and casks and skip style checks. Will exit with a - non-zero status if any errors are found. - EOS - flag "--os=", - description: "Audit the given operating system. (Pass `all` to audit all operating systems.)" - flag "--arch=", - description: "Audit the given CPU architecture. (Pass `all` to audit all architectures.)" - switch "--strict", - description: "Run additional, stricter style checks." - switch "--git", - description: "Run additional, slower style checks that navigate the Git repository." - switch "--online", - description: "Run additional, slower style checks that require a network connection." - switch "--installed", - description: "Only check formulae and casks that are currently installed." - switch "--eval-all", - description: "Evaluate all available formulae and casks, whether installed or not, to audit them. " \ - "Implied if `HOMEBREW_EVAL_ALL` is set." - switch "--new", - description: "Run various additional style checks to determine if a new formula or cask is eligible " \ - "for Homebrew. This should be used when creating new formulae or casks and implies " \ - "`--strict` and `--online`." - switch "--new-formula", - replacement: "--new", - # odeprecated: change this to true on disable and remove `args.new_formula?` calls - disable: false, - hidden: true - switch "--new-cask", - replacement: "--new", - # odeprecated: change this to true on disable and remove `args.new_formula?` calls - disable: false, - hidden: true - switch "--[no-]signing", - description: "Audit for signed apps, which are required on ARM" - switch "--token-conflicts", - description: "Audit for token conflicts." - flag "--tap=", - description: "Check the formulae within the given tap, specified as `/`." - switch "--fix", - description: "Fix style violations automatically using RuboCop's auto-correct feature." - switch "--display-cop-names", - description: "Include the RuboCop cop name for each violation in the output. This is the default.", - hidden: true - switch "--display-filename", - description: "Prefix every line of output with the file or formula name being audited, to " \ - "make output easy to grep." - switch "--skip-style", - description: "Skip running non-RuboCop style checks. Useful if you plan on running " \ - "`brew style` separately. Enabled by default unless a formula is specified by name." - switch "-D", "--audit-debug", - description: "Enable debugging and profiling of audit methods." - comma_array "--only", - description: "Specify a comma-separated list to only run the methods named " \ - "`audit_`." - comma_array "--except", - description: "Specify a comma-separated list to skip running the methods named " \ - "`audit_`." - comma_array "--only-cops", - description: "Specify a comma-separated list to check for violations of only the listed " \ - "RuboCop cops." - comma_array "--except-cops", - description: "Specify a comma-separated list to skip checking for violations of the " \ - "listed RuboCop cops." - switch "--formula", "--formulae", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - description: "Treat all named arguments as casks." - - conflicts "--only", "--except" - conflicts "--only-cops", "--except-cops", "--strict" - conflicts "--only-cops", "--except-cops", "--only" - conflicts "--formula", "--cask" - conflicts "--installed", "--all" - - named_args [:formula, :cask], without_api: true - end - end + module DevCmd + class Audit < AbstractCommand + cmd_args do + description <<~EOS + Check for Homebrew coding style violations. This should be run before + submitting a new formula or cask. If no | are provided, check all + locally available formulae and casks and skip style checks. Will exit with a + non-zero status if any errors are found. + EOS + flag "--os=", + description: "Audit the given operating system. (Pass `all` to audit all operating systems.)" + flag "--arch=", + description: "Audit the given CPU architecture. (Pass `all` to audit all architectures.)" + switch "--strict", + description: "Run additional, stricter style checks." + switch "--git", + description: "Run additional, slower style checks that navigate the Git repository." + switch "--online", + description: "Run additional, slower style checks that require a network connection." + switch "--installed", + description: "Only check formulae and casks that are currently installed." + switch "--eval-all", + description: "Evaluate all available formulae and casks, whether installed or not, to audit them. " \ + "Implied if `HOMEBREW_EVAL_ALL` is set." + switch "--new", + description: "Run various additional style checks to determine if a new formula or cask is eligible " \ + "for Homebrew. This should be used when creating new formulae or casks and implies " \ + "`--strict` and `--online`." + switch "--new-formula", + replacement: "--new", + # odeprecated: change this to true on disable and remove `args.new_formula?` calls + disable: false, + hidden: true + switch "--new-cask", + replacement: "--new", + # odeprecated: change this to true on disable and remove `args.new_formula?` calls + disable: false, + hidden: true + switch "--[no-]signing", + description: "Audit for signed apps, which are required on ARM" + switch "--token-conflicts", + description: "Audit for token conflicts." + flag "--tap=", + description: "Check the formulae within the given tap, specified as `/`." + switch "--fix", + description: "Fix style violations automatically using RuboCop's auto-correct feature." + switch "--display-cop-names", + description: "Include the RuboCop cop name for each violation in the output. This is the default.", + hidden: true + switch "--display-filename", + description: "Prefix every line of output with the file or formula name being audited, to " \ + "make output easy to grep." + switch "--skip-style", + description: "Skip running non-RuboCop style checks. Useful if you plan on running " \ + "`brew style` separately. Enabled by default unless a formula is specified by name." + switch "-D", "--audit-debug", + description: "Enable debugging and profiling of audit methods." + comma_array "--only", + description: "Specify a comma-separated list to only run the methods named " \ + "`audit_`." + comma_array "--except", + description: "Specify a comma-separated list to skip running the methods named " \ + "`audit_`." + comma_array "--only-cops", + description: "Specify a comma-separated list to check for violations of only the listed " \ + "RuboCop cops." + comma_array "--except-cops", + description: "Specify a comma-separated list to skip checking for violations of the " \ + "listed RuboCop cops." + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." + + conflicts "--only", "--except" + conflicts "--only-cops", "--except-cops", "--strict" + conflicts "--only-cops", "--except-cops", "--only" + conflicts "--formula", "--cask" + conflicts "--installed", "--all" + + named_args [:formula, :cask], without_api: true + end - sig { void } - def self.audit - args = audit_args.parse + sig { override.void } + def run + new_cask = args.new? || args.new_cask? + new_formula = args.new? || args.new_formula? + + Formulary.enable_factory_cache! + + os_arch_combinations = args.os_arch_combinations + + Homebrew.auditing = true + Homebrew.inject_dump_stats!(FormulaAuditor, /^audit_/) if args.audit_debug? + + strict = new_formula || args.strict? + online = new_formula || args.online? + tap_audit = args.tap.present? + skip_style = args.skip_style? || args.no_named? || tap_audit + no_named_args = T.let(false, T::Boolean) + + ENV.activate_extensions! + ENV.setup_build_environment + + audit_formulae, audit_casks = Homebrew.with_no_api_env do # audit requires full Ruby source + if args.tap + Tap.fetch(T.must(args.tap)).then do |tap| + [ + tap.formula_files.map { |path| Formulary.factory(path) }, + tap.cask_files.map { |path| Cask::CaskLoader.load(path) }, + ] + end + elsif args.installed? + no_named_args = true + [Formula.installed, Cask::Caskroom.casks] + elsif args.no_named? + if !args.eval_all? && !Homebrew::EnvConfig.eval_all? + # This odisabled should probably stick around indefinitely. + odisabled "brew audit", + "brew audit --eval-all or HOMEBREW_EVAL_ALL" + end + no_named_args = true + [ + Formula.all(eval_all: args.eval_all?), + Cask::Cask.all(eval_all: args.eval_all?), + ] + else + if args.named.any? { |named_arg| named_arg.end_with?(".rb") } + # This odisabled should probably stick around indefinitely, + # until at least we have a way to exclude error on these in the CLI parser. + odisabled "brew audit [path ...]", + "brew audit [name ...]" + end + + args.named.to_formulae_and_casks + .partition { |formula_or_cask| formula_or_cask.is_a?(Formula) } + end + end - new_cask = args.new? || args.new_cask? - new_formula = args.new? || args.new_formula? + if audit_formulae.empty? && audit_casks.empty? && !args.tap + ofail "No matching formulae or casks to audit!" + return + end - Formulary.enable_factory_cache! + gem_groups = ["audit"] + gem_groups << "style" unless skip_style + Homebrew.install_bundler_gems!(groups: gem_groups) - os_arch_combinations = args.os_arch_combinations + style_files = args.named.to_paths unless skip_style - Homebrew.auditing = true - inject_dump_stats!(FormulaAuditor, /^audit_/) if args.audit_debug? + only_cops = args.only_cops + except_cops = args.except_cops + style_options = { fix: args.fix?, debug: args.debug?, verbose: args.verbose? } - strict = new_formula || args.strict? - online = new_formula || args.online? - tap_audit = args.tap.present? - skip_style = args.skip_style? || args.no_named? || tap_audit - no_named_args = T.let(false, T::Boolean) + if only_cops + style_options[:only_cops] = only_cops + elsif new_formula || new_cask + nil + elsif except_cops + style_options[:except_cops] = except_cops + elsif !strict + style_options[:except_cops] = [:FormulaAuditStrict] + end - ENV.activate_extensions! - ENV.setup_build_environment + # Run tap audits first + named_arg_taps = [*audit_formulae, *audit_casks].map(&:tap).uniq if !args.tap && !no_named_args + tap_problems = Tap.installed.each_with_object({}) do |tap, problems| + next if args.tap && tap != args.tap + next if named_arg_taps&.exclude?(tap) - audit_formulae, audit_casks = Homebrew.with_no_api_env do # audit requires full Ruby source - if args.tap - Tap.fetch(args.tap).then do |tap| - [ - tap.formula_files.map { |path| Formulary.factory(path) }, - tap.cask_files.map { |path| Cask::CaskLoader.load(path) }, - ] - end - elsif args.installed? - no_named_args = true - [Formula.installed, Cask::Caskroom.casks] - elsif args.no_named? - if !args.eval_all? && !Homebrew::EnvConfig.eval_all? - # This odisabled should probably stick around indefinitely. - odisabled "brew audit", - "brew audit --eval-all or HOMEBREW_EVAL_ALL" + ta = TapAuditor.new(tap, strict: args.strict?) + ta.audit + + problems[[tap.name, tap.path]] = ta.problems if ta.problems.any? end - no_named_args = true - [ - Formula.all(eval_all: args.eval_all?), - Cask::Cask.all(eval_all: args.eval_all?), - ] - else - if args.named.any? { |named_arg| named_arg.end_with?(".rb") } - # This odisabled should probably stick around indefinitely, - # until at least we have a way to exclude error on these in the CLI parser. - odisabled "brew audit [path ...]", - "brew audit [name ...]" + + # Check style in a single batch run up front for performance + style_offenses = Style.check_style_json(style_files, **style_options) if style_files + # load licenses + spdx_license_data = SPDX.license_data + spdx_exception_data = SPDX.exception_data + + formula_problems = audit_formulae.sort.each_with_object({}) do |f, problems| + path = f.path + + only = only_cops ? ["style"] : args.only + options = { + new_formula:, + strict:, + online:, + git: args.git?, + only:, + except: args.except, + spdx_license_data:, + spdx_exception_data:, + style_offenses: style_offenses&.for_path(f.path), + tap_audit:, + }.compact + + errors = os_arch_combinations.flat_map do |os, arch| + SimulateSystem.with(os:, arch:) do + odebug "Auditing Formula #{f} on os #{os} and arch #{arch}" + + audit_proc = proc { FormulaAuditor.new(Formulary.factory(path), **options).tap(&:audit) } + + # Audit requires full Ruby source so disable API. We shouldn't do this for taps however so that we + # don't unnecessarily require a full Homebrew/core clone. + fa = if f.core_formula? + Homebrew.with_no_api_env(&audit_proc) + else + audit_proc.call + end + + fa.problems + fa.new_formula_problems + end + end.uniq + + problems[[f.full_name, path]] = errors if errors.any? end - args.named.to_formulae_and_casks - .partition { |formula_or_cask| formula_or_cask.is_a?(Formula) } - end - end + require "cask/auditor" if audit_casks.any? + + cask_problems = audit_casks.each_with_object({}) do |cask, problems| + path = cask.sourcefile_path + + errors = os_arch_combinations.flat_map do |os, arch| + next [] if os == :linux + + SimulateSystem.with(os:, arch:) do + odebug "Auditing Cask #{cask} on os #{os} and arch #{arch}" + + Cask::Auditor.audit( + Cask::CaskLoader.load(path), + # For switches, we add `|| nil` so that `nil` will be passed + # instead of `false` if they aren't set. + # This way, we can distinguish between "not set" and "set to false". + audit_online: args.online? || nil, + audit_strict: args.strict? || nil, + + # No need for `|| nil` for `--[no-]signing` + # because boolean switches are already `nil` if not passed + audit_signing: args.signing?, + audit_new_cask: new_cask || nil, + audit_token_conflicts: args.token_conflicts? || nil, + quarantine: true, + any_named_args: !no_named_args, + only: args.only, + except: args.except, + ).to_a + end + end.uniq + + problems[[cask.full_name, path]] = errors if errors.any? + end - if audit_formulae.empty? && audit_casks.empty? && !args.tap - ofail "No matching formulae or casks to audit!" - return - end + print_problems(tap_problems) + print_problems(formula_problems) + print_problems(cask_problems) - gem_groups = ["audit"] - gem_groups << "style" unless skip_style - Homebrew.install_bundler_gems!(groups: gem_groups) + tap_count = tap_problems.keys.count + formula_count = formula_problems.keys.count + cask_count = cask_problems.keys.count - style_files = args.named.to_paths unless skip_style + corrected_problem_count = (formula_problems.values + cask_problems.values) + .sum { |problems| problems.count { |problem| problem.fetch(:corrected) } } - only_cops = args.only_cops - except_cops = args.except_cops - style_options = { fix: args.fix?, debug: args.debug?, verbose: args.verbose? } + tap_problem_count = tap_problems.sum { |_, problems| problems.count } + formula_problem_count = formula_problems.sum { |_, problems| problems.count } + cask_problem_count = cask_problems.sum { |_, problems| problems.count } + total_problems_count = formula_problem_count + cask_problem_count + tap_problem_count - if only_cops - style_options[:only_cops] = only_cops - elsif new_formula || new_cask - nil - elsif except_cops - style_options[:except_cops] = except_cops - elsif !strict - style_options[:except_cops] = [:FormulaAuditStrict] - end + if total_problems_count.positive? + errors_summary = Utils.pluralize("problem", total_problems_count, include_count: true) - # Run tap audits first - named_arg_taps = [*audit_formulae, *audit_casks].map(&:tap).uniq if !args.tap && !no_named_args - tap_problems = Tap.installed.each_with_object({}) do |tap, problems| - next if args.tap && tap != args.tap - next if named_arg_taps&.exclude?(tap) + error_sources = [] + if formula_count.positive? + error_sources << Utils.pluralize("formula", formula_count, plural: "e", include_count: true) + end + error_sources << Utils.pluralize("cask", cask_count, include_count: true) if cask_count.positive? + error_sources << Utils.pluralize("tap", tap_count, include_count: true) if tap_count.positive? - ta = TapAuditor.new(tap, strict: args.strict?) - ta.audit + errors_summary += " in #{error_sources.to_sentence}" if error_sources.any? - problems[[tap.name, tap.path]] = ta.problems if ta.problems.any? - end + errors_summary += " detected" - # Check style in a single batch run up front for performance - style_offenses = Style.check_style_json(style_files, **style_options) if style_files - # load licenses - spdx_license_data = SPDX.license_data - spdx_exception_data = SPDX.exception_data - - formula_problems = audit_formulae.sort.each_with_object({}) do |f, problems| - path = f.path - - only = only_cops ? ["style"] : args.only - options = { - new_formula:, - strict:, - online:, - git: args.git?, - only:, - except: args.except, - spdx_license_data:, - spdx_exception_data:, - style_offenses: style_offenses&.for_path(f.path), - tap_audit:, - }.compact - - errors = os_arch_combinations.flat_map do |os, arch| - SimulateSystem.with(os:, arch:) do - odebug "Auditing Formula #{f} on os #{os} and arch #{arch}" - - audit_proc = proc { FormulaAuditor.new(Formulary.factory(path), **options).tap(&:audit) } - - # Audit requires full Ruby source so disable API. - # We shouldn't do this for taps however so that we don't unnecessarily require a full Homebrew/core clone. - fa = if f.core_formula? - Homebrew.with_no_api_env(&audit_proc) - else - audit_proc.call + if corrected_problem_count.positive? + errors_summary += + ", #{Utils.pluralize("problem", corrected_problem_count, include_count: true)} corrected" end - fa.problems + fa.new_formula_problems + ofail "#{errors_summary}." end - end.uniq - problems[[f.full_name, path]] = errors if errors.any? - end + return unless ENV["GITHUB_ACTIONS"] + + annotations = formula_problems.merge(cask_problems).flat_map do |(_, path), problems| + problems.map do |problem| + GitHub::Actions::Annotation.new( + :error, + problem[:message], + file: path, + line: problem[:location]&.line, + column: problem[:location]&.column, + ) + end + end.compact - require "cask/auditor" if audit_casks.any? - - cask_problems = audit_casks.each_with_object({}) do |cask, problems| - path = cask.sourcefile_path - - errors = os_arch_combinations.flat_map do |os, arch| - next [] if os == :linux - - SimulateSystem.with(os:, arch:) do - odebug "Auditing Cask #{cask} on os #{os} and arch #{arch}" - - Cask::Auditor.audit( - Cask::CaskLoader.load(path), - # For switches, we add `|| nil` so that `nil` will be passed - # instead of `false` if they aren't set. - # This way, we can distinguish between "not set" and "set to false". - audit_online: args.online? || nil, - audit_strict: args.strict? || nil, - - # No need for `|| nil` for `--[no-]signing` - # because boolean switches are already `nil` if not passed - audit_signing: args.signing?, - audit_new_cask: new_cask || nil, - audit_token_conflicts: args.token_conflicts? || nil, - quarantine: true, - any_named_args: !no_named_args, - only: args.only, - except: args.except, - ).to_a + annotations.each do |annotation| + puts annotation if annotation.relevant? end - end.uniq - - problems[[cask.full_name, path]] = errors if errors.any? - end - - print_problems(tap_problems, display_filename: args.display_filename?) - print_problems(formula_problems, display_filename: args.display_filename?) - print_problems(cask_problems, display_filename: args.display_filename?) - - tap_count = tap_problems.keys.count - formula_count = formula_problems.keys.count - cask_count = cask_problems.keys.count - - corrected_problem_count = (formula_problems.values + cask_problems.values) - .sum { |problems| problems.count { |problem| problem.fetch(:corrected) } } - - tap_problem_count = tap_problems.sum { |_, problems| problems.count } - formula_problem_count = formula_problems.sum { |_, problems| problems.count } - cask_problem_count = cask_problems.sum { |_, problems| problems.count } - total_problems_count = formula_problem_count + cask_problem_count + tap_problem_count - - if total_problems_count.positive? - errors_summary = Utils.pluralize("problem", total_problems_count, include_count: true) - - error_sources = [] - if formula_count.positive? - error_sources << Utils.pluralize("formula", formula_count, plural: "e", include_count: true) end - error_sources << Utils.pluralize("cask", cask_count, include_count: true) if cask_count.positive? - error_sources << Utils.pluralize("tap", tap_count, include_count: true) if tap_count.positive? - - errors_summary += " in #{error_sources.to_sentence}" if error_sources.any? - errors_summary += " detected" + private - if corrected_problem_count.positive? - errors_summary += ", #{Utils.pluralize("problem", corrected_problem_count, include_count: true)} corrected" - end - - ofail "#{errors_summary}." - end + def print_problems(results) + results.each do |(name, path), problems| + problem_lines = format_problem_lines(problems) - return unless ENV["GITHUB_ACTIONS"] - - annotations = formula_problems.merge(cask_problems).flat_map do |(_, path), problems| - problems.map do |problem| - GitHub::Actions::Annotation.new( - :error, - problem[:message], - file: path, - line: problem[:location]&.line, - column: problem[:location]&.column, - ) + if args.display_filename? + problem_lines.each do |l| + puts "#{path}: #{l}" + end + else + puts name, problem_lines.map { |l| l.dup.prepend(" ") } + end + end end - end.compact - - annotations.each do |annotation| - puts annotation if annotation.relevant? - end - end - def self.print_problems(results, display_filename:) - results.each do |(name, path), problems| - problem_lines = format_problem_lines(problems) - - if display_filename - problem_lines.each do |l| - puts "#{path}: #{l}" + def format_problem_lines(problems) + problems.map do |problem| + status = " #{Formatter.success("[corrected]")}" if problem.fetch(:corrected) + location = problem.fetch(:location) + if location + location = "#{location.line&.to_s&.prepend("line ")}#{location.column&.to_s&.prepend(", col ")}: " + end + message = problem.fetch(:message) + "* #{location}#{message.chomp.gsub("\n", "\n ")}#{status}" end - else - puts name, problem_lines.map { |l| l.dup.prepend(" ") } end end end - - def self.format_problem_lines(problems) - problems.map do |problem| - status = " #{Formatter.success("[corrected]")}" if problem.fetch(:corrected) - location = problem.fetch(:location) - location = "#{location.line&.to_s&.prepend("line ")}#{location.column&.to_s&.prepend(", col ")}: " if location - message = problem.fetch(:message) - "* #{location}#{message.chomp.gsub("\n", "\n ")}#{status}" - end - end end diff --git a/Library/Homebrew/dev-cmd/bottle.rb b/Library/Homebrew/dev-cmd/bottle.rb index b082796d11470..c8632b8417870 100644 --- a/Library/Homebrew/dev-cmd/bottle.rb +++ b/Library/Homebrew/dev-cmd/bottle.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "formula" require "utils/bottles" require "tab" @@ -13,7 +14,12 @@ require "api" require "extend/hash/deep_merge" -BOTTLE_ERB = <<-EOS.freeze +module Homebrew + module DevCmd + class Bottle < AbstractCommand + include FileUtils + + BOTTLE_ERB = <<-EOS.freeze bottle do <% if [HOMEBREW_BOTTLE_DEFAULT_DOMAIN.to_s, "#{HOMEBREW_BOTTLE_DEFAULT_DOMAIN}/bottles"].exclude?(root_url) %> @@ -28,805 +34,803 @@ <%= line %> <% end %> end -EOS - -MAXIMUM_STRING_MATCHES = 100 - -ALLOWABLE_HOMEBREW_REPOSITORY_LINKS = [ - %r{#{Regexp.escape(HOMEBREW_LIBRARY)}/Homebrew/os/(mac|linux)/pkgconfig}, -].freeze - -module Homebrew - sig { returns(CLI::Parser) } - def self.bottle_args - Homebrew::CLI::Parser.new do - description <<~EOS - Generate a bottle (binary package) from a formula that was installed with - `--build-bottle`. - If the formula specifies a rebuild version, it will be incremented in the - generated DSL. Passing `--keep-old` will attempt to keep it at its original - value, while `--no-rebuild` will remove it. EOS - switch "--skip-relocation", - description: "Do not check if the bottle can be marked as relocatable." - switch "--force-core-tap", - description: "Build a bottle even if is not in `homebrew/core` or any installed taps." - switch "--no-rebuild", - description: "If the formula specifies a rebuild version, remove it from the generated DSL." - switch "--keep-old", - description: "If the formula specifies a rebuild version, attempt to preserve its value in the " \ - "generated DSL." - switch "--json", - description: "Write bottle information to a JSON file, which can be used as the value for " \ - "`--merge`." - switch "--merge", - description: "Generate an updated bottle block for a formula and optionally merge it into the " \ - "formula file. Instead of a formula name, requires the path to a JSON file generated " \ - "with `brew bottle --json` ." - switch "--write", - depends_on: "--merge", - description: "Write changes to the formula file. A new commit will be generated unless " \ - "`--no-commit` is passed." - switch "--no-commit", - depends_on: "--write", - description: "When passed with `--write`, a new commit will not generated after writing changes " \ - "to the formula file." - switch "--only-json-tab", - depends_on: "--json", - description: "When passed with `--json`, the tab will be written to the JSON file but not the bottle." - switch "--no-all-checks", - depends_on: "--merge", - description: "Don't try to create an `all` bottle or stop a no-change upload." - flag "--committer=", - description: "Specify a committer name and email in `git`'s standard author format." - flag "--root-url=", - description: "Use the specified as the root of the bottle's URL instead of Homebrew's default." - flag "--root-url-using=", - description: "Use the specified download strategy class for downloading the bottle's URL instead of " \ - "Homebrew's default." - - conflicts "--no-rebuild", "--keep-old" - - named_args [:installed_formula, :file], min: 1, without_api: true - end - end - def self.bottle - args = bottle_args.parse + MAXIMUM_STRING_MATCHES = 100 - if args.merge? - Homebrew.install_bundler_gems!(groups: ["ast"]) - return merge(args:) - end + ALLOWABLE_HOMEBREW_REPOSITORY_LINKS = [ + %r{#{Regexp.escape(HOMEBREW_LIBRARY)}/Homebrew/os/(mac|linux)/pkgconfig}, + ].freeze - gnu_tar_formula_ensure_installed_if_needed!(only_json_tab: args.only_json_tab?) + cmd_args do + description <<~EOS + Generate a bottle (binary package) from a formula that was installed with + `--build-bottle`. + If the formula specifies a rebuild version, it will be incremented in the + generated DSL. Passing `--keep-old` will attempt to keep it at its original + value, while `--no-rebuild` will remove it. + EOS + switch "--skip-relocation", + description: "Do not check if the bottle can be marked as relocatable." + switch "--force-core-tap", + description: "Build a bottle even if is not in `homebrew/core` or any installed taps." + switch "--no-rebuild", + description: "If the formula specifies a rebuild version, remove it from the generated DSL." + switch "--keep-old", + description: "If the formula specifies a rebuild version, attempt to preserve its value in the " \ + "generated DSL." + switch "--json", + description: "Write bottle information to a JSON file, which can be used as the value for " \ + "`--merge`." + switch "--merge", + description: "Generate an updated bottle block for a formula and optionally merge it into the " \ + "formula file. Instead of a formula name, requires the path to a JSON file generated " \ + "with `brew bottle --json` ." + switch "--write", + depends_on: "--merge", + description: "Write changes to the formula file. A new commit will be generated unless " \ + "`--no-commit` is passed." + switch "--no-commit", + depends_on: "--write", + description: "When passed with `--write`, a new commit will not generated after writing changes " \ + "to the formula file." + switch "--only-json-tab", + depends_on: "--json", + description: "When passed with `--json`, the tab will be written to the JSON file but not the bottle." + switch "--no-all-checks", + depends_on: "--merge", + description: "Don't try to create an `all` bottle or stop a no-change upload." + flag "--committer=", + description: "Specify a committer name and email in `git`'s standard author format." + flag "--root-url=", + description: "Use the specified as the root of the bottle's URL instead of Homebrew's default." + flag "--root-url-using=", + description: "Use the specified download strategy class for downloading the bottle's URL instead of " \ + "Homebrew's default." + + conflicts "--no-rebuild", "--keep-old" + + named_args [:installed_formula, :file], min: 1, without_api: true + end - args.named.to_resolved_formulae(uniq: false).each do |formula| - bottle_formula formula, args: - end - end + sig { override.void } + def run + if args.merge? + Homebrew.install_bundler_gems!(groups: ["ast"]) + return merge + end - def self.keg_contain?(string, keg, ignores, formula_and_runtime_deps_names = nil, args:) - @put_string_exists_header, @put_filenames = nil + gnu_tar_formula_ensure_installed_if_needed! - print_filename = lambda do |str, filename| - unless @put_string_exists_header - opoo "String '#{str}' still exists in these files:" - @put_string_exists_header = true + args.named.to_resolved_formulae(uniq: false).each do |formula| + bottle_formula formula + end end - @put_filenames ||= [] + def keg_contain?(string, keg, ignores, formula_and_runtime_deps_names = nil) + @put_string_exists_header, @put_filenames = nil - return false if @put_filenames.include?(filename) - - puts Formatter.error(filename.to_s) - @put_filenames << filename - end - - result = T.let(false, T::Boolean) + print_filename = lambda do |str, filename| + unless @put_string_exists_header + opoo "String '#{str}' still exists in these files:" + @put_string_exists_header = true + end - keg.each_unique_file_matching(string) do |file| - next if Metafiles::EXTENSIONS.include?(file.extname) # Skip document files. + @put_filenames ||= [] - linked_libraries = Keg.file_linked_libraries(file, string) - result ||= !linked_libraries.empty? + return false if @put_filenames.include?(filename) - if args.verbose? - print_filename.call(string, file) unless linked_libraries.empty? - linked_libraries.each do |lib| - puts " #{Tty.bold}-->#{Tty.reset} links to #{lib}" + puts Formatter.error(filename.to_s) + @put_filenames << filename end - end - text_matches = Keg.text_matches_in_file(file, string, ignores, linked_libraries, formula_and_runtime_deps_names) - result = true if text_matches.any? + result = T.let(false, T::Boolean) - next if !args.verbose? || text_matches.empty? + keg.each_unique_file_matching(string) do |file| + next if Metafiles::EXTENSIONS.include?(file.extname) # Skip document files. - print_filename.call(string, file) - text_matches.first(MAXIMUM_STRING_MATCHES).each do |match, offset| - puts " #{Tty.bold}-->#{Tty.reset} match '#{match}' at offset #{Tty.bold}0x#{offset}#{Tty.reset}" - end + linked_libraries = Keg.file_linked_libraries(file, string) + result ||= !linked_libraries.empty? - if text_matches.size > MAXIMUM_STRING_MATCHES - puts "Only the first #{MAXIMUM_STRING_MATCHES} matches were output." - end - end + if args.verbose? + print_filename.call(string, file) unless linked_libraries.empty? + linked_libraries.each do |lib| + puts " #{Tty.bold}-->#{Tty.reset} links to #{lib}" + end + end - keg_contain_absolute_symlink_starting_with?(string, keg, args:) || result - end + text_matches = Keg.text_matches_in_file(file, string, ignores, linked_libraries, + formula_and_runtime_deps_names) + result = true if text_matches.any? - def self.keg_contain_absolute_symlink_starting_with?(string, keg, args:) - absolute_symlinks_start_with_string = [] - keg.find do |pn| - next if !pn.symlink? || !(link = pn.readlink).absolute? + next if !args.verbose? || text_matches.empty? - absolute_symlinks_start_with_string << pn if link.to_s.start_with?(string) - end + print_filename.call(string, file) + text_matches.first(MAXIMUM_STRING_MATCHES).each do |match, offset| + puts " #{Tty.bold}-->#{Tty.reset} match '#{match}' at offset #{Tty.bold}0x#{offset}#{Tty.reset}" + end + + if text_matches.size > MAXIMUM_STRING_MATCHES + puts "Only the first #{MAXIMUM_STRING_MATCHES} matches were output." + end + end - if args.verbose? && absolute_symlinks_start_with_string.present? - opoo "Absolute symlink starting with #{string}:" - absolute_symlinks_start_with_string.each do |pn| - puts " #{pn} -> #{pn.resolved_path}" + keg_contain_absolute_symlink_starting_with?(string, keg) || result end - end - !absolute_symlinks_start_with_string.empty? - end + def keg_contain_absolute_symlink_starting_with?(string, keg) + absolute_symlinks_start_with_string = [] + keg.find do |pn| + next if !pn.symlink? || !(link = pn.readlink).absolute? - def self.cellar_parameter_needed?(cellar) - default_cellars = [ - Homebrew::DEFAULT_MACOS_CELLAR, - Homebrew::DEFAULT_MACOS_ARM_CELLAR, - Homebrew::DEFAULT_LINUX_CELLAR, - ] - cellar.present? && default_cellars.exclude?(cellar) - end + absolute_symlinks_start_with_string << pn if link.to_s.start_with?(string) + end - def self.generate_sha256_line(tag, digest, cellar, tag_column, digest_column) - line = "sha256 " - tag_column += line.length - digest_column += line.length - if cellar.is_a?(Symbol) - line += "cellar: :#{cellar}," - elsif cellar_parameter_needed?(cellar) - line += %Q(cellar: "#{cellar}",) - end - line += " " * (tag_column - line.length) - line += "#{tag}:" - line += " " * (digest_column - line.length) - %Q(#{line}"#{digest}") - end + if args.verbose? && absolute_symlinks_start_with_string.present? + opoo "Absolute symlink starting with #{string}:" + absolute_symlinks_start_with_string.each do |pn| + puts " #{pn} -> #{pn.resolved_path}" + end + end + + !absolute_symlinks_start_with_string.empty? + end - def self.bottle_output(bottle, root_url_using) - cellars = bottle.checksums.filter_map do |checksum| - cellar = checksum["cellar"] - next unless cellar_parameter_needed? cellar + def cellar_parameter_needed?(cellar) + default_cellars = [ + Homebrew::DEFAULT_MACOS_CELLAR, + Homebrew::DEFAULT_MACOS_ARM_CELLAR, + Homebrew::DEFAULT_LINUX_CELLAR, + ] + cellar.present? && default_cellars.exclude?(cellar) + end - case cellar - when String - %Q("#{cellar}") - when Symbol - ":#{cellar}" + def generate_sha256_line(tag, digest, cellar, tag_column, digest_column) + line = "sha256 " + tag_column += line.length + digest_column += line.length + if cellar.is_a?(Symbol) + line += "cellar: :#{cellar}," + elsif cellar_parameter_needed?(cellar) + line += %Q(cellar: "#{cellar}",) + end + line += " " * (tag_column - line.length) + line += "#{tag}:" + line += " " * (digest_column - line.length) + %Q(#{line}"#{digest}") end - end - tag_column = cellars.empty? ? 0 : "cellar: #{cellars.max_by(&:length)}, ".length - tags = bottle.checksums.map { |checksum| checksum["tag"] } - # Start where the tag ends, add the max length of the tag, add two for the `: ` - digest_column = tag_column + tags.max_by(&:length).length + 2 + def bottle_output(bottle, root_url_using) + cellars = bottle.checksums.filter_map do |checksum| + cellar = checksum["cellar"] + next unless cellar_parameter_needed? cellar - sha256_lines = bottle.checksums.map do |checksum| - generate_sha256_line(checksum["tag"], checksum["digest"], checksum["cellar"], tag_column, digest_column) - end - erb_binding = bottle.instance_eval { binding } - erb_binding.local_variable_set(:sha256_lines, sha256_lines) - erb_binding.local_variable_set(:root_url_using, root_url_using) - erb = ERB.new BOTTLE_ERB - erb.result(erb_binding).gsub(/^\s*$\n/, "") - end + case cellar + when String + %Q("#{cellar}") + when Symbol + ":#{cellar}" + end + end + tag_column = cellars.empty? ? 0 : "cellar: #{cellars.max_by(&:length)}, ".length - def self.sudo_purge - return unless ENV["HOMEBREW_BOTTLE_SUDO_PURGE"] + tags = bottle.checksums.map { |checksum| checksum["tag"] } + # Start where the tag ends, add the max length of the tag, add two for the `: ` + digest_column = tag_column + tags.max_by(&:length).length + 2 - system "/usr/bin/sudo", "--non-interactive", "/usr/sbin/purge" - end + sha256_lines = bottle.checksums.map do |checksum| + generate_sha256_line(checksum["tag"], checksum["digest"], checksum["cellar"], tag_column, digest_column) + end + erb_binding = bottle.instance_eval { binding } + erb_binding.local_variable_set(:sha256_lines, sha256_lines) + erb_binding.local_variable_set(:root_url_using, root_url_using) + erb = ERB.new BOTTLE_ERB + erb.result(erb_binding).gsub(/^\s*$\n/, "") + end - sig { returns(T::Array[String]) } - def self.tar_args - [].freeze - end + def sudo_purge + return unless ENV["HOMEBREW_BOTTLE_SUDO_PURGE"] - sig { params(gnu_tar_formula: Formula).returns(String) } - def self.gnu_tar(gnu_tar_formula) - "#{gnu_tar_formula.opt_bin}/tar" - end + system "/usr/bin/sudo", "--non-interactive", "/usr/sbin/purge" + end - sig { params(mtime: String).returns(T::Array[String]) } - def self.reproducible_gnutar_args(mtime) - # Ensure gnu tar is set up for reproducibility. - # https://reproducible-builds.org/docs/archives/ - [ - # File modification times - "--mtime=#{mtime}", - # File ordering - "--sort=name", - # Users, groups and numeric ids - "--owner=0", "--group=0", "--numeric-owner", - # PAX headers - "--format=pax", - # Set exthdr names to exclude PID (for GNU tar <1.33). Also don't store atime and ctime. - "--pax-option=globexthdr.name=/GlobalHead.%n,exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime" - ].freeze - end + sig { returns(T::Array[String]) } + def tar_args + [].freeze + end - sig { params(only_json_tab: T::Boolean).returns(T.nilable(Formula)) } - def self.gnu_tar_formula_ensure_installed_if_needed!(only_json_tab:) - gnu_tar_formula = begin - Formula["gnu-tar"] - rescue FormulaUnavailableError - nil - end - return if gnu_tar_formula.blank? + sig { params(gnu_tar_formula: Formula).returns(String) } + def gnu_tar(gnu_tar_formula) + "#{gnu_tar_formula.opt_bin}/tar" + end - ensure_formula_installed!(gnu_tar_formula, reason: "bottling") + sig { params(mtime: String).returns(T::Array[String]) } + def reproducible_gnutar_args(mtime) + # Ensure gnu tar is set up for reproducibility. + # https://reproducible-builds.org/docs/archives/ + [ + # File modification times + "--mtime=#{mtime}", + # File ordering + "--sort=name", + # Users, groups and numeric ids + "--owner=0", "--group=0", "--numeric-owner", + # PAX headers + "--format=pax", + # Set exthdr names to exclude PID (for GNU tar <1.33). Also don't store atime and ctime. + "--pax-option=globexthdr.name=/GlobalHead.%n,exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime" + ].freeze + end - gnu_tar_formula - end + sig { returns(T.nilable(Formula)) } + def gnu_tar_formula_ensure_installed_if_needed! + gnu_tar_formula = begin + Formula["gnu-tar"] + rescue FormulaUnavailableError + nil + end + return if gnu_tar_formula.blank? - sig { params(args: T.untyped, mtime: String).returns([String, T::Array[String]]) } - def self.setup_tar_and_args!(args, mtime) - # Without --only-json-tab bottles are never reproducible - default_tar_args = ["tar", tar_args].freeze - return default_tar_args unless args.only_json_tab? + ensure_formula_installed!(gnu_tar_formula, reason: "bottling") - # Use gnu-tar as it can be set up for reproducibility better than libarchive - # and to be consistent between macOS and Linux. - gnu_tar_formula = gnu_tar_formula_ensure_installed_if_needed!(only_json_tab: args.only_json_tab?) - return default_tar_args if gnu_tar_formula.blank? + gnu_tar_formula + end - [gnu_tar(gnu_tar_formula), reproducible_gnutar_args(mtime)].freeze - end + sig { params(mtime: String).returns([String, T::Array[String]]) } + def setup_tar_and_args!(mtime) + # Without --only-json-tab bottles are never reproducible + default_tar_args = ["tar", tar_args].freeze + return default_tar_args unless args.only_json_tab? - def self.formula_ignores(formula) - ignores = [] - cellar_regex = Regexp.escape(HOMEBREW_CELLAR) - prefix_regex = Regexp.escape(HOMEBREW_PREFIX) + # Use gnu-tar as it can be set up for reproducibility better than libarchive + # and to be consistent between macOS and Linux. + gnu_tar_formula = gnu_tar_formula_ensure_installed_if_needed! + return default_tar_args if gnu_tar_formula.blank? - # Ignore matches to go keg, because all go binaries are statically linked. - any_go_deps = formula.deps.any? do |dep| - Version.formula_optionally_versioned_regex(:go).match?(dep.name) - end - if any_go_deps - go_regex = Version.formula_optionally_versioned_regex(:go, full: false) - ignores << %r{#{cellar_regex}/#{go_regex}/[\d.]+/libexec} - end + [gnu_tar(gnu_tar_formula), reproducible_gnutar_args(mtime)].freeze + end - # TODO: Refactor and move to extend/os - # rubocop:disable Homebrew/MoveToExtendOS - ignores << case formula.name - # On Linux, GCC installation can be moved so long as the whole directory tree is moved together: - # https://gcc-help.gcc.gnu.narkive.com/GnwuCA7l/moving-gcc-from-the-installation-path-is-it-allowed. - when Version.formula_optionally_versioned_regex(:gcc) - Regexp.union(%r{#{cellar_regex}/gcc}, %r{#{prefix_regex}/opt/gcc}) if OS.linux? - # binutils is relocatable for the same reason: https://github.com/Homebrew/brew/pull/11899#issuecomment-906804451. - when Version.formula_optionally_versioned_regex(:binutils) - %r{#{cellar_regex}/binutils} if OS.linux? - end - # rubocop:enable Homebrew/MoveToExtendOS + def formula_ignores(formula) + ignores = [] + cellar_regex = Regexp.escape(HOMEBREW_CELLAR) + prefix_regex = Regexp.escape(HOMEBREW_PREFIX) - ignores.compact - end + # Ignore matches to go keg, because all go binaries are statically linked. + any_go_deps = formula.deps.any? do |dep| + Version.formula_optionally_versioned_regex(:go).match?(dep.name) + end + if any_go_deps + go_regex = Version.formula_optionally_versioned_regex(:go, full: false) + ignores << %r{#{cellar_regex}/#{go_regex}/[\d.]+/libexec} + end - def self.bottle_formula(formula, args:) - local_bottle_json = args.json? && formula.local_bottle_path.present? + # TODO: Refactor and move to extend/os + # rubocop:disable Homebrew/MoveToExtendOS + ignores << case formula.name + # On Linux, GCC installation can be moved so long as the whole directory tree is moved together: + # https://gcc-help.gcc.gnu.narkive.com/GnwuCA7l/moving-gcc-from-the-installation-path-is-it-allowed. + when Version.formula_optionally_versioned_regex(:gcc) + Regexp.union(%r{#{cellar_regex}/gcc}, %r{#{prefix_regex}/opt/gcc}) if OS.linux? + # binutils is relocatable for the same reason: https://github.com/Homebrew/brew/pull/11899#issuecomment-906804451. + when Version.formula_optionally_versioned_regex(:binutils) + %r{#{cellar_regex}/binutils} if OS.linux? + end + # rubocop:enable Homebrew/MoveToExtendOS - unless local_bottle_json - unless formula.latest_version_installed? - return ofail "Formula not installed or up-to-date: #{formula.full_name}" - end - unless Utils::Bottles.built_as? formula - return ofail "Formula was not installed with `--build-bottle`: #{formula.full_name}" + ignores.compact end - end - tap = formula.tap - if tap.nil? - return ofail "Formula not from core or any installed taps: #{formula.full_name}" unless args.force_core_tap? + def bottle_formula(formula) + local_bottle_json = args.json? && formula.local_bottle_path.present? - tap = CoreTap.instance - end - raise TapUnavailableError, tap.name unless tap.installed? + unless local_bottle_json + unless formula.latest_version_installed? + return ofail "Formula not installed or up-to-date: #{formula.full_name}" + end + unless Utils::Bottles.built_as? formula + return ofail "Formula was not installed with `--build-bottle`: #{formula.full_name}" + end + end - return ofail "Formula has no stable version: #{formula.full_name}" unless formula.stable + tap = formula.tap + if tap.nil? + return ofail "Formula not from core or any installed taps: #{formula.full_name}" unless args.force_core_tap? - bottle_tag, rebuild = if local_bottle_json - _, tag_string, rebuild_string = Utils::Bottles.extname_tag_rebuild(formula.local_bottle_path.to_s) - [tag_string.to_sym, rebuild_string.to_i] - end + tap = CoreTap.instance + end + raise TapUnavailableError, tap.name unless tap.installed? - bottle_tag = if bottle_tag - Utils::Bottles::Tag.from_symbol(bottle_tag) - else - Utils::Bottles.tag - end + return ofail "Formula has no stable version: #{formula.full_name}" unless formula.stable - rebuild ||= if args.no_rebuild? || !tap - 0 - elsif args.keep_old? - formula.bottle_specification.rebuild - else - ohai "Determining #{formula.full_name} bottle rebuild..." - FormulaVersions.new(formula).formula_at_revision("origin/HEAD") do |upstream_formula| - if formula.pkg_version == upstream_formula.pkg_version - upstream_formula.bottle_specification.rebuild + 1 - else - 0 + bottle_tag, rebuild = if local_bottle_json + _, tag_string, rebuild_string = Utils::Bottles.extname_tag_rebuild(formula.local_bottle_path.to_s) + [tag_string.to_sym, rebuild_string.to_i] end - end || 0 - end - filename = Bottle::Filename.create(formula, bottle_tag, rebuild) - local_filename = filename.to_s - bottle_path = Pathname.pwd/local_filename + bottle_tag = if bottle_tag + Utils::Bottles::Tag.from_symbol(bottle_tag) + else + Utils::Bottles.tag + end - tab = nil - keg = nil + rebuild ||= if args.no_rebuild? || !tap + 0 + elsif args.keep_old? + formula.bottle_specification.rebuild + else + ohai "Determining #{formula.full_name} bottle rebuild..." + FormulaVersions.new(formula).formula_at_revision("origin/HEAD") do |upstream_formula| + if formula.pkg_version == upstream_formula.pkg_version + upstream_formula.bottle_specification.rebuild + 1 + else + 0 + end + end || 0 + end - tap_path = tap.path - tap_git_revision = tap.git_head - tap_git_remote = tap.remote + filename = ::Bottle::Filename.create(formula, bottle_tag, rebuild) + local_filename = filename.to_s + bottle_path = Pathname.pwd/local_filename - root_url = args.root_url + tab = nil + keg = nil - relocatable = T.let(false, T::Boolean) - skip_relocation = T.let(false, T::Boolean) + tap_path = tap.path + tap_git_revision = tap.git_head + tap_git_remote = tap.remote - prefix = HOMEBREW_PREFIX.to_s - cellar = HOMEBREW_CELLAR.to_s + root_url = args.root_url - if local_bottle_json - bottle_path = formula.local_bottle_path - local_filename = bottle_path.basename.to_s + relocatable = T.let(false, T::Boolean) + skip_relocation = T.let(false, T::Boolean) - tab_path = Utils::Bottles.receipt_path(bottle_path) - raise "This bottle does not contain the file INSTALL_RECEIPT.json: #{bottle_path}" unless tab_path + prefix = HOMEBREW_PREFIX.to_s + cellar = HOMEBREW_CELLAR.to_s - tab_json = Utils::Bottles.file_from_bottle(bottle_path, tab_path) - tab = Tab.from_file_content(tab_json, tab_path) + if local_bottle_json + bottle_path = formula.local_bottle_path + local_filename = bottle_path.basename.to_s - tag_spec = Formula[formula.name].bottle_specification.tag_specification_for(bottle_tag, no_older_versions: true) - relocatable = [:any, :any_skip_relocation].include?(tag_spec.cellar) - skip_relocation = tag_spec.cellar == :any_skip_relocation + tab_path = Utils::Bottles.receipt_path(bottle_path) + raise "This bottle does not contain the file INSTALL_RECEIPT.json: #{bottle_path}" unless tab_path - prefix = bottle_tag.default_prefix - cellar = bottle_tag.default_cellar - else - tar_filename = filename.to_s.sub(/.gz$/, "") - tar_path = Pathname.pwd/tar_filename + tab_json = Utils::Bottles.file_from_bottle(bottle_path, tab_path) + tab = Tab.from_file_content(tab_json, tab_path) - keg = Keg.new(formula.prefix) - end + tag_spec = Formula[formula.name].bottle_specification + .tag_specification_for(bottle_tag, no_older_versions: true) + relocatable = [:any, :any_skip_relocation].include?(tag_spec.cellar) + skip_relocation = tag_spec.cellar == :any_skip_relocation - ohai "Bottling #{local_filename}..." - - formula_and_runtime_deps_names = [formula.name] + formula.runtime_dependencies.map(&:name) - - # this will be nil when using a local bottle - keg&.lock do - original_tab = nil - changed_files = nil - - begin - keg.delete_pyc_files! - - changed_files = keg.replace_locations_with_placeholders unless args.skip_relocation? - - Formula.clear_cache - Keg.clear_cache - Tab.clear_cache - Dependency.clear_cache - Requirement.clear_cache - tab = Tab.for_keg(keg) - original_tab = tab.dup - tab.poured_from_bottle = false - tab.time = nil - tab.changed_files = changed_files.dup - if args.only_json_tab? - tab.changed_files.delete(Pathname.new(Tab::FILENAME)) - tab.tabfile.unlink + prefix = bottle_tag.default_prefix + cellar = bottle_tag.default_cellar else - tab.write - end + tar_filename = filename.to_s.sub(/.gz$/, "") + tar_path = Pathname.pwd/tar_filename - keg.consistent_reproducible_symlink_permissions! - - cd cellar do - sudo_purge - # Tar then gzip for reproducible bottles. - tar_mtime = tab.source_modified_time.strftime("%Y-%m-%d %H:%M:%S") - tar, tar_args = setup_tar_and_args!(args, tar_mtime) - safe_system tar, "--create", "--numeric-owner", - *tar_args, - "--file", tar_path, "#{formula.name}/#{formula.pkg_version}" - sudo_purge - # Set filename as it affects the tarball checksum. - relocatable_tar_path = "#{formula}-bottle.tar" - mv T.must(tar_path), relocatable_tar_path - # Use gzip, faster to compress than bzip2, faster to uncompress than bzip2 - # or an uncompressed tarball (and more bandwidth friendly). - Utils::Gzip.compress_with_options(relocatable_tar_path, - mtime: tab.source_modified_time, - orig_name: relocatable_tar_path, - output: bottle_path) - sudo_purge + keg = Keg.new(formula.prefix) end - ohai "Detecting if #{local_filename} is relocatable..." if bottle_path.size > 1 * 1024 * 1024 - - prefix_check = if Homebrew.default_prefix?(prefix) - File.join(prefix, "opt") - else - prefix + ohai "Bottling #{local_filename}..." + + formula_and_runtime_deps_names = [formula.name] + formula.runtime_dependencies.map(&:name) + + # this will be nil when using a local bottle + keg&.lock do + original_tab = nil + changed_files = nil + + begin + keg.delete_pyc_files! + + changed_files = keg.replace_locations_with_placeholders unless args.skip_relocation? + + Formula.clear_cache + Keg.clear_cache + Tab.clear_cache + Dependency.clear_cache + Requirement.clear_cache + tab = Tab.for_keg(keg) + original_tab = tab.dup + tab.poured_from_bottle = false + tab.time = nil + tab.changed_files = changed_files.dup + if args.only_json_tab? + tab.changed_files.delete(Pathname.new(Tab::FILENAME)) + tab.tabfile.unlink + else + tab.write + end + + keg.consistent_reproducible_symlink_permissions! + + cd cellar do + sudo_purge + # Tar then gzip for reproducible bottles. + tar_mtime = tab.source_modified_time.strftime("%Y-%m-%d %H:%M:%S") + tar, tar_args = setup_tar_and_args!(tar_mtime) + safe_system tar, "--create", "--numeric-owner", + *tar_args, + "--file", tar_path, "#{formula.name}/#{formula.pkg_version}" + sudo_purge + # Set filename as it affects the tarball checksum. + relocatable_tar_path = "#{formula}-bottle.tar" + mv T.must(tar_path), relocatable_tar_path + # Use gzip, faster to compress than bzip2, faster to uncompress than bzip2 + # or an uncompressed tarball (and more bandwidth friendly). + Utils::Gzip.compress_with_options(relocatable_tar_path, + mtime: tab.source_modified_time, + orig_name: relocatable_tar_path, + output: bottle_path) + sudo_purge + end + + ohai "Detecting if #{local_filename} is relocatable..." if bottle_path.size > 1 * 1024 * 1024 + + prefix_check = if Homebrew.default_prefix?(prefix) + File.join(prefix, "opt") + else + prefix + end + + # Ignore matches to source code, which is not required at run time. + # These matches may be caused by debugging symbols. + ignores = [%r{/include/|\.(c|cc|cpp|h|hpp)$}] + + # Add additional workarounds to ignore + ignores += formula_ignores(formula) + + repository_reference = if HOMEBREW_PREFIX == HOMEBREW_REPOSITORY + HOMEBREW_LIBRARY + else + HOMEBREW_REPOSITORY + end.to_s + if keg_contain?(repository_reference, keg, ignores + ALLOWABLE_HOMEBREW_REPOSITORY_LINKS) + odie "Bottle contains non-relocatable reference to #{repository_reference}!" + end + + relocatable = true + if args.skip_relocation? + skip_relocation = true + else + relocatable = false if keg_contain?(prefix_check, keg, ignores, formula_and_runtime_deps_names) + relocatable = false if keg_contain?(cellar, keg, ignores, formula_and_runtime_deps_names) + relocatable = false if keg_contain?(HOMEBREW_LIBRARY.to_s, keg, ignores, formula_and_runtime_deps_names) + if prefix != prefix_check + relocatable = false if keg_contain_absolute_symlink_starting_with?(prefix, keg) + relocatable = false if keg_contain?("#{prefix}/etc", keg, ignores) + relocatable = false if keg_contain?("#{prefix}/var", keg, ignores) + relocatable = false if keg_contain?("#{prefix}/share/vim", keg, ignores) + end + skip_relocation = relocatable && !keg.require_relocation? + end + puts if !relocatable && args.verbose? + rescue Interrupt + ignore_interrupts { bottle_path.unlink if bottle_path.exist? } + raise + ensure + ignore_interrupts do + original_tab&.write + keg.replace_placeholders_with_locations changed_files unless args.skip_relocation? + end + end end - # Ignore matches to source code, which is not required at run time. - # These matches may be caused by debugging symbols. - ignores = [%r{/include/|\.(c|cc|cpp|h|hpp)$}] - - # Add additional workarounds to ignore - ignores += formula_ignores(formula) - - repository_reference = if HOMEBREW_PREFIX == HOMEBREW_REPOSITORY - HOMEBREW_LIBRARY + bottle = BottleSpecification.new + bottle.tap = tap + bottle.root_url(root_url) if root_url + bottle_cellar = if relocatable + if skip_relocation + :any_skip_relocation + else + :any + end else - HOMEBREW_REPOSITORY - end.to_s - if keg_contain?(repository_reference, keg, ignores + ALLOWABLE_HOMEBREW_REPOSITORY_LINKS, args:) - odie "Bottle contains non-relocatable reference to #{repository_reference}!" + cellar end - - relocatable = true - if args.skip_relocation? - skip_relocation = true - else - relocatable = false if keg_contain?(prefix_check, keg, ignores, formula_and_runtime_deps_names, args:) - relocatable = false if keg_contain?(cellar, keg, ignores, formula_and_runtime_deps_names, args:) - if keg_contain?(HOMEBREW_LIBRARY.to_s, keg, ignores, formula_and_runtime_deps_names, args:) - relocatable = false + bottle.rebuild rebuild + sha256 = bottle_path.sha256 + bottle.sha256 cellar: bottle_cellar, bottle_tag.to_sym => sha256 + + old_spec = formula.bottle_specification + if args.keep_old? && !old_spec.checksums.empty? + mismatches = [:root_url, :rebuild].reject do |key| + old_spec.send(key) == bottle.send(key) end - if prefix != prefix_check - relocatable = false if keg_contain_absolute_symlink_starting_with?(prefix, keg, args:) - relocatable = false if keg_contain?("#{prefix}/etc", keg, ignores, args:) - relocatable = false if keg_contain?("#{prefix}/var", keg, ignores, args:) - relocatable = false if keg_contain?("#{prefix}/share/vim", keg, ignores, args:) + unless mismatches.empty? + bottle_path.unlink if bottle_path.exist? + + mismatches.map! do |key| + old_value = old_spec.send(key).inspect + value = bottle.send(key).inspect + "#{key}: old: #{old_value}, new: #{value}" + end + + odie <<~EOS + `--keep-old` was passed but there are changes in: + #{mismatches.join("\n")} + EOS end - skip_relocation = relocatable && !keg.require_relocation? - end - puts if !relocatable && args.verbose? - rescue Interrupt - ignore_interrupts { bottle_path.unlink if bottle_path.exist? } - raise - ensure - ignore_interrupts do - original_tab&.write - keg.replace_placeholders_with_locations changed_files unless args.skip_relocation? - end - end - end - - bottle = BottleSpecification.new - bottle.tap = tap - bottle.root_url(root_url) if root_url - bottle_cellar = if relocatable - if skip_relocation - :any_skip_relocation - else - :any - end - else - cellar - end - bottle.rebuild rebuild - sha256 = bottle_path.sha256 - bottle.sha256 cellar: bottle_cellar, bottle_tag.to_sym => sha256 - - old_spec = formula.bottle_specification - if args.keep_old? && !old_spec.checksums.empty? - mismatches = [:root_url, :rebuild].reject do |key| - old_spec.send(key) == bottle.send(key) - end - unless mismatches.empty? - bottle_path.unlink if bottle_path.exist? - - mismatches.map! do |key| - old_value = old_spec.send(key).inspect - value = bottle.send(key).inspect - "#{key}: old: #{old_value}, new: #{value}" end - odie <<~EOS - `--keep-old` was passed but there are changes in: - #{mismatches.join("\n")} - EOS - end - end - - output = bottle_output(bottle, args.root_url_using) - - puts "./#{local_filename}" - puts output - - return unless args.json? - - json = { - formula.full_name => { - "formula" => { - "name" => formula.name, - "pkg_version" => formula.pkg_version.to_s, - "path" => formula.path.to_s.delete_prefix("#{HOMEBREW_REPOSITORY}/"), - "tap_git_path" => formula.path.to_s.delete_prefix("#{tap_path}/"), - "tap_git_revision" => tap_git_revision, - "tap_git_remote" => tap_git_remote, - # descriptions can contain emoji. sigh. - "desc" => formula.desc.to_s.encode( - Encoding.find("ASCII"), - invalid: :replace, undef: :replace, replace: "", - ).strip, - "license" => SPDX.license_expression_to_string(formula.license), - "homepage" => formula.homepage, - }, - "bottle" => { - "root_url" => bottle.root_url, - "cellar" => bottle_cellar.to_s, - "rebuild" => bottle.rebuild, - "date" => Pathname(filename.to_s).mtime.strftime("%F"), - "tags" => { - bottle_tag.to_s => { - "filename" => filename.url_encode, - "local_filename" => filename.to_s, - "sha256" => sha256, - "tab" => tab.to_bottle_hash, + output = bottle_output(bottle, args.root_url_using) + + puts "./#{local_filename}" + puts output + + return unless args.json? + + json = { + formula.full_name => { + "formula" => { + "name" => formula.name, + "pkg_version" => formula.pkg_version.to_s, + "path" => formula.path.to_s.delete_prefix("#{HOMEBREW_REPOSITORY}/"), + "tap_git_path" => formula.path.to_s.delete_prefix("#{tap_path}/"), + "tap_git_revision" => tap_git_revision, + "tap_git_remote" => tap_git_remote, + # descriptions can contain emoji. sigh. + "desc" => formula.desc.to_s.encode( + Encoding.find("ASCII"), + invalid: :replace, undef: :replace, replace: "", + ).strip, + "license" => SPDX.license_expression_to_string(formula.license), + "homepage" => formula.homepage, + }, + "bottle" => { + "root_url" => bottle.root_url, + "cellar" => bottle_cellar.to_s, + "rebuild" => bottle.rebuild, + "date" => Pathname(filename.to_s).mtime.strftime("%F"), + "tags" => { + bottle_tag.to_s => { + "filename" => filename.url_encode, + "local_filename" => filename.to_s, + "sha256" => sha256, + "tab" => tab.to_bottle_hash, + }, + }, }, }, - }, - }, - } - - puts "Writing #{filename.json}" if args.verbose? - json_path = Pathname(filename.json) - json_path.unlink if json_path.exist? - json_path.write(JSON.pretty_generate(json)) - end + } - def self.parse_json_files(filenames) - filenames.map do |filename| - JSON.parse(File.read(filename)) - end - end + puts "Writing #{filename.json}" if args.verbose? + json_path = Pathname(filename.json) + json_path.unlink if json_path.exist? + json_path.write(JSON.pretty_generate(json)) + end - def self.merge_json_files(json_files) - json_files.reduce({}) do |hash, json_file| - json_file.each_value do |json_hash| - json_bottle = json_hash["bottle"] - cellar = json_bottle.delete("cellar") - json_bottle["tags"].each_value do |json_platform| - json_platform["cellar"] ||= cellar + def parse_json_files(filenames) + filenames.map do |filename| + JSON.parse(File.read(filename)) end end - hash.deep_merge(json_file) - end - end - def self.merge(args:) - bottles_hash = merge_json_files(parse_json_files(args.named)) + def merge_json_files(json_files) + json_files.reduce({}) do |hash, json_file| + json_file.each_value do |json_hash| + json_bottle = json_hash["bottle"] + cellar = json_bottle.delete("cellar") + json_bottle["tags"].each_value do |json_platform| + json_platform["cellar"] ||= cellar + end + end + hash.deep_merge(json_file) + end + end - any_cellars = ["any", "any_skip_relocation"] - bottles_hash.each do |formula_name, bottle_hash| - ohai formula_name + def merge + bottles_hash = merge_json_files(parse_json_files(args.named)) - bottle = BottleSpecification.new - bottle.root_url bottle_hash["bottle"]["root_url"] - bottle.rebuild bottle_hash["bottle"]["rebuild"] + any_cellars = ["any", "any_skip_relocation"] + bottles_hash.each do |formula_name, bottle_hash| + ohai formula_name - path = HOMEBREW_REPOSITORY/bottle_hash["formula"]["path"] - formula = Formulary.factory(path) + bottle = BottleSpecification.new + bottle.root_url bottle_hash["bottle"]["root_url"] + bottle.rebuild bottle_hash["bottle"]["rebuild"] - old_bottle_spec = formula.bottle_specification - old_pkg_version = formula.pkg_version - FormulaVersions.new(formula).formula_at_revision("origin/HEAD") do |upstream_formula| - old_pkg_version = upstream_formula.pkg_version - end + path = HOMEBREW_REPOSITORY/bottle_hash["formula"]["path"] + formula = Formulary.factory(path) - old_bottle_spec_matches = old_bottle_spec && - bottle_hash["formula"]["pkg_version"] == old_pkg_version.to_s && - bottle.root_url == old_bottle_spec.root_url && - old_bottle_spec.collector.tags.present? - - # if all the cellars and checksums are the same: we can create an - # `all: $SHA256` bottle. - tag_hashes = bottle_hash["bottle"]["tags"].values - all_bottle = !args.no_all_checks? && - (!old_bottle_spec_matches || bottle.rebuild != old_bottle_spec.rebuild) && - tag_hashes.count > 1 && - tag_hashes.uniq { |tag_hash| "#{tag_hash["cellar"]}-#{tag_hash["sha256"]}" }.count == 1 - - bottle_hash["bottle"]["tags"].each do |tag, tag_hash| - cellar = tag_hash["cellar"] - cellar = cellar.to_sym if any_cellars.include?(cellar) - - tag_sym = if all_bottle - :all - else - tag.to_sym - end + old_bottle_spec = formula.bottle_specification + old_pkg_version = formula.pkg_version + FormulaVersions.new(formula).formula_at_revision("origin/HEAD") do |upstream_formula| + old_pkg_version = upstream_formula.pkg_version + end - sha256_hash = { cellar:, tag_sym => tag_hash["sha256"] } - bottle.sha256 sha256_hash + old_bottle_spec_matches = old_bottle_spec && + bottle_hash["formula"]["pkg_version"] == old_pkg_version.to_s && + bottle.root_url == old_bottle_spec.root_url && + old_bottle_spec.collector.tags.present? + + # if all the cellars and checksums are the same: we can create an + # `all: $SHA256` bottle. + tag_hashes = bottle_hash["bottle"]["tags"].values + all_bottle = !args.no_all_checks? && + (!old_bottle_spec_matches || bottle.rebuild != old_bottle_spec.rebuild) && + tag_hashes.count > 1 && + tag_hashes.uniq { |tag_hash| "#{tag_hash["cellar"]}-#{tag_hash["sha256"]}" }.count == 1 + + bottle_hash["bottle"]["tags"].each do |tag, tag_hash| + cellar = tag_hash["cellar"] + cellar = cellar.to_sym if any_cellars.include?(cellar) + + tag_sym = if all_bottle + :all + else + tag.to_sym + end + + sha256_hash = { cellar:, tag_sym => tag_hash["sha256"] } + bottle.sha256 sha256_hash + + break if all_bottle + end - break if all_bottle - end + unless args.write? + puts bottle_output(bottle, args.root_url_using) + next + end - unless args.write? - puts bottle_output(bottle, args.root_url_using) - next - end + no_bottle_changes = if !args.no_all_checks? && old_bottle_spec_matches && + bottle.rebuild != old_bottle_spec.rebuild + bottle.collector.tags.all? do |tag| + tag_spec = bottle.collector.specification_for(tag) + next false if tag_spec.blank? - no_bottle_changes = if !args.no_all_checks? && old_bottle_spec_matches && - bottle.rebuild != old_bottle_spec.rebuild - bottle.collector.tags.all? do |tag| - tag_spec = bottle.collector.specification_for(tag) - next false if tag_spec.blank? + old_tag_spec = old_bottle_spec.collector.specification_for(tag) + next false if old_tag_spec.blank? - old_tag_spec = old_bottle_spec.collector.specification_for(tag) - next false if old_tag_spec.blank? + next false if tag_spec.cellar != old_tag_spec.cellar - next false if tag_spec.cellar != old_tag_spec.cellar + tag_spec.checksum.hexdigest == old_tag_spec.checksum.hexdigest + end + end - tag_spec.checksum.hexdigest == old_tag_spec.checksum.hexdigest - end - end + all_bottle_hash = T.let(nil, T.nilable(Hash)) + bottle_hash["bottle"]["tags"].each do |tag, tag_hash| + filename = ::Bottle::Filename.new( + formula_name, + PkgVersion.parse(bottle_hash["formula"]["pkg_version"]), + Utils::Bottles::Tag.from_symbol(tag.to_sym), + bottle_hash["bottle"]["rebuild"], + ) + + if all_bottle && all_bottle_hash.nil? + all_bottle_tag_hash = tag_hash.dup + + all_filename = ::Bottle::Filename.new( + formula_name, + PkgVersion.parse(bottle_hash["formula"]["pkg_version"]), + Utils::Bottles::Tag.from_symbol(:all), + bottle_hash["bottle"]["rebuild"], + ) + + all_bottle_tag_hash["filename"] = all_filename.url_encode + all_bottle_tag_hash["local_filename"] = all_filename.to_s + cellar = all_bottle_tag_hash.delete("cellar") + + all_bottle_formula_hash = bottle_hash.dup + all_bottle_formula_hash["bottle"]["cellar"] = cellar + all_bottle_formula_hash["bottle"]["tags"] = { all: all_bottle_tag_hash } + + all_bottle_hash = { formula_name => all_bottle_formula_hash } + + puts "Copying #{filename} to #{all_filename}" if args.verbose? + FileUtils.cp filename.to_s, all_filename.to_s + + puts "Writing #{all_filename.json}" if args.verbose? + all_local_json_path = Pathname(all_filename.json) + all_local_json_path.unlink if all_local_json_path.exist? + all_local_json_path.write(JSON.pretty_generate(all_bottle_hash)) + end + + if all_bottle || no_bottle_changes + puts "Removing #{filename} and #{filename.json}" if args.verbose? + FileUtils.rm_f [filename.to_s, filename.json] + end + end - all_bottle_hash = T.let(nil, T.nilable(Hash)) - bottle_hash["bottle"]["tags"].each do |tag, tag_hash| - filename = Bottle::Filename.new( - formula_name, - PkgVersion.parse(bottle_hash["formula"]["pkg_version"]), - Utils::Bottles::Tag.from_symbol(tag.to_sym), - bottle_hash["bottle"]["rebuild"], - ) - - if all_bottle && all_bottle_hash.nil? - all_bottle_tag_hash = tag_hash.dup - - all_filename = Bottle::Filename.new( - formula_name, - PkgVersion.parse(bottle_hash["formula"]["pkg_version"]), - Utils::Bottles::Tag.from_symbol(:all), - bottle_hash["bottle"]["rebuild"], - ) - - all_bottle_tag_hash["filename"] = all_filename.url_encode - all_bottle_tag_hash["local_filename"] = all_filename.to_s - cellar = all_bottle_tag_hash.delete("cellar") - - all_bottle_formula_hash = bottle_hash.dup - all_bottle_formula_hash["bottle"]["cellar"] = cellar - all_bottle_formula_hash["bottle"]["tags"] = { all: all_bottle_tag_hash } - - all_bottle_hash = { formula_name => all_bottle_formula_hash } - - puts "Copying #{filename} to #{all_filename}" if args.verbose? - FileUtils.cp filename.to_s, all_filename.to_s - - puts "Writing #{all_filename.json}" if args.verbose? - all_local_json_path = Pathname(all_filename.json) - all_local_json_path.unlink if all_local_json_path.exist? - all_local_json_path.write(JSON.pretty_generate(all_bottle_hash)) - end + next if no_bottle_changes - if all_bottle || no_bottle_changes - puts "Removing #{filename} and #{filename.json}" if args.verbose? - FileUtils.rm_f [filename.to_s, filename.json] - end - end + require "utils/ast" + formula_ast = Utils::AST::FormulaAST.new(path.read) + checksums = old_checksums(formula, formula_ast, bottle_hash) + update_or_add = checksums.nil? ? "add" : "update" - next if no_bottle_changes + checksums&.each(&bottle.method(:sha256)) + output = bottle_output(bottle, args.root_url_using) + puts output - require "utils/ast" - formula_ast = Utils::AST::FormulaAST.new(path.read) - checksums = old_checksums(formula, formula_ast, bottle_hash, args:) - update_or_add = checksums.nil? ? "add" : "update" + case update_or_add + when "update" + formula_ast.replace_bottle_block(output) + when "add" + formula_ast.add_bottle_block(output) + end + path.atomic_write(formula_ast.process) - checksums&.each(&bottle.method(:sha256)) - output = bottle_output(bottle, args.root_url_using) - puts output + next if args.no_commit? - case update_or_add - when "update" - formula_ast.replace_bottle_block(output) - when "add" - formula_ast.add_bottle_block(output) - end - path.atomic_write(formula_ast.process) + Utils::Git.set_name_email!(committer: args.committer.blank?) + Utils::Git.setup_gpg! - next if args.no_commit? + if (committer = args.committer) + committer = Utils.parse_author!(committer) + ENV["GIT_COMMITTER_NAME"] = committer[:name] + ENV["GIT_COMMITTER_EMAIL"] = committer[:email] + end - Utils::Git.set_name_email!(committer: args.committer.blank?) - Utils::Git.setup_gpg! + short_name = formula_name.split("/", -1).last + pkg_version = bottle_hash["formula"]["pkg_version"] - if (committer = args.committer) - committer = Utils.parse_author!(committer) - ENV["GIT_COMMITTER_NAME"] = committer[:name] - ENV["GIT_COMMITTER_EMAIL"] = committer[:email] + path.parent.cd do + safe_system "git", "commit", "--no-edit", "--verbose", + "--message=#{short_name}: #{update_or_add} #{pkg_version} bottle.", + "--", path + end + end end - short_name = formula_name.split("/", -1).last - pkg_version = bottle_hash["formula"]["pkg_version"] + def merge_bottle_spec(old_keys, old_bottle_spec, new_bottle_hash) + mismatches = [] + checksums = [] - path.parent.cd do - safe_system "git", "commit", "--no-edit", "--verbose", - "--message=#{short_name}: #{update_or_add} #{pkg_version} bottle.", - "--", path - end - end - end + new_values = { + root_url: new_bottle_hash["root_url"], + rebuild: new_bottle_hash["rebuild"], + } - def self.merge_bottle_spec(old_keys, old_bottle_spec, new_bottle_hash) - mismatches = [] - checksums = [] + skip_keys = [:sha256, :cellar] + old_keys.each do |key| + next if skip_keys.include?(key) - new_values = { - root_url: new_bottle_hash["root_url"], - rebuild: new_bottle_hash["rebuild"], - } + old_value = old_bottle_spec.send(key).to_s + new_value = new_values[key].to_s - skip_keys = [:sha256, :cellar] - old_keys.each do |key| - next if skip_keys.include?(key) + next if old_value.present? && new_value == old_value - old_value = old_bottle_spec.send(key).to_s - new_value = new_values[key].to_s + mismatches << "#{key}: old: #{old_value.inspect}, new: #{new_value.inspect}" + end - next if old_value.present? && new_value == old_value + return [mismatches, checksums] if old_keys.exclude? :sha256 - mismatches << "#{key}: old: #{old_value.inspect}, new: #{new_value.inspect}" - end + old_bottle_spec.collector.each_tag do |tag| + old_tag_spec = old_bottle_spec.collector.specification_for(tag) + old_hexdigest = old_tag_spec.checksum.hexdigest + old_cellar = old_tag_spec.cellar + new_value = new_bottle_hash.dig("tags", tag.to_s) + if new_value.present? && new_value["sha256"] != old_hexdigest + mismatches << "sha256 #{tag}: old: #{old_hexdigest.inspect}, new: #{new_value["sha256"].inspect}" + elsif new_value.present? && new_value["cellar"] != old_cellar.to_s + mismatches << "cellar #{tag}: old: #{old_cellar.to_s.inspect}, new: #{new_value["cellar"].inspect}" + else + checksums << { cellar: old_cellar, tag.to_sym => old_hexdigest } + end + end - return [mismatches, checksums] if old_keys.exclude? :sha256 - - old_bottle_spec.collector.each_tag do |tag| - old_tag_spec = old_bottle_spec.collector.specification_for(tag) - old_hexdigest = old_tag_spec.checksum.hexdigest - old_cellar = old_tag_spec.cellar - new_value = new_bottle_hash.dig("tags", tag.to_s) - if new_value.present? && new_value["sha256"] != old_hexdigest - mismatches << "sha256 #{tag}: old: #{old_hexdigest.inspect}, new: #{new_value["sha256"].inspect}" - elsif new_value.present? && new_value["cellar"] != old_cellar.to_s - mismatches << "cellar #{tag}: old: #{old_cellar.to_s.inspect}, new: #{new_value["cellar"].inspect}" - else - checksums << { cellar: old_cellar, tag.to_sym => old_hexdigest } + [mismatches, checksums] end - end - [mismatches, checksums] - end - - def self.old_checksums(formula, formula_ast, bottle_hash, args:) - bottle_node = formula_ast.bottle_block - return if bottle_node.nil? - return [] unless args.keep_old? - - old_keys = T.cast(Utils::AST.body_children(bottle_node.body), T::Array[RuboCop::AST::SendNode]).map(&:method_name) - old_bottle_spec = formula.bottle_specification - mismatches, checksums = merge_bottle_spec(old_keys, old_bottle_spec, bottle_hash["bottle"]) - if mismatches.present? - odie <<~EOS - `--keep-old` was passed but there are changes in: - #{mismatches.join("\n")} - EOS + def old_checksums(formula, formula_ast, bottle_hash) + bottle_node = formula_ast.bottle_block + return if bottle_node.nil? + return [] unless args.keep_old? + + old_keys = T.cast(Utils::AST.body_children(bottle_node.body), T::Array[RuboCop::AST::SendNode]) + .map(&:method_name) + old_bottle_spec = formula.bottle_specification + mismatches, checksums = merge_bottle_spec(old_keys, old_bottle_spec, bottle_hash["bottle"]) + if mismatches.present? + odie <<~EOS + `--keep-old` was passed but there are changes in: + #{mismatches.join("\n")} + EOS + end + checksums + end end - checksums end end diff --git a/Library/Homebrew/dev-cmd/bump-cask-pr.rb b/Library/Homebrew/dev-cmd/bump-cask-pr.rb index e51cc9390b7b5..cc235e9af9d3c 100644 --- a/Library/Homebrew/dev-cmd/bump-cask-pr.rb +++ b/Library/Homebrew/dev-cmd/bump-cask-pr.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require "abstract_command" require "bump_version_parser" require "cask" require "cask/download" @@ -8,320 +9,319 @@ require "utils/tar" module Homebrew - module_function - - sig { returns(CLI::Parser) } - def bump_cask_pr_args - Homebrew::CLI::Parser.new do - description <<~EOS - Create a pull request to update with a new version. - - A best effort to determine the will be made if the value is not - supplied by the user. - EOS - switch "-n", "--dry-run", - description: "Print what would be done rather than doing it." - switch "--write-only", - description: "Make the expected file modifications without taking any Git actions." - switch "--commit", - depends_on: "--write-only", - description: "When passed with `--write-only`, generate a new commit after writing changes " \ - "to the cask file." - switch "--no-audit", - description: "Don't run `brew audit` before opening the PR." - switch "--online", - hidden: true - switch "--no-style", - description: "Don't run `brew style --fix` before opening the PR." - switch "--no-browse", - description: "Print the pull request URL instead of opening in a browser." - switch "--no-fork", - description: "Don't try to fork the repository." - flag "--version=", - description: "Specify the new for the cask." - flag "--version-arm=", - description: "Specify the new cask for the ARM architecture." - flag "--version-intel=", - description: "Specify the new cask for the Intel architecture." - flag "--message=", - description: "Prepend to the default pull request message." - flag "--url=", - description: "Specify the for the new download." - flag "--sha256=", - description: "Specify the checksum of the new download." - flag "--fork-org=", - description: "Use the specified GitHub organization for forking." - switch "-f", "--force", - hidden: true - - conflicts "--dry-run", "--write" - conflicts "--no-audit", "--online" - conflicts "--version=", "--version-arm=" - conflicts "--version=", "--version-intel=" - - named_args :cask, number: 1, without_api: true - end - end - - sig { void } - def bump_cask_pr - args = bump_cask_pr_args.parse + module DevCmd + class BumpCaskPr < AbstractCommand + cmd_args do + description <<~EOS + Create a pull request to update with a new version. + + A best effort to determine the will be made if the value is not + supplied by the user. + EOS + switch "-n", "--dry-run", + description: "Print what would be done rather than doing it." + switch "--write-only", + description: "Make the expected file modifications without taking any Git actions." + switch "--commit", + depends_on: "--write-only", + description: "When passed with `--write-only`, generate a new commit after writing changes " \ + "to the cask file." + switch "--no-audit", + description: "Don't run `brew audit` before opening the PR." + switch "--online", + hidden: true + switch "--no-style", + description: "Don't run `brew style --fix` before opening the PR." + switch "--no-browse", + description: "Print the pull request URL instead of opening in a browser." + switch "--no-fork", + description: "Don't try to fork the repository." + flag "--version=", + description: "Specify the new for the cask." + flag "--version-arm=", + description: "Specify the new cask for the ARM architecture." + flag "--version-intel=", + description: "Specify the new cask for the Intel architecture." + flag "--message=", + description: "Prepend to the default pull request message." + flag "--url=", + description: "Specify the for the new download." + flag "--sha256=", + description: "Specify the checksum of the new download." + flag "--fork-org=", + description: "Use the specified GitHub organization for forking." + switch "-f", "--force", + hidden: true + + conflicts "--dry-run", "--write" + conflicts "--no-audit", "--online" + conflicts "--version=", "--version-arm=" + conflicts "--version=", "--version-intel=" + + named_args :cask, number: 1, without_api: true + end - odeprecated "brew bump-cask-pr --online" if args.online? - odisabled "brew bump-cask-pr --force" if args.force? + sig { override.void } + def run + odeprecated "brew bump-cask-pr --online" if args.online? + odisabled "brew bump-cask-pr --force" if args.force? - # This will be run by `brew audit` or `brew style` later so run it first to - # not start spamming during normal output. - gem_groups = [] - gem_groups << "style" if !args.no_audit? || !args.no_style? - gem_groups << "audit" unless args.no_audit? - Homebrew.install_bundler_gems!(groups: gem_groups) unless gem_groups.empty? + # This will be run by `brew audit` or `brew style` later so run it first to + # not start spamming during normal output. + gem_groups = [] + gem_groups << "style" if !args.no_audit? || !args.no_style? + gem_groups << "audit" unless args.no_audit? + Homebrew.install_bundler_gems!(groups: gem_groups) unless gem_groups.empty? - # As this command is simplifying user-run commands then let's just use a - # user path, too. - ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s + # As this command is simplifying user-run commands then let's just use a + # user path, too. + ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s - # Use the user's browser, too. - ENV["BROWSER"] = EnvConfig.browser + # Use the user's browser, too. + ENV["BROWSER"] = EnvConfig.browser - cask = args.named.to_casks.first + cask = args.named.to_casks.first - odie "This cask is not in a tap!" if cask.tap.blank? - odie "This cask's tap is not a Git repository!" unless cask.tap.git? + odie "This cask is not in a tap!" if cask.tap.blank? + odie "This cask's tap is not a Git repository!" unless cask.tap.git? - odie <<~EOS unless cask.tap.allow_bump?(cask.token) - Whoops, the #{cask.token} cask has its version update - pull requests automatically opened by BrewTestBot! - We'd still love your contributions, though, so try another one - that's not in the autobump list: - #{Formatter.url("#{cask.tap.remote}/blob/master/.github/autobump.txt")} - EOS + odie <<~EOS unless cask.tap.allow_bump?(cask.token) + Whoops, the #{cask.token} cask has its version update + pull requests automatically opened by BrewTestBot! + We'd still love your contributions, though, so try another one + that's not in the autobump list: + #{Formatter.url("#{cask.tap.remote}/blob/master/.github/autobump.txt")} + EOS - new_version = BumpVersionParser.new( - general: args.version, - intel: args.version_intel, - arm: args.version_arm, - ) + new_version = BumpVersionParser.new( + general: args.version, + intel: args.version_intel, + arm: args.version_arm, + ) - new_hash = unless (new_hash = args.sha256).nil? - raise UsageError, "`--sha256` must not be empty." if new_hash.blank? + new_hash = unless (new_hash = args.sha256).nil? + raise UsageError, "`--sha256` must not be empty." if new_hash.blank? - ["no_check", ":no_check"].include?(new_hash) ? :no_check : new_hash - end + ["no_check", ":no_check"].include?(new_hash) ? :no_check : new_hash + end - new_base_url = unless (new_base_url = args.url).nil? - raise UsageError, "`--url` must not be empty." if new_base_url.blank? + new_base_url = unless (new_base_url = args.url).nil? + raise UsageError, "`--url` must not be empty." if new_base_url.blank? - begin - URI(new_base_url) - rescue URI::InvalidURIError - raise UsageError, "`--url` is not valid." - end - end + begin + URI(new_base_url) + rescue URI::InvalidURIError + raise UsageError, "`--url` is not valid." + end + end - if new_version.blank? && new_base_url.nil? && new_hash.nil? - raise UsageError, "No `--version`, `--url` or `--sha256` argument specified!" - end + if new_version.blank? && new_base_url.nil? && new_hash.nil? + raise UsageError, "No `--version`, `--url` or `--sha256` argument specified!" + end - check_pull_requests(cask, args:, new_version:) + check_pull_requests(cask, new_version:) - replacement_pairs ||= [] - branch_name = "bump-#{cask.token}" - commit_message = nil + replacement_pairs ||= [] + branch_name = "bump-#{cask.token}" + commit_message = nil - old_contents = File.read(cask.sourcefile_path) + old_contents = File.read(cask.sourcefile_path) - if new_base_url - commit_message ||= "#{cask.token}: update URL" + if new_base_url + commit_message ||= "#{cask.token}: update URL" - m = /^ +url "(.+?)"\n/m.match(old_contents) - odie "Could not find old URL in cask!" if m.nil? + m = /^ +url "(.+?)"\n/m.match(old_contents) + odie "Could not find old URL in cask!" if m.nil? - old_base_url = m.captures.fetch(0) + old_base_url = m.captures.fetch(0) - replacement_pairs << [ - /#{Regexp.escape(old_base_url)}/, - new_base_url.to_s, - ] - end + replacement_pairs << [ + /#{Regexp.escape(old_base_url)}/, + new_base_url.to_s, + ] + end - if new_version.present? - # For simplicity, our naming defers to the arm version if we multiple architectures are specified - branch_version = new_version.arm || new_version.general - if branch_version.is_a?(Cask::DSL::Version) - commit_version = shortened_version(branch_version, cask:) - branch_name = "bump-#{cask.token}-#{branch_version.tr(",:", "-")}" - commit_message ||= "#{cask.token} #{commit_version}" + if new_version.present? + # For simplicity, our naming defers to the arm version if we multiple architectures are specified + branch_version = new_version.arm || new_version.general + if branch_version.is_a?(Cask::DSL::Version) + commit_version = shortened_version(branch_version, cask:) + branch_name = "bump-#{cask.token}-#{branch_version.tr(",:", "-")}" + commit_message ||= "#{cask.token} #{commit_version}" + end + replacement_pairs = replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs) + end + # Now that we have all replacement pairs, we will replace them further down + + commit_message ||= "#{cask.token}: update checksum" if new_hash + + # Remove nested arrays where elements are identical + replacement_pairs = replacement_pairs.reject { |pair| pair[0] == pair[1] }.uniq.compact + Utils::Inreplace.inreplace_pairs(cask.sourcefile_path, + replacement_pairs, + read_only_run: args.dry_run?, + silent: args.quiet?) + + run_cask_audit(cask, old_contents) + run_cask_style(cask, old_contents) + + pr_info = { + branch_name:, + commit_message:, + old_contents:, + pr_message: "Created with `brew bump-cask-pr`.", + sourcefile_path: cask.sourcefile_path, + tap: cask.tap, + } + GitHub.create_bump_pr(pr_info, args:) end - replacement_pairs = replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs) - end - # Now that we have all replacement pairs, we will replace them further down - - commit_message ||= "#{cask.token}: update checksum" if new_hash - - # Remove nested arrays where elements are identical - replacement_pairs = replacement_pairs.reject { |pair| pair[0] == pair[1] }.uniq.compact - Utils::Inreplace.inreplace_pairs(cask.sourcefile_path, - replacement_pairs, - read_only_run: args.dry_run?, - silent: args.quiet?) - - run_cask_audit(cask, old_contents, args:) - run_cask_style(cask, old_contents, args:) - - pr_info = { - branch_name:, - commit_message:, - old_contents:, - pr_message: "Created with `brew bump-cask-pr`.", - sourcefile_path: cask.sourcefile_path, - tap: cask.tap, - } - GitHub.create_bump_pr(pr_info, args:) - end - sig { params(version: Cask::DSL::Version, cask: Cask::Cask).returns(Cask::DSL::Version) } - def shortened_version(version, cask:) - if version.before_comma == cask.version.before_comma - version - else - version.before_comma - end - end + private - sig { - params( - cask: Cask::Cask, - new_hash: T.any(NilClass, String, Symbol), - new_version: BumpVersionParser, - replacement_pairs: T::Array[[T.any(Regexp, String), T.any(Regexp, String)]], - ).returns(T::Array[[T.any(Regexp, String), T.any(Regexp, String)]]) - } - def replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs) - # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture. - arch_options = cask.on_system_blocks_exist? ? OnSystem::ARCH_OPTIONS : [:arm] - arch_options.each do |arch| - SimulateSystem.with(arch:) do - old_cask = Cask::CaskLoader.load(cask.sourcefile_path) - old_version = old_cask.version - bump_version = new_version.send(arch) || new_version.general - - old_version_regex = old_version.latest? ? ":latest" : %Q(["']#{Regexp.escape(old_version.to_s)}["']) - replacement_pairs << [/version\s+#{old_version_regex}/m, - "version #{bump_version.latest? ? ":latest" : %Q("#{bump_version}")}"] - - # We are replacing our version here so we can get the new hash - tmp_contents = Utils::Inreplace.inreplace_pairs(cask.sourcefile_path, - replacement_pairs.uniq.compact, - read_only_run: true, - silent: true) - - tmp_cask = Cask::CaskLoader.load(tmp_contents) - old_hash = tmp_cask.sha256 - if tmp_cask.version.latest? || new_hash == :no_check - opoo "Ignoring specified `--sha256=` argument." if new_hash.is_a?(String) - replacement_pairs << [/"#{old_hash}"/, ":no_check"] if old_hash != :no_check - elsif old_hash == :no_check && new_hash != :no_check - replacement_pairs << [":no_check", "\"#{new_hash}\""] if new_hash.is_a?(String) - elsif new_hash && !cask.on_system_blocks_exist? && cask.languages.empty? - replacement_pairs << [old_hash.to_s, new_hash.to_s] - elsif old_hash != :no_check - opoo "Multiple checksum replacements required; ignoring specified `--sha256` argument." if new_hash - languages = if cask.languages.empty? - [nil] - else - cask.languages - end - languages.each do |language| - new_cask = Cask::CaskLoader.load(tmp_contents) - new_cask.config = if language.blank? - tmp_cask.config - else - tmp_cask.config.merge(Cask::Config.new(explicit: { languages: [language] })) - end - download = Cask::Download.new(new_cask, quarantine: true).fetch(verify_download_integrity: false) - Utils::Tar.validate_file(download) + sig { params(version: Cask::DSL::Version, cask: Cask::Cask).returns(Cask::DSL::Version) } + def shortened_version(version, cask:) + if version.before_comma == cask.version.before_comma + version + else + version.before_comma + end + end - if new_cask.sha256.to_s != download.sha256 - replacement_pairs << [new_cask.sha256.to_s, - download.sha256] + sig { + params( + cask: Cask::Cask, + new_hash: T.any(NilClass, String, Symbol), + new_version: BumpVersionParser, + replacement_pairs: T::Array[[T.any(Regexp, String), T.any(Regexp, String)]], + ).returns(T::Array[[T.any(Regexp, String), T.any(Regexp, String)]]) + } + def replace_version_and_checksum(cask, new_hash, new_version, replacement_pairs) + # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture. + arch_options = cask.on_system_blocks_exist? ? OnSystem::ARCH_OPTIONS : [:arm] + arch_options.each do |arch| + SimulateSystem.with(arch:) do + old_cask = Cask::CaskLoader.load(cask.sourcefile_path) + old_version = old_cask.version + bump_version = new_version.send(arch) || new_version.general + + old_version_regex = old_version.latest? ? ":latest" : %Q(["']#{Regexp.escape(old_version.to_s)}["']) + replacement_pairs << [/version\s+#{old_version_regex}/m, + "version #{bump_version.latest? ? ":latest" : %Q("#{bump_version}")}"] + + # We are replacing our version here so we can get the new hash + tmp_contents = Utils::Inreplace.inreplace_pairs(cask.sourcefile_path, + replacement_pairs.uniq.compact, + read_only_run: true, + silent: true) + + tmp_cask = Cask::CaskLoader.load(tmp_contents) + old_hash = tmp_cask.sha256 + if tmp_cask.version.latest? || new_hash == :no_check + opoo "Ignoring specified `--sha256=` argument." if new_hash.is_a?(String) + replacement_pairs << [/"#{old_hash}"/, ":no_check"] if old_hash != :no_check + elsif old_hash == :no_check && new_hash != :no_check + replacement_pairs << [":no_check", "\"#{new_hash}\""] if new_hash.is_a?(String) + elsif new_hash && !cask.on_system_blocks_exist? && cask.languages.empty? + replacement_pairs << [old_hash.to_s, new_hash.to_s] + elsif old_hash != :no_check + opoo "Multiple checksum replacements required; ignoring specified `--sha256` argument." if new_hash + languages = if cask.languages.empty? + [nil] + else + cask.languages + end + languages.each do |language| + new_cask = Cask::CaskLoader.load(tmp_contents) + new_cask.config = if language.blank? + tmp_cask.config + else + tmp_cask.config.merge(Cask::Config.new(explicit: { languages: [language] })) + end + download = Cask::Download.new(new_cask, quarantine: true).fetch(verify_download_integrity: false) + Utils::Tar.validate_file(download) + + if new_cask.sha256.to_s != download.sha256 + replacement_pairs << [new_cask.sha256.to_s, + download.sha256] + end + end end end end + replacement_pairs end - end - replacement_pairs - end - sig { params(cask: Cask::Cask, args: CLI::Args, new_version: BumpVersionParser).void } - def check_pull_requests(cask, args:, new_version:) - tap_remote_repo = cask.tap.full_name || cask.tap.remote_repo - - GitHub.check_for_duplicate_pull_requests(cask.token, tap_remote_repo, - state: "open", - version: nil, - file: cask.sourcefile_path.relative_path_from(cask.tap.path).to_s, - quiet: args.quiet?) - - # if we haven't already found open requests, try for an exact match across closed requests - new_version.instance_variables.each do |version_type| - version = new_version.instance_variable_get(version_type) - next if version.blank? - - GitHub.check_for_duplicate_pull_requests( - cask.token, - tap_remote_repo, - state: "closed", - version: shortened_version(version, cask:), - file: cask.sourcefile_path.relative_path_from(cask.tap.path).to_s, - quiet: args.quiet?, - ) - end - end + sig { params(cask: Cask::Cask, new_version: BumpVersionParser).void } + def check_pull_requests(cask, new_version:) + tap_remote_repo = cask.tap.full_name || cask.tap.remote_repo + + GitHub.check_for_duplicate_pull_requests(cask.token, tap_remote_repo, + state: "open", + version: nil, + file: cask.sourcefile_path.relative_path_from(cask.tap.path).to_s, + quiet: args.quiet?) + + # if we haven't already found open requests, try for an exact match across closed requests + new_version.instance_variables.each do |version_type| + version = new_version.instance_variable_get(version_type) + next if version.blank? + + GitHub.check_for_duplicate_pull_requests( + cask.token, + tap_remote_repo, + state: "closed", + version: shortened_version(version, cask:), + file: cask.sourcefile_path.relative_path_from(cask.tap.path).to_s, + quiet: args.quiet?, + ) + end + end + + sig { params(cask: Cask::Cask, old_contents: String).void } + def run_cask_audit(cask, old_contents) + if args.dry_run? + if args.no_audit? + ohai "Skipping `brew audit`" + else + ohai "brew audit --cask --online #{cask.full_name}" + end + return + end + failed_audit = false + if args.no_audit? + ohai "Skipping `brew audit`" + else + system HOMEBREW_BREW_FILE, "audit", "--cask", "--online", cask.full_name + failed_audit = !$CHILD_STATUS.success? + end + return unless failed_audit - sig { params(cask: Cask::Cask, old_contents: String, args: T.untyped).void } - def run_cask_audit(cask, old_contents, args:) - if args.dry_run? - if args.no_audit? - ohai "Skipping `brew audit`" - else - ohai "brew audit --cask --online #{cask.full_name}" + cask.sourcefile_path.atomic_write(old_contents) + odie "`brew audit` failed!" end - return - end - failed_audit = false - if args.no_audit? - ohai "Skipping `brew audit`" - else - system HOMEBREW_BREW_FILE, "audit", "--cask", "--online", cask.full_name - failed_audit = !$CHILD_STATUS.success? - end - return unless failed_audit - cask.sourcefile_path.atomic_write(old_contents) - odie "`brew audit` failed!" - end + sig { params(cask: Cask::Cask, old_contents: String).void } + def run_cask_style(cask, old_contents) + if args.dry_run? + if args.no_style? + ohai "Skipping `brew style --fix`" + else + ohai "brew style --fix #{cask.sourcefile_path.basename}" + end + return + end + failed_style = false + if args.no_style? + ohai "Skipping `brew style --fix`" + else + system HOMEBREW_BREW_FILE, "style", "--fix", cask.sourcefile_path + failed_style = !$CHILD_STATUS.success? + end + return unless failed_style - sig { params(cask: Cask::Cask, old_contents: String, args: T.untyped).void } - def run_cask_style(cask, old_contents, args:) - if args.dry_run? - if args.no_style? - ohai "Skipping `brew style --fix`" - else - ohai "brew style --fix #{cask.sourcefile_path.basename}" + cask.sourcefile_path.atomic_write(old_contents) + odie "`brew style --fix` failed!" end - return end - failed_style = false - if args.no_style? - ohai "Skipping `brew style --fix`" - else - system HOMEBREW_BREW_FILE, "style", "--fix", cask.sourcefile_path - failed_style = !$CHILD_STATUS.success? - end - return unless failed_style - - cask.sourcefile_path.atomic_write(old_contents) - odie "`brew style --fix` failed!" end end diff --git a/Library/Homebrew/dev-cmd/bump-formula-pr.rb b/Library/Homebrew/dev-cmd/bump-formula-pr.rb index 8bd5b7fd0eeb9..84cfe7328d57f 100644 --- a/Library/Homebrew/dev-cmd/bump-formula-pr.rb +++ b/Library/Homebrew/dev-cmd/bump-formula-pr.rb @@ -1,535 +1,537 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "formula" require "cli/parser" require "utils/pypi" require "utils/tar" module Homebrew - module_function - - sig { returns(CLI::Parser) } - def bump_formula_pr_args - Homebrew::CLI::Parser.new do - description <<~EOS - Create a pull request to update with a new URL or a new tag. - - If a is specified, the checksum of the new download should also - be specified. A best effort to determine the will be made if not supplied - by the user. - - If a is specified, the Git commit corresponding to that tag - should also be specified. A best effort to determine the will be made - if the value is not supplied by the user. - - If a is specified, a best effort to determine the and or - the and will be made if both values are not supplied by the user. - - *Note:* this command cannot be used to transition a formula from a - URL-and-SHA-256 style specification into a tag-and-revision style specification, - nor vice versa. It must use whichever style specification the formula already uses. - EOS - switch "-n", "--dry-run", - description: "Print what would be done rather than doing it." - switch "--write-only", - description: "Make the expected file modifications without taking any Git actions." - switch "--commit", - depends_on: "--write-only", - description: "When passed with `--write-only`, generate a new commit after writing changes " \ - "to the formula file." - switch "--no-audit", - description: "Don't run `brew audit` before opening the PR." - switch "--strict", - description: "Run `brew audit --strict` before opening the PR." - switch "--online", - description: "Run `brew audit --online` before opening the PR." - switch "--no-browse", - description: "Print the pull request URL instead of opening in a browser." - switch "--no-fork", - description: "Don't try to fork the repository." - comma_array "--mirror", - description: "Use the specified as a mirror URL. If is a comma-separated list " \ - "of URLs, multiple mirrors will be added." - flag "--fork-org=", - description: "Use the specified GitHub organization for forking." - flag "--version=", - description: "Use the specified to override the value parsed from the URL or tag. Note " \ - "that `--version=0` can be used to delete an existing version override from a " \ - "formula if it has become redundant." - flag "--message=", - description: "Prepend to the default pull request message." - flag "--url=", - description: "Specify the for the new download. If a is specified, the " \ - "checksum of the new download should also be specified." - flag "--sha256=", - depends_on: "--url=", - description: "Specify the checksum of the new download." - flag "--tag=", - description: "Specify the new git commit for the formula." - flag "--revision=", - description: "Specify the new commit corresponding to the specified git " \ - "or specified ." - switch "-f", "--force", - description: "Remove all mirrors if `--mirror` was not specified." - switch "--install-dependencies", - description: "Install missing dependencies required to update resources." - flag "--python-package-name=", - description: "Use the specified when finding Python resources for . " \ - "If no package name is specified, it will be inferred from the formula's stable URL." - comma_array "--python-extra-packages=", - description: "Include these additional Python packages when finding resources." - comma_array "--python-exclude-packages=", - description: "Exclude these Python packages when finding resources." - - conflicts "--dry-run", "--write-only" - conflicts "--no-audit", "--strict" - conflicts "--no-audit", "--online" - conflicts "--url", "--tag" - - named_args :formula, max: 1, without_api: true - end - end + module DevCmd + class BumpFormulaPr < AbstractCommand + cmd_args do + description <<~EOS + Create a pull request to update with a new URL or a new tag. + + If a is specified, the checksum of the new download should also + be specified. A best effort to determine the will be made if not supplied + by the user. + + If a is specified, the Git commit corresponding to that tag + should also be specified. A best effort to determine the will be made + if the value is not supplied by the user. + + If a is specified, a best effort to determine the and or + the and will be made if both values are not supplied by the user. + + *Note:* this command cannot be used to transition a formula from a + URL-and-SHA-256 style specification into a tag-and-revision style specification, + nor vice versa. It must use whichever style specification the formula already uses. + EOS + switch "-n", "--dry-run", + description: "Print what would be done rather than doing it." + switch "--write-only", + description: "Make the expected file modifications without taking any Git actions." + switch "--commit", + depends_on: "--write-only", + description: "When passed with `--write-only`, generate a new commit after writing changes " \ + "to the formula file." + switch "--no-audit", + description: "Don't run `brew audit` before opening the PR." + switch "--strict", + description: "Run `brew audit --strict` before opening the PR." + switch "--online", + description: "Run `brew audit --online` before opening the PR." + switch "--no-browse", + description: "Print the pull request URL instead of opening in a browser." + switch "--no-fork", + description: "Don't try to fork the repository." + comma_array "--mirror", + description: "Use the specified as a mirror URL. If is a comma-separated list " \ + "of URLs, multiple mirrors will be added." + flag "--fork-org=", + description: "Use the specified GitHub organization for forking." + flag "--version=", + description: "Use the specified to override the value parsed from the URL or tag. Note " \ + "that `--version=0` can be used to delete an existing version override from a " \ + "formula if it has become redundant." + flag "--message=", + description: "Prepend to the default pull request message." + flag "--url=", + description: "Specify the for the new download. If a is specified, the " \ + "checksum of the new download should also be specified." + flag "--sha256=", + depends_on: "--url=", + description: "Specify the checksum of the new download." + flag "--tag=", + description: "Specify the new git commit for the formula." + flag "--revision=", + description: "Specify the new commit corresponding to the specified git " \ + "or specified ." + switch "-f", "--force", + description: "Remove all mirrors if `--mirror` was not specified." + switch "--install-dependencies", + description: "Install missing dependencies required to update resources." + flag "--python-package-name=", + description: "Use the specified when finding Python resources for . " \ + "If no package name is specified, it will be inferred from the formula's stable URL." + comma_array "--python-extra-packages=", + description: "Include these additional Python packages when finding resources." + comma_array "--python-exclude-packages=", + description: "Exclude these Python packages when finding resources." + + conflicts "--dry-run", "--write-only" + conflicts "--no-audit", "--strict" + conflicts "--no-audit", "--online" + conflicts "--url", "--tag" + + named_args :formula, max: 1, without_api: true + end - def bump_formula_pr - args = bump_formula_pr_args.parse + sig { override.void } + def run + if args.revision.present? && args.tag.nil? && args.version.nil? + raise UsageError, "`--revision` must be passed with either `--tag` or `--version`!" + end - if args.revision.present? && args.tag.nil? && args.version.nil? - raise UsageError, "`--revision` must be passed with either `--tag` or `--version`!" - end + # As this command is simplifying user-run commands then let's just use a + # user path, too. + ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s - # As this command is simplifying user-run commands then let's just use a - # user path, too. - ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s + # Use the user's browser, too. + ENV["BROWSER"] = Homebrew::EnvConfig.browser - # Use the user's browser, too. - ENV["BROWSER"] = Homebrew::EnvConfig.browser + formula = args.named.to_formulae.first + new_url = args.url + raise FormulaUnspecifiedError if formula.blank? - formula = args.named.to_formulae.first - new_url = args.url - raise FormulaUnspecifiedError if formula.blank? + odie "This formula is disabled!" if formula.disabled? + odie "This formula is deprecated and does not build!" if formula.deprecation_reason == :does_not_build + odie "This formula is not in a tap!" if formula.tap.blank? + odie "This formula's tap is not a Git repository!" unless formula.tap.git? - odie "This formula is disabled!" if formula.disabled? - odie "This formula is deprecated and does not build!" if formula.deprecation_reason == :does_not_build - odie "This formula is not in a tap!" if formula.tap.blank? - odie "This formula's tap is not a Git repository!" unless formula.tap.git? + odie <<~EOS unless formula.tap.allow_bump?(formula.name) + Whoops, the #{formula.name} formula has its version update + pull requests automatically opened by BrewTestBot! + We'd still love your contributions, though, so try another one + that's not in the autobump list: + #{Formatter.url("#{formula.tap.remote}/blob/master/.github/autobump.txt")} + EOS - odie <<~EOS unless formula.tap.allow_bump?(formula.name) - Whoops, the #{formula.name} formula has its version update - pull requests automatically opened by BrewTestBot! - We'd still love your contributions, though, so try another one - that's not in the autobump list: - #{Formatter.url("#{formula.tap.remote}/blob/master/.github/autobump.txt")} - EOS + formula_spec = formula.stable + odie "#{formula}: no stable specification found!" if formula_spec.blank? - formula_spec = formula.stable - odie "#{formula}: no stable specification found!" if formula_spec.blank? + # This will be run by `brew audit` later so run it first to not start + # spamming during normal output. + Homebrew.install_bundler_gems!(groups: ["audit", "style"]) unless args.no_audit? - # This will be run by `brew audit` later so run it first to not start - # spamming during normal output. - Homebrew.install_bundler_gems!(groups: ["audit", "style"]) unless args.no_audit? + tap_remote_repo = formula.tap.full_name || formula.tap.remote_repo + remote = "origin" + remote_branch = formula.tap.git_repo.origin_branch_name + previous_branch = "-" - tap_remote_repo = formula.tap.full_name || formula.tap.remote_repo - remote = "origin" - remote_branch = formula.tap.git_repo.origin_branch_name - previous_branch = "-" + check_open_pull_requests(formula, tap_remote_repo) - check_open_pull_requests(formula, tap_remote_repo, args:) + new_version = args.version + check_new_version(formula, tap_remote_repo, version: new_version) if new_version.present? - new_version = args.version - check_new_version(formula, tap_remote_repo, version: new_version, args:) if new_version.present? + opoo "This formula has patches that may be resolved upstream." if formula.patchlist.present? + if formula.resources.any? { |resource| !resource.name.start_with?("homebrew-") } + opoo "This formula has resources that may need to be updated." + end - opoo "This formula has patches that may be resolved upstream." if formula.patchlist.present? - if formula.resources.any? { |resource| !resource.name.start_with?("homebrew-") } - opoo "This formula has resources that may need to be updated." - end + old_mirrors = formula_spec.mirrors + new_mirrors ||= args.mirror + new_mirror ||= determine_mirror(new_url) + new_mirrors ||= [new_mirror] if new_mirror.present? + + check_for_mirrors(formula, old_mirrors, new_mirrors) if new_url.present? + + old_hash = formula_spec.checksum&.hexdigest + new_hash = args.sha256 + new_tag = args.tag + new_revision = args.revision + old_url = formula_spec.url + old_tag = formula_spec.specs[:tag] + old_formula_version = formula_version(formula) + old_version = old_formula_version.to_s + forced_version = new_version.present? + new_url_hash = if new_url.present? && new_hash.present? + check_new_version(formula, tap_remote_repo, url: new_url) if new_version.blank? + true + elsif new_tag.present? && new_revision.present? + check_new_version(formula, tap_remote_repo, url: old_url, tag: new_tag) if new_version.blank? + false + elsif old_hash.blank? + if new_tag.blank? && new_version.blank? && new_revision.blank? + raise UsageError, "#{formula}: no `--tag` or `--version` argument specified!" + end - old_mirrors = formula_spec.mirrors - new_mirrors ||= args.mirror - new_mirror ||= determine_mirror(new_url) - new_mirrors ||= [new_mirror] if new_mirror.present? - - check_for_mirrors(formula, old_mirrors, new_mirrors, args:) if new_url.present? - - old_hash = formula_spec.checksum&.hexdigest - new_hash = args.sha256 - new_tag = args.tag - new_revision = args.revision - old_url = formula_spec.url - old_tag = formula_spec.specs[:tag] - old_formula_version = formula_version(formula) - old_version = old_formula_version.to_s - forced_version = new_version.present? - new_url_hash = if new_url.present? && new_hash.present? - check_new_version(formula, tap_remote_repo, url: new_url, args:) if new_version.blank? - true - elsif new_tag.present? && new_revision.present? - check_new_version(formula, tap_remote_repo, url: old_url, tag: new_tag, args:) if new_version.blank? - false - elsif old_hash.blank? - if new_tag.blank? && new_version.blank? && new_revision.blank? - raise UsageError, "#{formula}: no `--tag` or `--version` argument specified!" - end + if old_tag.present? + new_tag ||= old_tag.gsub(old_version, new_version) + if new_tag == old_tag + odie <<~EOS + You need to bump this formula manually since the new tag + and old tag are both #{new_tag}. + EOS + end + check_new_version(formula, tap_remote_repo, url: old_url, tag: new_tag) if new_version.blank? + resource_path, forced_version = fetch_resource_and_forced_version(formula, new_version, old_url, + tag: new_tag) + new_revision = Utils.popen_read("git", "-C", resource_path.to_s, "rev-parse", "-q", "--verify", "HEAD") + new_revision = new_revision.strip + elsif new_revision.blank? + odie "#{formula}: the current URL requires specifying a `--revision=` argument." + end + false + elsif new_url.blank? && new_version.blank? + raise UsageError, "#{formula}: no `--url` or `--version` argument specified!" + else + new_url ||= PyPI.update_pypi_url(old_url, T.must(new_version)) + if new_url.blank? + new_url = update_url(old_url, old_version, T.must(new_version)) + if new_mirrors.blank? && old_mirrors.present? + new_mirrors = old_mirrors.map do |old_mirror| + update_url(old_mirror, old_version, T.must(new_version)) + end + end + end + if new_url == old_url + odie <<~EOS + You need to bump this formula manually since the new URL + and old URL are both: + #{new_url} + EOS + end + check_new_version(formula, tap_remote_repo, url: new_url) if new_version.blank? + resource_path, forced_version = fetch_resource_and_forced_version(formula, new_version, new_url) + Utils::Tar.validate_file(resource_path) + new_hash = resource_path.sha256 + end - if old_tag.present? - new_tag ||= old_tag.gsub(old_version, new_version) - if new_tag == old_tag - odie <<~EOS - You need to bump this formula manually since the new tag - and old tag are both #{new_tag}. - EOS + replacement_pairs = [] + if formula.revision.nonzero? + replacement_pairs << [ + /^ revision \d+\n(\n( head "))?/m, + "\\2", + ] end - check_new_version(formula, tap_remote_repo, url: old_url, tag: new_tag, args:) if new_version.blank? - resource_path, forced_version = fetch_resource_and_forced_version(formula, new_version, old_url, tag: new_tag) - new_revision = Utils.popen_read("git", "-C", resource_path.to_s, "rev-parse", "-q", "--verify", "HEAD") - new_revision = new_revision.strip - elsif new_revision.blank? - odie "#{formula}: the current URL requires specifying a `--revision=` argument." - end - false - elsif new_url.blank? && new_version.blank? - raise UsageError, "#{formula}: no `--url` or `--version` argument specified!" - else - new_url ||= PyPI.update_pypi_url(old_url, new_version) - if new_url.blank? - new_url = update_url(old_url, old_version, new_version) - if new_mirrors.blank? && old_mirrors.present? - new_mirrors = old_mirrors.map do |old_mirror| - update_url(old_mirror, old_version, new_version) - end + + replacement_pairs += formula_spec.mirrors.map do |mirror| + [ + / +mirror "#{Regexp.escape(mirror)}"\n/m, + "", + ] end - end - if new_url == old_url - odie <<~EOS - You need to bump this formula manually since the new URL - and old URL are both: - #{new_url} - EOS - end - check_new_version(formula, tap_remote_repo, url: new_url, args:) if new_version.blank? - resource_path, forced_version = fetch_resource_and_forced_version(formula, new_version, new_url) - Utils::Tar.validate_file(resource_path) - new_hash = resource_path.sha256 - end - replacement_pairs = [] - if formula.revision.nonzero? - replacement_pairs << [ - /^ revision \d+\n(\n( head "))?/m, - "\\2", - ] - end + replacement_pairs += if new_url_hash.present? + [ + [ + /#{Regexp.escape(formula_spec.url)}/, + new_url, + ], + [ + old_hash, + new_hash, + ], + ] + elsif new_tag.present? + [ + [ + /tag:(\s+")#{formula_spec.specs[:tag]}(?=")/, + "tag:\\1#{new_tag}\\2", + ], + [ + formula_spec.specs[:revision], + new_revision, + ], + ] + elsif new_url.present? + [ + [ + /#{Regexp.escape(formula_spec.url)}/, + new_url, + ], + [ + formula_spec.specs[:revision], + new_revision, + ], + ] + else + [ + [ + formula_spec.specs[:revision], + new_revision, + ], + ] + end - replacement_pairs += formula_spec.mirrors.map do |mirror| - [ - / +mirror "#{Regexp.escape(mirror)}"\n/m, - "", - ] - end + old_contents = formula.path.read - replacement_pairs += if new_url_hash.present? - [ - [ - /#{Regexp.escape(formula_spec.url)}/, - new_url, - ], - [ - old_hash, - new_hash, - ], - ] - elsif new_tag.present? - [ - [ - /tag:(\s+")#{formula_spec.specs[:tag]}(?=")/, - "tag:\\1#{new_tag}\\2", - ], - [ - formula_spec.specs[:revision], - new_revision, - ], - ] - elsif new_url.present? - [ - [ - /#{Regexp.escape(formula_spec.url)}/, - new_url, - ], - [ - formula_spec.specs[:revision], - new_revision, - ], - ] - else - [ - [ - formula_spec.specs[:revision], - new_revision, - ], - ] - end + if new_mirrors.present? + replacement_pairs << [ + /^( +)(url "#{Regexp.escape(T.must(new_url))}"[^\n]*?\n)/m, + "\\1\\2\\1mirror \"#{new_mirrors.join("\"\n\\1mirror \"")}\"\n", + ] + end - old_contents = formula.path.read + if forced_version && new_version != "0" + replacement_pairs << if old_contents.include?("version \"#{old_formula_version}\"") + [ + "version \"#{old_formula_version}\"", + "version \"#{new_version}\"", + ] + elsif new_mirrors.present? + [ + /^( +)(mirror "#{Regexp.escape(new_mirrors.last)}"\n)/m, + "\\1\\2\\1version \"#{new_version}\"\n", + ] + elsif new_url.present? + [ + /^( +)(url "#{Regexp.escape(new_url)}"[^\n]*?\n)/m, + "\\1\\2\\1version \"#{new_version}\"\n", + ] + elsif new_revision.present? + [ + /^( {2})( +)(:revision => "#{new_revision}"\n)/m, + "\\1\\2\\3\\1version \"#{new_version}\"\n", + ] + end + elsif forced_version && new_version == "0" + replacement_pairs << [ + /^ version "[\w.\-+]+"\n/m, + "", + ] + end + new_contents = Utils::Inreplace.inreplace_pairs(formula.path, + replacement_pairs.uniq.compact, + read_only_run: args.dry_run?, + silent: args.quiet?) - if new_mirrors.present? - replacement_pairs << [ - /^( +)(url "#{Regexp.escape(new_url)}"[^\n]*?\n)/m, - "\\1\\2\\1mirror \"#{new_mirrors.join("\"\n\\1mirror \"")}\"\n", - ] - end + new_formula_version = formula_version(formula, new_contents) - if forced_version && new_version != "0" - replacement_pairs << if old_contents.include?("version \"#{old_formula_version}\"") - [ - "version \"#{old_formula_version}\"", - "version \"#{new_version}\"", - ] - elsif new_mirrors.present? - [ - /^( +)(mirror "#{Regexp.escape(new_mirrors.last)}"\n)/m, - "\\1\\2\\1version \"#{new_version}\"\n", - ] - elsif new_url.present? - [ - /^( +)(url "#{Regexp.escape(new_url)}"[^\n]*?\n)/m, - "\\1\\2\\1version \"#{new_version}\"\n", - ] - elsif new_revision.present? - [ - /^( {2})( +)(:revision => "#{new_revision}"\n)/m, - "\\1\\2\\3\\1version \"#{new_version}\"\n", - ] - end - elsif forced_version && new_version == "0" - replacement_pairs << [ - /^ version "[\w.\-+]+"\n/m, - "", - ] - end - new_contents = Utils::Inreplace.inreplace_pairs(formula.path, - replacement_pairs.uniq.compact, - read_only_run: args.dry_run?, - silent: args.quiet?) - - new_formula_version = formula_version(formula, new_contents) - - if new_formula_version < old_formula_version - formula.path.atomic_write(old_contents) unless args.dry_run? - odie <<~EOS - You need to bump this formula manually since changing the version - from #{old_formula_version} to #{new_formula_version} would be a downgrade. - EOS - elsif new_formula_version == old_formula_version - formula.path.atomic_write(old_contents) unless args.dry_run? - odie <<~EOS - You need to bump this formula manually since the new version - and old version are both #{new_formula_version}. - EOS - end + if new_formula_version < old_formula_version + formula.path.atomic_write(old_contents) unless args.dry_run? + odie <<~EOS + You need to bump this formula manually since changing the version + from #{old_formula_version} to #{new_formula_version} would be a downgrade. + EOS + elsif new_formula_version == old_formula_version + formula.path.atomic_write(old_contents) unless args.dry_run? + odie <<~EOS + You need to bump this formula manually since the new version + and old version are both #{new_formula_version}. + EOS + end - alias_rename = alias_update_pair(formula, new_formula_version) - if alias_rename.present? - ohai "Renaming alias #{alias_rename.first} to #{alias_rename.last}" - alias_rename.map! { |a| formula.tap.alias_dir/a } - end + alias_rename = alias_update_pair(formula, new_formula_version) + if alias_rename.present? + ohai "Renaming alias #{alias_rename.first} to #{alias_rename.last}" + alias_rename.map! { |a| formula.tap.alias_dir/a } + end - unless args.dry_run? - resources_checked = PyPI.update_python_resources! formula, - version: new_formula_version.to_s, - package_name: args.python_package_name, - extra_packages: args.python_extra_packages, - exclude_packages: args.python_exclude_packages, - install_dependencies: args.install_dependencies?, - silent: args.quiet?, - ignore_non_pypi_packages: true - end + unless args.dry_run? + resources_checked = PyPI.update_python_resources! formula, + version: new_formula_version.to_s, + package_name: args.python_package_name, + extra_packages: args.python_extra_packages, + exclude_packages: args.python_exclude_packages, + install_dependencies: args.install_dependencies?, + silent: args.quiet?, + ignore_non_pypi_packages: true + end - run_audit(formula, alias_rename, old_contents, args:) + run_audit(formula, alias_rename, old_contents) - pr_message = "Created with `brew bump-formula-pr`." - if resources_checked.nil? && formula.resources.any? { |resource| !resource.name.start_with?("homebrew-") } - pr_message += <<~EOS + pr_message = "Created with `brew bump-formula-pr`." + if resources_checked.nil? && formula.resources.any? { |resource| !resource.name.start_with?("homebrew-") } + pr_message += <<~EOS - - [ ] `resource` blocks have been checked for updates. - EOS - end + - [ ] `resource` blocks have been checked for updates. + EOS + end - if new_url =~ %r{^https://github\.com/([\w-]+)/([\w-]+)/archive/refs/tags/(v?[.0-9]+)\.tar\.} - owner = Regexp.last_match(1) - repo = Regexp.last_match(2) - tag = Regexp.last_match(3) - github_release_data = begin - GitHub::API.open_rest("#{GitHub::API_URL}/repos/#{owner}/#{repo}/releases/tags/#{tag}") - rescue GitHub::API::HTTPNotFoundError - # If this is a 404: we can't do anything. - nil - end + if new_url =~ %r{^https://github\.com/([\w-]+)/([\w-]+)/archive/refs/tags/(v?[.0-9]+)\.tar\.} + owner = Regexp.last_match(1) + repo = Regexp.last_match(2) + tag = Regexp.last_match(3) + github_release_data = begin + GitHub::API.open_rest("#{GitHub::API_URL}/repos/#{owner}/#{repo}/releases/tags/#{tag}") + rescue GitHub::API::HTTPNotFoundError + # If this is a 404: we can't do anything. + nil + end - if github_release_data.present? - pre = "pre" if github_release_data["prerelease"].present? - pr_message += <<~XML -
- #{pre}release notes -
#{github_release_data["body"]}
-
- XML + if github_release_data.present? + pre = "pre" if github_release_data["prerelease"].present? + pr_message += <<~XML +
+ #{pre}release notes +
#{github_release_data["body"]}
+
+ XML + end + end + + pr_info = { + sourcefile_path: formula.path, + old_contents:, + additional_files: alias_rename, + remote:, + remote_branch:, + branch_name: "bump-#{formula.name}-#{new_formula_version}", + commit_message: "#{formula.name} #{new_formula_version}", + previous_branch:, + tap: formula.tap, + tap_remote_repo:, + pr_message:, + } + GitHub.create_bump_pr(pr_info, args:) end - end - pr_info = { - sourcefile_path: formula.path, - old_contents:, - additional_files: alias_rename, - remote:, - remote_branch:, - branch_name: "bump-#{formula.name}-#{new_formula_version}", - commit_message: "#{formula.name} #{new_formula_version}", - previous_branch:, - tap: formula.tap, - tap_remote_repo:, - pr_message:, - } - GitHub.create_bump_pr(pr_info, args:) - end + private + + def determine_mirror(url) + case url + when %r{.*ftp\.gnu\.org/gnu.*} + url.sub "ftp.gnu.org/gnu", "ftpmirror.gnu.org" + when %r{.*download\.savannah\.gnu\.org/*} + url.sub "download.savannah.gnu.org", "download-mirror.savannah.gnu.org" + when %r{.*www\.apache\.org/dyn/closer\.lua\?path=.*} + url.sub "www.apache.org/dyn/closer.lua?path=", "archive.apache.org/dist/" + when %r{.*mirrors\.ocf\.berkeley\.edu/debian.*} + url.sub "mirrors.ocf.berkeley.edu/debian", "mirrorservice.org/sites/ftp.debian.org/debian" + end + end - def determine_mirror(url) - case url - when %r{.*ftp\.gnu\.org/gnu.*} - url.sub "ftp.gnu.org/gnu", "ftpmirror.gnu.org" - when %r{.*download\.savannah\.gnu\.org/*} - url.sub "download.savannah.gnu.org", "download-mirror.savannah.gnu.org" - when %r{.*www\.apache\.org/dyn/closer\.lua\?path=.*} - url.sub "www.apache.org/dyn/closer.lua?path=", "archive.apache.org/dist/" - when %r{.*mirrors\.ocf\.berkeley\.edu/debian.*} - url.sub "mirrors.ocf.berkeley.edu/debian", "mirrorservice.org/sites/ftp.debian.org/debian" - end - end + def check_for_mirrors(formula, old_mirrors, new_mirrors) + return if new_mirrors.present? || old_mirrors.empty? - def check_for_mirrors(formula, old_mirrors, new_mirrors, args:) - return if new_mirrors.present? || old_mirrors.empty? + if args.force? + opoo "#{formula}: Removing all mirrors because a `--mirror=` argument was not specified." + else + odie <<~EOS + #{formula}: a `--mirror=` argument for updating the mirror URL(s) was not specified. + Use `--force` to remove all mirrors. + EOS + end + end - if args.force? - opoo "#{formula}: Removing all mirrors because a `--mirror=` argument was not specified." - else - odie <<~EOS - #{formula}: a `--mirror=` argument for updating the mirror URL(s) was not specified. - Use `--force` to remove all mirrors. - EOS - end - end + sig { params(old_url: String, old_version: String, new_version: String).returns(String) } + def update_url(old_url, old_version, new_version) + new_url = old_url.gsub(old_version, new_version) + return new_url if (old_version_parts = old_version.split(".")).length < 2 + return new_url if (new_version_parts = new_version.split(".")).length != old_version_parts.length - sig { params(old_url: String, old_version: String, new_version: String).returns(String) } - def update_url(old_url, old_version, new_version) - new_url = old_url.gsub(old_version, new_version) - return new_url if (old_version_parts = old_version.split(".")).length < 2 - return new_url if (new_version_parts = new_version.split(".")).length != old_version_parts.length + partial_old_version = T.must(old_version_parts[0..-2]).join(".") + partial_new_version = T.must(new_version_parts[0..-2]).join(".") + new_url.gsub(%r{/(v?)#{Regexp.escape(partial_old_version)}/}, "/\\1#{partial_new_version}/") + end - partial_old_version = T.must(old_version_parts[0..-2]).join(".") - partial_new_version = T.must(new_version_parts[0..-2]).join(".") - new_url.gsub(%r{/(v?)#{Regexp.escape(partial_old_version)}/}, "/\\1#{partial_new_version}/") - end + def fetch_resource_and_forced_version(formula, new_version, url, **specs) + resource = Resource.new + resource.url(url, **specs) + resource.owner = Resource.new(formula.name) + forced_version = new_version && new_version != resource.version.to_s + resource.version(new_version) if forced_version + odie "Couldn't identify version, specify it using `--version=`." if resource.version.blank? + [resource.fetch, forced_version] + end - def fetch_resource_and_forced_version(formula, new_version, url, **specs) - resource = Resource.new - resource.url(url, **specs) - resource.owner = Resource.new(formula.name) - forced_version = new_version && new_version != resource.version.to_s - resource.version(new_version) if forced_version - odie "Couldn't identify version, specify it using `--version=`." if resource.version.blank? - [resource.fetch, forced_version] - end + def formula_version(formula, contents = nil) + spec = :stable + name = formula.name + path = formula.path + if contents.present? + Formulary.from_contents(name, path, contents, spec).version + else + Formulary::FormulaLoader.new(name, path).get_formula(spec).version + end + end - def formula_version(formula, contents = nil) - spec = :stable - name = formula.name - path = formula.path - if contents.present? - Formulary.from_contents(name, path, contents, spec).version - else - Formulary::FormulaLoader.new(name, path).get_formula(spec).version - end - end + def check_open_pull_requests(formula, tap_remote_repo) + GitHub.check_for_duplicate_pull_requests(formula.name, tap_remote_repo, + state: "open", + file: formula.path.relative_path_from(formula.tap.path).to_s, + quiet: args.quiet?) + end - def check_open_pull_requests(formula, tap_remote_repo, args:) - GitHub.check_for_duplicate_pull_requests(formula.name, tap_remote_repo, - state: "open", - file: formula.path.relative_path_from(formula.tap.path).to_s, - quiet: args.quiet?) - end + def check_new_version(formula, tap_remote_repo, version: nil, url: nil, tag: nil) + if version.nil? + specs = {} + specs[:tag] = tag if tag.present? + version = Version.detect(url, **specs).to_s + return if version.blank? + end - def check_new_version(formula, tap_remote_repo, args:, version: nil, url: nil, tag: nil) - if version.nil? - specs = {} - specs[:tag] = tag if tag.present? - version = Version.detect(url, **specs).to_s - return if version.blank? - end + check_throttle(formula, version) + check_closed_pull_requests(formula, tap_remote_repo, version:) + end - check_throttle(formula, version) - check_closed_pull_requests(formula, tap_remote_repo, args:, version:) - end + def check_throttle(formula, new_version) + throttled_rate = formula.tap.audit_exceptions.dig(:throttled_formulae, formula.name) + return if throttled_rate.blank? - def check_throttle(formula, new_version) - throttled_rate = formula.tap.audit_exceptions.dig(:throttled_formulae, formula.name) - return if throttled_rate.blank? + formula_suffix = Version.new(new_version).patch.to_i + return if formula_suffix.modulo(throttled_rate).zero? - formula_suffix = Version.new(new_version).patch.to_i - return if formula_suffix.modulo(throttled_rate).zero? + odie "#{formula} should only be updated every #{throttled_rate} releases on multiples of #{throttled_rate}" + end - odie "#{formula} should only be updated every #{throttled_rate} releases on multiples of #{throttled_rate}" - end + def check_closed_pull_requests(formula, tap_remote_repo, version:) + # if we haven't already found open requests, try for an exact match across closed requests + GitHub.check_for_duplicate_pull_requests(formula.name, tap_remote_repo, + version:, + state: "closed", + file: formula.path.relative_path_from(formula.tap.path).to_s, + quiet: args.quiet?) + end - def check_closed_pull_requests(formula, tap_remote_repo, args:, version:) - # if we haven't already found open requests, try for an exact match across closed requests - GitHub.check_for_duplicate_pull_requests(formula.name, tap_remote_repo, - version:, - state: "closed", - file: formula.path.relative_path_from(formula.tap.path).to_s, - quiet: args.quiet?) - end + def alias_update_pair(formula, new_formula_version) + versioned_alias = formula.aliases.grep(/^.*@\d+(\.\d+)?$/).first + return if versioned_alias.nil? - def alias_update_pair(formula, new_formula_version) - versioned_alias = formula.aliases.grep(/^.*@\d+(\.\d+)?$/).first - return if versioned_alias.nil? + name, old_alias_version = versioned_alias.split("@") + new_alias_regex = (old_alias_version.split(".").length == 1) ? /^\d+/ : /^\d+\.\d+/ + new_alias_version, = *new_formula_version.to_s.match(new_alias_regex) + return if Version.new(new_alias_version) <= Version.new(old_alias_version) - name, old_alias_version = versioned_alias.split("@") - new_alias_regex = (old_alias_version.split(".").length == 1) ? /^\d+/ : /^\d+\.\d+/ - new_alias_version, = *new_formula_version.to_s.match(new_alias_regex) - return if Version.new(new_alias_version) <= Version.new(old_alias_version) + [versioned_alias, "#{name}@#{new_alias_version}"] + end - [versioned_alias, "#{name}@#{new_alias_version}"] - end + def run_audit(formula, alias_rename, old_contents) + audit_args = ["--formula"] + audit_args << "--strict" if args.strict? + audit_args << "--online" if args.online? + if args.dry_run? + if args.no_audit? + ohai "Skipping `brew audit`" + elsif audit_args.present? + ohai "brew audit #{audit_args.join(" ")} #{formula.path.basename}" + else + ohai "brew audit #{formula.path.basename}" + end + return + end + FileUtils.mv alias_rename.first, alias_rename.last if alias_rename.present? + failed_audit = false + if args.no_audit? + ohai "Skipping `brew audit`" + elsif audit_args.present? + system HOMEBREW_BREW_FILE, "audit", *audit_args, formula.full_name + failed_audit = !$CHILD_STATUS.success? + else + system HOMEBREW_BREW_FILE, "audit", formula.full_name + failed_audit = !$CHILD_STATUS.success? + end + return unless failed_audit - def run_audit(formula, alias_rename, old_contents, args:) - audit_args = ["--formula"] - audit_args << "--strict" if args.strict? - audit_args << "--online" if args.online? - if args.dry_run? - if args.no_audit? - ohai "Skipping `brew audit`" - elsif audit_args.present? - ohai "brew audit #{audit_args.join(" ")} #{formula.path.basename}" - else - ohai "brew audit #{formula.path.basename}" + formula.path.atomic_write(old_contents) + FileUtils.mv alias_rename.last, alias_rename.first if alias_rename.present? + odie "`brew audit` failed!" end - return end - FileUtils.mv alias_rename.first, alias_rename.last if alias_rename.present? - failed_audit = false - if args.no_audit? - ohai "Skipping `brew audit`" - elsif audit_args.present? - system HOMEBREW_BREW_FILE, "audit", *audit_args, formula.full_name - failed_audit = !$CHILD_STATUS.success? - else - system HOMEBREW_BREW_FILE, "audit", formula.full_name - failed_audit = !$CHILD_STATUS.success? - end - return unless failed_audit - - formula.path.atomic_write(old_contents) - FileUtils.mv alias_rename.last, alias_rename.first if alias_rename.present? - odie "`brew audit` failed!" end end diff --git a/Library/Homebrew/dev-cmd/bump-revision.rb b/Library/Homebrew/dev-cmd/bump-revision.rb index 683ea0b1b6daa..2bbf5b085f00f 100644 --- a/Library/Homebrew/dev-cmd/bump-revision.rb +++ b/Library/Homebrew/dev-cmd/bump-revision.rb @@ -1,76 +1,75 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "formula" require "cli/parser" module Homebrew - module_function + module DevCmd + class BumpRevision < AbstractCommand + cmd_args do + description <<~EOS + Create a commit to increment the revision of . If no revision is + present, "revision 1" will be added. + EOS + switch "-n", "--dry-run", + description: "Print what would be done rather than doing it." + switch "--remove-bottle-block", + description: "Remove the bottle block in addition to bumping the revision." + switch "--write-only", + description: "Make the expected file modifications without taking any Git actions." + flag "--message=", + description: "Append to the default commit message." - sig { returns(CLI::Parser) } - def bump_revision_args - Homebrew::CLI::Parser.new do - description <<~EOS - Create a commit to increment the revision of . If no revision is - present, "revision 1" will be added. - EOS - switch "-n", "--dry-run", - description: "Print what would be done rather than doing it." - switch "--remove-bottle-block", - description: "Remove the bottle block in addition to bumping the revision." - switch "--write-only", - description: "Make the expected file modifications without taking any Git actions." - flag "--message=", - description: "Append to the default commit message." + conflicts "--dry-run", "--write-only" - conflicts "--dry-run", "--write-only" - - named_args :formula, min: 1, without_api: true - end - end - - def bump_revision - args = bump_revision_args.parse + named_args :formula, min: 1, without_api: true + end - # As this command is simplifying user-run commands then let's just use a - # user path, too. - ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s + sig { override.void } + def run + # As this command is simplifying user-run commands then let's just use a + # user path, too. + ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s - args.named.to_formulae.each do |formula| - current_revision = formula.revision - new_revision = current_revision + 1 + args.named.to_formulae.each do |formula| + current_revision = formula.revision + new_revision = current_revision + 1 - if args.dry_run? - unless args.quiet? - old_text = "revision #{current_revision}" - new_text = "revision #{new_revision}" - if current_revision.zero? - ohai "add #{new_text.inspect}" + if args.dry_run? + unless args.quiet? + old_text = "revision #{current_revision}" + new_text = "revision #{new_revision}" + if current_revision.zero? + ohai "add #{new_text.inspect}" + else + ohai "replace #{old_text.inspect} with #{new_text.inspect}" + end + end else - ohai "replace #{old_text.inspect} with #{new_text.inspect}" - end - end - else - Homebrew.install_bundler_gems!(groups: ["ast"]) - require "utils/ast" + Homebrew.install_bundler_gems!(groups: ["ast"]) + require "utils/ast" - formula_ast = Utils::AST::FormulaAST.new(formula.path.read) - if current_revision.zero? - formula_ast.add_stanza(:revision, new_revision) - else - formula_ast.replace_stanza(:revision, new_revision) - end - formula_ast.remove_stanza(:bottle) if args.remove_bottle_block? - formula.path.atomic_write(formula_ast.process) - end + formula_ast = Utils::AST::FormulaAST.new(formula.path.read) + if current_revision.zero? + formula_ast.add_stanza(:revision, new_revision) + else + formula_ast.replace_stanza(:revision, new_revision) + end + formula_ast.remove_stanza(:bottle) if args.remove_bottle_block? + formula.path.atomic_write(formula_ast.process) + end - message = "#{formula.name}: revision bump #{args.message}" - if args.dry_run? - ohai "git commit --no-edit --verbose --message=#{message} -- #{formula.path}" - elsif !args.write_only? - formula.path.parent.cd do - safe_system "git", "commit", "--no-edit", "--verbose", - "--message=#{message}", "--", formula.path + message = "#{formula.name}: revision bump #{args.message}" + if args.dry_run? + ohai "git commit --no-edit --verbose --message=#{message} -- #{formula.path}" + elsif !args.write_only? + formula.path.parent.cd do + safe_system "git", "commit", "--no-edit", "--verbose", + "--message=#{message}", "--", formula.path + end + end end end end diff --git a/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb b/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb index f734db58401e5..15faafce133b2 100644 --- a/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb +++ b/Library/Homebrew/dev-cmd/bump-unversioned-casks.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "timeout" @@ -11,167 +11,168 @@ require "unversioned_cask_checker" module Homebrew - extend SystemCommand::Mixin - - sig { returns(CLI::Parser) } - def self.bump_unversioned_casks_args - Homebrew::CLI::Parser.new do - description <<~EOS - Check all casks with unversioned URLs in a given for updates. - EOS - switch "-n", "--dry-run", - description: "Do everything except caching state and opening pull requests." - flag "--limit=", - description: "Maximum runtime in minutes." - flag "--state-file=", - description: "File for caching state." - - named_args [:cask, :tap], min: 1, without_api: true - end - end - - sig { void } - def self.bump_unversioned_casks - args = bump_unversioned_casks_args.parse + module DevCmd + class BumpUnversionedCask < AbstractCommand + include SystemCommand::Mixin + + cmd_args do + description <<~EOS + Check all casks with unversioned URLs in a given for updates. + EOS + switch "-n", "--dry-run", + description: "Do everything except caching state and opening pull requests." + flag "--limit=", + description: "Maximum runtime in minutes." + flag "--state-file=", + description: "File for caching state." + + named_args [:cask, :tap], min: 1, without_api: true + end - Homebrew.install_bundler_gems!(groups: ["bump_unversioned_casks"]) + sig { override.void } + def run + Homebrew.install_bundler_gems!(groups: ["bump_unversioned_casks"]) - state_file = if args.state_file.present? - Pathname(args.state_file).expand_path - else - HOMEBREW_CACHE/"bump_unversioned_casks.json" - end - state_file.dirname.mkpath + state_file = if args.state_file.present? + Pathname(T.must(args.state_file)).expand_path + else + HOMEBREW_CACHE/"bump_unversioned_casks.json" + end + state_file.dirname.mkpath - state = state_file.exist? ? JSON.parse(state_file.read) : {} + state = state_file.exist? ? JSON.parse(state_file.read) : {} - casks = args.named.to_paths(only: :cask, recurse_tap: true).map { |path| Cask::CaskLoader.load(path) } + casks = args.named.to_paths(only: :cask, recurse_tap: true).map { |path| Cask::CaskLoader.load(path) } - unversioned_casks = casks.select do |cask| - cask.url&.unversioned? && !cask.livecheckable? && !cask.discontinued? - end + unversioned_casks = casks.select do |cask| + cask.url&.unversioned? && !cask.livecheckable? && !cask.discontinued? + end - ohai "Unversioned Casks: #{unversioned_casks.count} (#{state.size} cached)" + ohai "Unversioned Casks: #{unversioned_casks.count} (#{state.size} cached)" - checked, unchecked = unversioned_casks.partition { |c| state.key?(c.full_name) } + checked, unchecked = unversioned_casks.partition { |c| state.key?(c.full_name) } - queue = Queue.new + queue = Queue.new - # Start with random casks which have not been checked. - unchecked.shuffle.each do |c| - queue.enq c - end + # Start with random casks which have not been checked. + unchecked.shuffle.each do |c| + queue.enq c + end - # Continue with previously checked casks, ordered by when they were last checked. - checked.sort_by { |c| state.dig(c.full_name, "check_time") }.each do |c| - queue.enq c - end + # Continue with previously checked casks, ordered by when they were last checked. + checked.sort_by { |c| state.dig(c.full_name, "check_time") }.each do |c| + queue.enq c + end - limit = args.limit.presence&.to_i - end_time = Time.now + (limit * 60) if limit + limit = args.limit.presence&.to_i + end_time = Time.now + (limit * 60) if limit - until queue.empty? || (end_time && end_time < Time.now) - cask = queue.deq + until queue.empty? || (end_time && end_time < Time.now) + cask = queue.deq - key = cask.full_name + key = cask.full_name - new_state = bump_unversioned_cask(cask, state: state.fetch(key, {}), dry_run: args.dry_run?) + new_state = bump_unversioned_cask(cask, state: state.fetch(key, {})) - next unless new_state + next unless new_state - state[key] = new_state + state[key] = new_state - state_file.atomic_write JSON.pretty_generate(state) unless args.dry_run? - end - end + state_file.atomic_write JSON.pretty_generate(state) unless args.dry_run? + end + end - sig { - params(cask: Cask::Cask, state: T::Hash[String, T.untyped], dry_run: T.nilable(T::Boolean)) - .returns(T.nilable(T::Hash[String, T.untyped])) - } - def self.bump_unversioned_cask(cask, state:, dry_run:) - ohai "Checking #{cask.full_name}" + private - unversioned_cask_checker = UnversionedCaskChecker.new(cask) + sig { + params(cask: Cask::Cask, state: T::Hash[String, T.untyped]) + .returns(T.nilable(T::Hash[String, T.untyped])) + } + def bump_unversioned_cask(cask, state:) + ohai "Checking #{cask.full_name}" - if !unversioned_cask_checker.single_app_cask? && - !unversioned_cask_checker.single_pkg_cask? && - !unversioned_cask_checker.single_qlplugin_cask? - opoo "Skipping, not a single-app or PKG cask." - return - end + unversioned_cask_checker = UnversionedCaskChecker.new(cask) - last_check_time = state["check_time"]&.then { |t| Time.parse(t) } + if !unversioned_cask_checker.single_app_cask? && + !unversioned_cask_checker.single_pkg_cask? && + !unversioned_cask_checker.single_qlplugin_cask? + opoo "Skipping, not a single-app or PKG cask." + return + end - check_time = Time.now - if last_check_time && (check_time - last_check_time) / 3600 < 24 - opoo "Skipping, already checked within the last 24 hours." - return - end + last_check_time = state["check_time"]&.then { |t| Time.parse(t) } - last_sha256 = state["sha256"] - last_time = state["time"]&.then { |t| Time.parse(t) } - last_file_size = state["file_size"] + check_time = Time.now + if last_check_time && (check_time - last_check_time) / 3600 < 24 + opoo "Skipping, already checked within the last 24 hours." + return + end - download = Cask::Download.new(cask) - time, file_size = begin - download.time_file_size - rescue - [nil, nil] - end + last_sha256 = state["sha256"] + last_time = state["time"]&.then { |t| Time.parse(t) } + last_file_size = state["file_size"] - if last_time != time || last_file_size != file_size - sha256 = begin - Timeout.timeout(5 * 60) do - unversioned_cask_checker.installer.download.sha256 + download = Cask::Download.new(cask) + time, file_size = begin + download.time_file_size + rescue + [nil, nil] end - rescue => e - onoe e - end - if sha256.present? && last_sha256 != sha256 - version = begin - Timeout.timeout(60) do - unversioned_cask_checker.guess_cask_version + if last_time != time || last_file_size != file_size + sha256 = begin + Timeout.timeout(5 * 60) do + unversioned_cask_checker.installer.download.sha256 + end + rescue => e + onoe e end - rescue Timeout::Error - onoe "Timed out guessing version for cask '#{cask}'." - end - if version - if cask.version == version - oh1 "Cask #{cask} is up-to-date at #{version}" - else - bump_cask_pr_args = [ - "bump-cask-pr", - "--version", version.to_s, - "--sha256", ":no_check", - "--message", "Automatic update via `brew bump-unversioned-casks`.", - cask.sourcefile_path - ] - - if dry_run - bump_cask_pr_args << "--dry-run" - oh1 "Would bump #{cask} from #{cask.version} to #{version}" - else - oh1 "Bumping #{cask} from #{cask.version} to #{version}" + if sha256.present? && last_sha256 != sha256 + version = begin + Timeout.timeout(60) do + unversioned_cask_checker.guess_cask_version + end + rescue Timeout::Error + onoe "Timed out guessing version for cask '#{cask}'." end - begin - system_command! HOMEBREW_BREW_FILE, args: bump_cask_pr_args - rescue ErrorDuringExecution => e - onoe e + if version + if cask.version == version + oh1 "Cask #{cask} is up-to-date at #{version}" + else + bump_cask_pr_args = [ + "bump-cask-pr", + "--version", version.to_s, + "--sha256", ":no_check", + "--message", "Automatic update via `brew bump-unversioned-casks`.", + cask.sourcefile_path + ] + + if args.dry_run? + bump_cask_pr_args << "--dry-run" + oh1 "Would bump #{cask} from #{cask.version} to #{version}" + else + oh1 "Bumping #{cask} from #{cask.version} to #{version}" + end + + begin + system_command! HOMEBREW_BREW_FILE, args: bump_cask_pr_args + rescue ErrorDuringExecution => e + onoe e + end + end end end end + + { + "sha256" => sha256, + "check_time" => check_time.iso8601, + "time" => time&.iso8601, + "file_size" => file_size, + } end end - - { - "sha256" => sha256, - "check_time" => check_time.iso8601, - "time" => time&.iso8601, - "file_size" => file_size, - } end end diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index a0838af837c36..09e392344f505 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -1,554 +1,553 @@ # typed: strict # frozen_string_literal: true +require "abstract_command" require "bump_version_parser" require "cli/parser" require "livecheck/livecheck" module Homebrew - module_function - - class VersionBumpInfo < T::Struct - const :type, Symbol - const :multiple_versions, T::Boolean - const :version_name, String - const :current_version, BumpVersionParser - const :repology_latest, T.any(String, Version) - const :new_version, BumpVersionParser - const :open_pull_requests, T.nilable(T.any(T::Array[String], String)) - const :closed_pull_requests, T.nilable(T.any(T::Array[String], String)) - end - - sig { returns(CLI::Parser) } - def bump_args - CLI::Parser.new do - description <<~EOS - Display out-of-date brew formulae and the latest version available. If the - returned current and livecheck versions differ or when querying specific - formulae, also displays whether a pull request has been opened with the URL. - EOS - switch "--full-name", - description: "Print formulae/casks with fully-qualified names." - switch "--no-pull-requests", - description: "Do not retrieve pull requests from GitHub." - switch "--formula", "--formulae", - description: "Check only formulae." - switch "--cask", "--casks", - description: "Check only casks." - flag "--tap=", - description: "Check formulae and casks within the given tap, specified as `/`." - switch "--installed", - description: "Check formulae and casks that are currently installed." - switch "--no-fork", - description: "Don't try to fork the repository." - switch "--open-pr", - description: "Open a pull request for the new version if none have been opened yet." - flag "--limit=", - description: "Limit number of package results returned." - flag "--start-with=", - description: "Letter or word that the list of package results should alphabetically follow." - switch "-f", "--force", - hidden: true - - conflicts "--cask", "--formula" - conflicts "--tap=", "--installed" - conflicts "--no-pull-requests", "--open-pr" - - named_args [:formula, :cask], without_api: true - end - end - - sig { void } - def bump - args = bump_args.parse - - Homebrew.install_bundler_gems!(groups: ["livecheck"]) - - if args.limit.present? && !args.formula? && !args.cask? - raise UsageError, "`--limit` must be used with either `--formula` or `--cask`." - end - - odisabled "brew bump --force" if args.force? - - Homebrew.with_no_api_env do - formulae_and_casks = if args.tap - tap = Tap.fetch(args.tap) - raise UsageError, "`--tap` cannot be used with official taps." if tap.official? - - formulae = args.cask? ? [] : tap.formula_files.map { |path| Formulary.factory(path) } - casks = args.formula? ? [] : tap.cask_files.map { |path| Cask::CaskLoader.load(path) } - formulae + casks - elsif args.installed? - formulae = args.cask? ? [] : Formula.installed - casks = args.formula? ? [] : Cask::Caskroom.casks - formulae + casks - elsif args.named.present? - if args.formula? - args.named.to_formulae - elsif args.cask? - args.named.to_casks - else - args.named.to_formulae_and_casks - end + module DevCmd + class Bump < AbstractCommand + class VersionBumpInfo < T::Struct + const :type, Symbol + const :multiple_versions, T::Boolean + const :version_name, String + const :current_version, BumpVersionParser + const :repology_latest, T.any(String, Version) + const :new_version, BumpVersionParser + const :open_pull_requests, T.nilable(T.any(T::Array[String], String)) + const :closed_pull_requests, T.nilable(T.any(T::Array[String], String)) end - formulae_and_casks = formulae_and_casks&.sort_by do |formula_or_cask| - formula_or_cask.respond_to?(:token) ? formula_or_cask.token : formula_or_cask.name + cmd_args do + description <<~EOS + Display out-of-date brew formulae and the latest version available. If the + returned current and livecheck versions differ or when querying specific + formulae, also displays whether a pull request has been opened with the URL. + EOS + switch "--full-name", + description: "Print formulae/casks with fully-qualified names." + switch "--no-pull-requests", + description: "Do not retrieve pull requests from GitHub." + switch "--formula", "--formulae", + description: "Check only formulae." + switch "--cask", "--casks", + description: "Check only casks." + flag "--tap=", + description: "Check formulae and casks within the given tap, specified as `/`." + switch "--installed", + description: "Check formulae and casks that are currently installed." + switch "--no-fork", + description: "Don't try to fork the repository." + switch "--open-pr", + description: "Open a pull request for the new version if none have been opened yet." + flag "--limit=", + description: "Limit number of package results returned." + flag "--start-with=", + description: "Letter or word that the list of package results should alphabetically follow." + switch "-f", "--force", + hidden: true + + conflicts "--cask", "--formula" + conflicts "--tap=", "--installed" + conflicts "--no-pull-requests", "--open-pr" + + named_args [:formula, :cask], without_api: true end - unless Utils::Curl.curl_supports_tls13? - begin - ensure_formula_installed!("curl", reason: "Repology queries") unless HOMEBREW_BREWED_CURL_PATH.exist? - rescue FormulaUnavailableError - opoo "A newer `curl` is required for Repology queries." + sig { override.void } + def run + Homebrew.install_bundler_gems!(groups: ["livecheck"]) + + if args.limit.present? && !args.formula? && !args.cask? + raise UsageError, "`--limit` must be used with either `--formula` or `--cask`." end - end - if formulae_and_casks.present? - handle_formula_and_casks(formulae_and_casks, args) - else - handle_api_response(args) - end - end - end + odisabled "brew bump --force" if args.force? + + Homebrew.with_no_api_env do + formulae_and_casks = if args.tap + tap = Tap.fetch(T.must(args.tap)) + raise UsageError, "`--tap` cannot be used with official taps." if tap.official? + + formulae = args.cask? ? [] : tap.formula_files.map { |path| Formulary.factory(path) } + casks = args.formula? ? [] : tap.cask_files.map { |path| Cask::CaskLoader.load(path) } + formulae + casks + elsif args.installed? + formulae = args.cask? ? [] : Formula.installed + casks = args.formula? ? [] : Cask::Caskroom.casks + formulae + casks + elsif args.named.present? + if args.formula? + args.named.to_formulae + elsif args.cask? + args.named.to_casks + else + args.named.to_formulae_and_casks + end + end - sig { params(formula_or_cask: T.any(Formula, Cask::Cask), args: CLI::Args).returns(T::Boolean) } - def skip_repology?(formula_or_cask, args:) - (ENV["CI"].present? && args.open_pr? && formula_or_cask.livecheckable?) || - (formula_or_cask.is_a?(Formula) && formula_or_cask.versioned_formula?) - end + formulae_and_casks = formulae_and_casks&.sort_by do |formula_or_cask| + formula_or_cask.respond_to?(:token) ? formula_or_cask.token : formula_or_cask.name + end - sig { params(formulae_and_casks: T::Array[T.any(Formula, Cask::Cask)], args: CLI::Args).void } - def handle_formula_and_casks(formulae_and_casks, args) - Livecheck.load_other_tap_strategies(formulae_and_casks) - - ambiguous_casks = [] - if !args.formula? && !args.cask? - ambiguous_casks = formulae_and_casks - .group_by { |item| Livecheck.package_or_resource_name(item, full_name: true) } - .values - .select { |items| items.length > 1 } - .flatten - .select { |item| item.is_a?(Cask::Cask) } - end + unless Utils::Curl.curl_supports_tls13? + begin + ensure_formula_installed!("curl", reason: "Repology queries") unless HOMEBREW_BREWED_CURL_PATH.exist? + rescue FormulaUnavailableError + opoo "A newer `curl` is required for Repology queries." + end + end - ambiguous_names = [] - unless args.full_name? - ambiguous_names = (formulae_and_casks - ambiguous_casks) - .group_by { |item| Livecheck.package_or_resource_name(item) } - .values - .select { |items| items.length > 1 } - .flatten - end + if formulae_and_casks.present? + handle_formula_and_casks(formulae_and_casks) + else + handle_api_response + end + end + end - formulae_and_casks.each_with_index do |formula_or_cask, i| - puts if i.positive? - next if skip_ineligible_formulae(formula_or_cask) + private - use_full_name = args.full_name? || ambiguous_names.include?(formula_or_cask) - name = Livecheck.package_or_resource_name(formula_or_cask, full_name: use_full_name) - repository = if formula_or_cask.is_a?(Formula) - Repology::HOMEBREW_CORE - else - Repology::HOMEBREW_CASK + sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean) } + def skip_repology?(formula_or_cask) + (ENV["CI"].present? && args.open_pr? && formula_or_cask.livecheckable?) || + (formula_or_cask.is_a?(Formula) && formula_or_cask.versioned_formula?) end - package_data = Repology.single_package_query(name, repository:) unless skip_repology?(formula_or_cask, args:) + sig { params(formulae_and_casks: T::Array[T.any(Formula, Cask::Cask)]).void } + def handle_formula_and_casks(formulae_and_casks) + Livecheck.load_other_tap_strategies(formulae_and_casks) + + ambiguous_casks = [] + if !args.formula? && !args.cask? + ambiguous_casks = formulae_and_casks + .group_by { |item| Livecheck.package_or_resource_name(item, full_name: true) } + .values + .select { |items| items.length > 1 } + .flatten + .select { |item| item.is_a?(Cask::Cask) } + end - retrieve_and_display_info_and_open_pr( - formula_or_cask, - name, - package_data&.values&.first, - args:, - ambiguous_cask: ambiguous_casks.include?(formula_or_cask), - ) - end - end + ambiguous_names = [] + unless args.full_name? + ambiguous_names = (formulae_and_casks - ambiguous_casks) + .group_by { |item| Livecheck.package_or_resource_name(item) } + .values + .select { |items| items.length > 1 } + .flatten + end - sig { params(args: CLI::Args).void } - def handle_api_response(args) - limit = args.limit.to_i if args.limit.present? + formulae_and_casks.each_with_index do |formula_or_cask, i| + puts if i.positive? + next if skip_ineligible_formulae(formula_or_cask) - api_response = {} - unless args.cask? - api_response[:formulae] = - Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CORE) - end - unless args.formula? - api_response[:casks] = - Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CASK) - end + use_full_name = args.full_name? || ambiguous_names.include?(formula_or_cask) + name = Livecheck.package_or_resource_name(formula_or_cask, full_name: use_full_name) + repository = if formula_or_cask.is_a?(Formula) + Repology::HOMEBREW_CORE + else + Repology::HOMEBREW_CASK + end - api_response.each_with_index do |(package_type, outdated_packages), idx| - repository = if package_type == :formulae - Repology::HOMEBREW_CORE - else - Repology::HOMEBREW_CASK + package_data = Repology.single_package_query(name, repository:) unless skip_repology?(formula_or_cask) + + retrieve_and_display_info_and_open_pr( + formula_or_cask, + name, + package_data&.values&.first, + ambiguous_cask: ambiguous_casks.include?(formula_or_cask), + ) + end end - puts if idx.positive? - oh1 package_type.capitalize if api_response.size > 1 - outdated_packages.each_with_index do |(_name, repositories), i| - break if limit && i >= limit + sig { void } + def handle_api_response + limit = args.limit.to_i if args.limit.present? - homebrew_repo = repositories.find do |repo| - repo["repo"] == repository + api_response = {} + unless args.cask? + api_response[:formulae] = + Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CORE) + end + unless args.formula? + api_response[:casks] = + Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CASK) end - next if homebrew_repo.blank? - - formula_or_cask = begin - if repository == Repology::HOMEBREW_CORE - Formula[homebrew_repo["srcname"]] + api_response.each_with_index do |(package_type, outdated_packages), idx| + repository = if package_type == :formulae + Repology::HOMEBREW_CORE else - Cask::CaskLoader.load(homebrew_repo["srcname"]) + Repology::HOMEBREW_CASK + end + puts if idx.positive? + oh1 package_type.capitalize if api_response.size > 1 + + outdated_packages.each_with_index do |(_name, repositories), i| + break if limit && i >= limit + + homebrew_repo = repositories.find do |repo| + repo["repo"] == repository + end + + next if homebrew_repo.blank? + + formula_or_cask = begin + if repository == Repology::HOMEBREW_CORE + Formula[homebrew_repo["srcname"]] + else + Cask::CaskLoader.load(homebrew_repo["srcname"]) + end + rescue + next + end + name = Livecheck.package_or_resource_name(formula_or_cask) + ambiguous_cask = begin + formula_or_cask.is_a?(Cask::Cask) && !args.cask? && Formula[name] + rescue FormulaUnavailableError + false + end + + puts if i.positive? + next if skip_ineligible_formulae(formula_or_cask) + + retrieve_and_display_info_and_open_pr( + formula_or_cask, + name, + repositories, + ambiguous_cask:, + ) end - rescue - next end - name = Livecheck.package_or_resource_name(formula_or_cask) - ambiguous_cask = begin - formula_or_cask.is_a?(Cask::Cask) && !args.cask? && Formula[name] - rescue FormulaUnavailableError - false + end + + sig { + params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean) + } + def skip_ineligible_formulae(formula_or_cask) + if formula_or_cask.is_a?(Formula) + skip = formula_or_cask.disabled? || formula_or_cask.head_only? + name = formula_or_cask.name + text = "Formula is #{formula_or_cask.disabled? ? "disabled" : "HEAD-only"}.\n" + else + skip = formula_or_cask.disabled? + name = formula_or_cask.token + text = "Cask is disabled.\n" end + if (tap = formula_or_cask.tap) && !tap.allow_bump?(name) + skip = true + text = "#{text.split.first} is on autobump list.\n" + end + return false unless skip + + ohai name + puts text + true + end - puts if i.positive? - next if skip_ineligible_formulae(formula_or_cask) + sig { + params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T.any(Version, String)) + } + def livecheck_result(formula_or_cask) + name = Livecheck.package_or_resource_name(formula_or_cask) - retrieve_and_display_info_and_open_pr( + referenced_formula_or_cask, = Livecheck.resolve_livecheck_reference( formula_or_cask, - name, - repositories, - args:, - ambiguous_cask:, + full_name: false, + debug: false, ) - end - end - end - sig { - params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean) - } - def skip_ineligible_formulae(formula_or_cask) - if formula_or_cask.is_a?(Formula) - skip = formula_or_cask.disabled? || formula_or_cask.head_only? - name = formula_or_cask.name - text = "Formula is #{formula_or_cask.disabled? ? "disabled" : "HEAD-only"}.\n" - else - skip = formula_or_cask.disabled? - name = formula_or_cask.token - text = "Cask is disabled.\n" - end - if (tap = formula_or_cask.tap) && !tap.allow_bump?(name) - skip = true - text = "#{text.split.first} is on autobump list.\n" - end - return false unless skip - - ohai name - puts text - true - end + # Check skip conditions for a referenced formula/cask + if referenced_formula_or_cask + skip_info = Livecheck::SkipConditions.referenced_skip_information( + referenced_formula_or_cask, + name, + full_name: false, + verbose: false, + ) + end - sig { - params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T.any(Version, String)) - } - def livecheck_result(formula_or_cask) - name = Livecheck.package_or_resource_name(formula_or_cask) - - referenced_formula_or_cask, = Livecheck.resolve_livecheck_reference( - formula_or_cask, - full_name: false, - debug: false, - ) - - # Check skip conditions for a referenced formula/cask - if referenced_formula_or_cask - skip_info = Livecheck::SkipConditions.referenced_skip_information( - referenced_formula_or_cask, - name, - full_name: false, - verbose: false, - ) - end + skip_info ||= Livecheck::SkipConditions.skip_information( + formula_or_cask, + full_name: false, + verbose: false, + ) - skip_info ||= Livecheck::SkipConditions.skip_information( - formula_or_cask, - full_name: false, - verbose: false, - ) + if skip_info.present? + return "#{skip_info[:status]}#{" - #{skip_info[:messages].join(", ")}" if skip_info[:messages].present?}" + end - if skip_info.present? - return "#{skip_info[:status]}#{" - #{skip_info[:messages].join(", ")}" if skip_info[:messages].present?}" - end + version_info = Livecheck.latest_version( + formula_or_cask, + referenced_formula_or_cask:, + json: true, full_name: false, verbose: true, debug: false + ) + return "unable to get versions" if version_info.blank? - version_info = Livecheck.latest_version( - formula_or_cask, - referenced_formula_or_cask:, - json: true, full_name: false, verbose: true, debug: false - ) - return "unable to get versions" if version_info.blank? + latest = version_info[:latest] - latest = version_info[:latest] + Version.new(latest) + rescue => e + "error: #{e}" + end - Version.new(latest) - rescue => e - "error: #{e}" - end + sig { + params( + formula_or_cask: T.any(Formula, Cask::Cask), + name: String, + state: String, + version: T.nilable(String), + ).returns T.nilable(T.any(T::Array[String], String)) + } + def retrieve_pull_requests(formula_or_cask, name, state:, version: nil) + tap_remote_repo = formula_or_cask.tap&.remote_repo || formula_or_cask.tap&.full_name + pull_requests = begin + GitHub.fetch_pull_requests(name, tap_remote_repo, state:, version:) + rescue GitHub::API::ValidationFailedError => e + odebug "Error fetching pull requests for #{formula_or_cask} #{name}: #{e}" + nil + end - sig { - params( - formula_or_cask: T.any(Formula, Cask::Cask), - name: String, - state: String, - version: T.nilable(String), - ).returns T.nilable(T.any(T::Array[String], String)) - } - def retrieve_pull_requests(formula_or_cask, name, state:, version: nil) - tap_remote_repo = formula_or_cask.tap&.remote_repo || formula_or_cask.tap&.full_name - pull_requests = begin - GitHub.fetch_pull_requests(name, tap_remote_repo, state:, version:) - rescue GitHub::API::ValidationFailedError => e - odebug "Error fetching pull requests for #{formula_or_cask} #{name}: #{e}" - nil - end + pull_requests&.map { |pr| "#{pr["title"]} (#{Formatter.url(pr["html_url"])})" }&.join(", ") + end - pull_requests&.map { |pr| "#{pr["title"]} (#{Formatter.url(pr["html_url"])})" }&.join(", ") - end + sig { + params( + formula_or_cask: T.any(Formula, Cask::Cask), + repositories: T::Array[T.untyped], + name: String, + ).returns(VersionBumpInfo) + } + def retrieve_versions_by_arch(formula_or_cask:, repositories:, name:) + is_cask_with_blocks = formula_or_cask.is_a?(Cask::Cask) && formula_or_cask.on_system_blocks_exist? + type, version_name = if formula_or_cask.is_a?(Formula) + [:formula, "formula version:"] + else + [:cask, "cask version: "] + end - sig { - params( - formula_or_cask: T.any(Formula, Cask::Cask), - repositories: T::Array[T.untyped], - args: CLI::Args, - name: String, - ).returns(VersionBumpInfo) - } - def retrieve_versions_by_arch(formula_or_cask:, repositories:, args:, name:) - is_cask_with_blocks = formula_or_cask.is_a?(Cask::Cask) && formula_or_cask.on_system_blocks_exist? - type, version_name = formula_or_cask.is_a?(Formula) ? [:formula, "formula version:"] : [:cask, "cask version: "] + old_versions = {} + new_versions = {} + + repology_latest = repositories.present? ? Repology.latest_version(repositories) : "not found" + + # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture. + arch_options = is_cask_with_blocks ? OnSystem::ARCH_OPTIONS : [:arm] + + arch_options.each do |arch| + SimulateSystem.with(arch:) do + version_key = is_cask_with_blocks ? arch : :general + + # We reload the formula/cask here to ensure we're getting the correct version for the current arch + if formula_or_cask.is_a?(Formula) + loaded_formula_or_cask = formula_or_cask + current_version_value = T.must(loaded_formula_or_cask.stable).version + else + loaded_formula_or_cask = Cask::CaskLoader.load(formula_or_cask.sourcefile_path) + current_version_value = Version.new(loaded_formula_or_cask.version) + end + + livecheck_latest = livecheck_result(loaded_formula_or_cask) + + new_version_value = if (livecheck_latest.is_a?(Version) && livecheck_latest >= current_version_value) || + current_version_value == "latest" + livecheck_latest + elsif livecheck_latest.is_a?(String) && livecheck_latest.start_with?("skipped") + "skipped" + elsif repology_latest.is_a?(Version) && + repology_latest > current_version_value && + !loaded_formula_or_cask.livecheckable? && + current_version_value != "latest" + repology_latest + end.presence + + # Store old and new versions + old_versions[version_key] = current_version_value + new_versions[version_key] = new_version_value + end + end - old_versions = {} - new_versions = {} + # If arm and intel versions are identical, as it happens with casks where only the checksums differ, + # we consolidate them into a single version. + if old_versions[:arm].present? && old_versions[:arm] == old_versions[:intel] + old_versions = { general: old_versions[:arm] } + end + if new_versions[:arm].present? && new_versions[:arm] == new_versions[:intel] + new_versions = { general: new_versions[:arm] } + end - repology_latest = repositories.present? ? Repology.latest_version(repositories) : "not found" + multiple_versions = old_versions.values_at(:arm, :intel).all?(&:present?) || + new_versions.values_at(:arm, :intel).all?(&:present?) - # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture. - arch_options = is_cask_with_blocks ? OnSystem::ARCH_OPTIONS : [:arm] + current_version = BumpVersionParser.new(general: old_versions[:general], + arm: old_versions[:arm], + intel: old_versions[:intel]) - arch_options.each do |arch| - SimulateSystem.with(arch:) do - version_key = is_cask_with_blocks ? arch : :general + begin + new_version = BumpVersionParser.new(general: new_versions[:general], + arm: new_versions[:arm], + intel: new_versions[:intel]) + rescue + # When livecheck fails, we fail gracefully. Otherwise VersionParser will + # raise a usage error + new_version = BumpVersionParser.new(general: "unable to get versions") + end - # We reload the formula/cask here to ensure we're getting the correct version for the current arch - if formula_or_cask.is_a?(Formula) - loaded_formula_or_cask = formula_or_cask - current_version_value = T.must(loaded_formula_or_cask.stable).version + # We use the arm version for the pull request version. This is consistent + # with the behavior of bump-cask-pr. + pull_request_version = if multiple_versions && new_version.general != "unable to get versions" + new_version.arm.to_s else - loaded_formula_or_cask = Cask::CaskLoader.load(formula_or_cask.sourcefile_path) - current_version_value = Version.new(loaded_formula_or_cask.version) + new_version.general.to_s end - livecheck_latest = livecheck_result(loaded_formula_or_cask) - - new_version_value = if (livecheck_latest.is_a?(Version) && livecheck_latest >= current_version_value) || - current_version_value == "latest" - livecheck_latest - elsif livecheck_latest.is_a?(String) && livecheck_latest.start_with?("skipped") - "skipped" - elsif repology_latest.is_a?(Version) && - repology_latest > current_version_value && - !loaded_formula_or_cask.livecheckable? && - current_version_value != "latest" - repology_latest + open_pull_requests = if !args.no_pull_requests? && (args.named.present? || new_version.present?) + retrieve_pull_requests(formula_or_cask, name, state: "open") end.presence - # Store old and new versions - old_versions[version_key] = current_version_value - new_versions[version_key] = new_version_value - end - end - - # If arm and intel versions are identical, as it happens with casks where only the checksums differ, - # we consolidate them into a single version. - if old_versions[:arm].present? && old_versions[:arm] == old_versions[:intel] - old_versions = { general: old_versions[:arm] } - end - if new_versions[:arm].present? && new_versions[:arm] == new_versions[:intel] - new_versions = { general: new_versions[:arm] } - end - - multiple_versions = old_versions.values_at(:arm, :intel).all?(&:present?) || - new_versions.values_at(:arm, :intel).all?(&:present?) - - current_version = BumpVersionParser.new(general: old_versions[:general], - arm: old_versions[:arm], - intel: old_versions[:intel]) - - begin - new_version = BumpVersionParser.new(general: new_versions[:general], - arm: new_versions[:arm], - intel: new_versions[:intel]) - rescue - # When livecheck fails, we fail gracefully. Otherwise VersionParser will - # raise a usage error - new_version = BumpVersionParser.new(general: "unable to get versions") - end - - # We use the arm version for the pull request version. This is consistent - # with the behavior of bump-cask-pr. - pull_request_version = if multiple_versions && new_version.general != "unable to get versions" - new_version.arm.to_s - else - new_version.general.to_s - end + closed_pull_requests = if !args.no_pull_requests? && open_pull_requests.blank? && new_version.present? + retrieve_pull_requests(formula_or_cask, name, state: "closed", version: pull_request_version) + end.presence - open_pull_requests = if !args.no_pull_requests? && (args.named.present? || new_version.present?) - retrieve_pull_requests(formula_or_cask, name, state: "open") - end.presence - - closed_pull_requests = if !args.no_pull_requests? && open_pull_requests.blank? && new_version.present? - retrieve_pull_requests(formula_or_cask, name, state: "closed", version: pull_request_version) - end.presence - - VersionBumpInfo.new( - type:, - multiple_versions:, - version_name:, - current_version:, - repology_latest:, - new_version:, - open_pull_requests:, - closed_pull_requests:, - ) - end + VersionBumpInfo.new( + type:, + multiple_versions:, + version_name:, + current_version:, + repology_latest:, + new_version:, + open_pull_requests:, + closed_pull_requests:, + ) + end - sig { - params( - formula_or_cask: T.any(Formula, Cask::Cask), - name: String, - repositories: T::Array[T.untyped], - args: CLI::Args, - ambiguous_cask: T::Boolean, - ).void - } - def retrieve_and_display_info_and_open_pr(formula_or_cask, name, repositories, args:, ambiguous_cask: false) - version_info = retrieve_versions_by_arch(formula_or_cask:, - repositories:, - args:, - name:) - - current_version = version_info.current_version - new_version = version_info.new_version - repology_latest = version_info.repology_latest - - # Check if all versions are equal - versions_equal = [:arm, :intel, :general].all? do |key| - current_version.send(key) == new_version.send(key) - end + sig { + params( + formula_or_cask: T.any(Formula, Cask::Cask), + name: String, + repositories: T::Array[T.untyped], + ambiguous_cask: T::Boolean, + ).void + } + def retrieve_and_display_info_and_open_pr(formula_or_cask, name, repositories, ambiguous_cask: false) + version_info = retrieve_versions_by_arch(formula_or_cask:, + repositories:, + name:) + + current_version = version_info.current_version + new_version = version_info.new_version + repology_latest = version_info.repology_latest + + # Check if all versions are equal + versions_equal = [:arm, :intel, :general].all? do |key| + current_version.send(key) == new_version.send(key) + end - title_name = ambiguous_cask ? "#{name} (cask)" : name - title = if (repology_latest == current_version.general || !repology_latest.is_a?(Version)) && versions_equal - "#{title_name} #{Tty.green}is up to date!#{Tty.reset}" - else - title_name - end + title_name = ambiguous_cask ? "#{name} (cask)" : name + title = if (repology_latest == current_version.general || !repology_latest.is_a?(Version)) && versions_equal + "#{title_name} #{Tty.green}is up to date!#{Tty.reset}" + else + title_name + end - # Conditionally format output based on type of formula_or_cask - current_versions = if version_info.multiple_versions - "arm: #{current_version.arm} + # Conditionally format output based on type of formula_or_cask + current_versions = if version_info.multiple_versions + "arm: #{current_version.arm} intel: #{current_version.intel}" - else - current_version.general.to_s - end - current_versions << " (deprecated)" if formula_or_cask.deprecated? + else + current_version.general.to_s + end + current_versions << " (deprecated)" if formula_or_cask.deprecated? - new_versions = if version_info.multiple_versions && new_version.arm && new_version.intel - "arm: #{new_version.arm} + new_versions = if version_info.multiple_versions && new_version.arm && new_version.intel + "arm: #{new_version.arm} intel: #{new_version.intel}" - else - new_version.general - end + else + new_version.general + end - version_label = version_info.version_name - open_pull_requests = version_info.open_pull_requests.presence - closed_pull_requests = version_info.closed_pull_requests.presence - - ohai title - puts <<~EOS - Current #{version_label} #{current_versions} - Latest livecheck version: #{new_versions} - EOS - puts <<~EOS unless skip_repology?(formula_or_cask, args:) - Latest Repology version: #{repology_latest} - EOS - if formula_or_cask.is_a?(Formula) && formula_or_cask.synced_with_other_formulae? - outdated_synced_formulae = synced_with(formula_or_cask, new_version.general) - puts <<~EOS if outdated_synced_formulae.present? - Version syncing: #{title_name} version should be kept in sync with - #{outdated_synced_formulae.join(", ")}. - EOS - end - puts <<~EOS unless args.no_pull_requests? - Open pull requests: #{open_pull_requests || "none"} - Closed pull requests: #{closed_pull_requests || "none"} - EOS - - return unless args.open_pr? - - if repology_latest.is_a?(Version) && - repology_latest > current_version.general && - repology_latest > new_version.general && - formula_or_cask.livecheckable? - puts "#{title_name} was not bumped to the Repology version because it's livecheckable." - end - if new_version.blank? || versions_equal || - (!new_version.general.is_a?(Version) && !version_info.multiple_versions) - return - end + version_label = version_info.version_name + open_pull_requests = version_info.open_pull_requests.presence + closed_pull_requests = version_info.closed_pull_requests.presence + + ohai title + puts <<~EOS + Current #{version_label} #{current_versions} + Latest livecheck version: #{new_versions} + EOS + puts <<~EOS unless skip_repology?(formula_or_cask) + Latest Repology version: #{repology_latest} + EOS + if formula_or_cask.is_a?(Formula) && formula_or_cask.synced_with_other_formulae? + outdated_synced_formulae = synced_with(formula_or_cask, new_version.general) + puts <<~EOS if outdated_synced_formulae.present? + Version syncing: #{title_name} version should be kept in sync with + #{outdated_synced_formulae.join(", ")}. + EOS + end + puts <<~EOS unless args.no_pull_requests? + Open pull requests: #{open_pull_requests || "none"} + Closed pull requests: #{closed_pull_requests || "none"} + EOS + + return unless args.open_pr? + + if repology_latest.is_a?(Version) && + repology_latest > current_version.general && + repology_latest > new_version.general && + formula_or_cask.livecheckable? + puts "#{title_name} was not bumped to the Repology version because it's livecheckable." + end + if new_version.blank? || versions_equal || + (!new_version.general.is_a?(Version) && !version_info.multiple_versions) + return + end - return if open_pull_requests.present? || closed_pull_requests.present? + return if open_pull_requests.present? || closed_pull_requests.present? - version_args = if version_info.multiple_versions - %W[--version-arm=#{new_version.arm} --version-intel=#{new_version.intel}] - else - "--version=#{new_version.general}" - end + version_args = if version_info.multiple_versions + %W[--version-arm=#{new_version.arm} --version-intel=#{new_version.intel}] + else + "--version=#{new_version.general}" + end - bump_cask_pr_args = [ - "bump-#{version_info.type}-pr", - name, - *version_args, - "--no-browse", - "--message=Created by `brew bump`", - ] + bump_cask_pr_args = [ + "bump-#{version_info.type}-pr", + name, + *version_args, + "--no-browse", + "--message=Created by `brew bump`", + ] - bump_cask_pr_args << "--no-fork" if args.no_fork? + bump_cask_pr_args << "--no-fork" if args.no_fork? - system HOMEBREW_BREW_FILE, *bump_cask_pr_args - end + system HOMEBREW_BREW_FILE, *bump_cask_pr_args + end - sig { - params( - formula: Formula, - new_version: T.nilable(T.any(Version, Cask::DSL::Version)), - ).returns(T::Array[String]) - } - def synced_with(formula, new_version) - synced_with = [] + sig { + params( + formula: Formula, + new_version: T.nilable(T.any(Version, Cask::DSL::Version)), + ).returns(T::Array[String]) + } + def synced_with(formula, new_version) + synced_with = [] - formula.tap&.synced_versions_formulae&.each do |synced_formulae| - next unless synced_formulae.include?(formula.name) + formula.tap&.synced_versions_formulae&.each do |synced_formulae| + next unless synced_formulae.include?(formula.name) - synced_formulae.each do |synced_formula| - synced_formula = Formulary.factory(synced_formula) - next if synced_formula == formula.name + synced_formulae.each do |synced_formula| + synced_formula = Formulary.factory(synced_formula) + next if synced_formula == formula.name - synced_with << synced_formula.name if synced_formula.version != new_version + synced_with << synced_formula.name if synced_formula.version != new_version + end + end + + synced_with end end - - synced_with end end diff --git a/Library/Homebrew/dev-cmd/cat.rb b/Library/Homebrew/dev-cmd/cat.rb index 0d8c70eecd626..e0e6d46fce33f 100644 --- a/Library/Homebrew/dev-cmd/cat.rb +++ b/Library/Homebrew/dev-cmd/cat.rb @@ -1,62 +1,65 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "cli/parser" module Homebrew - sig { returns(CLI::Parser) } - def self.cat_args - Homebrew::CLI::Parser.new do - description <<~EOS - Display the source of a or . - EOS + module DevCmd + class Cat < AbstractCommand + include FileUtils - switch "--formula", "--formulae", - description: "Treat all named arguments as formulae." - switch "--cask", "--casks", - description: "Treat all named arguments as casks." + cmd_args do + description <<~EOS + Display the source of a or . + EOS - conflicts "--formula", "--cask" + switch "--formula", "--formulae", + description: "Treat all named arguments as formulae." + switch "--cask", "--casks", + description: "Treat all named arguments as casks." - named_args [:formula, :cask], min: 1, without_api: true - end - end + conflicts "--formula", "--cask" - def self.cat - args = cat_args.parse - - cd HOMEBREW_REPOSITORY do - pager = if Homebrew::EnvConfig.bat? - ENV["BAT_CONFIG_PATH"] = Homebrew::EnvConfig.bat_config_path - ENV["BAT_THEME"] = Homebrew::EnvConfig.bat_theme - ensure_formula_installed!( - "bat", - reason: "displaying / source", - # The user might want to capture the output of `brew cat ...` - # Redirect stdout to stderr - output_to_stderr: true, - ).opt_bin/"bat" - else - "cat" + named_args [:formula, :cask], min: 1, without_api: true end - args.named.to_paths.each do |path| - next path if path.exist? + sig { override.void } + def run + cd HOMEBREW_REPOSITORY do + pager = if Homebrew::EnvConfig.bat? + ENV["BAT_CONFIG_PATH"] = Homebrew::EnvConfig.bat_config_path + ENV["BAT_THEME"] = Homebrew::EnvConfig.bat_theme + ensure_formula_installed!( + "bat", + reason: "displaying / source", + # The user might want to capture the output of `brew cat ...` + # Redirect stdout to stderr + output_to_stderr: true, + ).opt_bin/"bat" + else + "cat" + end - path = path.basename(".rb") if args.cask? + args.named.to_paths.each do |path| + next path if path.exist? - ofail "#{path}'s source doesn't exist on disk." - end + path = path.basename(".rb") if args.cask? - if Homebrew.failed? - $stderr.puts "The name may be wrong, or the tap hasn't been tapped. Instead try:" - treat_as = "--cask " if args.cask? - treat_as = "--formula " if args.formula? - $stderr.puts " brew info --github #{treat_as}#{args.named.join(" ")}" - return - end + ofail "#{path}'s source doesn't exist on disk." + end + + if Homebrew.failed? + $stderr.puts "The name may be wrong, or the tap hasn't been tapped. Instead try:" + treat_as = "--cask " if args.cask? + treat_as = "--formula " if args.formula? + $stderr.puts " brew info --github #{treat_as}#{args.named.join(" ")}" + return + end - safe_system pager, *args.named.to_paths + safe_system pager, *args.named.to_paths + end + end end end end diff --git a/Library/Homebrew/dev-cmd/command.rb b/Library/Homebrew/dev-cmd/command.rb index 18afa2316b58e..54784a48f065d 100644 --- a/Library/Homebrew/dev-cmd/command.rb +++ b/Library/Homebrew/dev-cmd/command.rb @@ -1,30 +1,29 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "commands" require "cli/parser" module Homebrew - module_function + module DevCmd + class Command < AbstractCommand + cmd_args do + description <<~EOS + Display the path to the file being used when invoking `brew` . + EOS - sig { returns(CLI::Parser) } - def command_args - Homebrew::CLI::Parser.new do - description <<~EOS - Display the path to the file being used when invoking `brew` . - EOS + named_args :command, min: 1 + end - named_args :command, min: 1 - end - end - - def command - args = command_args.parse - - args.named.each do |cmd| - path = Commands.path(cmd) - odie "Unknown command: #{cmd}" unless path - puts path + sig { override.void } + def run + args.named.each do |cmd| + path = Commands.path(cmd) + odie "Unknown command: #{cmd}" unless path + puts path + end + end end end end diff --git a/Library/Homebrew/dev-cmd/contributions.rb b/Library/Homebrew/dev-cmd/contributions.rb index 4c38746a30d21..22fd4fdad6e75 100644 --- a/Library/Homebrew/dev-cmd/contributions.rb +++ b/Library/Homebrew/dev-cmd/contributions.rb @@ -5,209 +5,209 @@ require "csv" module Homebrew - module_function - - PRIMARY_REPOS = %w[brew core cask].freeze - SUPPORTED_REPOS = [ - PRIMARY_REPOS, - OFFICIAL_CMD_TAPS.keys.map { |t| t.delete_prefix("homebrew/") }, - OFFICIAL_CASK_TAPS.reject { |t| t == "cask" }, - ].flatten.freeze - MAX_REPO_COMMITS = 1000 - - sig { returns(CLI::Parser) } - def contributions_args - Homebrew::CLI::Parser.new do - usage_banner "`contributions` [--user=] [<--repositories>`=`] [<--csv>]" - description <<~EOS - Summarise contributions to Homebrew repositories. - EOS - - comma_array "--repositories", - description: "Specify a comma-separated list of repositories to search. " \ - "Supported repositories: #{SUPPORTED_REPOS.map { |t| "`#{t}`" }.to_sentence}. " \ - "Omitting this flag, or specifying `--repositories=primary`, searches only the " \ - "main repositories: brew,core,cask. " \ - "Specifying `--repositories=all`, searches all repositories. " - flag "--from=", - description: "Date (ISO-8601 format) to start searching contributions. " \ - "Omitting this flag searches the last year." - - flag "--to=", - description: "Date (ISO-8601 format) to stop searching contributions." - - comma_array "--user=", - description: "Specify a comma-separated list of GitHub usernames or email addresses to find " \ - "contributions from. Omitting this flag searches maintainers." - - switch "--csv", - description: "Print a CSV of contributions across repositories over the time period." - end - end - - sig { void } - def contributions - args = contributions_args.parse - - results = {} - grand_totals = {} - - repos = if args.repositories.blank? || args.repositories.include?("primary") - PRIMARY_REPOS - elsif args.repositories.include?("all") - SUPPORTED_REPOS - else - args.repositories - end - - from = args.from.presence || Date.today.prev_year.iso8601 - - contribution_types = [:author, :committer, :coauthorship, :review] - - users = args.user.presence || GitHub.members_by_team("Homebrew", "maintainers").keys - users.each do |username| - # TODO: Using the GitHub username to scan the `git log` undercounts some - # contributions as people might not always have configured their Git - # committer details to match the ones on GitHub. - # TODO: Switch to using the GitHub APIs instead of `git log` if - # they ever support trailers. - results[username] = scan_repositories(repos, username, args, from:) - grand_totals[username] = total(results[username]) - - contributions = contribution_types.filter_map do |type| - type_count = grand_totals[username][type] - next if type_count.to_i.zero? - - "#{Utils.pluralize("time", type_count, include_count: true)} (#{type})" + module DevCmd + class Contributions < AbstractCommand + PRIMARY_REPOS = %w[brew core cask].freeze + SUPPORTED_REPOS = [ + PRIMARY_REPOS, + OFFICIAL_CMD_TAPS.keys.map { |t| t.delete_prefix("homebrew/") }, + OFFICIAL_CASK_TAPS.reject { |t| t == "cask" }, + ].flatten.freeze + MAX_REPO_COMMITS = 1000 + + cmd_args do + usage_banner "`contributions` [--user=] [<--repositories>`=`] [<--csv>]" + description <<~EOS + Summarise contributions to Homebrew repositories. + EOS + + comma_array "--repositories", + description: "Specify a comma-separated list of repositories to search. " \ + "Supported repositories: #{SUPPORTED_REPOS.map { |t| "`#{t}`" }.to_sentence}. " \ + "Omitting this flag, or specifying `--repositories=primary`, searches only the " \ + "main repositories: brew,core,cask. " \ + "Specifying `--repositories=all`, searches all repositories. " + flag "--from=", + description: "Date (ISO-8601 format) to start searching contributions. " \ + "Omitting this flag searches the last year." + + flag "--to=", + description: "Date (ISO-8601 format) to stop searching contributions." + + comma_array "--user=", + description: "Specify a comma-separated list of GitHub usernames or email addresses to find " \ + "contributions from. Omitting this flag searches maintainers." + + switch "--csv", + description: "Print a CSV of contributions across repositories over the time period." end - contributions << "#{Utils.pluralize("time", grand_totals[username].values.sum, include_count: true)} (total)" - - puts [ - "#{username} contributed", - *contributions.to_sentence, - "#{time_period(from:, to: args.to)}.", - ].join(" ") - end - - return unless args.csv? - puts - puts generate_csv(grand_totals) - end - - sig { params(repo: String).returns(Pathname) } - def find_repo_path_for_repo(repo) - return HOMEBREW_REPOSITORY if repo == "brew" - - Tap.fetch("homebrew", repo).path - end + sig { override.void } + def run + results = {} + grand_totals = {} + + repos = if args.repositories.blank? || T.must(args.repositories).include?("primary") + PRIMARY_REPOS + elsif T.must(args.repositories).include?("all") + SUPPORTED_REPOS + else + args.repositories + end + + from = args.from.presence || Date.today.prev_year.iso8601 + + contribution_types = [:author, :committer, :coauthorship, :review] + + users = args.user.presence || GitHub.members_by_team("Homebrew", "maintainers").keys + users.each do |username| + # TODO: Using the GitHub username to scan the `git log` undercounts some + # contributions as people might not always have configured their Git + # committer details to match the ones on GitHub. + # TODO: Switch to using the GitHub APIs instead of `git log` if + # they ever support trailers. + results[username] = scan_repositories(repos, username, from:) + grand_totals[username] = total(results[username]) + + contributions = contribution_types.filter_map do |type| + type_count = grand_totals[username][type] + next if type_count.to_i.zero? + + "#{Utils.pluralize("time", type_count, include_count: true)} (#{type})" + end + contributions << + "#{Utils.pluralize("time", grand_totals[username].values.sum, include_count: true)} (total)" + + puts [ + "#{username} contributed", + *contributions.to_sentence, + "#{time_period(from:, to: args.to)}.", + ].join(" ") + end + + return unless args.csv? + + puts + puts generate_csv(grand_totals) + end - sig { params(from: T.nilable(String), to: T.nilable(String)).returns(String) } - def time_period(from:, to:) - if from && to - "between #{from} and #{to}" - elsif from - "after #{from}" - elsif to - "before #{to}" - else - "in all time" - end - end + private - sig { params(totals: Hash).returns(String) } - def generate_csv(totals) - CSV.generate do |csv| - csv << %w[user repo author committer coauthorship review total] + sig { params(repo: String).returns(Pathname) } + def find_repo_path_for_repo(repo) + return HOMEBREW_REPOSITORY if repo == "brew" - totals.sort_by { |_, v| -v.values.sum }.each do |user, total| - csv << grand_total_row(user, total) + Tap.fetch("homebrew", repo).path end - end - end - sig { params(user: String, grand_total: Hash).returns(Array) } - def grand_total_row(user, grand_total) - [ - user, - "all", - grand_total[:author], - grand_total[:committer], - grand_total[:coauthorship], - grand_total[:review], - grand_total.values.sum, - ] - end + sig { params(from: T.nilable(String), to: T.nilable(String)).returns(String) } + def time_period(from:, to:) + if from && to + "between #{from} and #{to}" + elsif from + "after #{from}" + elsif to + "before #{to}" + else + "in all time" + end + end - def scan_repositories(repos, person, args, from:) - data = {} + sig { params(totals: Hash).returns(String) } + def generate_csv(totals) + CSV.generate do |csv| + csv << %w[user repo author committer coauthorship review total] - repos.each do |repo| - if SUPPORTED_REPOS.exclude?(repo) - return ofail "Unsupported repository: #{repo}. Try one of #{SUPPORTED_REPOS.join(", ")}." + totals.sort_by { |_, v| -v.values.sum }.each do |user, total| + csv << grand_total_row(user, total) + end + end end - repo_path = find_repo_path_for_repo(repo) - tap = Tap.fetch("homebrew", repo) - unless repo_path.exist? - opoo "Repository #{repo} not yet tapped! Tapping it now..." - tap.install + sig { params(user: String, grand_total: Hash).returns(Array) } + def grand_total_row(user, grand_total) + [ + user, + "all", + grand_total[:author], + grand_total[:committer], + grand_total[:coauthorship], + grand_total[:review], + grand_total.values.sum, + ] end - repo_full_name = if repo == "brew" - "homebrew/brew" - else - tap.full_name + def scan_repositories(repos, person, from:) + data = {} + + repos.each do |repo| + if SUPPORTED_REPOS.exclude?(repo) + return ofail "Unsupported repository: #{repo}. Try one of #{SUPPORTED_REPOS.join(", ")}." + end + + repo_path = find_repo_path_for_repo(repo) + tap = Tap.fetch("homebrew", repo) + unless repo_path.exist? + opoo "Repository #{repo} not yet tapped! Tapping it now..." + tap.install + end + + repo_full_name = if repo == "brew" + "homebrew/brew" + else + tap.full_name + end + + puts "Determining contributions for #{person} on #{repo_full_name}..." if args.verbose? + + author_commits, committer_commits = GitHub.count_repo_commits(repo_full_name, person, args, + max: MAX_REPO_COMMITS) + data[repo] = { + author: author_commits, + committer: committer_commits, + coauthorship: git_log_trailers_cmd(T.must(repo_path), person, "Co-authored-by", from:, to: args.to), + review: count_reviews(repo_full_name, person), + } + end + + data end - puts "Determining contributions for #{person} on #{repo_full_name}..." if args.verbose? - - author_commits, committer_commits = GitHub.count_repo_commits(repo_full_name, person, args, - max: MAX_REPO_COMMITS) - data[repo] = { - author: author_commits, - committer: committer_commits, - coauthorship: git_log_trailers_cmd(T.must(repo_path), person, "Co-authored-by", from:, to: args.to), - review: count_reviews(repo_full_name, person, args), - } - end - - data - end + sig { params(results: Hash).returns(Hash) } + def total(results) + totals = { author: 0, committer: 0, coauthorship: 0, review: 0 } - sig { params(results: Hash).returns(Hash) } - def total(results) - totals = { author: 0, committer: 0, coauthorship: 0, review: 0 } + results.each_value do |counts| + counts.each do |kind, count| + totals[kind] += count + end + end - results.each_value do |counts| - counts.each do |kind, count| - totals[kind] += count + totals end - end - totals - end + sig { + params(repo_path: Pathname, person: String, trailer: String, from: T.nilable(String), + to: T.nilable(String)).returns(Integer) + } + def git_log_trailers_cmd(repo_path, person, trailer, from:, to:) + cmd = ["git", "-C", repo_path, "log", "--oneline"] + cmd << "--format='%(trailers:key=#{trailer}:)'" + cmd << "--before=#{to}" if to + cmd << "--after=#{from}" if from - sig { - params(repo_path: Pathname, person: String, trailer: String, from: T.nilable(String), - to: T.nilable(String)).returns(Integer) - } - def git_log_trailers_cmd(repo_path, person, trailer, from:, to:) - cmd = ["git", "-C", repo_path, "log", "--oneline"] - cmd << "--format='%(trailers:key=#{trailer}:)'" - cmd << "--before=#{to}" if to - cmd << "--after=#{from}" if from - - Utils.safe_popen_read(*cmd).lines.count { |l| l.include?(person) } - end + Utils.safe_popen_read(*cmd).lines.count { |l| l.include?(person) } + end - sig { params(repo_full_name: String, person: String, args: Homebrew::CLI::Args).returns(Integer) } - def count_reviews(repo_full_name, person, args) - GitHub.count_issues("", is: "pr", repo: repo_full_name, reviewed_by: person, review: "approved", args:) - rescue GitHub::API::ValidationFailedError - if args.verbose? - onoe "Couldn't search GitHub for PRs by #{person}. Their profile might be private. Defaulting to 0." + sig { params(repo_full_name: String, person: String).returns(Integer) } + def count_reviews(repo_full_name, person) + GitHub.count_issues("", is: "pr", repo: repo_full_name, reviewed_by: person, review: "approved", args:) + rescue GitHub::API::ValidationFailedError + if args.verbose? + onoe "Couldn't search GitHub for PRs by #{person}. Their profile might be private. Defaulting to 0." + end + 0 # Users who have made their contributions private are not searchable to determine counts. + end end - 0 # Users who have made their contributions private are not searchable to determine counts. end end diff --git a/Library/Homebrew/extend/os/mac/dev-cmd/bottle.rb b/Library/Homebrew/extend/os/mac/dev-cmd/bottle.rb index 92f5108d613da..f6313608ca9dd 100644 --- a/Library/Homebrew/extend/os/mac/dev-cmd/bottle.rb +++ b/Library/Homebrew/extend/os/mac/dev-cmd/bottle.rb @@ -2,23 +2,25 @@ # frozen_string_literal: true module Homebrew - class << self - undef tar_args + module DevCmd + class Bottle < AbstractCommand + undef tar_args - sig { returns(T::Array[String]) } - def tar_args - if MacOS.version >= :catalina - ["--no-mac-metadata", "--no-acls", "--no-xattrs"].freeze - else - [].freeze + sig { returns(T::Array[String]) } + def tar_args + if MacOS.version >= :catalina + ["--no-mac-metadata", "--no-acls", "--no-xattrs"].freeze + else + [].freeze + end end - end - undef gnu_tar + undef gnu_tar - sig { params(gnu_tar_formula: Formula).returns(String) } - def gnu_tar(gnu_tar_formula) - "#{gnu_tar_formula.opt_bin}/gtar" + sig { params(gnu_tar_formula: Formula).returns(String) } + def gnu_tar(gnu_tar_formula) + "#{gnu_tar_formula.opt_bin}/gtar" + end end end end diff --git a/Library/Homebrew/sorbet/tapioca/compilers/args.rb b/Library/Homebrew/sorbet/tapioca/compilers/args.rb index b79843eb8fa81..62458615615b4 100644 --- a/Library/Homebrew/sorbet/tapioca/compilers/args.rb +++ b/Library/Homebrew/sorbet/tapioca/compilers/args.rb @@ -49,7 +49,8 @@ def decorate end else root.create_path(Homebrew::CLI::Args) do |klass| - create_args_methods(klass, T.must(T.cast(constant, T.class_of(Homebrew::AbstractCommand)).parser)) + parser = T.cast(constant, T.class_of(Homebrew::AbstractCommand)).parser + create_args_methods(klass, parser) end end end diff --git a/Library/Homebrew/test/cli/parser_spec.rb b/Library/Homebrew/test/cli/parser_spec.rb index 5232fc46736a7..1c6516ef9c3db 100644 --- a/Library/Homebrew/test/cli/parser_spec.rb +++ b/Library/Homebrew/test/cli/parser_spec.rb @@ -583,7 +583,7 @@ # commands for formulae and casks on Linux. it "succeeds for developer commands" do require "dev-cmd/cat" - args = Homebrew.cat_args.parse(["--cask", "cask_name"]) + args = Homebrew::DevCmd::Cat.new(["--cask", "cask_name"]).args expect(args.cask?).to be(true) end end diff --git a/Library/Homebrew/test/dev-cmd/audit_spec.rb b/Library/Homebrew/test/dev-cmd/audit_spec.rb index 4b8ea0b327df1..5205b58cd2fd9 100644 --- a/Library/Homebrew/test/dev-cmd/audit_spec.rb +++ b/Library/Homebrew/test/dev-cmd/audit_spec.rb @@ -5,7 +5,7 @@ require "cmd/shared_examples/args_parse" require "utils/spdx" -RSpec.describe "brew audit" do +RSpec.describe Homebrew::DevCmd::Audit do it_behaves_like "parseable arguments" end diff --git a/Library/Homebrew/test/dev-cmd/bottle_spec.rb b/Library/Homebrew/test/dev-cmd/bottle_spec.rb index 85fe9e93ff23b..58dbd58cc667f 100644 --- a/Library/Homebrew/test/dev-cmd/bottle_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bottle_spec.rb @@ -3,7 +3,7 @@ require "cmd/shared_examples/args_parse" require "dev-cmd/bottle" -RSpec.describe "brew bottle" do +RSpec.describe Homebrew::DevCmd::Bottle do def stub_hash(parameters) <<~EOS { @@ -30,7 +30,7 @@ def stub_hash(parameters) EOS end - it_behaves_like "parseable arguments" + it_behaves_like "parseable arguments", argv: ["foo"] it "builds a bottle for the given Formula", :integration_test do install_test_formula "testball", build_bottle: true @@ -308,8 +308,8 @@ def install end end - describe Homebrew do - subject(:homebrew) { described_class } + describe "bottle_cmd" do + subject(:homebrew) { described_class.new(["foo"]) } let(:hello_hash_big_sur) do JSON.parse stub_hash( diff --git a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb index 9a8b296c954a8..8454650c9df5b 100644 --- a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb @@ -3,6 +3,6 @@ require "cmd/shared_examples/args_parse" require "dev-cmd/bump-cask-pr" -RSpec.describe "brew bump-cask-pr" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::BumpCaskPr do + it_behaves_like "parseable arguments", argv: ["foo"] end diff --git a/Library/Homebrew/test/dev-cmd/bump-formula-pr_spec.rb b/Library/Homebrew/test/dev-cmd/bump-formula-pr_spec.rb index 8a141d220a829..2bde7a8d2e4b9 100644 --- a/Library/Homebrew/test/dev-cmd/bump-formula-pr_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-formula-pr_spec.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/bump-formula-pr" -RSpec.describe "brew bump-formula-pr" do +RSpec.describe Homebrew::DevCmd::BumpFormulaPr do it_behaves_like "parseable arguments" end diff --git a/Library/Homebrew/test/dev-cmd/bump-revision_spec.rb b/Library/Homebrew/test/dev-cmd/bump-revision_spec.rb index 13d9833bf03ca..10c2646451760 100644 --- a/Library/Homebrew/test/dev-cmd/bump-revision_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-revision_spec.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/bump-revision" -RSpec.describe "brew bump-revision" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::BumpRevision do + it_behaves_like "parseable arguments", argv: ["foo"] end diff --git a/Library/Homebrew/test/dev-cmd/bump-unversioned-casks_spec.rb b/Library/Homebrew/test/dev-cmd/bump-unversioned-casks_spec.rb index 578deae57346c..a7ab01f2c22e4 100644 --- a/Library/Homebrew/test/dev-cmd/bump-unversioned-casks_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-unversioned-casks_spec.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/bump-unversioned-casks" -RSpec.describe "brew bump-unversioned-casks" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::BumpUnversionedCask do + it_behaves_like "parseable arguments", argv: ["foo"] end diff --git a/Library/Homebrew/test/dev-cmd/bump_spec.rb b/Library/Homebrew/test/dev-cmd/bump_spec.rb index 11b422088a2d2..31e7447e2345c 100644 --- a/Library/Homebrew/test/dev-cmd/bump_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump_spec.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/bump" -RSpec.describe "brew bump" do +RSpec.describe Homebrew::DevCmd::Bump do it_behaves_like "parseable arguments" describe "formula", :integration_test, :needs_homebrew_curl, :needs_network do diff --git a/Library/Homebrew/test/dev-cmd/cat_spec.rb b/Library/Homebrew/test/dev-cmd/cat_spec.rb index 162e3d4f3d35c..b00cd32f819f6 100644 --- a/Library/Homebrew/test/dev-cmd/cat_spec.rb +++ b/Library/Homebrew/test/dev-cmd/cat_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/cat" -RSpec.describe "brew cat" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::Cat do + it_behaves_like "parseable arguments", argv: ["foo"] it "prints the content of a given Formula", :integration_test do formula_file = setup_test_formula "testball" diff --git a/Library/Homebrew/test/dev-cmd/command_spec.rb b/Library/Homebrew/test/dev-cmd/command_spec.rb index 05469f698f305..1ea4ef92651a7 100644 --- a/Library/Homebrew/test/dev-cmd/command_spec.rb +++ b/Library/Homebrew/test/dev-cmd/command_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/command" -RSpec.describe "brew command" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::Command do + it_behaves_like "parseable arguments", argv: ["foo"] it "returns the file for a given command", :integration_test do expect { brew "command", "info" } diff --git a/Library/Homebrew/test/dev-cmd/contributions_spec.rb b/Library/Homebrew/test/dev-cmd/contributions_spec.rb index 0433c95efaf9e..e49fc847d9e24 100644 --- a/Library/Homebrew/test/dev-cmd/contributions_spec.rb +++ b/Library/Homebrew/test/dev-cmd/contributions_spec.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/contributions" -RSpec.describe "brew contributions" do +RSpec.describe Homebrew::DevCmd::Contributions do it_behaves_like "parseable arguments" end