diff --git a/Library/Homebrew/cleanup.rb b/Library/Homebrew/cleanup.rb index 4eb4689c9b..e381088a1d 100644 --- a/Library/Homebrew/cleanup.rb +++ b/Library/Homebrew/cleanup.rb @@ -160,7 +160,7 @@ module Homebrew cleanup = Cleanup.new(dry_run: dry_run) if cleanup.periodic_clean_due? cleanup.periodic_clean! - elsif f.latest_version_installed? && !cleanup.skip_clean_formula?(f) + elsif f.latest_version_installed? && !Cleanup.skip_clean_formula?(f) ohai "Running `brew cleanup #{f}`..." puts_no_install_cleanup_disable_message_if_not_already! cleanup.cleanup_formula(f) @@ -177,7 +177,7 @@ module Homebrew @puts_no_install_cleanup_disable_message_if_not_already = true end - def skip_clean_formula?(f) + def self.skip_clean_formula?(f) return false if Homebrew::EnvConfig.no_cleanup_formulae.blank? skip_clean_formulae = Homebrew::EnvConfig.no_cleanup_formulae.split(",") @@ -215,10 +215,13 @@ module Homebrew if args.empty? Formula.installed .sort_by(&:name) - .reject { |f| skip_clean_formula?(f) } + .reject { |f| Cleanup.skip_clean_formula?(f) } .each do |formula| cleanup_formula(formula, quiet: quiet, ds_store: false, cache_db: false) end + + Cleanup.autoremove(dry_run: dry_run?) if Homebrew::EnvConfig.autoremove? + cleanup_cache cleanup_logs cleanup_lockfiles @@ -253,7 +256,7 @@ module Homebrew nil end - if formula && skip_clean_formula?(formula) + if formula && Cleanup.skip_clean_formula?(formula) onoe "Refusing to clean #{formula} because it is listed in " \ "#{Tty.bold}HOMEBREW_NO_CLEANUP_FORMULAE#{Tty.reset}!" elsif formula @@ -519,5 +522,36 @@ module Homebrew print "and #{d} directories " if d.positive? puts "from #{HOMEBREW_PREFIX}" end + + def self.autoremove(dry_run: false) + require "cask/caskroom" + + # If this runs after install, uninstall, reinstall or upgrade, + # the cache of installed formulae may no longer be valid. + Formula.clear_cache unless dry_run + + # Remove formulae listed in HOMEBREW_NO_CLEANUP_FORMULAE. + formulae = Formula.installed.reject(&method(:skip_clean_formula?)) + casks = Cask::Caskroom.casks + + removable_formulae = Formula.unused_formulae_with_no_dependents(formulae, casks) + + return if removable_formulae.blank? + + formulae_names = removable_formulae.map(&:full_name).sort + + verb = dry_run ? "Would autoremove" : "Autoremoving" + oh1 "#{verb} #{formulae_names.count} unneeded #{"formula".pluralize(formulae_names.count)}:" + puts formulae_names.join("\n") + return if dry_run + + require "uninstall" + + kegs_by_rack = removable_formulae.map(&:any_installed_keg).group_by(&:rack) + Uninstall.uninstall_kegs(kegs_by_rack) + + # The installed formula cache will be invalid after uninstalling. + Formula.clear_cache + end end end diff --git a/Library/Homebrew/cmd/autoremove.rb b/Library/Homebrew/cmd/autoremove.rb index a80cb4ae49..94dea99605 100644 --- a/Library/Homebrew/cmd/autoremove.rb +++ b/Library/Homebrew/cmd/autoremove.rb @@ -1,9 +1,8 @@ # typed: true # frozen_string_literal: true -require "formula" +require "cleanup" require "cli/parser" -require "uninstall" module Homebrew module_function @@ -20,37 +19,9 @@ module Homebrew end end - def get_removable_formulae(formulae) - removable_formulae = Formula.installed_formulae_with_no_dependents(formulae).reject do |f| - Tab.for_keg(f.any_installed_keg).installed_on_request - end - - removable_formulae += get_removable_formulae(formulae - removable_formulae) if removable_formulae.present? - - removable_formulae - end - def autoremove args = autoremove_args.parse - removable_formulae = get_removable_formulae(Formula.installed) - - if (casks = Cask::Caskroom.casks.presence) - removable_formulae -= casks.flat_map { |cask| cask.depends_on[:formula] } - .compact - .map { |f| Formula[f] } - .flat_map { |f| [f, *f.runtime_formula_dependencies].compact } - end - return if removable_formulae.blank? - - formulae_names = removable_formulae.map(&:full_name).sort - - verb = args.dry_run? ? "Would uninstall" : "Uninstalling" - oh1 "#{verb} #{formulae_names.count} unneeded #{"formula".pluralize(formulae_names.count)}:" - puts formulae_names.join("\n") - return if args.dry_run? - - kegs_by_rack = removable_formulae.map(&:any_installed_keg).group_by(&:rack) - Uninstall.uninstall_kegs(kegs_by_rack) + Cleanup.autoremove(dry_run: args.dry_run?) end end diff --git a/Library/Homebrew/cmd/leaves.rb b/Library/Homebrew/cmd/leaves.rb index 1c003b4b96..ec134de4a1 100644 --- a/Library/Homebrew/cmd/leaves.rb +++ b/Library/Homebrew/cmd/leaves.rb @@ -37,7 +37,7 @@ module Homebrew def leaves args = leaves_args.parse - leaves_list = Formula.installed_formulae_with_no_dependents + leaves_list = Formula.formulae_with_no_formula_dependents(Formula.installed) leaves_list.select!(&method(:installed_on_request?)) if args.installed_on_request? leaves_list.select!(&method(:installed_as_dependency?)) if args.installed_as_dependency? diff --git a/Library/Homebrew/cmd/uninstall.rb b/Library/Homebrew/cmd/uninstall.rb index 8c40d4219d..50a9abd5d1 100644 --- a/Library/Homebrew/cmd/uninstall.rb +++ b/Library/Homebrew/cmd/uninstall.rb @@ -50,6 +50,11 @@ module Homebrew all_kegs: args.force?, ) + # If ignore_unavailable is true and the named args + # are a series of invalid kegs and casks, + # #to_kegs_to_casks will return empty arrays. + return if all_kegs.blank? && casks.blank? + kegs_by_rack = all_kegs.group_by(&:rack) Uninstall.uninstall_kegs( @@ -73,5 +78,7 @@ module Homebrew force: args.force?, ) end + + Cleanup.autoremove if Homebrew::EnvConfig.autoremove? end end diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index 6397f0abb4..014b24d27d 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -36,6 +36,12 @@ module Homebrew "disable auto-update entirely with HOMEBREW_NO_AUTO_UPDATE.", default: 300, }, + HOMEBREW_AUTOREMOVE: { + description: "If set, calls to `brew cleanup` and `brew uninstall` will automatically " \ + "remove unused formula dependents and if HOMEBREW_NO_INSTALL_CLEANUP is not set, " \ + "`brew cleanup` will start running `brew autoremove` periodically.", + boolean: true, + }, HOMEBREW_BAT: { description: "If set, use `bat` for the `brew cat` command.", boolean: true, @@ -263,8 +269,8 @@ module Homebrew boolean: true, }, HOMEBREW_NO_CLEANUP_FORMULAE: { - description: "A comma-separated list of formulae. Homebrew will refuse to clean up a " \ - "formula if it appears on this list.", + description: "A comma-separated list of formulae. Homebrew will refuse to clean up " \ + "or autoremove a formula if it appears on this list.", }, HOMEBREW_NO_COLOR: { description: "If set, do not print text with colour added.", diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index d2f9ea56f2..0f2b71767d 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -1706,14 +1706,46 @@ class Formula end.uniq(&:name) end - # An array of all installed {Formula} without dependents + # An array of all installed {Formula} with {Cask} dependents. # @private - def self.installed_formulae_with_no_dependents(formulae = installed) + def self.formulae_with_cask_dependents(casks) + casks.flat_map { |cask| cask.depends_on[:formula] } + .compact + .map { |f| Formula[f] } + .flat_map { |f| [f, *f.runtime_formula_dependencies].compact } + end + + # An array of all installed {Formula} without {Formula} dependents + # @private + def self.formulae_with_no_formula_dependents(formulae) return [] if formulae.blank? formulae - formulae.flat_map(&:runtime_formula_dependencies) end + # Recursive function that returns an array of {Formula} without + # {Formula} dependents that weren't installed on request. + # @private + def self.unused_formulae_with_no_formula_dependents(formulae) + unused_formulae = formulae_with_no_formula_dependents(formulae).reject do |f| + Tab.for_keg(f.any_installed_keg).installed_on_request + end + + if unused_formulae.present? + unused_formulae += unused_formulae_with_no_formula_dependents(formulae - unused_formulae) + end + + unused_formulae + end + + # An array of {Formula} without {Formula} or {Cask} + # dependents that weren't installed on request. + # @private + def self.unused_formulae_with_no_dependents(formulae, casks) + unused_formulae = unused_formulae_with_no_formula_dependents(formulae) + unused_formulae - formulae_with_cask_dependents(casks) + end + def self.installed_with_alias_path(alias_path) return [] if alias_path.nil? diff --git a/Library/Homebrew/sorbet/rbi/hidden-definitions/hidden.rbi b/Library/Homebrew/sorbet/rbi/hidden-definitions/hidden.rbi index ba6af6ace7..f22b19bd3a 100644 --- a/Library/Homebrew/sorbet/rbi/hidden-definitions/hidden.rbi +++ b/Library/Homebrew/sorbet/rbi/hidden-definitions/hidden.rbi @@ -2442,6 +2442,8 @@ module Homebrew::EnvConfig def self.artifact_domain(); end + def self.autoremove?(); end + def self.auto_update_secs(); end def self.bat?(); end diff --git a/Library/Homebrew/test/cmd/autoremove_spec.rb b/Library/Homebrew/test/cmd/autoremove_spec.rb index 24069e0bb7..1644fbf009 100644 --- a/Library/Homebrew/test/cmd/autoremove_spec.rb +++ b/Library/Homebrew/test/cmd/autoremove_spec.rb @@ -5,4 +5,34 @@ require "cmd/shared_examples/args_parse" describe "brew autoremove" do it_behaves_like "parseable arguments" + + describe "integration test" do + let(:requested_formula) { Formula["testball1"] } + let(:unused_formula) { Formula["testball2"] } + + before do + install_test_formula "testball1" + install_test_formula "testball2" + + # Make testball2 an unused dependency + tab = Tab.for_name("testball2") + tab.installed_on_request = false + tab.installed_as_dependency = true + tab.write + end + + it "only removes unused dependencies", :integration_test do + expect(requested_formula.any_version_installed?).to be true + expect(unused_formula.any_version_installed?).to be true + + # When there are unused dependencies + expect { brew "autoremove" } + .to be_a_success + .and output(/Autoremoving/).to_stdout + .and not_to_output.to_stderr + + expect(requested_formula.any_version_installed?).to be true + expect(unused_formula.any_version_installed?).to be false + end + end end diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index 0883985623..928f53809b 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -446,40 +446,133 @@ describe Formula do end end - describe "::installed_formulae_with_no_dependents" do - let(:formula_is_dep) do - formula "foo" do - url "foo-1.1" + shared_context "with formulae for dependency testing" do + let(:formula_with_deps) do + formula "zero" do + url "zero-1.0" end end - let(:formula_with_deps) do - formula "bar" do - url "bar-1.0" + let(:formula_is_dep1) do + formula "one" do + url "one-1.1" + end + end + + let(:formula_is_dep2) do + formula "two" do + url "two-1.1" end end let(:formulae) do [ formula_with_deps, - formula_is_dep, + formula_is_dep1, + formula_is_dep2, ] end before do - allow(formula_with_deps).to receive(:runtime_formula_dependencies).and_return([formula_is_dep]) + allow(formula_with_deps).to receive(:runtime_formula_dependencies).and_return([formula_is_dep1, + formula_is_dep2]) + allow(formula_is_dep1).to receive(:runtime_formula_dependencies).and_return([formula_is_dep2]) end + end - specify "without formulae parameter" do - allow(described_class).to receive(:installed).and_return(formulae) + describe "::formulae_with_no_formula_dependents" do + include_context "with formulae for dependency testing" - expect(described_class.installed_formulae_with_no_dependents) + it "filters out dependencies" do + expect(described_class.formulae_with_no_formula_dependents(formulae)) .to eq([formula_with_deps]) end + end - specify "with formulae parameter" do - expect(described_class.installed_formulae_with_no_dependents(formulae)) - .to eq([formula_with_deps]) + describe "::unused_formulae_with_no_formula_dependents" do + include_context "with formulae for dependency testing" + + let(:tab_from_keg) { double } + + before do + allow(Tab).to receive(:for_keg).and_return(tab_from_keg) + end + + specify "installed on request" do + allow(tab_from_keg).to receive(:installed_on_request).and_return(true) + expect(described_class.unused_formulae_with_no_formula_dependents(formulae)) + .to eq([]) + end + + specify "not installed on request" do + allow(tab_from_keg).to receive(:installed_on_request).and_return(false) + expect(described_class.unused_formulae_with_no_formula_dependents(formulae)) + .to eq(formulae) + end + end + + shared_context "with formulae and casks for dependency testing" do + include_context "with formulae for dependency testing" + + require "cask/cask_loader" + + let(:cask_one_dep) do + Cask::CaskLoader.load(+<<-RUBY) + cask "red" do + depends_on formula: "two" + end + RUBY + end + + let(:cask_multiple_deps) do + Cask::CaskLoader.load(+<<-RUBY) + cask "blue" do + depends_on formula: "zero" + end + RUBY + end + + let(:cask_no_deps1) do + Cask::CaskLoader.load(+<<-RUBY) + cask "green" do + end + RUBY + end + + let(:cask_no_deps2) do + Cask::CaskLoader.load(+<<-RUBY) + cask "purple" do + end + RUBY + end + + let(:casks_no_deps) { [cask_no_deps1, cask_no_deps2] } + let(:casks_one_dep) { [cask_no_deps1, cask_no_deps2, cask_one_dep] } + let(:casks_multiple_deps) { [cask_no_deps1, cask_no_deps2, cask_multiple_deps] } + + before do + allow(described_class).to receive("[]").with("zero").and_return(formula_with_deps) + allow(described_class).to receive("[]").with("one").and_return(formula_is_dep1) + allow(described_class).to receive("[]").with("two").and_return(formula_is_dep2) + end + end + + describe "::formulae_with_cask_dependents" do + include_context "with formulae and casks for dependency testing" + + specify "no dependents" do + expect(described_class.formulae_with_cask_dependents(casks_no_deps)) + .to eq([]) + end + + specify "one dependent" do + expect(described_class.formulae_with_cask_dependents(casks_one_dep)) + .to eq([formula_is_dep2]) + end + + specify "multiple dependents" do + expect(described_class.formulae_with_cask_dependents(casks_multiple_deps)) + .to eq(formulae) end end diff --git a/docs/Manpage.md b/docs/Manpage.md index b29bae2c63..c08de40f3c 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -1960,6 +1960,9 @@ example, run `export HOMEBREW_NO_INSECURE_REDIRECT=1` rather than just *Default:* `300`. +- `HOMEBREW_AUTOREMOVE` +
If set, calls to `brew cleanup` and `brew uninstall` will automatically remove unused formula dependents and if HOMEBREW_NO_INSTALL_CLEANUP is not set, `brew cleanup` will start running `brew autoremove` periodically. + - `HOMEBREW_BAT`
If set, use `bat` for the `brew cat` command. @@ -2140,7 +2143,7 @@ example, run `export HOMEBREW_NO_INSECURE_REDIRECT=1` rather than just
If set, do not check for broken linkage of dependents or outdated dependents after installing, upgrading or reinstalling formulae. This will result in fewer dependents (and their dependencies) being upgraded or reinstalled but may result in more breakage from running `brew install *`formula`*` or `brew upgrade *`formula`*`. - `HOMEBREW_NO_CLEANUP_FORMULAE` -
A comma-separated list of formulae. Homebrew will refuse to clean up a formula if it appears on this list. +
A comma-separated list of formulae. Homebrew will refuse to clean up or autoremove a formula if it appears on this list. - `HOMEBREW_NO_COLOR`
If set, do not print text with colour added. diff --git a/manpages/brew.1 b/manpages/brew.1 index 5d2cfe0259..4ff661d7fa 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -2779,6 +2779,12 @@ Run \fBbrew update\fR once every \fBHOMEBREW_AUTO_UPDATE_SECS\fR seconds before \fIDefault:\fR \fB300\fR\. . .TP +\fBHOMEBREW_AUTOREMOVE\fR +. +.br +If set, calls to \fBbrew cleanup\fR and \fBbrew uninstall\fR will automatically remove unused formula dependents and if HOMEBREW_NO_INSTALL_CLEANUP is not set, \fBbrew cleanup\fR will start running \fBbrew autoremove\fR periodically\. +. +.TP \fBHOMEBREW_BAT\fR . .br @@ -3118,7 +3124,7 @@ If set, do not check for broken linkage of dependents or outdated dependents aft \fBHOMEBREW_NO_CLEANUP_FORMULAE\fR . .br -A comma\-separated list of formulae\. Homebrew will refuse to clean up a formula if it appears on this list\. +A comma\-separated list of formulae\. Homebrew will refuse to clean up or autoremove a formula if it appears on this list\. . .TP \fBHOMEBREW_NO_COLOR\fR