diff --git a/Library/Homebrew/cmd/info.rb b/Library/Homebrew/cmd/info.rb index 325a6dc9248e9..98e7bf4717f9e 100644 --- a/Library/Homebrew/cmd/info.rb +++ b/Library/Homebrew/cmd/info.rb @@ -24,6 +24,29 @@ class NameSize < T::Struct end private_constant :NameSize + class ExclusiveDep < T::Struct + const :name, String + const :size, Integer + end + private_constant :ExclusiveDep + + class SharedDep < T::Struct + const :name, String + const :size, Integer + const :also_needed_by, T::Array[String] + end + private_constant :SharedDep + + class FormulaFootprint < T::Struct + const :name, String + const :direct_size, Integer + const :exclusive_deps, T::Array[ExclusiveDep] + const :shared_deps, T::Array[SharedDep] + const :exclusive_deps_size, Integer + const :total_footprint, Integer + end + private_constant :FormulaFootprint + VALID_DAYS = %w[30 90 365].freeze VALID_FORMULA_CATEGORIES = %w[install install-on-request build-error].freeze VALID_CATEGORIES = T.let((VALID_FORMULA_CATEGORIES + %w[cask-install os-version]).freeze, T::Array[String]) @@ -80,6 +103,9 @@ class NameSize < T::Struct description: "Treat all named arguments as casks." switch "--sizes", description: "Show the size of installed formulae and casks." + switch "--deps", + depends_on: "--sizes", + description: "Show dependency size breakdown with exclusive deps and total disk footprint." conflicts "--installed", "--eval-all" conflicts "--formula", "--cask" @@ -92,7 +118,14 @@ class NameSize < T::Struct sig { override.void } def run if args.sizes? - if args.no_named? + if args.deps? + formulae = if args.no_named? + Formula.installed + else + args.named.to_formulae + end + print_sizes_with_deps(formulae) + elsif args.no_named? print_sizes else formulae, casks = args.named.to_formulae_to_casks @@ -998,6 +1031,127 @@ def print_sizes(formulae: [], casks: []) cask_sizes.sort_by! { |c| -c.size } print_sizes_table("Casks sizes:", cask_sizes) end + + sig { params(formulae: T::Array[Formula]).void } + def print_sizes_with_deps(formulae) + reverse_map = T.let({}, T::Hash[String, T::Set[String]]) + + Formula.installed.each do |installed_formula| + installed_formula.runtime_dependencies.each do |dep| + deps = reverse_map[dep.name] ||= Set.new + deps << installed_formula.name + end + end + + analyses = formulae.filter_map do |formula| + kegs = formula.installed_kegs + next if kegs.empty? + + direct_size = kegs.sum(&:disk_usage) + exclusive_deps = T.let([], T::Array[ExclusiveDep]) + shared_deps = T.let([], T::Array[SharedDep]) + + formula.runtime_dependencies.each do |dep| + dep_kegs = Formula[dep.name].installed_kegs + next if dep_kegs.empty? + + dep_size = dep_kegs.sum(&:disk_usage) + dependents = reverse_map[dep.name] || Set.new + + if dependents.size == 1 && dependents.include?(formula.name) + exclusive_deps << ExclusiveDep.new(name: dep.name, size: dep_size) + else + also_needed_by = (dependents.to_a - [formula.name]).sort + shared_deps << SharedDep.new(name: dep.name, size: dep_size, also_needed_by:) + end + end + + exclusive_deps_size = exclusive_deps.sum(&:size) + total_footprint = direct_size + exclusive_deps_size + + FormulaFootprint.new( + name: formula.full_name, + direct_size:, + exclusive_deps:, + shared_deps:, + exclusive_deps_size:, + total_footprint:, + ) + end + + if args.no_named? + return if analyses.empty? + + analyses.sort_by! { |a| -a.total_footprint } + + ohai "Formulae footprint:" + + name_width = (analyses.map { |a| a.name.length } + [7]).max + fmt = "%-#{name_width}s %10s %10s %10s" + + puts format(fmt, "Name", "Direct", "Excl.Deps", "Total") + analyses.each do |a| + puts format( + fmt, + a.name, + Formatter.disk_usage_readable(a.direct_size), + Formatter.disk_usage_readable(a.exclusive_deps_size), + Formatter.disk_usage_readable(a.total_footprint), + ) + end + + grand_total = analyses.sum(&:total_footprint) + puts format(fmt, "Total", "", "", Formatter.disk_usage_readable(grand_total)) + else + analyses.each_with_index do |analysis, i| + puts if i.positive? + + name = analysis.name + direct = Formatter.disk_usage_readable(analysis.direct_size) + exclusive = analysis.exclusive_deps + shared = analysis.shared_deps + total = Formatter.disk_usage_readable(analysis.total_footprint) + + if exclusive.empty? && shared.empty? + puts "#{name}: #{direct}" + next + end + + if exclusive.empty? + puts "#{name}: #{direct} (direct), no exclusive deps" + else + dep_count = exclusive.size + excl_size = Formatter.disk_usage_readable(analysis.exclusive_deps_size) + puts "#{name}: #{direct} (direct) + #{excl_size} " \ + "(#{dep_count} exclusive #{(dep_count == 1) ? "dep" : "deps"}) = #{total} total" + end + + if shared.any? + shared_size = Formatter.disk_usage_readable(shared.sum(&:size)) + puts " #{shared_size} in shared deps (would not be freed)" + end + + next unless args.verbose? + + if exclusive.any? + puts "" + puts "Exclusive dependencies (only needed by #{name}):" + exclusive.sort_by { |d| -d.size }.each do |dep| + puts " #{dep.name.ljust(16)} #{Formatter.disk_usage_readable(dep.size)}" + end + end + + next if shared.none? + + puts "" + puts "Shared dependencies (also needed by other formulae):" + shared.sort_by { |d| -d.size }.each do |dep| + also_str = dep.also_needed_by.empty? ? "" : " (also: #{dep.also_needed_by.join(", ")})" + puts " #{dep.name.ljust(16)} #{Formatter.disk_usage_readable(dep.size)}#{also_str}" + end + end + end + end end end end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/info.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/info.rbi index b1fb21788bd1e..3b9864ef0d29e 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/info.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/info.rbi @@ -26,6 +26,9 @@ class Homebrew::Cmd::Info::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def days; end + sig { returns(T::Boolean) } + def deps?; end + sig { returns(T::Boolean) } def eval_all?; end diff --git a/Library/Homebrew/test/cmd/info_spec.rb b/Library/Homebrew/test/cmd/info_spec.rb index c447c58454706..6d25a162c9045 100644 --- a/Library/Homebrew/test/cmd/info_spec.rb +++ b/Library/Homebrew/test/cmd/info_spec.rb @@ -1112,6 +1112,81 @@ def installed_info_cask .and not_to_output.to_stderr end + describe "#print_sizes_with_deps" do + it "prints direct size for a formula with no dependencies" do + testball = formula("testball") { url "https://brew.sh/testball-0.1.tar.gz" } + keg = instance_double(Keg, disk_usage: 1_000_000, runtime_dependencies: []) + allow(testball).to receive_messages(installed_kegs: [keg], any_installed_keg: keg) + allow(Formula).to receive(:installed).and_return([testball]) + + info = described_class.new(["--sizes", "--deps", "testball"]) + + expect { info.send(:print_sizes_with_deps, [testball]) } + .to output(/testball: 1MB\n/).to_stdout + .and not_to_output.to_stderr + end + + it "shows exclusive dependency breakdown in footprint" do + testball = formula("testball") { url "https://brew.sh/testball-0.1.tar.gz" } + libfoo = formula("libfoo") { url "https://brew.sh/libfoo-1.0.tar.gz" } + + testball_keg = instance_double(Keg, + disk_usage: 1_000_000, + runtime_dependencies: [{ "full_name" => "libfoo", "version" => "1.0" }]) + libfoo_keg = instance_double(Keg, disk_usage: 500_000, runtime_dependencies: []) + + allow(testball).to receive_messages(installed_kegs: [testball_keg], any_installed_keg: testball_keg) + allow(libfoo).to receive_messages(installed_kegs: [libfoo_keg], any_installed_keg: libfoo_keg) + allow(Formula).to receive(:installed).and_return([testball]) + allow(Formulary).to receive(:factory).with("libfoo").and_return(libfoo) + + info = described_class.new(["--sizes", "--deps", "testball"]) + + expect { info.send(:print_sizes_with_deps, [testball]) } + .to output(/testball: 1MB \(direct\) \+ 500KB \(1 exclusive dep\) = 1\.5MB total/).to_stdout + .and not_to_output.to_stderr + end + + it "shows shared dependency info when a dep is needed by multiple formulae" do + testball = formula("testball") { url "https://brew.sh/testball-0.1.tar.gz" } + other = formula("other") { url "https://brew.sh/other-0.1.tar.gz" } + libbar = formula("libbar") { url "https://brew.sh/libbar-1.0.tar.gz" } + + testball_keg = instance_double(Keg, + disk_usage: 1_000_000, + runtime_dependencies: [{ "full_name" => "libbar", "version" => "1.0" }]) + other_keg = instance_double(Keg, + disk_usage: 800_000, + runtime_dependencies: [{ "full_name" => "libbar", "version" => "1.0" }]) + libbar_keg = instance_double(Keg, disk_usage: 500_000, runtime_dependencies: []) + + allow(testball).to receive_messages(installed_kegs: [testball_keg], any_installed_keg: testball_keg) + allow(other).to receive_messages(installed_kegs: [other_keg], any_installed_keg: other_keg) + allow(libbar).to receive_messages(installed_kegs: [libbar_keg], any_installed_keg: libbar_keg) + allow(Formula).to receive(:installed).and_return([testball, other]) + allow(Formulary).to receive(:factory).with("libbar").and_return(libbar) + + info = described_class.new(["--sizes", "--deps", "testball"]) + + expect { info.send(:print_sizes_with_deps, [testball]) } + .to output(/testball: 1MB \(direct\), no exclusive deps\n\s+500KB in shared deps/).to_stdout + .and not_to_output.to_stderr + end + + it "prints a table when no formulae are named" do + testball = formula("testball") { url "https://brew.sh/testball-0.1.tar.gz" } + keg = instance_double(Keg, disk_usage: 2_000_000, runtime_dependencies: []) + allow(testball).to receive_messages(installed_kegs: [keg], any_installed_keg: keg) + allow(Formula).to receive(:installed).and_return([testball]) + + info = described_class.new(["--sizes", "--deps"]) + + expect { info.send(:print_sizes_with_deps, [testball]) } + .to output(/Formulae footprint:.*Name\s+Direct\s+Excl\.Deps\s+Total.*testball/m).to_stdout + .and not_to_output.to_stderr + end + end + describe "::installation_status" do it "prints on-request installs explicitly" do expect(described_class.installation_status(instance_double(Tab, installed_on_request: true)))