From a751118c37abf5e78adbd498ffad3493305d40f5 Mon Sep 17 00:00:00 2001 From: Thiago Genez <102752+thiagogenez@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:19:24 +0100 Subject: [PATCH 1/5] bundle: preserve formula conflict handling - Keep formulae with declared conflicts out of batched installs so the existing installer can unlink conflicting formulae first. - Cover existing conflicts and conflicts introduced by a batch. --- Library/Homebrew/bundle/installer.rb | 5 +- .../Homebrew/test/bundle/installer_spec.rb | 66 +++++++++++++++++-- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb index e7661661dce62..789f0bb599e45 100644 --- a/Library/Homebrew/bundle/installer.rb +++ b/Library/Homebrew/bundle/installer.rb @@ -254,7 +254,10 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:) package = if entry.cls == Brew require "formula" - Formula[entry.full_name] + formula = Formula[entry.full_name] + return false if formula.conflicts.present? + + formula else require "cask/cask_loader" ::Cask::CaskLoader.load(entry.full_name) diff --git a/Library/Homebrew/test/bundle/installer_spec.rb b/Library/Homebrew/test/bundle/installer_spec.rb index 8d1f424c4c72a..d59fb612a792f 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,53 @@ described_class.install!([formula_entry], quiet: true) end + it "unlinks installed conflicts before installing a formula" do + conflicting_formula = formula("mysql") do + T.bind(self, T.class_of(Formula)) + url "mysql-1.0" + conflicts_with "mysql@8.4" + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) + + 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 conflicts installed by the batch before installing a formula" do + conflicting_formula = formula("ctags") do + T.bind(self, T.class_of(Formula)) + url "ctags-1.0" + conflicts_with "universal-ctags" + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/ctags-lsp", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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 "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 +330,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 +343,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 +363,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 +432,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: []) From 2ffd5caf822afe373f7ff29337ddefc65eebaa60 Mon Sep 17 00:00:00 2001 From: Thiago Genez <102752+thiagogenez@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:10:29 +0100 Subject: [PATCH 2/5] bundle: check conflicts before batching - Keep batch installation when conflicts are absent. - Check requested formula and cask dependency trees for conflicts. - Include optional dependencies to cover saved install options. --- Library/Homebrew/bundle/installer.rb | 41 ++- .../Homebrew/test/bundle/installer_spec.rb | 289 +++++++++++++++++- 2 files changed, 326 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb index 789f0bb599e45..d46543e3377fd 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? @@ -255,7 +257,16 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:) package = if entry.cls == Brew require "formula" formula = Formula[entry.full_name] - return false if formula.conflicts.present? + conflicts = formula.conflicts.map(&:name) + if conflicts.present? + return false if conflicts.any? { |name| Brew.formula_installed?(name) } + return false if entries.any? do |candidate| + [Brew, Cask].include?(candidate.cls) && candidate.options[:args].present? + end + + 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 @@ -277,6 +288,30 @@ 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" + + entries.flat_map do |entry| + formulae = case entry.cls.name + when "Homebrew::Bundle::Brew" + [Formula[entry.full_name]] + when "Homebrew::Bundle::Cask" + require "cask/cask_loader" + require "utils/topological_hash" + + ::Utils::TopologicalHash.graph_package_dependencies(::Cask::CaskLoader.load(entry.full_name)) + .keys.grep(Formula) + else + [] + end + formulae.flat_map do |formula| + formula.recursive_dependencies { nil }.map(&:name).prepend(formula.full_name) + end + end.to_set + end + sig { params( entries: T::Array[InstallableEntry], diff --git a/Library/Homebrew/test/bundle/installer_spec.rb b/Library/Homebrew/test/bundle/installer_spec.rb index d59fb612a792f..8d9086367b93f 100644 --- a/Library/Homebrew/test/bundle/installer_spec.rb +++ b/Library/Homebrew/test/bundle/installer_spec.rb @@ -245,6 +245,29 @@ described_class.install!([formula_entry], quiet: true) end + it "batches a formula whose conflicts are neither installed nor requested" do + conflicting_formula = formula("mysql") do + T.bind(self, T.class_of(Formula)) + url "mysql-1.0" + conflicts_with "mysql@8.4" + end + unrelated_formula = formula("redis") do + T.bind(self, T.class_of(Formula)) + url "redis-1.0" + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(unrelated_formula).to receive(:tap).and_return(CoreTap.instance) + 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(:formula_installed?).and_return(false) + + 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 "unlinks installed conflicts before installing a formula" do conflicting_formula = formula("mysql") do T.bind(self, T.class_of(Formula)) @@ -265,14 +288,59 @@ described_class.install!([formula_entry], quiet: true) end - it "unlinks conflicts installed by the batch before installing a formula" do + it "unlinks a conflicting formula requested through an alias before installing a formula" do + conflicting_formula = formula("mysql") do + T.bind(self, T.class_of(Formula)) + url "mysql-1.0" + conflicts_with "mysql@8.4" + end + requested_formula = formula("mysql@8.4") do + T.bind(self, T.class_of(Formula)) + url "mysql-8.4" + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) + allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) + allow(Formula).to receive(:[]).with("mysql-lts").and_return(requested_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/mysql@8.4", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) + 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 "unlinks conflicts enabled by saved install options before installing a formula" do conflicting_formula = formula("ctags") do T.bind(self, T.class_of(Formula)) url "ctags-1.0" conflicts_with "universal-ctags" end + requested_formula = formula("ctags-lsp") do + T.bind(self, T.class_of(Formula)) + url "ctags-lsp-1.0" + depends_on "universal-ctags" => :optional + end + stub_formula_loader(formula("universal-ctags") do + T.bind(self, T.class_of(Formula)) + url "universal-ctags-1.0" + end) allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Formula).to receive(:[]).with("ctags-lsp").and_return(requested_formula) + tab = Tab.for_formula(requested_formula) + tab.used_options = ["--with-universal-ctags"] + allow(Tab).to receive(:for_formula).and_call_original + allow(Tab).to receive(:for_formula).with(requested_formula).and_return(tab) allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) @@ -292,6 +360,101 @@ ], quiet: true) end + it "unlinks conflicts installed by a cask before installing a formula" do + conflicting_formula = formula("ctags") do + T.bind(self, T.class_of(Formula)) + url "ctags-1.0" + conflicts_with "universal-ctags" + end + dependency_formula = formula("universal-ctags") do + T.bind(self, T.class_of(Formula)) + url "universal-ctags-1.0" + end + requested_cask = ::Cask::Cask.new("editor") do + version "1.0" + url "https://example.com/editor.zip" + depends_on formula: "universal-ctags" + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(requested_cask).to receive(:tap).and_return(CoreCaskTap.instance) + allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Formula).to receive(:[]).with("universal-ctags").and_return(dependency_formula) + allow(::Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "--adopt", "homebrew/cask/editor", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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 + + it "unlinks conflicts enabled by another formula's install options" do + conflicting_formula = formula("ctags") do + T.bind(self, T.class_of(Formula)) + url "ctags-1.0" + conflicts_with "universal-ctags" + end + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "universal-ctags" => :optional + end + allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) + allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) + allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + + expect(Homebrew::Bundle).to receive(:brew) + .with("install", "--formula", "editor", "--with-universal-ctags", verbose: false).ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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, "editor", { args: ["with-universal-ctags"] }), + Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), + ], quiet: true) + end + + it "expands dependencies once when checking multiple formulae for conflicts" do + first_formula = formula("mysql") do + T.bind(self, T.class_of(Formula)) + url "mysql-1.0" + conflicts_with "mysql@8.4" + end + second_formula = formula("redis") do + T.bind(self, T.class_of(Formula)) + url "redis-1.0" + conflicts_with "valkey" + end + allow(first_formula).to receive(:tap).and_return(CoreTap.instance) + allow(second_formula).to receive(:tap).and_return(CoreTap.instance) + allow(Formula).to receive(:[]).with("mysql").and_return(first_formula) + allow(Formula).to receive(:[]).with("redis").and_return(second_formula) + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) + + expect(described_class).to receive(:formula_names_with_dependencies).once.and_call_original + + described_class.install!([formula_entry, second_formula_entry], quiet: true) + 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) @@ -548,6 +711,130 @@ end end + describe ".formula_names_with_dependencies" do + let(:entry) do + Homebrew::Bundle::Installer::InstallableEntry.new( + entry: Homebrew::Bundle::Dsl::Entry.new(:brew, "editor"), verb: "Installing", + cls: Homebrew::Bundle::Brew, install_name: "editor" + ) + end + let(:ctags_formula) do + formula("universal-ctags") do + T.bind(self, T.class_of(Formula)) + url "universal-ctags-1.0" + end + end + + before do + stub_formula_loader(ctags_formula) + end + + it "includes optional dependencies even when no install options are specified" do + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "universal-ctags" => :optional + end + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + + expect(described_class.formula_names_with_dependencies([entry])).to contain_exactly("editor", "universal-ctags") + end + + it "includes optional dependencies enabled by a dependency's saved install options" do + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "backend" + end + dependency_formula = 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) + tab = Tab.for_formula(dependency_formula) + tab.used_options = ["--with-universal-ctags"] + allow(Tab).to receive(:for_formula).and_call_original + allow(Tab).to receive(:for_formula).with(dependency_formula).and_return(tab) + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + + expect(described_class.formula_names_with_dependencies([entry])) + .to contain_exactly("editor", "backend", "universal-ctags") + end + + it "includes optional dependencies enabled through another dependency path" do + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "plain" + depends_on "enabled" + end + stub_formula_loader(formula("plain") do + T.bind(self, T.class_of(Formula)) + url "plain-1.0" + depends_on "backend" + end) + stub_formula_loader(formula("enabled") do + T.bind(self, T.class_of(Formula)) + url "enabled-1.0" + depends_on "backend" => "with-universal-ctags" + end) + stub_formula_loader(formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + depends_on "universal-ctags" => :optional + end) + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + + expect(described_class.formula_names_with_dependencies([entry])) + .to contain_exactly("editor", "plain", "enabled", "backend", "universal-ctags") + end + + it "includes optional dependencies enabled by a renamed dependency option" do + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "backend" => "with-old-ctags" + end + stub_formula_loader(formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + depends_on "universal-ctags" => :optional + deprecated_option "with-old-ctags" => "with-universal-ctags" + end) + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + + expect(described_class.formula_names_with_dependencies([entry])) + .to contain_exactly("editor", "backend", "universal-ctags") + end + + it "does not revisit descendants of a shared dependency" do + requested_formula = formula("editor") do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "first" + depends_on "second" + end + %w[first second].each do |name| + stub_formula_loader(formula(name) do + T.bind(self, T.class_of(Formula)) + url "#{name}-1.0" + depends_on "backend" + end) + end + stub_formula_loader(formula("backend") do + T.bind(self, T.class_of(Formula)) + url "backend-1.0" + depends_on "universal-ctags" + end) + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) + + expect(ctags_formula).to receive(:deps).once.and_call_original + + described_class.formula_names_with_dependencies([entry]) + end + end + describe "native extension batches" do it "delegates entries to an extension that registers batch support" do first_entry = Homebrew::Bundle::Dsl::Entry.new(:npm, "typescript") From 07f3a79439615be57336435310bd8551ae0cedfe Mon Sep 17 00:00:00 2001 From: Thiago Genez <102752+thiagogenez@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:40:51 +0100 Subject: [PATCH 3/5] bundle: share dependency expansion - Reuse `Cask.formula_dependencies` for batch conflict checks. - Share formula expansion and reuse loaded cask dependencies. - Preserve cleanup's installed-cask behaviour. --- Library/Homebrew/bundle/brew.rb | 8 ++ Library/Homebrew/bundle/cask.rb | 20 ++++- Library/Homebrew/bundle/installer.rb | 24 ++---- Library/Homebrew/test/bundle/cask_spec.rb | 75 +++++++++++++++++++ .../Homebrew/test/bundle/installer_spec.rb | 18 +++++ 5 files changed, 124 insertions(+), 21 deletions(-) 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 d46543e3377fd..bfb4ecf578571 100644 --- a/Library/Homebrew/bundle/installer.rb +++ b/Library/Homebrew/bundle/installer.rb @@ -293,23 +293,13 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:, formula_names:) def self.formula_names_with_dependencies(entries) require "formula" - entries.flat_map do |entry| - formulae = case entry.cls.name - when "Homebrew::Bundle::Brew" - [Formula[entry.full_name]] - when "Homebrew::Bundle::Cask" - require "cask/cask_loader" - require "utils/topological_hash" - - ::Utils::TopologicalHash.graph_package_dependencies(::Cask::CaskLoader.load(entry.full_name)) - .keys.grep(Formula) - else - [] - end - formulae.flat_map do |formula| - formula.recursive_dependencies { nil }.map(&:name).prepend(formula.full_name) - end - end.to_set + 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 { diff --git a/Library/Homebrew/test/bundle/cask_spec.rb b/Library/Homebrew/test/bundle/cask_spec.rb index 08b29b1cad9d2..39ad8127933f0 100644 --- a/Library/Homebrew/test/bundle/cask_spec.rb +++ b/Library/Homebrew/test/bundle/cask_spec.rb @@ -124,6 +124,81 @@ end describe "#formula_dependencies" do + context "with recursive dependencies" do + 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(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([ + instance_double(Cask::Cask, to_s: "editor", full_name: "editor", depends_on: {}), + ]) + + 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([ + instance_double(Cask::Cask, to_s: "editor", full_name: "editor", depends_on: {}), + ]) + + 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 8d9086367b93f..977f01135ea89 100644 --- a/Library/Homebrew/test/bundle/installer_spec.rb +++ b/Library/Homebrew/test/bundle/installer_spec.rb @@ -729,6 +729,24 @@ stub_formula_loader(ctags_formula) end + it "reuses formulae already loaded for cask dependencies" do + requested_cask = ::Cask::Cask.new("editor") do + version "1.0" + url "https://example.com/editor.zip" + depends_on formula: "universal-ctags" + end + allow(::Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) + cask_entry = Homebrew::Bundle::Installer::InstallableEntry.new( + entry: Homebrew::Bundle::Dsl::Entry.new(:cask, "editor"), verb: "Installing", + cls: Homebrew::Bundle::Cask, install_name: "editor" + ) + + allow(Formula).to receive(:[]).and_call_original + expect(Formula).to receive(:[]).with("universal-ctags").once.and_call_original + + described_class.formula_names_with_dependencies([cask_entry]) + end + it "includes optional dependencies even when no install options are specified" do requested_formula = formula("editor") do T.bind(self, T.class_of(Formula)) From 4b2e32ea83dfe2b48a13baad08d4cdfee3a4d4b8 Mon Sep 17 00:00:00 2001 From: Thiago Genez <102752+thiagogenez@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:04:53 +0100 Subject: [PATCH 4/5] bundle: share conflict test setup --- Library/Homebrew/test/bundle/cask_spec.rb | 9 +- .../Homebrew/test/bundle/installer_spec.rb | 322 ++++++++---------- 2 files changed, 146 insertions(+), 185 deletions(-) diff --git a/Library/Homebrew/test/bundle/cask_spec.rb b/Library/Homebrew/test/bundle/cask_spec.rb index 39ad8127933f0..46886c7450ee8 100644 --- a/Library/Homebrew/test/bundle/cask_spec.rb +++ b/Library/Homebrew/test/bundle/cask_spec.rb @@ -125,6 +125,7 @@ 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)) @@ -177,17 +178,13 @@ end it "uses the current definition when an installed cask has no dependencies" do - allow(described_class).to receive(:casks).and_return([ - instance_double(Cask::Cask, to_s: "editor", full_name: "editor", depends_on: {}), - ]) + 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([ - instance_double(Cask::Cask, to_s: "editor", full_name: "editor", depends_on: {}), - ]) + allow(described_class).to receive(:casks).and_return([installed_cask]) expect(dumper.formula_dependencies(["editor"])).to be_empty end diff --git a/Library/Homebrew/test/bundle/installer_spec.rb b/Library/Homebrew/test/bundle/installer_spec.rb index 977f01135ea89..17ea3631f7a6a 100644 --- a/Library/Homebrew/test/bundle/installer_spec.rb +++ b/Library/Homebrew/test/bundle/installer_spec.rb @@ -245,214 +245,178 @@ described_class.install!([formula_entry], quiet: true) end - it "batches a formula whose conflicts are neither installed nor requested" do - conflicting_formula = formula("mysql") do - T.bind(self, T.class_of(Formula)) - url "mysql-1.0" - conflicts_with "mysql@8.4" - end - unrelated_formula = formula("redis") do - T.bind(self, T.class_of(Formula)) - url "redis-1.0" + 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 - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(unrelated_formula).to receive(:tap).and_return(CoreTap.instance) - 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(:formula_installed?).and_return(false) - - 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 "unlinks installed conflicts before installing a formula" do - conflicting_formula = formula("mysql") do - T.bind(self, T.class_of(Formula)) - url "mysql-1.0" - conflicts_with "mysql@8.4" + before do + allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) end - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) - allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) - 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) + it "batches a formula whose conflicts are neither installed nor requested" do + unrelated_formula = formula("redis", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "redis-1.0" + end + allow(Formula).to receive(:[]).with("redis").and_return(unrelated_formula) - described_class.install!([formula_entry], quiet: true) - end + expect(Homebrew::Bundle).to receive(:brew) + .with("install", "homebrew/core/mysql", "homebrew/core/redis", verbose: false) + .and_return(true) - it "unlinks a conflicting formula requested through an alias before installing a formula" do - conflicting_formula = formula("mysql") do - T.bind(self, T.class_of(Formula)) - url "mysql-1.0" - conflicts_with "mysql@8.4" + described_class.install!([formula_entry, second_formula_entry], quiet: true) end - requested_formula = formula("mysql@8.4") do - T.bind(self, T.class_of(Formula)) - url "mysql-8.4" - end - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) - allow(Formula).to receive(:[]).with("mysql").and_return(conflicting_formula) - allow(Formula).to receive(:[]).with("mysql-lts").and_return(requested_formula) - allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/mysql@8.4", verbose: false) - .ordered do + it "unlinks installed conflicts before installing a formula" do allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) - 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 + 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) - it "unlinks conflicts enabled by saved install options before installing a formula" do - conflicting_formula = formula("ctags") do - T.bind(self, T.class_of(Formula)) - url "ctags-1.0" - conflicts_with "universal-ctags" + described_class.install!([formula_entry], quiet: true) end - requested_formula = formula("ctags-lsp") do - T.bind(self, T.class_of(Formula)) - url "ctags-lsp-1.0" - depends_on "universal-ctags" => :optional - end - stub_formula_loader(formula("universal-ctags") do - T.bind(self, T.class_of(Formula)) - url "universal-ctags-1.0" - end) - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) - allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) - allow(Formula).to receive(:[]).with("ctags-lsp").and_return(requested_formula) - tab = Tab.for_formula(requested_formula) - tab.used_options = ["--with-universal-ctags"] - allow(Tab).to receive(:for_formula).and_call_original - allow(Tab).to receive(:for_formula).with(requested_formula).and_return(tab) - allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/ctags-lsp", verbose: false) - .ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) - 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) + 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) - described_class.install!([ - Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags-lsp"), - Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), - ], quiet: true) - end + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/mysql@8.4", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) + 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) - it "unlinks conflicts installed by a cask before installing a formula" do - conflicting_formula = formula("ctags") do - T.bind(self, T.class_of(Formula)) - url "ctags-1.0" - conflicts_with "universal-ctags" + described_class.install!([Homebrew::Bundle::Dsl::Entry.new(:brew, "mysql-lts"), formula_entry], quiet: true) end - dependency_formula = formula("universal-ctags") do - T.bind(self, T.class_of(Formula)) - url "universal-ctags-1.0" - end - requested_cask = ::Cask::Cask.new("editor") do - version "1.0" - url "https://example.com/editor.zip" - depends_on formula: "universal-ctags" - end - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(requested_cask).to receive(:tap).and_return(CoreCaskTap.instance) - allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) - allow(Formula).to receive(:[]).with("universal-ctags").and_return(dependency_formula) - allow(::Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) - allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - expect(Homebrew::Bundle).to receive(:brew).with("install", "--adopt", "homebrew/cask/editor", verbose: false) - .ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) - 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) + 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!([ - Homebrew::Bundle::Dsl::Entry.new(:cask, "editor"), - Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), - ], quiet: true) + described_class.install!([formula_entry, second_formula_entry], quiet: true) + end end - it "unlinks conflicts enabled by another formula's install options" do - conflicting_formula = formula("ctags") do - T.bind(self, T.class_of(Formula)) - url "ctags-1.0" - conflicts_with "universal-ctags" + 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 - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "universal-ctags" => :optional + let(:dependency_formula) do + formula("universal-ctags") do + T.bind(self, T.class_of(Formula)) + url "universal-ctags-1.0" + end end - allow(conflicting_formula).to receive(:tap).and_return(CoreTap.instance) - allow(requested_formula).to receive(:tap).and_return(CoreTap.instance) - allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - expect(Homebrew::Bundle).to receive(:brew) - .with("install", "--formula", "editor", "--with-universal-ctags", verbose: false).ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) - true + before do + allow(Formula).to receive(:[]).with("ctags").and_return(conflicting_formula) + allow(Homebrew::Bundle::Brew).to receive(:install!).and_call_original + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) 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, "editor", { args: ["with-universal-ctags"] }), - Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), - ], quiet: true) - end + it "unlinks conflicts enabled by saved install options before installing a formula" do + requested_formula = formula("ctags-lsp", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "ctags-lsp-1.0" + depends_on "universal-ctags" => :optional + end + stub_formula_loader(dependency_formula) + allow(Formula).to receive(:[]).with("ctags-lsp").and_return(requested_formula) + tab = Tab.for_formula(requested_formula) + tab.used_options = ["--with-universal-ctags"] + allow(Tab).to receive(:for_formula).and_call_original + allow(Tab).to receive(:for_formula).with(requested_formula).and_return(tab) + + expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/ctags-lsp", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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) - it "expands dependencies once when checking multiple formulae for conflicts" do - first_formula = formula("mysql") do - T.bind(self, T.class_of(Formula)) - url "mysql-1.0" - conflicts_with "mysql@8.4" - end - second_formula = formula("redis") do - T.bind(self, T.class_of(Formula)) - url "redis-1.0" - conflicts_with "valkey" + expect(Homebrew::Bundle).to receive(:brew).with("install", "--adopt", "homebrew/cask/editor", verbose: false) + .ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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 - allow(first_formula).to receive(:tap).and_return(CoreTap.instance) - allow(second_formula).to receive(:tap).and_return(CoreTap.instance) - allow(Formula).to receive(:[]).with("mysql").and_return(first_formula) - allow(Formula).to receive(:[]).with("redis").and_return(second_formula) - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) - expect(described_class).to receive(:formula_names_with_dependencies).once.and_call_original + it "unlinks conflicts enabled by another formula's install options" do + requested_formula = formula("editor", tap: CoreTap.instance) do + T.bind(self, T.class_of(Formula)) + url "editor-1.0" + depends_on "universal-ctags" => :optional + end + allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - described_class.install!([formula_entry, second_formula_entry], quiet: true) + expect(Homebrew::Bundle).to receive(:brew) + .with("install", "--formula", "editor", "--with-universal-ctags", verbose: false).ordered do + allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + 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, "editor", { args: ["with-universal-ctags"] }), + 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 From bbe902116f4c7cc81b548cae8e8a6bceaf4f023b Mon Sep 17 00:00:00 2001 From: Thiago Genez <102752+thiagogenez@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:25:54 +0100 Subject: [PATCH 5/5] bundle: narrow conflict checks - Remove the blanket `args` check for unrelated entries. - Keep tests focused on batching and conflict resolution. - Exercise installed-state refresh through the test Cellar. --- Library/Homebrew/bundle/installer.rb | 3 - Library/Homebrew/test/bundle/cask_spec.rb | 2 + .../Homebrew/test/bundle/installer_spec.rb | 216 +++--------------- 3 files changed, 34 insertions(+), 187 deletions(-) diff --git a/Library/Homebrew/bundle/installer.rb b/Library/Homebrew/bundle/installer.rb index bfb4ecf578571..e2a9e13a74122 100644 --- a/Library/Homebrew/bundle/installer.rb +++ b/Library/Homebrew/bundle/installer.rb @@ -260,9 +260,6 @@ def self.homebrew_batchable?(entry, entries:, installed_taps:, formula_names:) conflicts = formula.conflicts.map(&:name) if conflicts.present? return false if conflicts.any? { |name| Brew.formula_installed?(name) } - return false if entries.any? do |candidate| - [Brew, Cask].include?(candidate.cls) && candidate.options[:args].present? - end 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) } diff --git a/Library/Homebrew/test/bundle/cask_spec.rb b/Library/Homebrew/test/bundle/cask_spec.rb index 46886c7450ee8..928af3dad9e6f 100644 --- a/Library/Homebrew/test/bundle/cask_spec.rb +++ b/Library/Homebrew/test/bundle/cask_spec.rb @@ -154,6 +154,8 @@ 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 diff --git a/Library/Homebrew/test/bundle/installer_spec.rb b/Library/Homebrew/test/bundle/installer_spec.rb index 17ea3631f7a6a..6c19b2ac1e0cd 100644 --- a/Library/Homebrew/test/bundle/installer_spec.rb +++ b/Library/Homebrew/test/bundle/installer_spec.rb @@ -253,20 +253,22 @@ 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 - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) end it "batches a formula whose conflicts are neither installed nor requested" do - unrelated_formula = formula("redis", tap: CoreTap.instance) do - T.bind(self, T.class_of(Formula)) - url "redis-1.0" - end - allow(Formula).to receive(:[]).with("redis").and_return(unrelated_formula) - expect(Homebrew::Bundle).to receive(:brew) .with("install", "homebrew/core/mysql", "homebrew/core/redis", verbose: false) .and_return(true) @@ -274,8 +276,18 @@ 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 - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) + (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) @@ -294,7 +306,7 @@ expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/mysql@8.4", verbose: false) .ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("mysql@8.4").and_return(true) + requested_formula.prefix.mkpath true end expect(Homebrew::Bundle).to receive(:brew).with("unlink", "mysql@8.4", verbose: false) @@ -335,27 +347,29 @@ 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 - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).and_return(false) end - it "unlinks conflicts enabled by saved install options before installing a formula" do + 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 "universal-ctags" => :optional + 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) - tab = Tab.for_formula(requested_formula) - tab.used_options = ["--with-universal-ctags"] - allow(Tab).to receive(:for_formula).and_call_original - allow(Tab).to receive(:for_formula).with(requested_formula).and_return(tab) expect(Homebrew::Bundle).to receive(:brew).with("install", "homebrew/core/ctags-lsp", verbose: false) .ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + dependency_formula.prefix.mkpath true end expect(Homebrew::Bundle).to receive(:brew).with("unlink", "universal-ctags", verbose: false) @@ -380,7 +394,7 @@ expect(Homebrew::Bundle).to receive(:brew).with("install", "--adopt", "homebrew/cask/editor", verbose: false) .ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) + dependency_formula.prefix.mkpath true end expect(Homebrew::Bundle).to receive(:brew).with("unlink", "universal-ctags", verbose: false) @@ -393,30 +407,6 @@ Homebrew::Bundle::Dsl::Entry.new(:brew, "ctags"), ], quiet: true) end - - it "unlinks conflicts enabled by another formula's install options" do - requested_formula = formula("editor", tap: CoreTap.instance) do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "universal-ctags" => :optional - end - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(Homebrew::Bundle).to receive(:brew) - .with("install", "--formula", "editor", "--with-universal-ctags", verbose: false).ordered do - allow(Homebrew::Bundle::Brew).to receive(:formula_installed?).with("universal-ctags").and_return(true) - 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, "editor", { args: ["with-universal-ctags"] }), - 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 @@ -675,148 +665,6 @@ end end - describe ".formula_names_with_dependencies" do - let(:entry) do - Homebrew::Bundle::Installer::InstallableEntry.new( - entry: Homebrew::Bundle::Dsl::Entry.new(:brew, "editor"), verb: "Installing", - cls: Homebrew::Bundle::Brew, install_name: "editor" - ) - end - let(:ctags_formula) do - formula("universal-ctags") do - T.bind(self, T.class_of(Formula)) - url "universal-ctags-1.0" - end - end - - before do - stub_formula_loader(ctags_formula) - end - - it "reuses formulae already loaded for cask dependencies" do - requested_cask = ::Cask::Cask.new("editor") do - version "1.0" - url "https://example.com/editor.zip" - depends_on formula: "universal-ctags" - end - allow(::Cask::CaskLoader).to receive(:load).with("editor").and_return(requested_cask) - cask_entry = Homebrew::Bundle::Installer::InstallableEntry.new( - entry: Homebrew::Bundle::Dsl::Entry.new(:cask, "editor"), verb: "Installing", - cls: Homebrew::Bundle::Cask, install_name: "editor" - ) - - allow(Formula).to receive(:[]).and_call_original - expect(Formula).to receive(:[]).with("universal-ctags").once.and_call_original - - described_class.formula_names_with_dependencies([cask_entry]) - end - - it "includes optional dependencies even when no install options are specified" do - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "universal-ctags" => :optional - end - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(described_class.formula_names_with_dependencies([entry])).to contain_exactly("editor", "universal-ctags") - end - - it "includes optional dependencies enabled by a dependency's saved install options" do - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "backend" - end - dependency_formula = 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) - tab = Tab.for_formula(dependency_formula) - tab.used_options = ["--with-universal-ctags"] - allow(Tab).to receive(:for_formula).and_call_original - allow(Tab).to receive(:for_formula).with(dependency_formula).and_return(tab) - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(described_class.formula_names_with_dependencies([entry])) - .to contain_exactly("editor", "backend", "universal-ctags") - end - - it "includes optional dependencies enabled through another dependency path" do - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "plain" - depends_on "enabled" - end - stub_formula_loader(formula("plain") do - T.bind(self, T.class_of(Formula)) - url "plain-1.0" - depends_on "backend" - end) - stub_formula_loader(formula("enabled") do - T.bind(self, T.class_of(Formula)) - url "enabled-1.0" - depends_on "backend" => "with-universal-ctags" - end) - stub_formula_loader(formula("backend") do - T.bind(self, T.class_of(Formula)) - url "backend-1.0" - depends_on "universal-ctags" => :optional - end) - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(described_class.formula_names_with_dependencies([entry])) - .to contain_exactly("editor", "plain", "enabled", "backend", "universal-ctags") - end - - it "includes optional dependencies enabled by a renamed dependency option" do - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "backend" => "with-old-ctags" - end - stub_formula_loader(formula("backend") do - T.bind(self, T.class_of(Formula)) - url "backend-1.0" - depends_on "universal-ctags" => :optional - deprecated_option "with-old-ctags" => "with-universal-ctags" - end) - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(described_class.formula_names_with_dependencies([entry])) - .to contain_exactly("editor", "backend", "universal-ctags") - end - - it "does not revisit descendants of a shared dependency" do - requested_formula = formula("editor") do - T.bind(self, T.class_of(Formula)) - url "editor-1.0" - depends_on "first" - depends_on "second" - end - %w[first second].each do |name| - stub_formula_loader(formula(name) do - T.bind(self, T.class_of(Formula)) - url "#{name}-1.0" - depends_on "backend" - end) - end - stub_formula_loader(formula("backend") do - T.bind(self, T.class_of(Formula)) - url "backend-1.0" - depends_on "universal-ctags" - end) - allow(Formula).to receive(:[]).with("editor").and_return(requested_formula) - - expect(ctags_formula).to receive(:deps).once.and_call_original - - described_class.formula_names_with_dependencies([entry]) - end - end - describe "native extension batches" do it "delegates entries to an extension that registers batch support" do first_entry = Homebrew::Bundle::Dsl::Entry.new(:npm, "typescript")