Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Library/Homebrew/bundle/brew.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 16 additions & 4 deletions Library/Homebrew/bundle/cask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,25 +217,37 @@ 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
end
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

Expand Down
31 changes: 28 additions & 3 deletions Library/Homebrew/bundle/installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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?
Expand All @@ -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)
Expand All @@ -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)
Comment thread
MikeMcQuaid marked this conversation as resolved.
Resolved
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],
Expand Down
74 changes: 74 additions & 0 deletions Library/Homebrew/test/bundle/cask_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
Loading
Loading