Mike McQuaid e8bfa23877
Support Cask renames when installing/dumping
This adds support for Cask old tokens used for renames of Casks.

We'll now correctly check these at installation time to avoid repeatedly
installing renamed Casks and dump them in the Brewfile. We also use this
logic to avoid cleaning up renamed Casks.
2025-07-10 08:05:36 +00:00

315 lines
13 KiB
Ruby

# frozen_string_literal: true
require "bundle"
require "bundle/commands/cleanup"
RSpec.describe Homebrew::Bundle::Commands::Cleanup do
describe "read Brewfile and current installation", :no_api do
before do
described_class.reset!
# don't try to load gcc/glibc
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
allow_any_instance_of(Pathname).to receive(:read).and_return <<~EOS
tap 'x'
tap 'y'
cask '123'
brew 'a'
brew 'b'
brew 'd2'
brew 'homebrew/tap/f'
brew 'homebrew/tap/g'
brew 'homebrew/tap/h'
brew 'homebrew/tap/i2'
brew 'homebrew/tap/hasdependency'
brew 'hasbuilddependency1'
brew 'hasbuilddependency2'
mas 'appstoreapp1', id: 1
vscode 'VsCodeExtension1'
EOS
%w[a b d2 homebrew/tap/f homebrew/tap/g homebrew/tap/h homebrew/tap/i2
homebrew/tap/hasdependency hasbuilddependency1 hasbuilddependency2].each do |full_name|
tap_name, _, name = full_name.rpartition("/")
tap = tap_name.present? ? Tap.fetch(tap_name) : nil
f = formula(name, tap:) { url "#{name}-1.0" }
stub_formula_loader f, full_name
end
end
it "computes which casks to uninstall" do
cask_123 = instance_double(Cask::Cask, to_s: "123", old_tokens: [])
cask_456 = instance_double(Cask::Cask, to_s: "456", old_tokens: [])
allow(Homebrew::Bundle::CaskDumper).to receive(:casks).and_return([cask_123, cask_456])
expect(described_class.casks_to_uninstall).to eql(%w[456])
end
it "computes which formulae to uninstall" do
dependencies_arrays_hash = { dependencies: [], build_dependencies: [] }
formulae_hash = [
{ name: "a2", full_name: "a2", aliases: ["a"], dependencies: ["d"] },
{ name: "c", full_name: "c" },
{ name: "d", full_name: "homebrew/tap/d", aliases: ["d2"] },
{ name: "e", full_name: "homebrew/tap/e" },
{ name: "f", full_name: "homebrew/tap/f" },
{ name: "h", full_name: "other/tap/h" },
{ name: "i", full_name: "homebrew/tap/i", aliases: ["i2"] },
{ name: "hasdependency", full_name: "homebrew/tap/hasdependency", dependencies: ["isdependency"] },
{ name: "isdependency", full_name: "homebrew/tap/isdependency" },
{
name: "hasbuilddependency1",
full_name: "hasbuilddependency1",
poured_from_bottle?: true,
build_dependencies: ["builddependency1"],
},
{
name: "hasbuilddependency2",
full_name: "hasbuilddependency2",
poured_from_bottle?: false,
build_dependencies: ["builddependency2"],
},
{ name: "builddependency1", full_name: "builddependency1" },
{ name: "builddependency2", full_name: "builddependency2" },
{ name: "caskdependency", full_name: "homebrew/tap/caskdependency" },
].map { |formula| dependencies_arrays_hash.merge(formula) }
allow(Homebrew::Bundle::FormulaDumper).to receive(:formulae).and_return(formulae_hash)
formulae_hash.each do |hash_formula|
name = hash_formula[:name]
full_name = hash_formula[:full_name]
tap_name = full_name.rpartition("/").first.presence || "homebrew/core"
tap = Tap.fetch(tap_name)
f = formula(name, tap:) { url "#{name}-1.0" }
stub_formula_loader f, full_name
end
allow(Homebrew::Bundle::CaskDumper).to receive(:formula_dependencies).and_return(%w[caskdependency])
expect(described_class.formulae_to_uninstall).to eql %w[
c
homebrew/tap/e
other/tap/h
builddependency1
]
end
it "computes which tap to untap" do
allow(Homebrew::Bundle::TapDumper).to \
receive(:tap_names).and_return(%w[z homebrew/core homebrew/tap])
expect(described_class.taps_to_untap).to eql(%w[z])
end
it "ignores unavailable formulae when computing which taps to keep" do
allow(Formulary).to \
receive(:factory).and_raise(TapFormulaUnavailableError.new(Tap.fetch("homebrew/tap"), "foo"))
allow(Homebrew::Bundle::TapDumper).to \
receive(:tap_names).and_return(%w[z homebrew/core homebrew/tap])
expect(described_class.taps_to_untap).to eql(%w[z homebrew/tap])
end
it "ignores formulae with .keepme references when computing which formulae to uninstall" do
name = full_name ="c"
allow(Homebrew::Bundle::FormulaDumper).to receive(:formulae).and_return([{ name:, full_name: }])
f = formula(name) { url "#{name}-1.0" }
stub_formula_loader f, name
keg = instance_double(Keg)
allow(keg).to receive(:keepme_refs).and_return(["/some/file"])
allow(f).to receive(:installed_kegs).and_return([keg])
expect(described_class.formulae_to_uninstall).to be_empty
end
it "computes which VSCode extensions to uninstall" do
allow(Homebrew::Bundle::VscodeExtensionDumper).to receive(:extensions).and_return(%w[z])
expect(described_class.vscode_extensions_to_uninstall).to eql(%w[z])
end
it "computes which VSCode extensions to uninstall irrespective of case of the extension name" do
allow(Homebrew::Bundle::VscodeExtensionDumper).to receive(:extensions).and_return(%w[z vscodeextension1])
expect(described_class.vscode_extensions_to_uninstall).to eql(%w[z])
end
end
context "when there are no formulae to uninstall and no taps to untap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "does nothing" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
end
context "when there are casks to uninstall" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls casks" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true) }.to output(/Uninstalled 2 casks/).to_stdout
end
it "does not uninstall casks if --formulae is disabled" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true, casks: false) }.not_to output.to_stdout
end
end
context "when there are casks to zap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls casks" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--cask", "--zap", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true, zap: true) }.to output(/Uninstalled 2 casks/).to_stdout
end
it "does not uninstall casks if --casks is disabled" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true, zap: true, casks: false) }.not_to output.to_stdout
end
end
context "when there are formulae to uninstall" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: %w[a b],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
it "uninstalls formulae" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "uninstall", "--formula", "--force", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true) }.to output(/Uninstalled 2 formulae/).to_stdout
end
it "does not uninstall formulae if --casks is disabled" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect { described_class.run(force: true, formulae: false) }.not_to output.to_stdout
end
end
context "when there are taps to untap" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: %w[a b],
vscode_extensions_to_uninstall: [])
end
it "untaps taps" do
expect(Kernel).to receive(:system).with(HOMEBREW_BREW_FILE, "untap", "a", "b")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
it "does not untap taps if --taps is disabled" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true, taps: false)
end
end
context "when there are VSCode extensions to uninstall" do
before do
described_class.reset!
allow(Homebrew::Bundle).to receive(:which_vscode).and_return(Pathname("code"))
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: %w[GitHub.codespaces])
end
it "uninstalls extensions" do
expect(Kernel).to receive(:system).with("code", "--uninstall-extension", "GitHub.codespaces")
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true)
end
it "does not uninstall extensions if --vscode is disabled" do
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
described_class.run(force: true, vscode: false)
end
end
context "when there are casks and formulae to uninstall and taps to untap but without passing `--force`" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: %w[a b],
formulae_to_uninstall: %w[a b],
taps_to_untap: %w[a b],
vscode_extensions_to_uninstall: %w[a b])
end
it "lists casks, formulae and taps" do
expect(Formatter).to receive(:columns).with(%w[a b]).exactly(4).times
expect(Kernel).not_to receive(:system)
expect(described_class).to receive(:system_output_no_stderr).and_return("")
expect do
described_class.run
end.to raise_error(SystemExit)
.and output(/Would uninstall formulae:.*Would untap:.*Would uninstall VSCode extensions:/m).to_stdout
end
end
context "when there is brew cleanup output" do
before do
described_class.reset!
allow(described_class).to receive_messages(casks_to_uninstall: [],
formulae_to_uninstall: [],
taps_to_untap: [],
vscode_extensions_to_uninstall: [])
end
def sane?
expect(described_class).to receive(:system_output_no_stderr).and_return("cleaned")
end
context "with --force" do
it "prints output" do
sane?
expect { described_class.run(force: true) }.to output(/cleaned/).to_stdout
end
end
context "without --force" do
it "prints output" do
sane?
expect { described_class.run }.to output(/cleaned/).to_stdout
end
end
end
describe "#system_output_no_stderr" do
it "shells out" do
expect(IO).to receive(:popen).and_return(StringIO.new("true"))
described_class.system_output_no_stderr("true")
end
end
end