diff --git a/Library/Homebrew/cmd/outdated.rb b/Library/Homebrew/cmd/outdated.rb index 39722c47cab56..c3f0daaa70c80 100644 --- a/Library/Homebrew/cmd/outdated.rb +++ b/Library/Homebrew/cmd/outdated.rb @@ -5,6 +5,7 @@ require "formula" require "cask/caskroom" require "api" +require "minimum_version" module Homebrew module Cmd @@ -26,6 +27,9 @@ class Outdated < AbstractCommand description: "Print output in JSON format. There are two versions: `v1` and `v2`. " \ "`v1` is deprecated and is currently the default if no version is specified. " \ "`v2` prints outdated formulae and casks." + flag "--minimum-version=", "--min-version=", + description: "Only list a named formula or cask with an installed version below the given " \ + "minimum version." switch "--fetch-HEAD", description: "Fetch the upstream repository to detect if the HEAD installation of the " \ "formula is outdated. Otherwise, the repository's HEAD will only be checked for " \ @@ -47,6 +51,9 @@ class Outdated < AbstractCommand sig { override.void } def run + raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if + minimum_version.present? && args.named.length != 1 + case json_version(args.json) when :v1 odie "`brew outdated --json=v1` is no longer supported. Use brew outdated --json=v2 instead." @@ -92,10 +99,12 @@ def print_outdated(formulae_or_casks) f = formula_or_cask if verbose? - outdated_kegs = f.outdated_kegs(fetch_head: args.fetch_HEAD?) + outdated_kegs = formula_outdated_kegs(f) latest_formula = f.latest_formula - current_version = if f.alias_changed? && !latest_formula.latest_version_installed? + current_version = if minimum_version.present? + minimum_version + elsif f.alias_changed? && !latest_formula.latest_version_installed? "#{latest_formula.name} (#{latest_formula.pkg_version})" elsif f.head? latest_head_version = f.latest_head_pkg_version(fetch_head: args.fetch_HEAD?) @@ -124,8 +133,18 @@ def print_outdated(formulae_or_casks) else c = formula_or_cask - puts c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), verbose?, - false, args.greedy_latest?, args.greedy_auto_updates?) + if minimum_version.present? + if verbose? + pinned_version = " [pinned at #{c.pinned_version}]" if c.pinned? + + puts "#{c.token} (#{c.installed_version}) < #{minimum_version}#{pinned_version}" + else + puts c.token + end + else + puts c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), verbose?, + false, args.greedy_latest?, args.greedy_auto_updates?) + end end end end @@ -140,8 +159,10 @@ def json_info(formulae_or_casks) if formula_or_cask.is_a?(Formula) f = formula_or_cask - outdated_versions = f.outdated_kegs(fetch_head: args.fetch_HEAD?).map(&:version) - current_version = if f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s } + outdated_versions = formula_outdated_kegs(f).map(&:version) + current_version = if minimum_version.present? + minimum_version + elsif f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s } "HEAD" else f.pkg_version.to_s @@ -155,11 +176,19 @@ def json_info(formulae_or_casks) else c = formula_or_cask - T.cast( - c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), - verbose?, true, args.greedy_latest?, args.greedy_auto_updates?), - T::Hash[Symbol, T.untyped], - ) + if minimum_version.present? + { name: c.token, + installed_versions: [T.must(c.installed_version)], + current_version: T.must(minimum_version), + pinned: c.pinned?, + pinned_version: c.pinned_version } + else + T.cast( + c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), + verbose?, true, args.greedy_latest?, args.greedy_auto_updates?), + T::Hash[Symbol, T.untyped], + ) + end end end end @@ -180,6 +209,9 @@ def json_version(version) version_hash.fetch(version) { raise UsageError, "invalid JSON version: #{version}" } end + sig { returns(T.nilable(String)) } + def minimum_version = args.minimum_version || args.min_version + sig { returns(T::Array[Formula]) } def outdated_formulae T.cast( @@ -217,8 +249,16 @@ def outdated_formulae_casks def select_outdated(formulae_or_casks) formulae_or_casks.select do |formula_or_cask| if formula_or_cask.is_a?(Formula) - formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?) + if minimum_version.present? + formula_outdated_kegs(formula_or_cask).present? + else + formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?) + end else + if minimum_version.present? + next MinimumVersion.cask_installed_below?(formula_or_cask, T.must(minimum_version)) + end + cask_greedy = upgrade_greedy_cask?(args.greedy?, formula_or_cask) formula_or_cask.outdated?(greedy: cask_greedy, @@ -228,6 +268,11 @@ def select_outdated(formulae_or_casks) end end + sig { params(formula: Formula).returns(T::Array[Keg]) } + def formula_outdated_kegs(formula) + MinimumVersion.formula_outdated_kegs(formula, minimum_version, fetch_head: args.fetch_HEAD?) + end + sig { params(greedy: T::Boolean, cask: Cask::Cask).returns(T::Boolean) } def upgrade_greedy_cask?(greedy, cask) return true if greedy diff --git a/Library/Homebrew/cmd/upgrade.rb b/Library/Homebrew/cmd/upgrade.rb index b1f21b7a0611a..455371e14cb59 100644 --- a/Library/Homebrew/cmd/upgrade.rb +++ b/Library/Homebrew/cmd/upgrade.rb @@ -9,6 +9,7 @@ require "cask/upgrade" require "api" require "reinstall" +require "minimum_version" module Homebrew module Cmd @@ -55,6 +56,9 @@ class FinalUpgradeSummary < T::Struct description: "Print the verification and post-install steps." switch "-n", "--dry-run", description: "Show what would be upgraded, but do not actually upgrade anything." + flag "--minimum-version=", "--min-version=", + description: "Only upgrade a named formula or cask with an installed version below the given " \ + "minimum version." switch "--ask", description: "Ask for confirmation before downloading and upgrading. " \ "Print the same plan as `--dry-run`, including available download sizes.", @@ -151,6 +155,8 @@ def run if args.build_from_source? && args.named.empty? raise ArgumentError, "`--build-from-source` requires at least one formula" end + raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if + minimum_version.present? && args.named.length != 1 formulae = T.let([], T::Array[Formula]) casks = T.let([], T::Array[Cask::Cask]) @@ -294,6 +300,35 @@ def run private + sig { returns(T.nilable(String)) } + def minimum_version = args.minimum_version || args.min_version + + sig { params(formula: Formula).returns(T::Boolean) } + def formula_outdated?(formula) + version = minimum_version + return formula.outdated?(fetch_head: args.fetch_HEAD?) if version.blank? + + formula.outdated?(fetch_head: args.fetch_HEAD?) && + MinimumVersion.formula_outdated_kegs(formula, version, fetch_head: args.fetch_HEAD?).present? + end + + sig { params(casks: T::Array[Cask::Cask], quiet: T::Boolean).returns(T::Array[Cask::Cask]) } + def minimum_version_casks(casks, quiet: args.quiet?) + version = minimum_version + return casks if version.blank? + + casks.select do |cask| + if MinimumVersion.cask_installed_below?(cask, version) + true + else + unless quiet + opoo "Not upgrading #{cask.token}, the installed version is not below the minimum version #{version}" + end + false + end + end + end + sig { params(formulae: T::Array[Formula], show_upgrade_summary: T::Boolean, dry_run: T::Boolean).returns(T.nilable(FormulaeUpgradeContext)) @@ -310,15 +345,32 @@ def formulae_upgrade_context(formulae, show_upgrade_summary: true, dry_run: args end end + not_outdated = T.let([], T::Array[Formula]) if formulae.blank? outdated = Formula.installed.select do |f| + formula_outdated?(f) + end + elsif minimum_version.present? + outdated, not_outdated = formulae.partition do |f| f.outdated?(fetch_head: args.fetch_HEAD?) end + outdated, minimum_version_skipped = outdated.partition do |f| + MinimumVersion.formula_outdated_kegs(f, minimum_version, fetch_head: args.fetch_HEAD?).present? + end + + minimum_version_skipped.each do |f| + next if args.quiet? + + opoo "Not upgrading #{f.full_specified_name}, the installed version is not below " \ + "the minimum version #{minimum_version}" + end else outdated, not_outdated = formulae.partition do |f| - f.outdated?(fetch_head: args.fetch_HEAD?) + formula_outdated?(f) end + end + if formulae.present? not_outdated.each do |f| latest_keg = f.installed_kegs.max_by(&:scheme_and_version) if latest_keg.nil? @@ -650,6 +702,9 @@ def prefetch_outdated_casks!(casks, download_queue:, prefetch_names: nil, return false if args.formula? return false if args.ask? + casks = minimum_version_casks(casks, quiet: true) + return false if minimum_version.present? && casks.empty? + outdated_casks = Cask::Upgrade.outdated_casks( casks, args:, @@ -709,6 +764,9 @@ def upgrade_outdated_casks!(casks, skip_prefetch: false, show_upgrade_summary: t download_queue: nil) return false if args.formula? + casks = minimum_version_casks(casks) + return false if minimum_version.present? && casks.empty? + Cask::Upgrade.upgrade_casks!( *casks, force: args.force?, diff --git a/Library/Homebrew/minimum_version.rb b/Library/Homebrew/minimum_version.rb new file mode 100644 index 0000000000000..bb165130366e3 --- /dev/null +++ b/Library/Homebrew/minimum_version.rb @@ -0,0 +1,49 @@ +# typed: strict +# frozen_string_literal: true + +require "cask/cask" +require "cask/dsl/version" +require "formula" +require "pkg_version" + +module Homebrew + module MinimumVersion + sig { + params(formula: Formula, minimum_version: T.nilable(String), fetch_head: T::Boolean).returns(T::Array[Keg]) + } + def self.formula_outdated_kegs(formula, minimum_version, fetch_head:) + return formula.outdated_kegs(fetch_head:) if minimum_version.blank? + + minimum_pkg_version = PkgVersion.parse(minimum_version) + formula.installed_kegs.select do |keg| + keg.version_scheme < formula.version_scheme || + (keg.version_scheme == formula.version_scheme && keg.version < minimum_pkg_version) + end + end + + sig { params(cask: Cask::Cask, minimum_version: String).returns(T::Boolean) } + def self.cask_installed_below?(cask, minimum_version) + minimum_cask_version = comparable_cask_version(minimum_version) + raise UsageError, "invalid `--minimum-version`: #{minimum_version}" if minimum_cask_version.nil? + + installed_version = cask.installed_version + return false if installed_version.blank? + + installed_cask_version = comparable_cask_version(installed_version) + return false if installed_cask_version.nil? + + installed_cask_version < minimum_cask_version + end + + sig { params(version: String).returns(T.nilable(::Version)) } + def self.comparable_cask_version(version) + cask_version = Cask::DSL::Version.new(version) + return if cask_version.latest? + + ::Version.new(cask_version.to_s) + rescue TypeError + nil + end + private_class_method :comparable_cask_version + end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/outdated.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/outdated.rbi index 9e1d46f0e4d47..5f7092b0c931c 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/outdated.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/outdated.rbi @@ -40,4 +40,10 @@ class Homebrew::Cmd::Outdated::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def json; end + + sig { returns(T.nilable(String)) } + def min_version; end + + sig { returns(T.nilable(String)) } + def minimum_version; end end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi index 7b3fa1a661041..91ee79c3364b6 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi @@ -107,6 +107,12 @@ class Homebrew::Cmd::UpgradeCmd::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def mdimporterdir; end + sig { returns(T.nilable(String)) } + def min_version; end + + sig { returns(T.nilable(String)) } + def minimum_version; end + sig { returns(T::Boolean) } def n?; end diff --git a/Library/Homebrew/test/cmd/outdated_spec.rb b/Library/Homebrew/test/cmd/outdated_spec.rb index 00713523b6e9f..e1f36281a18bf 100644 --- a/Library/Homebrew/test/cmd/outdated_spec.rb +++ b/Library/Homebrew/test/cmd/outdated_spec.rb @@ -7,6 +7,28 @@ RSpec.describe Homebrew::Cmd::Outdated do it_behaves_like "parseable arguments" + def install_formula_version(name, version, linked: false) + keg_path = HOMEBREW_CELLAR/name/version + keg_path.mkpath + tab = Tab.empty + tab.tabfile = keg_path/AbstractTab::FILENAME + tab.write + return unless linked + + (HOMEBREW_LINKED_KEGS/name).parent.mkpath + FileUtils.ln_s(keg_path, HOMEBREW_LINKED_KEGS/name) + end + + it "requires one named argument with --minimum-version" do + expect { described_class.new(["--minimum-version=1.2.3"]).run } + .to raise_error(UsageError, /`--minimum-version` requires exactly one formula or cask argument/) + end + + it "rejects multiple named arguments with --minimum-version" do + expect { described_class.new(["foo", "bar", "--minimum-version=1.2.3"]).run } + .to raise_error(UsageError, /`--minimum-version` requires exactly one formula or cask argument/) + end + it "excludes non-outdated auto-updating casks without --greedy-auto-updates", :cask do cask = Cask::CaskLoader.load(cask_path("auto-updates")) cmd = described_class.new([]) @@ -46,4 +68,100 @@ .to output("#{expected_json}\n").to_stdout .and be_a_success end + + it "reports a formula installed below the minimum version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.3" + RUBY + install_formula_version "minimum-version-formula", "1.2.2" + + expect { brew "outdated", "minimum-version-formula", "--min-version=1.2.3" } + .to output("minimum-version-formula\n").to_stdout + .and be_a_failure + end + + it "does not report a formula installed at --minimum-version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.3" + RUBY + install_formula_version "minimum-version-formula", "1.2.3", linked: true + + expect { brew "outdated", "minimum-version-formula", "--minimum-version=1.2.3" } + .to output("").to_stdout + .and be_a_success + end + + it "reports a cask installed below --minimum-version", :cask, :integration_test do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("outdated/local-caffeine"))) + + expect { brew "outdated", "--cask", "local-caffeine", "--minimum-version=1.2.3" } + .to output("local-caffeine\n").to_stdout + .and be_a_failure + end + + it "does not report a cask installed at --minimum-version", :cask, :integration_test do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("local-caffeine"))) + + expect { brew "outdated", "--cask", "local-caffeine", "--minimum-version=1.2.3" } + .to output("").to_stdout + .and be_a_success + end + + it "raises UsageError for an invalid cask --minimum-version", :cask do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("local-caffeine"))) + + expect { described_class.new(["--cask", "local-caffeine", "--minimum-version=1/2"]).run } + .to raise_error(UsageError, %r{invalid `--minimum-version`: 1/2}) + end + + it "does not report an uninstalled formula with --minimum-version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.3" + RUBY + + expect { brew "outdated", "minimum-version-formula", "--minimum-version=1.2.3" } + .to output("").to_stdout + .and be_a_success + end + + it "outputs JSON for a formula installed below --minimum-version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.3" + RUBY + install_formula_version "minimum-version-formula", "1.2.2" + + expected_json = JSON.pretty_generate({ + formulae: [{ + name: "minimum-version-formula", + installed_versions: ["1.2.2"], + current_version: "1.2.3", + pinned: false, + pinned_version: nil, + }], + casks: [], + }) + + expect { brew "outdated", "minimum-version-formula", "--minimum-version=1.2.3", "--json=v2" } + .to output("#{expected_json}\n").to_stdout + .and be_a_failure + end + + it "outputs JSON for a cask installed below --minimum-version", :cask, :integration_test do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("outdated/local-caffeine"))) + + expected_json = JSON.pretty_generate({ + formulae: [], + casks: [{ + name: "local-caffeine", + installed_versions: ["1.2.2"], + current_version: "1.2.3", + pinned: false, + pinned_version: nil, + }], + }) + + expect { brew "outdated", "--cask", "local-caffeine", "--minimum-version=1.2.3", "--json=v2" } + .to output("#{expected_json}\n").to_stdout + .and be_a_failure + end end diff --git a/Library/Homebrew/test/cmd/upgrade_spec.rb b/Library/Homebrew/test/cmd/upgrade_spec.rb index 40ef3a6e0d1d0..07abdfe771162 100644 --- a/Library/Homebrew/test/cmd/upgrade_spec.rb +++ b/Library/Homebrew/test/cmd/upgrade_spec.rb @@ -10,6 +10,18 @@ it_behaves_like "parseable arguments" + def install_formula_version(name, version, optlinked: false) + keg_path = HOMEBREW_CELLAR/name/version + keg_path.mkpath + tab = Tab.empty + tab.tabfile = keg_path/AbstractTab::FILENAME + tab.write + return unless optlinked + + (HOMEBREW_PREFIX/"opt").mkpath + FileUtils.ln_s(keg_path, HOMEBREW_PREFIX/"opt/#{name}") + end + it "upgrades a Formula", :integration_test do formula_name = "testball_bottle" formula_rack = HOMEBREW_CELLAR/formula_name @@ -58,6 +70,59 @@ expect(formula_rack/"0.1").not_to exist end + it "upgrades a named formula installed below the minimum version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.3" + RUBY + install_formula_version "minimum-version-formula", "1.2.2", optlinked: true + + expect { brew "upgrade", "minimum-version-formula", "--min-version=1.2.3", "--dry-run" } + .to output(/minimum-version-formula 1\.2\.2 -> 1\.2\.3/).to_stdout + .and be_a_success + end + + it "does not upgrade a named formula installed at --minimum-version", :integration_test do + setup_test_formula "minimum-version-formula", <<~RUBY + url "https://brew.sh/minimum-version-formula-1.2.4" + RUBY + install_formula_version "minimum-version-formula", "1.2.3", optlinked: true + + expect { brew "upgrade", "minimum-version-formula", "--minimum-version=1.2.3", "--dry-run" } + .to not_to_output(/Would upgrade/).to_stdout + .and output( + /Not upgrading minimum-version-formula, the installed version is not below the minimum version 1\.2\.3/, + ).to_stderr + .and be_a_success + end + + it "requires one named argument with --minimum-version" do + expect { described_class.new(["--minimum-version=1.2.3"]).run } + .to raise_error(UsageError, /`--minimum-version` requires exactly one formula or cask argument/) + end + + it "rejects multiple named arguments with --minimum-version" do + expect { described_class.new(["foo", "bar", "--minimum-version=1.2.3"]).run } + .to raise_error(UsageError, /`--minimum-version` requires exactly one formula or cask argument/) + end + + it "upgrades a named cask installed below --minimum-version", :cask, :integration_test do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("outdated/local-caffeine"))) + + expect { brew "upgrade", "--cask", "local-caffeine", "--minimum-version=1.2.3", "--dry-run" } + .to output(/local-caffeine 1\.2\.2 -> 1\.2\.3/).to_stdout + .and be_a_success + end + + it "does not upgrade a named cask installed at --minimum-version", :cask, :integration_test do + InstallHelper.stub_cask_installation(Cask::CaskLoader.load(cask_path("local-caffeine"))) + + expect { brew "upgrade", "--cask", "local-caffeine", "--minimum-version=1.2.3", "--dry-run" } + .to not_to_output(/Would upgrade/).to_stdout + .and output(/Not upgrading local-caffeine, the installed version is not below the minimum version 1\.2\.3/) + .to_stderr + .and be_a_success + end + it "reports unavailable names via ofail and continues upgrading" do error = FormulaOrCaskUnavailableError.new("nonexistent") formula = instance_double(Formula, full_name: "testball")