diff --git a/Library/Homebrew/cmd/autoremove.rb b/Library/Homebrew/cmd/autoremove.rb new file mode 100644 index 0000000000..fd5c786f5b --- /dev/null +++ b/Library/Homebrew/cmd/autoremove.rb @@ -0,0 +1,50 @@ +# typed: false +# frozen_string_literal: true + +require "formula" +require "cli/parser" +require "uninstall" + +module Homebrew + module_function + + def autoremove_args + Homebrew::CLI::Parser.new do + usage_banner <<~EOS + `autoremove` [] + + Uninstall formulae that were only installed as a dependency of another formula and are now no longer needed. + EOS + switch "-n", "--dry-run", + description: "List what would be uninstalled, but do not actually uninstall anything." + named 0 + 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) + 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) + end +end diff --git a/Library/Homebrew/cmd/leaves.rb b/Library/Homebrew/cmd/leaves.rb index d84468f5fd..7cb1497367 100644 --- a/Library/Homebrew/cmd/leaves.rb +++ b/Library/Homebrew/cmd/leaves.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require "formula" -require "tab" require "cli/parser" module Homebrew @@ -23,9 +22,6 @@ module Homebrew def leaves leaves_args.parse - installed = Formula.installed.sort - deps_of_installed = installed.flat_map(&:runtime_formula_dependencies) - leaves = installed.map(&:full_name) - deps_of_installed.map(&:full_name) - leaves.each(&method(:puts)) + Formula.installed_formulae_with_no_dependents.map(&:full_name).sort.each(&method(:puts)) end end diff --git a/Library/Homebrew/cmd/uninstall.rb b/Library/Homebrew/cmd/uninstall.rb index 41a0117c6e..2fadf91c50 100644 --- a/Library/Homebrew/cmd/uninstall.rb +++ b/Library/Homebrew/cmd/uninstall.rb @@ -8,6 +8,7 @@ require "migrator" require "cli/parser" require "cask/cmd" require "cask/cask_loader" +require "uninstall" module Homebrew module_function @@ -54,76 +55,10 @@ module Homebrew kegs_by_rack = all_kegs.group_by(&:rack) end - handle_unsatisfied_dependents(kegs_by_rack, - ignore_dependencies: args.ignore_dependencies?, - named_args: args.named) - return if Homebrew.failed? - - kegs_by_rack.each do |rack, kegs| - if args.force? - name = rack.basename - - if rack.directory? - puts "Uninstalling #{name}... (#{rack.abv})" - kegs.each do |keg| - keg.unlink - keg.uninstall - end - end - - rm_pin rack - else - kegs.each do |keg| - begin - f = Formulary.from_rack(rack) - if f.pinned? - onoe "#{f.full_name} is pinned. You must unpin it to uninstall." - next - end - rescue - nil - end - - keg.lock do - puts "Uninstalling #{keg}... (#{keg.abv})" - keg.unlink - keg.uninstall - rack = keg.rack - rm_pin rack - - if rack.directory? - versions = rack.subdirs.map(&:basename) - puts "#{keg.name} #{versions.to_sentence} #{"is".pluralize(versions.count)} still installed." - puts "Run `brew uninstall --force #{keg.name}` to remove all versions." - end - - next unless f - - paths = f.pkgetc.find.map(&:to_s) if f.pkgetc.exist? - if paths.present? - puts - opoo <<~EOS - The following #{f.name} configuration files have not been removed! - If desired, remove them manually with `rm -rf`: - #{paths.sort.uniq.join("\n ")} - EOS - end - - unversioned_name = f.name.gsub(/@.+$/, "") - maybe_paths = Dir.glob("#{f.etc}/*#{unversioned_name}*") - maybe_paths -= paths if paths.present? - if maybe_paths.present? - puts - opoo <<~EOS - The following may be #{f.name} configuration files and have not been removed! - If desired, remove them manually with `rm -rf`: - #{maybe_paths.sort.uniq.join("\n ")} - EOS - end - end - end - end - end + Uninstall.uninstall_kegs(kegs_by_rack, + force: args.force?, + ignore_dependencies: args.ignore_dependencies?, + named_args: args.named) return if casks.blank? @@ -144,74 +79,4 @@ module Homebrew end end end - - def handle_unsatisfied_dependents(kegs_by_rack, ignore_dependencies: false, named_args: []) - return if ignore_dependencies - - all_kegs = kegs_by_rack.values.flatten(1) - check_for_dependents(all_kegs, named_args: named_args) - rescue MethodDeprecatedError - # Silently ignore deprecations when uninstalling. - nil - end - - def check_for_dependents(kegs, named_args: []) - return false unless result = Keg.find_some_installed_dependents(kegs) - - if Homebrew::EnvConfig.developer? - DeveloperDependentsMessage.new(*result, named_args: named_args).output - else - NondeveloperDependentsMessage.new(*result, named_args: named_args).output - end - - true - end - - class DependentsMessage - attr_reader :reqs, :deps, :named_args - - def initialize(requireds, dependents, named_args: []) - @reqs = requireds - @deps = dependents - @named_args = named_args - end - - protected - - def sample_command - "brew uninstall --ignore-dependencies #{named_args.join(" ")}" - end - - def are_required_by_deps - "#{"is".pluralize(reqs.count)} required by #{deps.to_sentence}, " \ - "which #{"is".pluralize(deps.count)} currently installed" - end - end - - class DeveloperDependentsMessage < DependentsMessage - def output - opoo <<~EOS - #{reqs.to_sentence} #{are_required_by_deps}. - You can silence this warning with: - #{sample_command} - EOS - end - end - - class NondeveloperDependentsMessage < DependentsMessage - def output - ofail <<~EOS - Refusing to uninstall #{reqs.to_sentence} - because #{"it".pluralize(reqs.count)} #{are_required_by_deps}. - You can override this and force removal with: - #{sample_command} - EOS - end - end - - def rm_pin(rack) - Formulary.from_rack(rack).unpin - rescue - nil - end end diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 52f0ccae66..7b5e0fde35 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -1518,6 +1518,14 @@ class Formula end.uniq(&:name) end + # An array of all installed {Formula} without dependents + # @private + def self.installed_formulae_with_no_dependents(formulae = installed) + return [] if formulae.blank? + + formulae - formulae.flat_map(&:runtime_formula_dependencies) + end + def self.installed_with_alias_path(alias_path) return [] if alias_path.nil? diff --git a/Library/Homebrew/test/.rubocop_todo.yml b/Library/Homebrew/test/.rubocop_todo.yml index 3474b71ed0..e3f0383a8a 100644 --- a/Library/Homebrew/test/.rubocop_todo.yml +++ b/Library/Homebrew/test/.rubocop_todo.yml @@ -33,6 +33,7 @@ RSpec/MultipleDescribes: - 'cmd/--repository_spec.rb' - 'cmd/--version_spec.rb' - 'cmd/analytics_spec.rb' + - 'cmd/autoremove_spec.rb' - 'cmd/cleanup_spec.rb' - 'cmd/commands_spec.rb' - 'cmd/config_spec.rb' diff --git a/Library/Homebrew/test/cmd/autoremove_spec.rb b/Library/Homebrew/test/cmd/autoremove_spec.rb new file mode 100644 index 0000000000..4cd9c99f51 --- /dev/null +++ b/Library/Homebrew/test/cmd/autoremove_spec.rb @@ -0,0 +1,8 @@ +# typed: false +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" + +describe "Homebrew.autoremove_args" do + it_behaves_like "parseable arguments" +end diff --git a/Library/Homebrew/test/cmd/leaves_spec.rb b/Library/Homebrew/test/cmd/leaves_spec.rb index 4962a75603..705a85c7d3 100644 --- a/Library/Homebrew/test/cmd/leaves_spec.rb +++ b/Library/Homebrew/test/cmd/leaves_spec.rb @@ -8,14 +8,42 @@ describe "Homebrew.leaves_args" do end describe "brew leaves", :integration_test do - it "prints all Formulae that are not dependencies of other Formulae" do - setup_test_formula "foo" - setup_test_formula "bar" - (HOMEBREW_CELLAR/"foo/0.1/somedir").mkpath + context "when there are no installed Formulae" do + it "prints nothing" do + setup_test_formula "foo" + setup_test_formula "bar" - expect { brew "leaves" } - .to output("foo\n").to_stdout - .and not_to_output.to_stderr - .and be_a_success + expect { brew "leaves" } + .to not_to_output.to_stdout + .and not_to_output.to_stderr + .and be_a_success + end + end + + context "when there are only installed Formulae without dependencies" do + it "prints all installed Formulae" do + setup_test_formula "foo" + setup_test_formula "bar" + (HOMEBREW_CELLAR/"foo/0.1/somedir").mkpath + + expect { brew "leaves" } + .to output("foo\n").to_stdout + .and not_to_output.to_stderr + .and be_a_success + end + end + + context "when there are installed Formulae" do + it "prints all installed Formulae that are not dependencies of another installed Formula" do + setup_test_formula "foo" + setup_test_formula "bar" + (HOMEBREW_CELLAR/"foo/0.1/somedir").mkpath + (HOMEBREW_CELLAR/"bar/0.1/somedir").mkpath + + expect { brew "leaves" } + .to output("bar\n").to_stdout + .and not_to_output.to_stderr + .and be_a_success + end end end diff --git a/Library/Homebrew/test/cmd/uninstall_spec.rb b/Library/Homebrew/test/cmd/uninstall_spec.rb index 9d79d3e7da..d05f4025d0 100644 --- a/Library/Homebrew/test/cmd/uninstall_spec.rb +++ b/Library/Homebrew/test/cmd/uninstall_spec.rb @@ -19,61 +19,3 @@ describe "brew uninstall", :integration_test do .and be_a_success end end - -describe Homebrew do - let(:dependency) { formula("dependency") { url "f-1" } } - let(:dependent) do - formula("dependent") do - url "f-1" - depends_on "dependency" - end - end - - let(:kegs_by_rack) { { dependency.rack => [Keg.new(dependency.latest_installed_prefix)] } } - - before do - [dependency, dependent].each do |f| - f.latest_installed_prefix.mkpath - Keg.new(f.latest_installed_prefix).optlink - end - - tab = Tab.empty - tab.homebrew_version = "1.1.6" - tab.tabfile = dependent.latest_installed_prefix/Tab::FILENAME - tab.runtime_dependencies = [ - { "full_name" => "dependency", "version" => "1" }, - ] - tab.write - - stub_formula_loader dependency - stub_formula_loader dependent - end - - describe "::handle_unsatisfied_dependents" do - specify "when developer" do - ENV["HOMEBREW_DEVELOPER"] = "1" - - expect { - described_class.handle_unsatisfied_dependents(kegs_by_rack) - }.to output(/Warning/).to_stderr - - expect(described_class).not_to have_failed - end - - specify "when not developer" do - expect { - described_class.handle_unsatisfied_dependents(kegs_by_rack) - }.to output(/Error/).to_stderr - - expect(described_class).to have_failed - end - - specify "when not developer and `ignore_dependencies` is true" do - expect { - described_class.handle_unsatisfied_dependents(kegs_by_rack, ignore_dependencies: true) - }.not_to output.to_stderr - - expect(described_class).not_to have_failed - end - end -end diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index 9d9997dbd2..c91b27d04f 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -440,6 +440,43 @@ describe Formula do end end + describe "::installed_formulae_with_no_dependents" do + let(:formula_is_dep) do + formula "foo" do + url "foo-1.1" + end + end + + let(:formula_with_deps) do + formula "bar" do + url "bar-1.0" + end + end + + let(:formulae) do + [ + formula_with_deps, + formula_is_dep, + ] + end + + before do + allow(formula_with_deps).to receive(:runtime_formula_dependencies).and_return([formula_is_dep]) + end + + specify "without formulae parameter" do + allow(described_class).to receive(:installed).and_return(formulae) + + expect(described_class.installed_formulae_with_no_dependents) + .to eq([formula_with_deps]) + end + + specify "with formulae parameter" do + expect(described_class.installed_formulae_with_no_dependents(formulae)) + .to eq([formula_with_deps]) + end + end + describe "::installed_with_alias_path" do specify "with alias path with nil" do expect(described_class.installed_with_alias_path(nil)).to be_empty diff --git a/Library/Homebrew/test/uninstall_spec.rb b/Library/Homebrew/test/uninstall_spec.rb new file mode 100644 index 0000000000..d5f54077f2 --- /dev/null +++ b/Library/Homebrew/test/uninstall_spec.rb @@ -0,0 +1,62 @@ +# typed: false +# frozen_string_literal: true + +require "uninstall" + +describe Homebrew::Uninstall do + let(:dependency) { formula("dependency") { url "f-1" } } + let(:dependent) do + formula("dependent") do + url "f-1" + depends_on "dependency" + end + end + + let(:kegs_by_rack) { { dependency.rack => [Keg.new(dependency.latest_installed_prefix)] } } + + before do + [dependency, dependent].each do |f| + f.latest_installed_prefix.mkpath + Keg.new(f.latest_installed_prefix).optlink + end + + tab = Tab.empty + tab.homebrew_version = "1.1.6" + tab.tabfile = dependent.latest_installed_prefix/Tab::FILENAME + tab.runtime_dependencies = [ + { "full_name" => "dependency", "version" => "1" }, + ] + tab.write + + stub_formula_loader dependency + stub_formula_loader dependent + end + + describe "::handle_unsatisfied_dependents" do + specify "when developer" do + ENV["HOMEBREW_DEVELOPER"] = "1" + + expect { + described_class.handle_unsatisfied_dependents(kegs_by_rack) + }.to output(/Warning/).to_stderr + + expect(Homebrew).not_to have_failed + end + + specify "when not developer" do + expect { + described_class.handle_unsatisfied_dependents(kegs_by_rack) + }.to output(/Error/).to_stderr + + expect(Homebrew).to have_failed + end + + specify "when not developer and `ignore_dependencies` is true" do + expect { + described_class.handle_unsatisfied_dependents(kegs_by_rack, ignore_dependencies: true) + }.not_to output.to_stderr + + expect(Homebrew).not_to have_failed + end + end +end diff --git a/Library/Homebrew/uninstall.rb b/Library/Homebrew/uninstall.rb new file mode 100644 index 0000000000..214f33a662 --- /dev/null +++ b/Library/Homebrew/uninstall.rb @@ -0,0 +1,160 @@ +# typed: true +# frozen_string_literal: true + +require "keg" +require "formula" + +module Homebrew + # Helper module for uninstalling kegs. + # + # @api private + module Uninstall + module_function + + def uninstall_kegs(kegs_by_rack, force: false, ignore_dependencies: false, named_args: []) + handle_unsatisfied_dependents(kegs_by_rack, + ignore_dependencies: ignore_dependencies, + named_args: named_args) + return if Homebrew.failed? + + kegs_by_rack.each do |rack, kegs| + if force + name = rack.basename + + if rack.directory? + puts "Uninstalling #{name}... (#{rack.abv})" + kegs.each do |keg| + keg.unlink + keg.uninstall + end + end + + rm_pin rack + else + kegs.each do |keg| + begin + f = Formulary.from_rack(rack) + if f.pinned? + onoe "#{f.full_name} is pinned. You must unpin it to uninstall." + next + end + rescue + nil + end + + keg.lock do + puts "Uninstalling #{keg}... (#{keg.abv})" + keg.unlink + keg.uninstall + rack = keg.rack + rm_pin rack + + if rack.directory? + versions = rack.subdirs.map(&:basename) + puts "#{keg.name} #{versions.to_sentence} #{"is".pluralize(versions.count)} still installed." + puts "Run `brew uninstall --force #{keg.name}` to remove all versions." + end + + next unless f + + paths = f.pkgetc.find.map(&:to_s) if f.pkgetc.exist? + if paths.present? + puts + opoo <<~EOS + The following #{f.name} configuration files have not been removed! + If desired, remove them manually with `rm -rf`: + #{paths.sort.uniq.join("\n ")} + EOS + end + + unversioned_name = f.name.gsub(/@.+$/, "") + maybe_paths = Dir.glob("#{f.etc}/*#{unversioned_name}*") + maybe_paths -= paths if paths.present? + if maybe_paths.present? + puts + opoo <<~EOS + The following may be #{f.name} configuration files and have not been removed! + If desired, remove them manually with `rm -rf`: + #{maybe_paths.sort.uniq.join("\n ")} + EOS + end + end + end + end + end + end + + def handle_unsatisfied_dependents(kegs_by_rack, ignore_dependencies: false, named_args: []) + return if ignore_dependencies + + all_kegs = kegs_by_rack.values.flatten(1) + check_for_dependents(all_kegs, named_args: named_args) + rescue MethodDeprecatedError + # Silently ignore deprecations when uninstalling. + nil + end + + def check_for_dependents(kegs, named_args: []) + return false unless result = Keg.find_some_installed_dependents(kegs) + + if Homebrew::EnvConfig.developer? + DeveloperDependentsMessage.new(*result, named_args: named_args).output + else + NondeveloperDependentsMessage.new(*result, named_args: named_args).output + end + + true + end + + # @api private + class DependentsMessage + attr_reader :reqs, :deps, :named_args + + def initialize(requireds, dependents, named_args: []) + @reqs = requireds + @deps = dependents + @named_args = named_args + end + + protected + + def sample_command + "brew uninstall --ignore-dependencies #{named_args.join(" ")}" + end + + def are_required_by_deps + "#{"is".pluralize(reqs.count)} required by #{deps.to_sentence}, " \ + "which #{"is".pluralize(deps.count)} currently installed" + end + end + + # @api private + class DeveloperDependentsMessage < DependentsMessage + def output + opoo <<~EOS + #{reqs.to_sentence} #{are_required_by_deps}. + You can silence this warning with: + #{sample_command} + EOS + end + end + + # @api private + class NondeveloperDependentsMessage < DependentsMessage + def output + ofail <<~EOS + Refusing to uninstall #{reqs.to_sentence} + because #{"it".pluralize(reqs.count)} #{are_required_by_deps}. + You can override this and force removal with: + #{sample_command} + EOS + end + end + + def rm_pin(rack) + Formulary.from_rack(rack).unpin + rescue + nil + end + end +end diff --git a/Library/Homebrew/uninstall.rbi b/Library/Homebrew/uninstall.rbi new file mode 100644 index 0000000000..f801c23a87 --- /dev/null +++ b/Library/Homebrew/uninstall.rbi @@ -0,0 +1,7 @@ +# typed: strict + +module Homebrew + module Uninstall + include Kernel + end +end diff --git a/completions/internal_commands_list.txt b/completions/internal_commands_list.txt index cd9c60d4b0..0d277a1200 100644 --- a/completions/internal_commands_list.txt +++ b/completions/internal_commands_list.txt @@ -12,6 +12,7 @@ abv analytics audit +autoremove bottle bump bump-cask-pr diff --git a/docs/Manpage.md b/docs/Manpage.md index 318806b29d..4fb7f93ab7 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -56,6 +56,13 @@ Turn Homebrew's analytics on or off respectively. `brew analytics regenerate-uuid`: Regenerate the UUID used for Homebrew's analytics. +### `autoremove` [*`options`*] + +Uninstall formulae that were only installed as a dependency of another formula and are now no longer needed. + +* `-n`, `--dry-run`: + List what would be uninstalled, but do not actually uninstall anything. + ### `cask` *`command`* [*`options`*] [*`cask`*] Homebrew Cask provides a friendly CLI workflow for the administration of macOS applications distributed as binaries. diff --git a/manpages/brew.1 b/manpages/brew.1 index 3ea3804113..d62b6a8664 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -53,6 +53,13 @@ Control Homebrew\'s anonymous aggregate user behaviour analytics\. Read more at \fBbrew analytics regenerate\-uuid\fR Regenerate the UUID used for Homebrew\'s analytics\. . +.SS "\fBautoremove\fR [\fIoptions\fR]" +Uninstall formulae that were only installed as a dependency of another formula and are now no longer needed\. +. +.TP +\fB\-n\fR, \fB\-\-dry\-run\fR +List what would be uninstalled, but do not actually uninstall anything\. +. .SS "\fBcask\fR \fIcommand\fR [\fIoptions\fR] [\fIcask\fR]" Homebrew Cask provides a friendly CLI workflow for the administration of macOS applications distributed as binaries\. .