diff --git a/Library/Homebrew/bundle/brew.rb b/Library/Homebrew/bundle/brew.rb index ff386c8d7b05c..d899f3926f6e3 100644 --- a/Library/Homebrew/bundle/brew.rb +++ b/Library/Homebrew/bundle/brew.rb @@ -204,6 +204,14 @@ def formulae T.cast(@formulae, T::Array[T::Hash[Symbol, T.untyped]]) end + # Returns formula names and all declared dependencies, including optional dependencies. + sig { params(formulae: T::Array[Formula]).returns(T::Array[String]) } + def formula_names_with_dependencies(formulae) + formulae.flat_map do |formula| + formula.recursive_dependencies { nil }.map(&:name).prepend(formula.full_name) + end.uniq + end + # Returns the full `@formulae_by_full_name` map when called without a name, # or a single formula's attribute hash when called with a name. sig { diff --git a/Library/Homebrew/bundle/cask.rb b/Library/Homebrew/bundle/cask.rb index 3c561165ef7e9..599f99b93f975 100644 --- a/Library/Homebrew/bundle/cask.rb +++ b/Library/Homebrew/bundle/cask.rb @@ -217,13 +217,16 @@ def cask_oldnames end end - sig { params(cask_list: T::Array[String]).returns(T::Array[String]) } - def formula_dependencies(cask_list) + # Returns direct formula dependencies, preferring installed casks. + # Recursive queries include all declared formula dependencies from current cask + # definitions and raise if a cask is unavailable. + sig { params(cask_list: T::Array[String], recursive: T::Boolean).returns(T::Array[String]) } + def formula_dependencies(cask_list, recursive: false) return [] if cask_list.blank? require "cask/cask_loader" - installed_cask_objects = casks + installed_cask_objects = recursive ? [] : casks cask_list.flat_map do |cask_name| cask = installed_cask_objects.find do |installed_cask| cask_name == installed_cask.to_s || cask_name == installed_cask.full_name @@ -231,11 +234,20 @@ def formula_dependencies(cask_list) cask ||= begin ::Cask::CaskLoader.load(cask_name) rescue ::Cask::CaskUnavailableError + raise if recursive + nil end next unless cask + next cask.depends_on[:formula] unless recursive + + require "bundle/brew" + require "formula" + require "utils/topological_hash" - cask.depends_on[:formula] + Brew.formula_names_with_dependencies( + ::Utils::TopologicalHash.graph_package_dependencies(cask).keys.grep(Formula), + ) end.compact end diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb index e7661661dce62..e2a9e13a74122 100644 --- a/Library/Homebrew/bundle/installer.rb +++ b/Library/Homebrew/bundle/installer.rb @@ -97,8 +97,9 @@ def self.install!(entries, global: false, file: nil, no_lock: false, no_upgrade: installed_taps = ensure_entry_taps_installed!(pending_entries, tap_entries:) prepare_attestation_verification!(pending_entries) + formula_names = Set.new batchable, remaining = pending_entries.partition do |entry| - homebrew_batchable?(entry, entries: installable_entries, installed_taps:) + homebrew_batchable?(entry, entries: installable_entries, installed_taps:, formula_names:) end if batchable.present? @@ -238,9 +239,10 @@ def self.prepare_attestation_verification!(entries) entry: InstallableEntry, entries: T::Array[InstallableEntry], installed_taps: T::Array[String], + formula_names: T::Set[String], ).returns(T::Boolean) } - def self.homebrew_batchable?(entry, entries:, installed_taps:) + def self.homebrew_batchable?(entry, entries:, installed_taps:, formula_names:) case entry.cls.name when "Homebrew::Bundle::Brew" return false if (entry.options.keys - BATCHABLE_OPTION_KEYS).any? @@ -254,7 +256,16 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:) package = if entry.cls == Brew require "formula" - Formula[entry.full_name] + formula = Formula[entry.full_name] + conflicts = formula.conflicts.map(&:name) + if conflicts.present? + return false if conflicts.any? { |name| Brew.formula_installed?(name) } + + formula_names.merge(formula_names_with_dependencies(entries)) if formula_names.empty? + return false if conflicts.any? { |name| formula_names.include?(Formula[name].full_name) } + end + + formula else require "cask/cask_loader" ::Cask::CaskLoader.load(entry.full_name) @@ -274,6 +285,20 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:) end private_class_method :homebrew_batchable? + # Returns the requested formula names, including optional formula and cask dependencies. + sig { params(entries: T::Array[InstallableEntry]).returns(T::Set[String]) } + def self.formula_names_with_dependencies(entries) + require "formula" + + formula_names = Brew.formula_names_with_dependencies( + entries.filter_map { |entry| Formula[entry.full_name] if entry.cls == Brew }, + ) + formula_names += Cask.formula_dependencies( + entries.filter_map { |entry| entry.full_name if entry.cls == Cask }, recursive: true + ) + formula_names.to_set + end + sig { params( entries: T::Array[InstallableEntry], diff --git a/Library/Homebrew/test/bundle/cask_spec.rb b/Library/Homebrew/test/bundle/cask_spec.rb index 08b29b1cad9d2..928af3dad9e6f 100644 --- a/Library/Homebrew/test/bundle/cask_spec.rb +++ b/Library/Homebrew/test/bundle/cask_spec.rb @@ -124,6 +124,80 @@ end describe "#formula_dependencies" do + context "with recursive dependencies" do + let(:installed_cask) { instance_double(Cask::Cask, to_s: "editor", full_name: "editor", depends_on: {}) } + let(:dependency_formula) do + formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + end + end + let(:requested_cask) do + Cask::Cask.new("editor") do + version "1.0" + url "https://example.com/editor.zip" + depends_on cask: "editor-backend" + end + end + + before do + described_class.reset! + allow(Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) + allow(Cask::CaskLoader).to receive(:load).with("editor-backend", config: nil).and_return( + Cask::Cask.new("editor-backend") do + version "1.0" + url "https://example.com/editor-backend.zip" + depends_on formula: "backend" + end, + ) + allow(Formula).to receive(:[]).with("backend").and_return(dependency_formula) + end + + it "includes formula dependencies of nested casks" do + expect(Formula).to receive(:[]).with("backend").once.and_return(dependency_formula) + + expect(dumper.formula_dependencies(["editor"], recursive: true)).to eq(["backend"]) + end + + [:build, :test, :optional].each do |kind| + it "includes #{kind} dependencies of a cask's formula dependency" do + T.bind(self, RSpec::Core::ExampleGroup) + + stub_formula_loader(formula("extra") do + T.bind(self, T.class_of(Formula)) + url "extra-1.0" + end) + backend = formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + depends_on "extra" => kind + end + allow(Formula).to receive(:[]).with("backend").and_return(backend) + + expect(described_class.formula_dependencies(["editor"], recursive: true)) + .to contain_exactly("backend", "extra") + end + end + + it "uses the current definition when an installed cask has no dependencies" do + allow(described_class).to receive(:casks).and_return([installed_cask]) + + expect(dumper.formula_dependencies(["editor"], recursive: true)).to eq(["backend"]) + end + + it "still uses the installed definition for a non-recursive query" do + allow(described_class).to receive(:casks).and_return([installed_cask]) + + expect(dumper.formula_dependencies(["editor"])).to be_empty + end + + it "raises when a requested cask cannot be loaded" do + allow(Cask::CaskLoader).to receive(:load).with("editor").and_raise(Cask::CaskUnavailableError, "editor") + + expect { dumper.formula_dependencies(["editor"], recursive: true) }.to raise_error(Cask::CaskUnavailableError) + end + end + context "when the given casks don't have formula dependencies" do before do described_class.reset! diff --git a/Library/Homebrew/test/bundle/installer_spec.rb b/Library/Homebrew/test/bundle/installer_spec.rb index 8d1f424c4c72a..6c19b2ac1e0cd 100644 --- a/Library/Homebrew/test/bundle/installer_spec.rb +++ b/Library/Homebrew/test/bundle/installer_spec.rb @@ -28,7 +28,9 @@ installable_or_upgradable?: true, preinstall!: true) allow(Homebrew::Bundle::Tap).to receive_messages(preinstall!: true, install!: true, installed_taps: []) - allow(Formula).to receive(:[]) { |name| instance_double(Formula, full_name: name, tap: CoreTap.instance) } + allow(Formula).to receive(:[]) { |name| + instance_double(Formula, full_name: name, tap: CoreTap.instance, conflicts: []) + } allow(::Cask::CaskLoader).to receive(:load) .and_return(instance_double(::Cask::Cask, full_name: "google-chrome", tap: CoreCaskTap.instance)) @@ -92,7 +94,8 @@ expect(Homebrew::Trust).to receive(:trust!).with(:formula, "thirdparty/tap/bar").ordered.and_return(true) expect(Formula).to receive(:[]).with("thirdparty/tap/bar").ordered .and_return(instance_double(Formula, full_name: "thirdparty/tap/bar", - tap: Tap.fetch("thirdparty/tap"))) + tap: Tap.fetch("thirdparty/tap"), + conflicts: [])) described_class.install!([trusted_formula_entry], quiet: true) end @@ -242,6 +245,170 @@ described_class.install!([formula_entry], quiet: true) end + context "with formula conflicts" do + let(:conflicting_formula) do + formula("mysql", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "mysql-1.0" + conflicts_with "mysql@8.4" + end + end + let(:unrelated_formula) do + formula("redis", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "redis-1.0" + end + end + + before do + Homebrew::Bundle::Brew.reset! + allow(Formula).to receive(:installed).and_return([]) + allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) + allow(Formula).to receive(:[]).with("redis").and_return(unrelated_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + end + + it "batches a formula whose conflicts are neither installed nor requested" do + expect(Homebrew::Bundle).to receive(:brew) + .with("install", "homebrew/core/mysql", "homebrew/core/redis", verbose: false) + .and_return(true) + + described_class.install!([formula_entry, second_formula_entry], quiet: true) + end + + it "keeps batching when an unrelated formula has install arguments" do + entry_with_args = Homebrew::Bundle::Dsl::Entry.new(:brew, "redis", { args: ["build-from-source"] }) + + expect(Homebrew::Bundle).to receive(:brew) + .with("install", "homebrew/core/mysql", verbose: false) + .and_return(true) + + described_class.install!([formula_entry, entry_with_args], quiet: true) + end + + it "unlinks installed conflicts before installing a formula" do + (HOMEBREW_CELLAR/"mysql@8.4/8.4").mkpath + + expect(Homebrew::Bundle).to receive(:brew).with("unlink", "mysql@8.4", verbose: false) + .ordered.and_return(true) + expect(Homebrew::Bundle).to receive(:brew).with("install", "--formula", "mysql", verbose: false) + .ordered.and_return(true) + + described_class.install!([formula_entry], quiet: true) + end + + it "unlinks a conflicting formula requested through an alias before installing a formula" do + requested_formula = formula("mysql@8.4", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "mysql-8.4" + end + allow(Formula).to receive(:[]).with("mysql-lts").and_return(requested_formula) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/mysql@8.4", verbose: false) + .ordered do + requested_formula.prefix.mkpath + true + end + expect(Homebrew::Bundle).to receive(:brew).with("unlink", "mysql@8.4", verbose: false) + .ordered.and_return(true) + expect(Homebrew::Bundle).to receive(:brew).with("install", "--formula", "mysql", verbose: false) + .ordered.and_return(true) + + described_class.install!([Homebrew::Bundle::Dsl::Entry.new(:brew, "mysql-lts"), formula_entry], quiet: true) + end + + it "expands dependencies once when checking multiple formulae for conflicts" do + second_formula = formula("redis", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "redis-1.0" + conflicts_with "valkey" + end + allow(Formula).to receive(:[]).with("redis").and_return(second_formula) + + expect(described_class).to receive(:formula_names_with_dependencies).once.and_call_original + + described_class.install!([formula_entry, second_formula_entry], quiet: true) + end + end + + context "with conflicts introduced by dependencies" do + let(:conflicting_formula) do + formula("ctags", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "ctags-1.0" + conflicts_with "universal-ctags" + end + end + let(:dependency_formula) do + formula("universal-ctags") do + T.bind(self, T.class_of(Formula)) + url "universal-ctags-1.0" + end + end + + before do + Homebrew::Bundle::Brew.reset! + allow(Formula).to receive(:installed).and_return([]) + allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + end + + it "unlinks conflicts from transitive optional formula dependencies" do + requested_formula = formula("ctags-lsp", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "ctags-lsp-1.0" + depends_on "backend" + end + stub_formula_loader(formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + depends_on "universal-ctags" => :optional + end) + stub_formula_loader(dependency_formula) + allow(Formula).to receive(:[]).with("ctags-lsp").and_return(requested_formula) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/ctags-lsp", verbose: false) + .ordered do + dependency_formula.prefix.mkpath + true + end + expect(Homebrew::Bundle).to receive(:brew).with("unlink", "universal-ctags", verbose: false) + .ordered.and_return(true) + expect(Homebrew::Bundle).to receive(:brew).with("install", "--formula", "ctags", verbose: false) + .ordered.and_return(true) + + described_class.install!([ + Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags-lsp"), + Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), + ], quiet: true) + end + + it "unlinks conflicts installed by a cask before installing a formula" do + requested_cask = ::Cask::Cask.new("editor", tap: CoreCaskTap.instance) do + version "1.0" + url "https://example.com/editor.zip" + depends_on formula: "universal-ctags" + end + allow(Formula).to receive(:[]).with("universal-ctags").and_return(dependency_formula) + allow(::Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "--adopt", "homebrew/cask/editor", verbose: false) + .ordered do + dependency_formula.prefix.mkpath + true + end + expect(Homebrew::Bundle).to receive(:brew).with("unlink", "universal-ctags", verbose: false) + .ordered.and_return(true) + expect(Homebrew::Bundle).to receive(:brew).with("install", "--formula", "ctags", verbose: false) + .ordered.and_return(true) + + described_class.install!([ + Homebrew::Bundle::Dsl::Entry.new(:cask, "editor"), + Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), + ], quiet: true) + end + end + it "keeps an already up-to-date entry out of the batch but still finishes it" do allow(Homebrew::Bundle::Brew).to receive(:preinstall!).with("mysql", any_args).and_return(false) @@ -280,7 +447,8 @@ it "uses the resolved formula name when batching an alias" do allow(Formula).to receive(:[]).with("mysql").and_return(instance_double(Formula, full_name: "mysql@9.7", - tap: CoreTap.instance)) + tap: CoreTap.instance, + conflicts: [])) expect(Homebrew::Bundle).to receive(:brew) .with("install", "homebrew/core/mysql@9.7", verbose: false) @@ -292,7 +460,8 @@ it "preserves third-party tap names in a mixed batch" do allow(Formula).to receive(:[]).with("mysql") .and_return(instance_double(Formula, full_name: "thirdparty/tap/mysql", - tap: Tap.fetch("thirdparty/tap"))) + tap: Tap.fetch("thirdparty/tap"), + conflicts: [])) allow(::Cask::CaskLoader).to receive(:load) .and_return(instance_double(::Cask::Cask, full_name: "thirdparty/tap/google-chrome", tap: Tap.fetch("thirdparty/tap"))) @@ -311,7 +480,8 @@ entry = Homebrew::Bundle::Dsl::Entry.new(type, "local") if type == :brew allow(Formula).to receive(:[]).with("local") - .and_return(instance_double(Formula, full_name: "local", tap: nil)) + .and_return(instance_double(Formula, full_name: "local", tap: nil, + conflicts: [])) else allow(::Cask::CaskLoader).to receive(:load).with("local") .and_return(instance_double(::Cask::Cask, full_name: "local", @@ -379,7 +549,8 @@ (tap.cask_dir/"collision.rb").write('raise "Do not load this cask"') allow(Homebrew::Bundle::Tap).to receive(:installed_taps).and_return([tap.name]) allow(Formula).to receive(:[]).with("thirdparty/tap/collision") - .and_return(instance_double(Formula, full_name: "thirdparty/tap/collision", tap:)) + .and_return(instance_double(Formula, full_name: "thirdparty/tap/collision", tap:, + conflicts: [])) allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original allow(Homebrew::Bundle::Brew).to receive_messages(formula_installed?: false, formulae_by_full_name: nil, installed_formulae: [])