From a58ac8f888fe0eb7dddbba5b6a68fb1dbe043837 Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:12:39 -0400 Subject: [PATCH 1/8] bundle cleanup: report failed removals - Preserve failures while later cleanup steps continue. - Count successful operations without claiming failed removals. --- Library/Homebrew/bundle/extensions/cargo.rb | 2 +- .../Homebrew/bundle/extensions/extension.rb | 19 ++- Library/Homebrew/bundle/extensions/flatpak.rb | 6 +- Library/Homebrew/bundle/extensions/go.rb | 11 +- Library/Homebrew/bundle/extensions/krew.rb | 2 +- .../bundle/extensions/mac_app_store.rb | 4 +- Library/Homebrew/bundle/extensions/npm.rb | 2 +- Library/Homebrew/bundle/extensions/uv.rb | 2 +- .../bundle/extensions/vscode_extension.rb | 4 +- Library/Homebrew/bundle/extensions/winget.rb | 4 +- Library/Homebrew/bundle/subcommand/cleanup.rb | 20 +++- Library/Homebrew/test/bundle/cargo_spec.rb | 9 ++ Library/Homebrew/test/bundle/flatpak_spec.rb | 24 ++++ Library/Homebrew/test/bundle/go_spec.rb | 55 ++++++++- Library/Homebrew/test/bundle/krew_spec.rb | 10 ++ .../test/bundle/mac_app_store_spec.rb | 21 ++++ Library/Homebrew/test/bundle/npm_spec.rb | 47 ++++++++ Library/Homebrew/test/bundle/uv_spec.rb | 9 ++ .../test/bundle/vscode_extension_spec.rb | 26 ++++ Library/Homebrew/test/bundle/winget_spec.rb | 23 ++++ .../cmd/bundle/cleanup_subcommand_spec.rb | 111 ++++++++++++++++-- docs/Brew-Bundle-and-Brewfile.md | 8 ++ 22 files changed, 383 insertions(+), 36 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/cargo.rb b/Library/Homebrew/bundle/extensions/cargo.rb index 202341d5c1796..5ae02bf221976 100644 --- a/Library/Homebrew/bundle/extensions/cargo.rb +++ b/Library/Homebrew/bundle/extensions/cargo.rb @@ -160,7 +160,7 @@ def installed_packages @installed_packages = packages.dup end - sig { override.params(name: String, executable: Pathname).void } + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Bundle.system(executable.to_s, "uninstall", name, verbose: false) end diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb index 03246ae181f81..70421a96e9734 100644 --- a/Library/Homebrew/bundle/extensions/extension.rb +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -9,6 +9,7 @@ module Bundle class Extension < Homebrew::Bundle::PackageType extend T::Helpers + extend Utils::Output::Mixin abstract! @@ -241,15 +242,25 @@ def self.cleanup!(items) executable = package_manager_executable return if executable.nil? - with_env(package_manager_env(executable)) do - items.each do |name| + successes = with_env(package_manager_env(executable)) do + cleanup_each!(items) do |name| uninstall_package!(name, executable:) end end - puts "Uninstalled #{items.size} #{banner_name}#{"s" if items.size != 1}" + puts "Uninstalled #{successes} #{banner_name}" end - sig { params(name: String, executable: Pathname).void } + # Continue after unsuccessful commands while preserving the overall failure status. + sig { params(items: T::Array[String], block: T.proc.params(item: String).returns(T::Boolean)).returns(Integer) } + def self.cleanup_each!(items, &block) + items.count do |item| + success = yield item + ofail "Failed to uninstall #{banner_name}: #{cleanup_item_name(item)}" unless success + success + end + end + + sig { params(name: String, executable: Pathname).returns(T::Boolean) } def self.uninstall_package!(name, executable: Pathname.new("")) raise NotImplementedError, "#{self} must override `uninstall_package!` or `cleanup!`." end diff --git a/Library/Homebrew/bundle/extensions/flatpak.rb b/Library/Homebrew/bundle/extensions/flatpak.rb index 974acc9307578..9e755e98f557e 100644 --- a/Library/Homebrew/bundle/extensions/flatpak.rb +++ b/Library/Homebrew/bundle/extensions/flatpak.rb @@ -410,10 +410,10 @@ def cleanup_items(entries) sig { params(flatpaks: T::Array[String]).void } def cleanup!(flatpaks) - flatpaks.each do |flatpak_name| - Kernel.system("flatpak", "uninstall", "-y", "--system", flatpak_name) + successes = cleanup_each!(flatpaks) do |flatpak_name| + Kernel.system("flatpak", "uninstall", "-y", "--system", flatpak_name) == true end - puts "Uninstalled #{flatpaks.size} flatpak#{"s" if flatpaks.size != 1}" + puts "Uninstalled #{successes} flatpak#{"s" if successes != 1}" end end diff --git a/Library/Homebrew/bundle/extensions/go.rb b/Library/Homebrew/bundle/extensions/go.rb index 2fc9e6b318a2b..7583aa0701e55 100644 --- a/Library/Homebrew/bundle/extensions/go.rb +++ b/Library/Homebrew/bundle/extensions/go.rb @@ -118,10 +118,17 @@ def cleanup!(items) module_path = path_line.split("\t")[2]&.strip next unless items.include?(module_path) - FileUtils.rm_f(binary) + begin + File.unlink(binary) + rescue Errno::ENOENT + next + rescue SystemCallError => e + ofail "Failed to remove Go executable #{binary}: #{e}" + next + end removed += 1 end - puts "Uninstalled #{removed} #{banner_name}#{"s" if removed != 1}" + puts "Removed #{removed} Go executable#{"s" if removed != 1}" end end end diff --git a/Library/Homebrew/bundle/extensions/krew.rb b/Library/Homebrew/bundle/extensions/krew.rb index f4029a839169d..470ac208641d6 100644 --- a/Library/Homebrew/bundle/extensions/krew.rb +++ b/Library/Homebrew/bundle/extensions/krew.rb @@ -82,7 +82,7 @@ def parse_plugin_list(output) end private :parse_plugin_list - sig { override.params(name: String, executable: Pathname).void } + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Bundle.system(executable.to_s, "uninstall", name, verbose: false) end diff --git a/Library/Homebrew/bundle/extensions/mac_app_store.rb b/Library/Homebrew/bundle/extensions/mac_app_store.rb index 30308431c56fc..197bbd06f3e66 100644 --- a/Library/Homebrew/bundle/extensions/mac_app_store.rb +++ b/Library/Homebrew/bundle/extensions/mac_app_store.rb @@ -159,10 +159,10 @@ def cleanup!(items) mas = package_manager_executable return if mas.nil? - items.each do |item| + successes = cleanup_each!(items) do |item| Bundle.system(mas, "uninstall", parse_cleanup_item(item).id, verbose: false) end - puts "Uninstalled #{items.size} Mac App Store app#{"s" if items.size != 1}" + puts "Uninstalled #{successes} Mac App Store app#{"s" if successes != 1}" end sig { params(id: Integer).returns(T::Boolean) } diff --git a/Library/Homebrew/bundle/extensions/npm.rb b/Library/Homebrew/bundle/extensions/npm.rb index 6eecb3d2c57ad..07b37a45b5084 100644 --- a/Library/Homebrew/bundle/extensions/npm.rb +++ b/Library/Homebrew/bundle/extensions/npm.rb @@ -141,7 +141,7 @@ def outdated_packages @outdated_packages = [] end - sig { override.params(name: String, executable: Pathname).void } + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Bundle.system(executable.to_s, "uninstall", "-g", name, verbose: false) end diff --git a/Library/Homebrew/bundle/extensions/uv.rb b/Library/Homebrew/bundle/extensions/uv.rb index 58b98844c5b7e..e49e96e884214 100644 --- a/Library/Homebrew/bundle/extensions/uv.rb +++ b/Library/Homebrew/bundle/extensions/uv.rb @@ -363,7 +363,7 @@ def package_installed?(name, with: nil, source: nil) installed_packages.include?(package_record(name, with:, source:)) end - sig { override.params(name: String, executable: Pathname).void } + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Bundle.system(executable.to_s, "tool", "uninstall", name, verbose: false) end diff --git a/Library/Homebrew/bundle/extensions/vscode_extension.rb b/Library/Homebrew/bundle/extensions/vscode_extension.rb index 922260e22f638..1825e371bac44 100644 --- a/Library/Homebrew/bundle/extensions/vscode_extension.rb +++ b/Library/Homebrew/bundle/extensions/vscode_extension.rb @@ -189,8 +189,8 @@ def cleanup!(extensions) vscode = package_manager_executable return if vscode.nil? - extensions.each do |extension| - Kernel.system(vscode.to_s, "--uninstall-extension", extension) + cleanup_each!(extensions) do |extension| + Kernel.system(vscode.to_s, "--uninstall-extension", extension) == true end end end diff --git a/Library/Homebrew/bundle/extensions/winget.rb b/Library/Homebrew/bundle/extensions/winget.rb index d369f1e5ac8dd..11ec646ea7933 100644 --- a/Library/Homebrew/bundle/extensions/winget.rb +++ b/Library/Homebrew/bundle/extensions/winget.rb @@ -327,12 +327,12 @@ def cleanup!(items) winget = package_manager_executable return if winget.nil? - items.each do |item| + successes = cleanup_each!(items) do |item| app = parse_cleanup_item(item) Bundle.system(winget, "uninstall", "--id", app.id, "--exact", "--source", app.source, "--accept-source-agreements", "--disable-interactivity", verbose: false) end - puts "Uninstalled #{items.size} WinGet package#{"s" if items.size != 1}" + puts "Uninstalled #{successes} WinGet package#{"s" if successes != 1}" end sig { params(id: String, source: String).returns(T::Boolean) } diff --git a/Library/Homebrew/bundle/subcommand/cleanup.rb b/Library/Homebrew/bundle/subcommand/cleanup.rb index 38b93eb871f3d..4f99f8178c570 100644 --- a/Library/Homebrew/bundle/subcommand/cleanup.rb +++ b/Library/Homebrew/bundle/subcommand/cleanup.rb @@ -16,6 +16,8 @@ module Homebrew module Cmd class Bundle < Homebrew::AbstractCommand class CleanupSubcommand < Homebrew::AbstractSubcommand + extend Utils::Output::Mixin + subcommand_args do usage_banner <<~EOS `brew bundle cleanup`: @@ -164,16 +166,24 @@ def self.cleanup(global: false, file: nil, force: false, zap: false, dsl: nil, else [] end - Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--cask", *args, "--force", *casks - puts "Uninstalled #{casks.size} cask#{"s" if casks.size != 1}" + if Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--cask", *args, "--force", *casks + puts "Uninstalled #{casks.size} cask#{"s" if casks.size != 1}" + else + ofail "Failed to uninstall casks. Some may have been removed." + end end if formulae.any? - Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", *formulae - puts "Uninstalled #{formulae.size} formula#{"e" if formulae.size != 1}" + if Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", *formulae + puts "Uninstalled #{formulae.size} formula#{"e" if formulae.size != 1}" + else + ofail "Failed to uninstall formulae. Some may have been removed." + end end - Kernel.system HOMEBREW_BREW_FILE, "untap", *taps if taps.any? + if taps.any? && !Kernel.system(HOMEBREW_BREW_FILE, "untap", *taps) + ofail "Failed to untap. Some taps may have been removed." + end cleanup_extensions.each do |extension, items| next if items.empty? diff --git a/Library/Homebrew/test/bundle/cargo_spec.rb b/Library/Homebrew/test/bundle/cargo_spec.rb index 1135dff832d4c..902d03f7f050d 100644 --- a/Library/Homebrew/test/bundle/cargo_spec.rb +++ b/Library/Homebrew/test/bundle/cargo_spec.rb @@ -395,6 +395,15 @@ expect(described_class.cleanup_items(entries)).to eql(%w[fd-find bat]) end + it "returns the uninstall command result" do + allow(Homebrew::Bundle).to receive(:system) + .with("/tmp/rust/bin/cargo", "uninstall", "example", verbose: false).and_return(false) + + expect( + described_class.uninstall_package!("example", executable: Pathname("/tmp/rust/bin/cargo")), + ).to be(false) + end + it "returns frozen empty array when cargo is not installed" do allow(described_class).to receive(:package_manager_installed?).and_return(false) entries = [Homebrew::Bundle::Dsl::Entry.new(:cargo, "ripgrep")] diff --git a/Library/Homebrew/test/bundle/flatpak_spec.rb b/Library/Homebrew/test/bundle/flatpak_spec.rb index 78201e20ff7af..2e29c7d9e88c7 100644 --- a/Library/Homebrew/test/bundle/flatpak_spec.rb +++ b/Library/Homebrew/test/bundle/flatpak_spec.rb @@ -6,6 +6,30 @@ require "bundle/extensions/flatpak" RSpec.describe Homebrew::Bundle::Flatpak do + describe "cleanup" do + test_each([false, nil]) do |result| + it "reports #{result.inspect} without changing the command environment" do + Homebrew.failed = false + ENV["PATH"] = "/usr/bin:/bin" + allow(Kernel).to receive(:system) + .with("flatpak", "uninstall", "-y", "--system", "org.example.App") do + expect(ENV.fetch("PATH")).to eq("/usr/bin:/bin") + result + end + + expect do + described_class.cleanup!(["org.example.App"]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output( + a_string_including("Failed to uninstall Flatpak packages: org.example.App"), + ).to_stderr) + .and(output(/Uninstalled 0 flatpaks/).to_stdout), + ) + end + end + end + describe "checking" do subject(:checker) { described_class.new } diff --git a/Library/Homebrew/test/bundle/go_spec.rb b/Library/Homebrew/test/bundle/go_spec.rb index 3c0f37998b90e..fdd3ce9463c8b 100644 --- a/Library/Homebrew/test/bundle/go_spec.rb +++ b/Library/Homebrew/test/bundle/go_spec.rb @@ -148,11 +148,64 @@ allow(Utils).to receive(:popen_read).with(Pathname("go"), "version", "-m", binary, err: File::NULL) .and_return("\tpath\tgithub.com/charmbracelet/crush\n") - expect(FileUtils).to receive(:rm_f).with(binary) + expect(File).to receive(:unlink).with(binary) described_class.cleanup!(["github.com/charmbracelet/crush"]) end + context "with selected executables" do + let(:executables) { %w[/tmp/go/bin/first /tmp/go/bin/second] } + + before do + Homebrew.failed = false + allow(Utils).to receive(:popen_read) + .with(Pathname("go"), "env", "GOBIN", err: :err).and_return("/tmp/go/bin") + allow(Utils).to receive(:popen_read) + .with(Pathname("go"), "env", "GOPATH", err: :err).and_return("/tmp/go") + allow(File).to receive(:directory?).with("/tmp/go/bin").and_return(true) + allow(Dir).to receive(:glob).with("/tmp/go/bin/*").and_return(executables) + executables.each do |executable| + allow(File).to receive(:executable?).with(executable).and_return(true) + allow(File).to receive(:directory?).with(executable).and_return(false) + allow(File).to receive(:symlink?).with(executable).and_return(false) + allow(Utils).to receive(:popen_read) + .with(Pathname("go"), "version", "-m", executable, err: File::NULL) + .and_return("\tpath\texample.com/tools/example\n") + allow(File).to receive(:unlink).with(executable).and_return(1) + end + end + + it "counts removed executables rather than packages" do + expect do + described_class.cleanup!(["example.com/tools/example"]) + end.to output(/Removed 2 Go executables/).to_stdout + end + + it "reports deletion errors and continues with the next executable" do + expect(File).to receive(:unlink).with(executables.first).ordered.and_raise(Errno::EACCES) + expect(File).to receive(:unlink).with(executables.last).ordered.and_return(1) + + expect do + described_class.cleanup!(["example.com/tools/example"]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Removed 1 Go executable/).to_stdout) + .and(output( + a_string_including("Failed to remove Go executable /tmp/go/bin/first"), + ).to_stderr), + ) + end + + it "does not count an already absent executable as removed" do + allow(File).to receive(:unlink).with(executables.first).and_raise(Errno::ENOENT) + + expect do + described_class.cleanup!(["example.com/tools/example"]) + end.to output(/Removed 1 Go executable/).to_stdout + .and output("").to_stderr + end + end + it "returns frozen empty array when go is not installed" do allow(described_class).to receive(:package_manager_installed?).and_return(false) entries = [Homebrew::Bundle::Dsl::Entry.new(:go, "github.com/charmbracelet/crush")] diff --git a/Library/Homebrew/test/bundle/krew_spec.rb b/Library/Homebrew/test/bundle/krew_spec.rb index 57bec5b6e228b..248925cab8fcf 100644 --- a/Library/Homebrew/test/bundle/krew_spec.rb +++ b/Library/Homebrew/test/bundle/krew_spec.rb @@ -146,6 +146,16 @@ expect(described_class.cleanup_items(entries)).to eql(%w[ns neat]) end + it "returns the uninstall command result" do + allow(Homebrew::Bundle).to receive(:system).with("/usr/local/bin/kubectl-krew", "uninstall", "example", + verbose: false) + .and_return(false) + + expect( + described_class.uninstall_package!("example", executable: Pathname("/usr/local/bin/kubectl-krew")), + ).to be(false) + end + it "uninstalls plugins" do expect(Homebrew::Bundle).to receive(:system) do |*args, verbose:| expect(ENV.fetch("PATH", "")).to start_with("/usr/local/bin:") diff --git a/Library/Homebrew/test/bundle/mac_app_store_spec.rb b/Library/Homebrew/test/bundle/mac_app_store_spec.rb index 2823d76965af0..f548a1b5bf036 100644 --- a/Library/Homebrew/test/bundle/mac_app_store_spec.rb +++ b/Library/Homebrew/test/bundle/mac_app_store_spec.rb @@ -352,5 +352,26 @@ expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 Mac App Store app/).to_stdout end + + it "reports failed removals by readable app name without changing PATH" do + Homebrew.failed = false + ENV["PATH"] = "/usr/bin:/bin" + items = described_class.cleanup_items([Homebrew::Bundle::Dsl::Entry.new(:mas, "foo", id: 123)]) + allow(Homebrew::Bundle).to receive(:system) + .with(Pathname("mas"), "uninstall", "456", verbose: false) do + expect(ENV.fetch("PATH")).to eq("/usr/bin:/bin") + false + end + + expect do + described_class.cleanup!(items) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output( + a_string_including("Failed to uninstall Mac App Store dependencies: bar (456)"), + ).to_stderr) + .and(output(/Uninstalled 0 Mac App Store apps/).to_stdout), + ) + end end end diff --git a/Library/Homebrew/test/bundle/npm_spec.rb b/Library/Homebrew/test/bundle/npm_spec.rb index 0acdf3a2e0099..fb54c81d903eb 100644 --- a/Library/Homebrew/test/bundle/npm_spec.rb +++ b/Library/Homebrew/test/bundle/npm_spec.rb @@ -218,6 +218,53 @@ end end + describe ".cleanup! inherited from Extension" do + before do + Homebrew.failed = false + allow(described_class).to receive(:package_manager_executable) + .and_return(Pathname("/usr/bin/false")) + end + + it "reports a failed uninstall command" do + expect do + described_class.cleanup!(["example"]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Uninstalled 0 npm packages/).to_stdout) + .and(output(/Failed to uninstall npm packages: example/).to_stderr), + ) + end + + it "reports successful cleanup" do + allow(Homebrew::Bundle).to receive(:system).and_return(true) + + expect { described_class.cleanup!(["example"]) }.to output(/Uninstalled 1 npm package/).to_stdout + end + + it "counts successful commands and continues after failure" do + { "first" => true, "second" => false, "third" => true }.each do |name, success| + expect(Homebrew::Bundle).to receive(:system) + .with("/usr/bin/false", "uninstall", "-g", name, verbose: false).ordered.and_return(success) + end + + expect do + described_class.cleanup!(%w[first second third]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Uninstalled 2 npm packages/).to_stdout) + .and(output(/Failed to uninstall npm packages: second/).to_stderr), + ) + end + + it "does not clear an earlier failure after successful uninstalls" do + Homebrew.failed = true + allow(Homebrew::Bundle).to receive(:system).and_return(true) + allow(described_class).to receive(:puts) + + expect { described_class.cleanup!(["example"]) }.not_to change(Homebrew, :failed?) + end + end + describe "installing" do context "when npm is not installed" do before do diff --git a/Library/Homebrew/test/bundle/uv_spec.rb b/Library/Homebrew/test/bundle/uv_spec.rb index 121ee4f3c42e4..24018bd2a3e92 100644 --- a/Library/Homebrew/test/bundle/uv_spec.rb +++ b/Library/Homebrew/test/bundle/uv_spec.rb @@ -593,6 +593,15 @@ expect(described_class.cleanup_items(entries)).to eql(%w[mkdocs black]) end + it "returns the uninstall command result" do + allow(Homebrew::Bundle).to receive(:system) + .with("/tmp/uv/bin/uv", "tool", "uninstall", "example", verbose: false).and_return(false) + + expect( + described_class.uninstall_package!("example", executable: Pathname("/tmp/uv/bin/uv")), + ).to be(false) + end + it "returns frozen empty array when uv is not installed" do allow(described_class).to receive(:package_manager_installed?).and_return(false) entries = [Homebrew::Bundle::Dsl::Entry.new(:uv, "ruff")] diff --git a/Library/Homebrew/test/bundle/vscode_extension_spec.rb b/Library/Homebrew/test/bundle/vscode_extension_spec.rb index 34975497a57de..bc68d01b02965 100644 --- a/Library/Homebrew/test/bundle/vscode_extension_spec.rb +++ b/Library/Homebrew/test/bundle/vscode_extension_spec.rb @@ -7,6 +7,32 @@ require "extend/kernel" RSpec.describe Homebrew::Bundle::VscodeExtension do + describe "cleanup" do + test_each([false, nil]) do |result| + it "reports #{result.inspect} and continues cleanup" do + Homebrew.failed = false + ENV["PATH"] = "/usr/bin:/bin" + allow(described_class).to receive(:package_manager_executable).and_return(Pathname("code")) + expect(Kernel).to receive(:system) + .with("code", "--uninstall-extension", "publisher.first").ordered do + expect(ENV.fetch("PATH")).to eq("/usr/bin:/bin") + result + end + expect(Kernel).to receive(:system) + .with("code", "--uninstall-extension", "publisher.second") + .ordered.and_return(true) + + expect do + described_class.cleanup!(%w[publisher.first publisher.second]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(a_string_including("publisher.first")).to_stderr) + .and(output("").to_stdout), + ) + end + end + end + describe "dumping" do subject(:dumper) { described_class } diff --git a/Library/Homebrew/test/bundle/winget_spec.rb b/Library/Homebrew/test/bundle/winget_spec.rb index 776cdabe043a2..181e44a952e0e 100644 --- a/Library/Homebrew/test/bundle/winget_spec.rb +++ b/Library/Homebrew/test/bundle/winget_spec.rb @@ -370,5 +370,28 @@ expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 WinGet package/).to_stdout end + + it "reports failed removals by app name and source without changing PATH" do + Homebrew.failed = false + ENV["PATH"] = "/usr/bin:/bin" + items = described_class.cleanup_items([ + Homebrew::Bundle::Dsl::Entry.new(:winget, "Steam", id: "Valve.Steam"), + ]) + allow(Homebrew::Bundle).to receive(:system) + .with(Pathname("winget.exe"), "uninstall", "--id", "XPDC2RH70K22MN", + "--exact", "--source", "msstore", + "--accept-source-agreements", "--disable-interactivity", verbose: false) do + expect(ENV.fetch("PATH")).to eq("/usr/bin:/bin") + false + end + + expect do + described_class.cleanup!(items) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(a_string_including("Discord (XPDC2RH70K22MN, msstore)")).to_stderr) + .and(output(/Uninstalled 0 WinGet packages/).to_stdout), + ) + end end end diff --git a/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb b/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb index 2520d96323f55..bd9b09e2cb08c 100644 --- a/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb +++ b/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb @@ -403,7 +403,8 @@ end it "uninstalls casks" do - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", "b") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", + "b").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") expect do described_class.cleanup(force: true) @@ -428,7 +429,8 @@ end it "uninstalls casks" do - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--zap", "--force", "a", "b") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--zap", "--force", "a", + "b").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") expect do described_class.cleanup(force: true, zap: true) @@ -456,7 +458,8 @@ end it "uninstalls formulae" do - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", "b") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", + "b").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") expect do described_class.cleanup(force: true) @@ -483,7 +486,7 @@ end it "untaps taps" do - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") described_class.cleanup(force: true) end @@ -507,7 +510,7 @@ end it "uninstalls extensions" do - expect(Kernel).to receive(:system).with("code", "--uninstall-extension", "GitHub.codespaces") + expect(Kernel).to receive(:system).with("code", "--uninstall-extension", "GitHub.codespaces").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") described_class.cleanup(force: true) end @@ -530,7 +533,8 @@ end it "uninstalls flatpaks" do - expect(Kernel).to receive(:system).with("flatpak", "uninstall", "-y", "--system", "org.gnome.Calculator") + expect(Kernel).to receive(:system).with("flatpak", "uninstall", "-y", "--system", + "org.gnome.Calculator").and_return(true) expect(described_class).to receive(:system_output_no_stderr).and_return("") expect do described_class.cleanup(force: true) @@ -577,12 +581,14 @@ it "cleans up without suggesting --force when it prompts" do allow(Homebrew::Ask).to receive(:confirm?).with(action: "cleanup").and_return(true) allow(Homebrew::Bundle).to receive(:mark_as_installed_on_request!) - allow(Kernel).to receive(:system) + allow(Kernel).to receive(:system).and_return(true) allow(described_class).to receive(:system_output_no_stderr).and_return("") allow(Formatter).to receive(:columns).with(%w[a b]).and_return("a b") - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", "b") - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", "b") - expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", + "b").and_return(true) + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", + "b").and_return(true) + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b").and_return(true) expect(Homebrew::Cleanup).to receive(:dry_run_output).with(quiet: true).and_return("") expect { described_class.cleanup(ask: true) }.not_to output(/Run .brew bundle cleanup --force/).to_stdout @@ -623,6 +629,89 @@ end end + describe "cleanup failure reporting" do + before do + described_class.reset! + Homebrew::Bundle.extensions.each do |extension| + allow(extension).to receive(:cleanup_items).and_return([]) + end + Homebrew.failed = false + allow(Homebrew::Trust).to receive(:replace!) + allow(Homebrew::Bundle).to receive(:mark_as_installed_on_request!) + allow(Homebrew::Bundle::Npm).to receive(:cleanup_items).and_return(["remaining"]) + allow(Homebrew::Bundle::Npm).to receive(:cleanup!) + allow(described_class).to receive_messages(casks_to_uninstall: %w[first second], + formulae_to_uninstall: %w[first second], + taps_to_untap: %w[first/tap second/tap], + system_output_no_stderr: "") + allow(Kernel).to receive(:system).and_return(true) + end + + let(:dsl) { Homebrew::Bundle::Dsl.new(StringIO.new("")) } + + test_each([ + [["uninstall", "--cask", "--force", "first", "second"], "Failed to uninstall casks"], + [["uninstall", "--cask", "--zap", "--force", "first", "second"], "Failed to uninstall casks"], + [["uninstall", "--formula", "--force", "first", "second"], "Failed to uninstall formulae"], + [["untap", "first/tap", "second/tap"], "Failed to untap"], + ].product([false, nil])) do |(arguments, failure_message), result| + it "retains failure when #{arguments.join(" ")} returns #{result.inspect}" do + allow(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, *arguments).and_return(result) + expect(Kernel).to receive(:system) + .with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "first", "second") + expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "first/tap", "second/tap") + allow(described_class).to receive(:puts) + expect(Homebrew::Bundle::Npm).to receive(:cleanup!).with(["remaining"]) + expect(described_class).to receive(:system_output_no_stderr).with(HOMEBREW_BREW_FILE, + "cleanup").and_return("") + + expect do + described_class.cleanup(force: true, zap: arguments.include?("--zap"), dsl:) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(a_string_including(failure_message, "may have been removed")).to_stderr), + ) + end + end + + it "does not report successful removals when cask and formula commands fail" do + %w[cask formula].each do |type| + allow(Kernel).to receive(:system) + .with(HOMEBREW_BREW_FILE, "uninstall", "--#{type}", "--force", "first", "second") + .and_return(false) + end + + expect do + described_class.cleanup(force: true, dsl:, taps: false) + end.not_to output(/Uninstalled/).to_stdout + end + + it "preserves failure set before cleanup" do + Homebrew.failed = true + allow(described_class).to receive(:puts) + + expect do + described_class.cleanup(force: true, dsl:) + end.not_to change(Homebrew, :failed?) + end + + it "retains an extension failure after successful cache cleanup" do + expect(Homebrew::Bundle::Npm).to receive(:cleanup!).ordered.and_call_original + allow(Homebrew::Bundle::Npm).to receive(:package_manager_executable) + .and_return(Pathname("/usr/bin/false")) + allow(described_class).to receive(:puts) + expect(described_class).to receive(:system_output_no_stderr) + .with(HOMEBREW_BREW_FILE, "cleanup").ordered.and_return("") + + expect do + described_class.cleanup(force: true, dsl:, casks: false, formulae: false, taps: false) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Failed to uninstall npm packages: remaining/).to_stderr), + ) + end + end + describe "#system_output_no_stderr" do it "discards stderr without closing it" do stdout = nil @@ -654,7 +743,7 @@ ) allow(Homebrew::Bundle::VscodeExtension).to receive(:cleanup_items).and_return([]) allow(Homebrew::Bundle::Flatpak).to receive(:cleanup_items).and_return([]) - allow(Kernel).to receive(:system) + allow(Kernel).to receive(:system).and_return(true) allow(described_class).to receive(:system_output_no_stderr).and_return("") allow_any_instance_of(Pathname).to receive(:read).and_return("") end diff --git a/docs/Brew-Bundle-and-Brewfile.md b/docs/Brew-Bundle-and-Brewfile.md index e724964951c21..882dc4888b9b2 100644 --- a/docs/Brew-Bundle-and-Brewfile.md +++ b/docs/Brew-Bundle-and-Brewfile.md @@ -166,6 +166,14 @@ It removes trust entries granted manually or by another `Brewfile` if they are not declared in the selected file. A `Brewfile` with no trust declarations removes every explicit trust entry. +If a removal command reports failure, cleanup reports the error and attempts the remaining removals and cache cleanup. +It exits with a non-zero status. +Unexpected errors can stop cleanup before it reaches the remaining work. + +A failed command may already have removed some items. +Cleanup does not undo completed removals or changes to the trust store. +After resolving the error, rerun cleanup with the same options to retry the remaining work. + ### `brew bundle list` If you want to get a list of all the formulae in your `Brewfile`, you can use: From e9b1bb46ea9152ac8813f8eb55c3c3ec61f48507 Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:18:37 -0400 Subject: [PATCH 2/8] bundle: use pluralize for Flatpak cleanup --- Library/Homebrew/bundle/extensions/flatpak.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Library/Homebrew/bundle/extensions/flatpak.rb b/Library/Homebrew/bundle/extensions/flatpak.rb index 9e755e98f557e..c54a7e3167d4c 100644 --- a/Library/Homebrew/bundle/extensions/flatpak.rb +++ b/Library/Homebrew/bundle/extensions/flatpak.rb @@ -413,7 +413,7 @@ def cleanup!(flatpaks) successes = cleanup_each!(flatpaks) do |flatpak_name| Kernel.system("flatpak", "uninstall", "-y", "--system", flatpak_name) == true end - puts "Uninstalled #{successes} flatpak#{"s" if successes != 1}" + puts "Uninstalled #{Utils.pluralize(package_description, successes, include_count: true)}" end end From cec300faa65101de3068d714993fc30d78ce021c Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:22:43 -0400 Subject: [PATCH 3/8] bundle: share extension cleanup handling --- .../Homebrew/bundle/extensions/extension.rb | 19 ++++++------------- Library/Homebrew/bundle/extensions/flatpak.rb | 14 ++++++++------ .../bundle/extensions/mac_app_store.rb | 15 +++++++-------- .../bundle/extensions/vscode_extension.rb | 14 +++++++------- Library/Homebrew/bundle/extensions/winget.rb | 19 +++++++++---------- Library/Homebrew/test/bundle/flatpak_spec.rb | 15 ++++++++++----- Library/Homebrew/test/bundle/krew_spec.rb | 2 +- .../test/bundle/mac_app_store_spec.rb | 4 ++-- .../test/bundle/vscode_extension_spec.rb | 2 +- Library/Homebrew/test/bundle/winget_spec.rb | 4 ++-- .../cmd/bundle/cleanup_subcommand_spec.rb | 3 ++- 11 files changed, 55 insertions(+), 56 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb index 70421a96e9734..c140522412d09 100644 --- a/Library/Homebrew/bundle/extensions/extension.rb +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -237,27 +237,20 @@ def self.legacy_check_step :registered_extensions_to_install end + # Continue after unsuccessful commands while preserving the overall failure status. sig { params(items: T::Array[String]).void } def self.cleanup!(items) executable = package_manager_executable return if executable.nil? successes = with_env(package_manager_env(executable)) do - cleanup_each!(items) do |name| - uninstall_package!(name, executable:) + items.count do |item| + success = uninstall_package!(item, executable:) + ofail "Failed to uninstall #{banner_name}: #{cleanup_item_name(item)}" unless success + success end end - puts "Uninstalled #{successes} #{banner_name}" - end - - # Continue after unsuccessful commands while preserving the overall failure status. - sig { params(items: T::Array[String], block: T.proc.params(item: String).returns(T::Boolean)).returns(Integer) } - def self.cleanup_each!(items, &block) - items.count do |item| - success = yield item - ofail "Failed to uninstall #{banner_name}: #{cleanup_item_name(item)}" unless success - success - end + puts "Uninstalled #{Utils.pluralize(package_description, successes, include_count: true)}" end sig { params(name: String, executable: Pathname).returns(T::Boolean) } diff --git a/Library/Homebrew/bundle/extensions/flatpak.rb b/Library/Homebrew/bundle/extensions/flatpak.rb index c54a7e3167d4c..11ebcc622f3da 100644 --- a/Library/Homebrew/bundle/extensions/flatpak.rb +++ b/Library/Homebrew/bundle/extensions/flatpak.rb @@ -408,12 +408,14 @@ def cleanup_items(entries) packages - kept_flatpaks end - sig { params(flatpaks: T::Array[String]).void } - def cleanup!(flatpaks) - successes = cleanup_each!(flatpaks) do |flatpak_name| - Kernel.system("flatpak", "uninstall", "-y", "--system", flatpak_name) == true - end - puts "Uninstalled #{Utils.pluralize(package_description, successes, include_count: true)}" + sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(_executable) + {} + end + + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } + def uninstall_package!(name, executable: Pathname.new("")) + Kernel.system(executable.to_s, "uninstall", "-y", "--system", name) == true end end diff --git a/Library/Homebrew/bundle/extensions/mac_app_store.rb b/Library/Homebrew/bundle/extensions/mac_app_store.rb index 197bbd06f3e66..1923bc168ecce 100644 --- a/Library/Homebrew/bundle/extensions/mac_app_store.rb +++ b/Library/Homebrew/bundle/extensions/mac_app_store.rb @@ -154,15 +154,14 @@ def cleanup_items(entries) .map { |app| cleanup_item(app) } end - sig { override.params(items: T::Array[String]).void } - def cleanup!(items) - mas = package_manager_executable - return if mas.nil? + sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(_executable) + {} + end - successes = cleanup_each!(items) do |item| - Bundle.system(mas, "uninstall", parse_cleanup_item(item).id, verbose: false) - end - puts "Uninstalled #{successes} Mac App Store app#{"s" if successes != 1}" + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } + def uninstall_package!(name, executable: Pathname.new("")) + Bundle.system(executable, "uninstall", parse_cleanup_item(name).id, verbose: false) end sig { params(id: Integer).returns(T::Boolean) } diff --git a/Library/Homebrew/bundle/extensions/vscode_extension.rb b/Library/Homebrew/bundle/extensions/vscode_extension.rb index 1825e371bac44..7070efef01255 100644 --- a/Library/Homebrew/bundle/extensions/vscode_extension.rb +++ b/Library/Homebrew/bundle/extensions/vscode_extension.rb @@ -184,14 +184,14 @@ def cleanup_items(entries) packages - kept_extensions end - sig { params(extensions: T::Array[String]).void } - def cleanup!(extensions) - vscode = package_manager_executable - return if vscode.nil? + sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(_executable) + {} + end - cleanup_each!(extensions) do |extension| - Kernel.system(vscode.to_s, "--uninstall-extension", extension) == true - end + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } + def uninstall_package!(name, executable: Pathname.new("")) + Kernel.system(executable.to_s, "--uninstall-extension", name) == true end end end diff --git a/Library/Homebrew/bundle/extensions/winget.rb b/Library/Homebrew/bundle/extensions/winget.rb index 11ec646ea7933..3dd579adadb9f 100644 --- a/Library/Homebrew/bundle/extensions/winget.rb +++ b/Library/Homebrew/bundle/extensions/winget.rb @@ -322,17 +322,16 @@ def cleanup_items(entries) packages_to_cleanup.map { |app| cleanup_item(app) } end - sig { override.params(items: T::Array[String]).void } - def cleanup!(items) - winget = package_manager_executable - return if winget.nil? + sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(_executable) + {} + end - successes = cleanup_each!(items) do |item| - app = parse_cleanup_item(item) - Bundle.system(winget, "uninstall", "--id", app.id, "--exact", "--source", app.source, - "--accept-source-agreements", "--disable-interactivity", verbose: false) - end - puts "Uninstalled #{successes} WinGet package#{"s" if successes != 1}" + sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } + def uninstall_package!(name, executable: Pathname.new("")) + app = parse_cleanup_item(name) + Bundle.system(executable, "uninstall", "--id", app.id, "--exact", "--source", app.source, + "--accept-source-agreements", "--disable-interactivity", verbose: false) end sig { params(id: String, source: String).returns(T::Boolean) } diff --git a/Library/Homebrew/test/bundle/flatpak_spec.rb b/Library/Homebrew/test/bundle/flatpak_spec.rb index 2e29c7d9e88c7..f17bc6216a9b1 100644 --- a/Library/Homebrew/test/bundle/flatpak_spec.rb +++ b/Library/Homebrew/test/bundle/flatpak_spec.rb @@ -8,23 +8,28 @@ RSpec.describe Homebrew::Bundle::Flatpak do describe "cleanup" do test_each([false, nil]) do |result| - it "reports #{result.inspect} without changing the command environment" do + it "continues after #{result.inspect} with the resolved executable and unchanged environment" do Homebrew.failed = false ENV["PATH"] = "/usr/bin:/bin" - allow(Kernel).to receive(:system) - .with("flatpak", "uninstall", "-y", "--system", "org.example.App") do + allow(described_class).to receive(:package_manager_executable) + .and_return(Pathname("/example/bin/flatpak")) + expect(Kernel).to receive(:system) + .with("/example/bin/flatpak", "uninstall", "-y", "--system", "org.example.App").ordered do expect(ENV.fetch("PATH")).to eq("/usr/bin:/bin") result end + expect(Kernel).to receive(:system) + .with("/example/bin/flatpak", "uninstall", "-y", "--system", "org.example.Second") + .ordered.and_return(true) expect do - described_class.cleanup!(["org.example.App"]) + described_class.cleanup!(%w[org.example.App org.example.Second]) end.to( change(Homebrew, :failed?).from(false).to(true) .and(output( a_string_including("Failed to uninstall Flatpak packages: org.example.App"), ).to_stderr) - .and(output(/Uninstalled 0 flatpaks/).to_stdout), + .and(output(/Uninstalled 1 flatpak/).to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/krew_spec.rb b/Library/Homebrew/test/bundle/krew_spec.rb index 248925cab8fcf..de035ec18e5cc 100644 --- a/Library/Homebrew/test/bundle/krew_spec.rb +++ b/Library/Homebrew/test/bundle/krew_spec.rb @@ -164,7 +164,7 @@ true end - expect { described_class.cleanup!(["ns"]) }.to output(/Uninstalled 1 Krew plugin/).to_stdout + expect { described_class.cleanup!(["ns"]) }.to output(/Uninstalled 1 krew plugin/).to_stdout end end end diff --git a/Library/Homebrew/test/bundle/mac_app_store_spec.rb b/Library/Homebrew/test/bundle/mac_app_store_spec.rb index f548a1b5bf036..3b6437209ed62 100644 --- a/Library/Homebrew/test/bundle/mac_app_store_spec.rb +++ b/Library/Homebrew/test/bundle/mac_app_store_spec.rb @@ -350,7 +350,7 @@ expect(Homebrew::Bundle).to receive(:system).with(Pathname("mas"), "uninstall", "456", verbose: false) .and_return(true) - expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 Mac App Store app/).to_stdout + expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 app/).to_stdout end it "reports failed removals by readable app name without changing PATH" do @@ -370,7 +370,7 @@ .and(output( a_string_including("Failed to uninstall Mac App Store dependencies: bar (456)"), ).to_stderr) - .and(output(/Uninstalled 0 Mac App Store apps/).to_stdout), + .and(output(/Uninstalled 0 apps/).to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/vscode_extension_spec.rb b/Library/Homebrew/test/bundle/vscode_extension_spec.rb index bc68d01b02965..57e5b76ece8ed 100644 --- a/Library/Homebrew/test/bundle/vscode_extension_spec.rb +++ b/Library/Homebrew/test/bundle/vscode_extension_spec.rb @@ -27,7 +27,7 @@ end.to( change(Homebrew, :failed?).from(false).to(true) .and(output(a_string_including("publisher.first")).to_stderr) - .and(output("").to_stdout), + .and(output(/Uninstalled 1 vscode extension/).to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/winget_spec.rb b/Library/Homebrew/test/bundle/winget_spec.rb index 181e44a952e0e..ba4843cdd4177 100644 --- a/Library/Homebrew/test/bundle/winget_spec.rb +++ b/Library/Homebrew/test/bundle/winget_spec.rb @@ -368,7 +368,7 @@ "--accept-source-agreements", "--disable-interactivity", verbose: false) .and_return(true) - expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 WinGet package/).to_stdout + expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 winget package/).to_stdout end it "reports failed removals by app name and source without changing PATH" do @@ -390,7 +390,7 @@ end.to( change(Homebrew, :failed?).from(false).to(true) .and(output(a_string_including("Discord (XPDC2RH70K22MN, msstore)")).to_stderr) - .and(output(/Uninstalled 0 WinGet packages/).to_stdout), + .and(output(/Uninstalled 0 winget packages/).to_stdout), ) end end diff --git a/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb b/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb index bd9b09e2cb08c..8a51d8f7f9312 100644 --- a/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb +++ b/Library/Homebrew/test/cmd/bundle/cleanup_subcommand_spec.rb @@ -529,7 +529,8 @@ allow(described_class).to receive_messages(casks_to_uninstall: [], formulae_to_uninstall: [], taps_to_untap: []) allow(Homebrew::Bundle::VscodeExtension).to receive(:cleanup_items).and_return([]) - allow(Homebrew::Bundle::Flatpak).to receive(:cleanup_items).and_return(%w[org.gnome.Calculator]) + allow(Homebrew::Bundle::Flatpak).to receive_messages(cleanup_items: %w[org.gnome.Calculator], + package_manager_executable: Pathname("flatpak")) end it "uninstalls flatpaks" do From e48c597feecb75fc29127687c740a3179d268136 Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:01:39 -0400 Subject: [PATCH 4/8] bundle: report missing cleanup executables --- .../Homebrew/bundle/extensions/extension.rb | 5 ++++- Library/Homebrew/bundle/extensions/go.rb | 5 ++++- Library/Homebrew/test/bundle/go_spec.rb | 20 +++++++++++++++++++ Library/Homebrew/test/bundle/npm_spec.rb | 19 ++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb index c140522412d09..f952879d2e13c 100644 --- a/Library/Homebrew/bundle/extensions/extension.rb +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -241,7 +241,10 @@ def self.legacy_check_step sig { params(items: T::Array[String]).void } def self.cleanup!(items) executable = package_manager_executable - return if executable.nil? + if executable.nil? + ofail "Failed to uninstall #{banner_name}: executable not found" unless items.empty? + return + end successes = with_env(package_manager_env(executable)) do items.count do |item| diff --git a/Library/Homebrew/bundle/extensions/go.rb b/Library/Homebrew/bundle/extensions/go.rb index 7583aa0701e55..47100eb348edf 100644 --- a/Library/Homebrew/bundle/extensions/go.rb +++ b/Library/Homebrew/bundle/extensions/go.rb @@ -98,7 +98,10 @@ def installed_packages sig { override.params(items: T::Array[String]).void } def cleanup!(items) go = package_manager_executable - return if go.nil? + if go.nil? + ofail "Failed to remove Go executables: go executable not found" unless items.empty? + return + end gobin = Utils.popen_read(go, "env", "GOBIN", err: :err).chomp gopath = Utils.popen_read(go, "env", "GOPATH", err: :err).chomp diff --git a/Library/Homebrew/test/bundle/go_spec.rb b/Library/Homebrew/test/bundle/go_spec.rb index fdd3ce9463c8b..418a078a0deb1 100644 --- a/Library/Homebrew/test/bundle/go_spec.rb +++ b/Library/Homebrew/test/bundle/go_spec.rb @@ -134,6 +134,26 @@ .to eql(%w[github.com/golangci/golangci-lint/v2/cmd/golangci-lint]) end + it "reports a missing executable when removals are queued" do + Homebrew.failed = false + allow(described_class).to receive(:package_manager_executable).and_return(nil) + + expect do + described_class.cleanup!(["example.com/tools/example"]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Failed to remove Go executables: go executable not found/).to_stderr), + ) + end + + it "does not report a missing executable when no removals are queued" do + allow(described_class).to receive(:package_manager_executable).and_return(nil) + + expect do + described_class.cleanup!([]) + end.to(output("").to_stderr.and(output("").to_stdout)) + end + it "removes binaries by their literal names" do binary = "/Users/test/go/bin/cr;ush $(touch pwned)" allow(described_class).to receive(:`).and_raise("Shell execution is not permitted") diff --git a/Library/Homebrew/test/bundle/npm_spec.rb b/Library/Homebrew/test/bundle/npm_spec.rb index fb54c81d903eb..1ae05a2329e7f 100644 --- a/Library/Homebrew/test/bundle/npm_spec.rb +++ b/Library/Homebrew/test/bundle/npm_spec.rb @@ -225,6 +225,25 @@ .and_return(Pathname("/usr/bin/false")) end + it "reports a missing executable when removals are queued" do + allow(described_class).to receive(:package_manager_executable).and_return(nil) + + expect do + described_class.cleanup!(["example"]) + end.to( + change(Homebrew, :failed?).from(false).to(true) + .and(output(/Failed to uninstall npm packages: executable not found/).to_stderr), + ) + end + + it "does not report a missing executable when no removals are queued" do + allow(described_class).to receive(:package_manager_executable).and_return(nil) + + expect do + described_class.cleanup!([]) + end.to(output("").to_stderr.and(output("").to_stdout)) + end + it "reports a failed uninstall command" do expect do described_class.cleanup!(["example"]) From 536b3837d30fa75c0fdd09dc496f959bb2224e15 Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:31:08 -0400 Subject: [PATCH 5/8] bundle: remove cleanup failure commentary --- Library/Homebrew/bundle/extensions/extension.rb | 1 - docs/Brew-Bundle-and-Brewfile.md | 8 -------- 2 files changed, 9 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb index f952879d2e13c..5242f36b4bb6e 100644 --- a/Library/Homebrew/bundle/extensions/extension.rb +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -237,7 +237,6 @@ def self.legacy_check_step :registered_extensions_to_install end - # Continue after unsuccessful commands while preserving the overall failure status. sig { params(items: T::Array[String]).void } def self.cleanup!(items) executable = package_manager_executable diff --git a/docs/Brew-Bundle-and-Brewfile.md b/docs/Brew-Bundle-and-Brewfile.md index 882dc4888b9b2..e724964951c21 100644 --- a/docs/Brew-Bundle-and-Brewfile.md +++ b/docs/Brew-Bundle-and-Brewfile.md @@ -166,14 +166,6 @@ It removes trust entries granted manually or by another `Brewfile` if they are not declared in the selected file. A `Brewfile` with no trust declarations removes every explicit trust entry. -If a removal command reports failure, cleanup reports the error and attempts the remaining removals and cache cleanup. -It exits with a non-zero status. -Unexpected errors can stop cleanup before it reaches the remaining work. - -A failed command may already have removed some items. -Cleanup does not undo completed removals or changes to the trust store. -After resolving the error, rerun cleanup with the same options to retry the remaining work. - ### `brew bundle list` If you want to get a list of all the formulae in your `Brewfile`, you can use: From 45d6b3895fc383709f9796741a7d7b71daf3bd8c Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:22:24 -0400 Subject: [PATCH 6/8] bundle: tighten cleanup output tests --- Library/Homebrew/test/bundle/flatpak_spec.rb | 2 +- Library/Homebrew/test/bundle/go_spec.rb | 16 ++++++++++++---- Library/Homebrew/test/bundle/krew_spec.rb | 2 +- .../Homebrew/test/bundle/mac_app_store_spec.rb | 4 ++-- Library/Homebrew/test/bundle/npm_spec.rb | 15 ++++++++++++--- .../test/bundle/vscode_extension_spec.rb | 2 +- Library/Homebrew/test/bundle/winget_spec.rb | 4 ++-- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Library/Homebrew/test/bundle/flatpak_spec.rb b/Library/Homebrew/test/bundle/flatpak_spec.rb index f17bc6216a9b1..b431fc751b82b 100644 --- a/Library/Homebrew/test/bundle/flatpak_spec.rb +++ b/Library/Homebrew/test/bundle/flatpak_spec.rb @@ -29,7 +29,7 @@ .and(output( a_string_including("Failed to uninstall Flatpak packages: org.example.App"), ).to_stderr) - .and(output(/Uninstalled 1 flatpak/).to_stdout), + .and(output("Uninstalled 1 flatpak\n").to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/go_spec.rb b/Library/Homebrew/test/bundle/go_spec.rb index 418a078a0deb1..c0828dbb1db6d 100644 --- a/Library/Homebrew/test/bundle/go_spec.rb +++ b/Library/Homebrew/test/bundle/go_spec.rb @@ -198,7 +198,15 @@ it "counts removed executables rather than packages" do expect do described_class.cleanup!(["example.com/tools/example"]) - end.to output(/Removed 2 Go executables/).to_stdout + end.to output("Removed 2 Go executables\n").to_stdout.and output("").to_stderr + end + + it "does not mark successful cleanup as failed" do + allow(described_class).to receive(:puts) + + expect do + described_class.cleanup!(["example.com/tools/example"]) + end.not_to change(Homebrew, :failed?) end it "reports deletion errors and continues with the next executable" do @@ -209,7 +217,7 @@ described_class.cleanup!(["example.com/tools/example"]) end.to( change(Homebrew, :failed?).from(false).to(true) - .and(output(/Removed 1 Go executable/).to_stdout) + .and(output("Removed 1 Go executable\n").to_stdout) .and(output( a_string_including("Failed to remove Go executable /tmp/go/bin/first"), ).to_stderr), @@ -221,8 +229,8 @@ expect do described_class.cleanup!(["example.com/tools/example"]) - end.to output(/Removed 1 Go executable/).to_stdout - .and output("").to_stderr + end.to output("Removed 1 Go executable\n").to_stdout + .and output("").to_stderr end end diff --git a/Library/Homebrew/test/bundle/krew_spec.rb b/Library/Homebrew/test/bundle/krew_spec.rb index de035ec18e5cc..766b4638db36e 100644 --- a/Library/Homebrew/test/bundle/krew_spec.rb +++ b/Library/Homebrew/test/bundle/krew_spec.rb @@ -164,7 +164,7 @@ true end - expect { described_class.cleanup!(["ns"]) }.to output(/Uninstalled 1 krew plugin/).to_stdout + expect { described_class.cleanup!(["ns"]) }.to output("Uninstalled 1 krew plugin\n").to_stdout end end end diff --git a/Library/Homebrew/test/bundle/mac_app_store_spec.rb b/Library/Homebrew/test/bundle/mac_app_store_spec.rb index 3b6437209ed62..8fc17d327c704 100644 --- a/Library/Homebrew/test/bundle/mac_app_store_spec.rb +++ b/Library/Homebrew/test/bundle/mac_app_store_spec.rb @@ -350,7 +350,7 @@ expect(Homebrew::Bundle).to receive(:system).with(Pathname("mas"), "uninstall", "456", verbose: false) .and_return(true) - expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 app/).to_stdout + expect { described_class.cleanup!(items) }.to output("Uninstalled 1 app\n").to_stdout end it "reports failed removals by readable app name without changing PATH" do @@ -370,7 +370,7 @@ .and(output( a_string_including("Failed to uninstall Mac App Store dependencies: bar (456)"), ).to_stderr) - .and(output(/Uninstalled 0 apps/).to_stdout), + .and(output("Uninstalled 0 apps\n").to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/npm_spec.rb b/Library/Homebrew/test/bundle/npm_spec.rb index 1ae05a2329e7f..30c9c5d995200 100644 --- a/Library/Homebrew/test/bundle/npm_spec.rb +++ b/Library/Homebrew/test/bundle/npm_spec.rb @@ -249,7 +249,7 @@ described_class.cleanup!(["example"]) end.to( change(Homebrew, :failed?).from(false).to(true) - .and(output(/Uninstalled 0 npm packages/).to_stdout) + .and(output(/^Uninstalled 0 npm packages$/).to_stdout) .and(output(/Failed to uninstall npm packages: example/).to_stderr), ) end @@ -257,7 +257,16 @@ it "reports successful cleanup" do allow(Homebrew::Bundle).to receive(:system).and_return(true) - expect { described_class.cleanup!(["example"]) }.to output(/Uninstalled 1 npm package/).to_stdout + expect do + described_class.cleanup!(["example"]) + end.to output("Uninstalled 1 npm package\n").to_stdout.and output("").to_stderr + end + + it "does not mark successful cleanup as failed" do + allow(Homebrew::Bundle).to receive(:system).and_return(true) + allow(described_class).to receive(:puts) + + expect { described_class.cleanup!(["example"]) }.not_to change(Homebrew, :failed?) end it "counts successful commands and continues after failure" do @@ -270,7 +279,7 @@ described_class.cleanup!(%w[first second third]) end.to( change(Homebrew, :failed?).from(false).to(true) - .and(output(/Uninstalled 2 npm packages/).to_stdout) + .and(output("Uninstalled 2 npm packages\n").to_stdout) .and(output(/Failed to uninstall npm packages: second/).to_stderr), ) end diff --git a/Library/Homebrew/test/bundle/vscode_extension_spec.rb b/Library/Homebrew/test/bundle/vscode_extension_spec.rb index 57e5b76ece8ed..e3ee45468d8a0 100644 --- a/Library/Homebrew/test/bundle/vscode_extension_spec.rb +++ b/Library/Homebrew/test/bundle/vscode_extension_spec.rb @@ -27,7 +27,7 @@ end.to( change(Homebrew, :failed?).from(false).to(true) .and(output(a_string_including("publisher.first")).to_stderr) - .and(output(/Uninstalled 1 vscode extension/).to_stdout), + .and(output("Uninstalled 1 vscode extension\n").to_stdout), ) end end diff --git a/Library/Homebrew/test/bundle/winget_spec.rb b/Library/Homebrew/test/bundle/winget_spec.rb index ba4843cdd4177..545a123352ee7 100644 --- a/Library/Homebrew/test/bundle/winget_spec.rb +++ b/Library/Homebrew/test/bundle/winget_spec.rb @@ -368,7 +368,7 @@ "--accept-source-agreements", "--disable-interactivity", verbose: false) .and_return(true) - expect { described_class.cleanup!(items) }.to output(/Uninstalled 1 winget package/).to_stdout + expect { described_class.cleanup!(items) }.to output("Uninstalled 1 winget package\n").to_stdout end it "reports failed removals by app name and source without changing PATH" do @@ -390,7 +390,7 @@ end.to( change(Homebrew, :failed?).from(false).to(true) .and(output(a_string_including("Discord (XPDC2RH70K22MN, msstore)")).to_stderr) - .and(output(/Uninstalled 0 winget packages/).to_stdout), + .and(output("Uninstalled 0 winget packages\n").to_stdout), ) end end From e3adf67aa166fc6d77a3e96c518dd5b618c4c69a Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:49:48 -0400 Subject: [PATCH 7/8] bundle: use pluralize for cleanup summaries --- Library/Homebrew/bundle/extensions/go.rb | 2 +- Library/Homebrew/bundle/subcommand/cleanup.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/go.rb b/Library/Homebrew/bundle/extensions/go.rb index 47100eb348edf..18debdfdd51b4 100644 --- a/Library/Homebrew/bundle/extensions/go.rb +++ b/Library/Homebrew/bundle/extensions/go.rb @@ -131,7 +131,7 @@ def cleanup!(items) end removed += 1 end - puts "Removed #{removed} Go executable#{"s" if removed != 1}" + puts "Removed #{Utils.pluralize("Go executable", removed, include_count: true)}" end end end diff --git a/Library/Homebrew/bundle/subcommand/cleanup.rb b/Library/Homebrew/bundle/subcommand/cleanup.rb index 4f99f8178c570..c7c308499f538 100644 --- a/Library/Homebrew/bundle/subcommand/cleanup.rb +++ b/Library/Homebrew/bundle/subcommand/cleanup.rb @@ -167,7 +167,7 @@ def self.cleanup(global: false, file: nil, force: false, zap: false, dsl: nil, [] end if Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--cask", *args, "--force", *casks - puts "Uninstalled #{casks.size} cask#{"s" if casks.size != 1}" + puts "Uninstalled #{Utils.pluralize("cask", casks.size, include_count: true)}" else ofail "Failed to uninstall casks. Some may have been removed." end @@ -175,7 +175,7 @@ def self.cleanup(global: false, file: nil, force: false, zap: false, dsl: nil, if formulae.any? if Kernel.system HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", *formulae - puts "Uninstalled #{formulae.size} formula#{"e" if formulae.size != 1}" + puts "Uninstalled #{Utils.pluralize("formula", formulae.size, include_count: true)}" else ofail "Failed to uninstall formulae. Some may have been removed." end From 769de0a4d46e41e7358f54c25f61563173da2b7b Mon Sep 17 00:00:00 2001 From: edel67 <78402575+edel67@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:50 -0400 Subject: [PATCH 8/8] bundle: default to an empty manager environment --- Library/Homebrew/bundle/extensions/extension.rb | 6 +++--- Library/Homebrew/bundle/extensions/flatpak.rb | 5 ----- Library/Homebrew/bundle/extensions/krew.rb | 5 +++++ .../Homebrew/bundle/extensions/mac_app_store.rb | 5 ----- Library/Homebrew/bundle/extensions/npm.rb | 5 +++++ .../bundle/extensions/vscode_extension.rb | 5 ----- Library/Homebrew/bundle/extensions/winget.rb | 5 ----- Library/Homebrew/test/bundle/uv_spec.rb | 15 +++++++++++++++ 8 files changed, 28 insertions(+), 23 deletions(-) diff --git a/Library/Homebrew/bundle/extensions/extension.rb b/Library/Homebrew/bundle/extensions/extension.rb index 5242f36b4bb6e..2cd1d75b20e5f 100644 --- a/Library/Homebrew/bundle/extensions/extension.rb +++ b/Library/Homebrew/bundle/extensions/extension.rb @@ -64,9 +64,9 @@ def self.package_manager_executable! package_manager_executable || raise("#{package_manager_name} is not installed") end - sig { params(executable: Pathname).returns(T::Hash[String, String]) } - def self.package_manager_env(executable) - { "PATH" => "#{executable.dirname}:#{ORIGINAL_PATHS.join(":")}" } + sig { params(_executable: Pathname).returns(T::Hash[String, String]) } + def self.package_manager_env(_executable) + {} end sig { diff --git a/Library/Homebrew/bundle/extensions/flatpak.rb b/Library/Homebrew/bundle/extensions/flatpak.rb index 11ebcc622f3da..c24315abafdd9 100644 --- a/Library/Homebrew/bundle/extensions/flatpak.rb +++ b/Library/Homebrew/bundle/extensions/flatpak.rb @@ -408,11 +408,6 @@ def cleanup_items(entries) packages - kept_flatpaks end - sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } - def package_manager_env(_executable) - {} - end - sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Kernel.system(executable.to_s, "uninstall", "-y", "--system", name) == true diff --git a/Library/Homebrew/bundle/extensions/krew.rb b/Library/Homebrew/bundle/extensions/krew.rb index 470ac208641d6..fc143621fa116 100644 --- a/Library/Homebrew/bundle/extensions/krew.rb +++ b/Library/Homebrew/bundle/extensions/krew.rb @@ -33,6 +33,11 @@ def package_manager_executable @package_manager_executable ||= T.let(which("kubectl-krew", ORIGINAL_PATHS), T.nilable(Pathname)) end + sig { override.params(executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(executable) + { "PATH" => "#{executable.dirname}:#{ORIGINAL_PATHS.join(":")}" } + end + sig { override.returns(T::Array[String]) } def packages packages = @packages diff --git a/Library/Homebrew/bundle/extensions/mac_app_store.rb b/Library/Homebrew/bundle/extensions/mac_app_store.rb index 1923bc168ecce..7113f1b41d070 100644 --- a/Library/Homebrew/bundle/extensions/mac_app_store.rb +++ b/Library/Homebrew/bundle/extensions/mac_app_store.rb @@ -154,11 +154,6 @@ def cleanup_items(entries) .map { |app| cleanup_item(app) } end - sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } - def package_manager_env(_executable) - {} - end - sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Bundle.system(executable, "uninstall", parse_cleanup_item(name).id, verbose: false) diff --git a/Library/Homebrew/bundle/extensions/npm.rb b/Library/Homebrew/bundle/extensions/npm.rb index 07b37a45b5084..8009d1816f821 100644 --- a/Library/Homebrew/bundle/extensions/npm.rb +++ b/Library/Homebrew/bundle/extensions/npm.rb @@ -39,6 +39,11 @@ def package_manager_executable which("npm", ORIGINAL_PATHS) end + sig { override.params(executable: Pathname).returns(T::Hash[String, String]) } + def package_manager_env(executable) + { "PATH" => "#{executable.dirname}:#{ORIGINAL_PATHS.join(":")}" } + end + sig { override.params(name: String, _options: Homebrew::Bundle::EntryOptions).returns(String) } def install_verb(name = "", _options = {}) package_installed?(name) ? "Upgrading" : "Installing" diff --git a/Library/Homebrew/bundle/extensions/vscode_extension.rb b/Library/Homebrew/bundle/extensions/vscode_extension.rb index 7070efef01255..f4b7c286bac7d 100644 --- a/Library/Homebrew/bundle/extensions/vscode_extension.rb +++ b/Library/Homebrew/bundle/extensions/vscode_extension.rb @@ -184,11 +184,6 @@ def cleanup_items(entries) packages - kept_extensions end - sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } - def package_manager_env(_executable) - {} - end - sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) Kernel.system(executable.to_s, "--uninstall-extension", name) == true diff --git a/Library/Homebrew/bundle/extensions/winget.rb b/Library/Homebrew/bundle/extensions/winget.rb index 3dd579adadb9f..0a0b1ffd197a4 100644 --- a/Library/Homebrew/bundle/extensions/winget.rb +++ b/Library/Homebrew/bundle/extensions/winget.rb @@ -322,11 +322,6 @@ def cleanup_items(entries) packages_to_cleanup.map { |app| cleanup_item(app) } end - sig { override.params(_executable: Pathname).returns(T::Hash[String, String]) } - def package_manager_env(_executable) - {} - end - sig { override.params(name: String, executable: Pathname).returns(T::Boolean) } def uninstall_package!(name, executable: Pathname.new("")) app = parse_cleanup_item(name) diff --git a/Library/Homebrew/test/bundle/uv_spec.rb b/Library/Homebrew/test/bundle/uv_spec.rb index 24018bd2a3e92..f0e54eaf842c2 100644 --- a/Library/Homebrew/test/bundle/uv_spec.rb +++ b/Library/Homebrew/test/bundle/uv_spec.rb @@ -602,6 +602,21 @@ ).to be(false) end + it "preserves PATH when uninstalling tools" do + ENV["PATH"] = "/existing/bin" + uninstall_path = "" + allow(Homebrew::Bundle).to receive(:system) + .with("/tmp/uv/bin/uv", "tool", "uninstall", "example", verbose: false) do + uninstall_path = ENV.fetch("PATH") + true + end + allow(described_class).to receive(:puts) + + described_class.cleanup!(["example"]) + + expect(uninstall_path).to eq("/existing/bin") + end + it "returns frozen empty array when uv is not installed" do allow(described_class).to receive(:package_manager_installed?).and_return(false) entries = [Homebrew::Bundle::Dsl::Entry.new(:uv, "ruff")]