diff --git a/Library/Homebrew/cmd/install.rb b/Library/Homebrew/cmd/install.rb index f4ba0f2f29..5f19ce4671 100644 --- a/Library/Homebrew/cmd/install.rb +++ b/Library/Homebrew/cmd/install.rb @@ -120,6 +120,11 @@ module Homebrew [:switch, "--overwrite", { description: "Delete files that already exist in the prefix while linking.", }], + [:switch, "--ask", { + description: "Ask for confirmation before downloading and installing formulae. " \ + "Print bottles and dependencies download size and install size.", + env: :ask, + }], ].each do |args| options = args.pop send(*args, **options) @@ -302,6 +307,8 @@ module Homebrew Install.perform_preinstall_checks_once Install.check_cc_argv(args.cc) + Install.ask(formulae, args: args) if args.ask? + Install.install_formulae( installed_formulae, build_bottle: args.build_bottle?, diff --git a/Library/Homebrew/cmd/reinstall.rb b/Library/Homebrew/cmd/reinstall.rb index cadda01cbe..3c767f0619 100644 --- a/Library/Homebrew/cmd/reinstall.rb +++ b/Library/Homebrew/cmd/reinstall.rb @@ -63,6 +63,11 @@ module Homebrew [:switch, "-g", "--git", { description: "Create a Git repository, useful for creating patches to the software.", }], + [:switch, "--ask", { + description: "Ask for confirmation before downloading and upgrading formulae. " \ + "Print bottles and dependencies download size, install and net install size.", + env: :ask, + }], ].each do |args| options = args.pop send(*args, **options) @@ -126,6 +131,9 @@ module Homebrew unless formulae.empty? Install.perform_preinstall_checks_once + # If asking the user is enabled, show dependency and size information. + Install.ask(formulae, args: args) if args.ask? + formulae.each do |formula| if formula.pinned? onoe "#{formula.full_name} is pinned. You must unpin it to reinstall." diff --git a/Library/Homebrew/cmd/upgrade.rb b/Library/Homebrew/cmd/upgrade.rb index 6ab70581ac..b48ace323d 100644 --- a/Library/Homebrew/cmd/upgrade.rb +++ b/Library/Homebrew/cmd/upgrade.rb @@ -71,6 +71,11 @@ module Homebrew [:switch, "--overwrite", { description: "Delete files that already exist in the prefix while linking.", }], + [:switch, "--ask", { + description: "Ask for confirmation before downloading and upgrading formulae. " \ + "Print bottles and dependencies download size, install and net install size.", + env: :ask, + }], ].each do |args| options = args.pop send(*args, **options) @@ -211,11 +216,14 @@ module Homebrew "#{f.full_specified_name} #{f.pkg_version}" end end - puts formulae_upgrades.join("\n") + puts formulae_upgrades.join("\n") unless args.ask? end Install.perform_preinstall_checks_once + # Main block: if asking the user is enabled, show dependency and size information. + Install.ask(formulae_to_install, args: args) if args.ask? + Upgrade.upgrade_formulae( formulae_to_install, flags: args.flags_only, diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index d44acc84b8..213a0fdae8 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -48,6 +48,11 @@ module Homebrew "trying any other/default URLs.", boolean: true, }, + HOMEBREW_ASK: { + description: "If set, pass `--ask`to all formulae `brew install`, `brew upgrade` and `brew reinstall` " \ + "commands.", + boolean: true, + }, HOMEBREW_AUTO_UPDATE_SECS: { description: "Run `brew update` once every `$HOMEBREW_AUTO_UPDATE_SECS` seconds before some commands, " \ "e.g. `brew install`, `brew upgrade` and `brew tap`. Alternatively, " \ diff --git a/Library/Homebrew/extend/kernel.rb b/Library/Homebrew/extend/kernel.rb index 94bcf520c0..7d21851fd0 100644 --- a/Library/Homebrew/extend/kernel.rb +++ b/Library/Homebrew/extend/kernel.rb @@ -459,13 +459,13 @@ module Kernel end def disk_usage_readable(size_in_bytes) - if size_in_bytes >= 1_073_741_824 + if size_in_bytes.abs >= 1_073_741_824 size = size_in_bytes.to_f / 1_073_741_824 unit = "GB" - elsif size_in_bytes >= 1_048_576 + elsif size_in_bytes.abs >= 1_048_576 size = size_in_bytes.to_f / 1_048_576 unit = "MB" - elsif size_in_bytes >= 1_024 + elsif size_in_bytes.abs >= 1_024 size = size_in_bytes.to_f / 1_024 unit = "KB" else diff --git a/Library/Homebrew/install.rb b/Library/Homebrew/install.rb index 80c3c54069..1f14cbd211 100644 --- a/Library/Homebrew/install.rb +++ b/Library/Homebrew/install.rb @@ -327,6 +327,22 @@ module Homebrew puts formula_names.join(" ") end + # If asking the user is enabled, show dependency and size information. + def ask(formulae, args:) + ohai "Looking for bottles..." + + sized_formulae = compute_sized_formulae(formulae, args: args) + sizes = compute_total_sizes(sized_formulae, debug: args.debug?) + + puts "#{::Utils.pluralize("Formula", sized_formulae.count, plural: "e")} \ +(#{sized_formulae.count}): #{sized_formulae.join(", ")}\n\n" + puts "Download Size: #{disk_usage_readable(sizes[:download])}" + puts "Install Size: #{disk_usage_readable(sizes[:installed])}" + puts "Net Install Size: #{disk_usage_readable(sizes[:net])}" if sizes[:net] != 0 + + ask_input + end + private def perform_preinstall_checks(all_fatal: false) @@ -363,6 +379,86 @@ module Homebrew Upgrade.install_formula(formula_installer, upgrade:) end + + def ask_input + ohai "Do you want to proceed with the installation? [Y/y/yes/N/n]" + accepted_inputs = %w[y yes] + declined_inputs = %w[n no] + loop do + result = $stdin.gets + return unless result + + result = result.chomp.strip.downcase + if accepted_inputs.include?(result) + break + elsif declined_inputs.include?(result) + exit 1 + else + puts "Invalid input. Please enter 'Y', 'y', or 'yes' to proceed, or 'N' to abort." + end + end + end + + # Build a unique list of formulae to size by including: + # 1. The original formulae to install. + # 2. Their outdated dependents (subject to pruning criteria). + # 3. Optionally, any installed formula that depends on one of these and is outdated. + def compute_sized_formulae(formulae, args:) + sized_formulae = formulae.flat_map do |formula| + # Always include the formula itself. + formula_list = [formula] + + deps = args.build_from_source? ? formula.deps.build : formula.deps.required + + outdated_dependents = deps.map(&:to_formula).reject(&:pinned?).select do |dep| + dep.installed_kegs.empty? || (dep.bottled? && dep.outdated?) + end + deps.map(&:to_formula).each do |f| + outdated_dependents.concat(f.recursive_dependencies.map(&:to_formula).reject(&:pinned?).select do |dep| + dep.installed_kegs.empty? || (dep.bottled? && dep.outdated?) + end) + end + formula_list.concat(outdated_dependents) + + formula_list + end + + # Add any installed formula that depends on one of the sized formulae and is outdated. + unless Homebrew::EnvConfig.no_installed_dependents_check? + sized_formulae.concat(Formula.installed.select do |installed_formula| + installed_formula.bottled? && installed_formula.outdated? && + installed_formula.deps.required.map(&:to_formula).intersect?(sized_formulae) + end) + end + + sized_formulae.uniq(&:to_s).compact + end + + # Compute the total sizes (download, installed, and net) for the given formulae. + def compute_total_sizes(sized_formulae, debug: false) + total_download_size = 0 + total_installed_size = 0 + total_net_size = 0 + + sized_formulae.select(&:bottle).each do |formula| + bottle = formula.bottle + # Fetch additional bottle metadata (if necessary). + bottle.fetch_tab(quiet: !debug) + + total_download_size += bottle.bottle_size.to_i if bottle.bottle_size + total_installed_size += bottle.installed_size.to_i if bottle.installed_size + + # Sum disk usage for all installed kegs of the formula. + next if formula.installed_kegs.none? + + kegs_dep_size = formula.installed_kegs.sum { |keg| keg.disk_usage.to_i } + total_net_size += bottle.installed_size.to_i - kegs_dep_size if bottle.installed_size + end + + { download: total_download_size, + installed: total_installed_size, + net: total_net_size } + end end end end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/install_cmd.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/install_cmd.rbi index 2bd0d91e02..164120bae8 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/install_cmd.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/install_cmd.rbi @@ -20,6 +20,9 @@ class Homebrew::Cmd::InstallCmd::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def appdir; end + sig { returns(T::Boolean) } + def ask?; end + sig { returns(T.nilable(String)) } def audio_unit_plugindir; end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/reinstall.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/reinstall.rbi index 10f9c8fb8f..beee25bbbe 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/reinstall.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/reinstall.rbi @@ -17,6 +17,9 @@ class Homebrew::Cmd::Reinstall::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def appdir; end + sig { returns(T::Boolean) } + def ask?; end + sig { returns(T.nilable(String)) } def audio_unit_plugindir; end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi index bc2ba6224b..3eb336f954 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi @@ -14,6 +14,9 @@ class Homebrew::Cmd::UpgradeCmd::Args < Homebrew::CLI::Args sig { returns(T.nilable(String)) } def appdir; end + sig { returns(T::Boolean) } + def ask?; end + sig { returns(T.nilable(String)) } def audio_unit_plugindir; end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi index 2e4bfabb92..80238758ce 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi @@ -28,6 +28,9 @@ module Homebrew::EnvConfig sig { returns(T::Boolean) } def artifact_domain_no_fallback?; end + sig { returns(T::Boolean) } + def ask?; end + sig { returns(T.nilable(::String)) } def auto_update_secs; end diff --git a/Library/Homebrew/test/cmd/install_spec.rb b/Library/Homebrew/test/cmd/install_spec.rb index 4772947bf0..ef74f06cc3 100644 --- a/Library/Homebrew/test/cmd/install_spec.rb +++ b/Library/Homebrew/test/cmd/install_spec.rb @@ -4,6 +4,7 @@ require "cmd/install" require "cmd/shared_examples/args_parse" RSpec.describe Homebrew::Cmd::InstallCmd do + include FileUtils it_behaves_like "parseable arguments" it "installs formulae", :integration_test do @@ -84,4 +85,44 @@ RSpec.describe Homebrew::Cmd::InstallCmd do expect(HOMEBREW_CELLAR/"testball1/0.1/bin/test.dSYM/Contents/Resources/DWARF/test").to be_a_file if OS.mac? expect(HOMEBREW_CACHE/"Sources/testball1").to be_a_directory end + + it "installs with asking for user prompts without installed dependent checks", :integration_test do + setup_test_formula "testball1" + + expect do + brew "install", "--ask", "testball1" + end.to output(/.*Formula\s*\(1\):\s*testball1.*/).to_stdout.and not_to_output.to_stderr + + expect(HOMEBREW_CELLAR/"testball1/0.1/bin/test").to be_a_file + end + + it "installs with asking for user prompts with installed dependent checks", :integration_test do + setup_test_formula "testball1", <<~RUBY + depends_on "testball5" + # should work as its not building but test doesnt pass if dependant + # depends_on "build" => :build + depends_on "installed" + RUBY + setup_test_formula "installed" + setup_test_formula "testball5", <<~RUBY + depends_on "testball4" + RUBY + setup_test_formula "testball4", "" + setup_test_formula "hiop" + setup_test_formula "build" + + # Mock `Formula#any_version_installed?` by creating the tab in a plausible keg directory + keg_dir = HOMEBREW_CELLAR/"installed"/"1.0" + keg_dir.mkpath + touch keg_dir/AbstractTab::FILENAME + + expect do + brew "install", "--ask", "testball1" + end.to output(/.*Formulae\s*\(3\):\s*testball1\s*,?\s*testball5\s*,?\s*testball4.*/).to_stdout + .and not_to_output.to_stderr + + expect(HOMEBREW_CELLAR/"testball1/0.1/bin/test").to be_a_file + expect(HOMEBREW_CELLAR/"testball4/0.1/bin/testball4").to be_a_file + expect(HOMEBREW_CELLAR/"testball5/0.1/bin/testball5").to be_a_file + end end diff --git a/Library/Homebrew/test/cmd/reinstall_spec.rb b/Library/Homebrew/test/cmd/reinstall_spec.rb index fd5690de26..77e55c994c 100644 --- a/Library/Homebrew/test/cmd/reinstall_spec.rb +++ b/Library/Homebrew/test/cmd/reinstall_spec.rb @@ -20,4 +20,18 @@ RSpec.describe Homebrew::Cmd::Reinstall do expect(foo_dir).to exist end + + it "reinstalls a Formula with ask input", :integration_test do + install_test_formula "testball" + foo_dir = HOMEBREW_CELLAR/"testball/0.1/bin" + expect(foo_dir).to exist + FileUtils.rm_r(foo_dir) + + expect { brew "reinstall", "--ask", "testball" } + .to output(/.*Formula\s*\(1\):\s*testball.*/).to_stdout + .and not_to_output.to_stderr + .and be_a_success + + expect(foo_dir).to exist + end end diff --git a/Library/Homebrew/test/cmd/upgrade_spec.rb b/Library/Homebrew/test/cmd/upgrade_spec.rb index 4e7953ee74..27b9fc7891 100644 --- a/Library/Homebrew/test/cmd/upgrade_spec.rb +++ b/Library/Homebrew/test/cmd/upgrade_spec.rb @@ -4,6 +4,7 @@ require "cmd/shared_examples/args_parse" require "cmd/upgrade" RSpec.describe Homebrew::Cmd::UpgradeCmd do + include FileUtils it_behaves_like "parseable arguments" it "upgrades a Formula and cleans up old versions", :integration_test do @@ -15,4 +16,68 @@ RSpec.describe Homebrew::Cmd::UpgradeCmd do expect(HOMEBREW_CELLAR/"testball/0.1").to be_a_directory expect(HOMEBREW_CELLAR/"testball/0.0.1").not_to exist end + + it "upgrades with asking for user prompts", :integration_test do + setup_test_formula "testball" + (HOMEBREW_CELLAR/"testball/0.0.1/foo").mkpath + + expect do + brew "upgrade", "--ask" + end.to output(/.*Formula\s*\(1\):\s*testball.*/).to_stdout.and not_to_output.to_stderr + + expect(HOMEBREW_CELLAR/"testball/0.1").to be_a_directory + expect(HOMEBREW_CELLAR/"testball/0.0.1").not_to exist + end + + it "upgrades with asking for user prompts with dependants checks", :integration_test do + setup_test_formula "testball", <<~RUBY + depends_on "testball5" + # should work as its not building but test doesnt pass if dependant + # depends_on "build" => :build + depends_on "installed" + RUBY + setup_test_formula "installed" + setup_test_formula "testball5", <<~RUBY + depends_on "testball4" + RUBY + setup_test_formula "testball4" + setup_test_formula "hiop" + setup_test_formula "build" + + (HOMEBREW_CELLAR/"testball/0.0.1/foo").mkpath + (HOMEBREW_CELLAR/"testball5/0.0.1/foo").mkpath + (HOMEBREW_CELLAR/"testball4/0.0.1/foo").mkpath + + keg_dir = HOMEBREW_CELLAR/"installed"/"1.0" + keg_dir.mkpath + touch keg_dir/AbstractTab::FILENAME + + regex = / + Formulae\s*\(3\):\s* + ( + testball|testball5|testball4 + ) + \s*,\s* + (?!\1) + ( + testball|testball5|testball4 + ) + \s*,\s* + (?!\1|\2) + ( + testball|testball5|testball4 + ) + /x + expect do + brew "upgrade", "--ask" + end.to output(regex) + .to_stdout.and not_to_output.to_stderr + + expect(HOMEBREW_CELLAR/"testball/0.1").to be_a_directory + expect(HOMEBREW_CELLAR/"testball/0.0.1").not_to exist + expect(HOMEBREW_CELLAR/"testball5/0.1").to be_a_directory + expect(HOMEBREW_CELLAR/"testball5/0.0.1").not_to exist + expect(HOMEBREW_CELLAR/"testball4/0.1").to be_a_directory + expect(HOMEBREW_CELLAR/"testball4/0.0.1").not_to exist + end end diff --git a/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1-linux.tbz b/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1-linux.tbz new file mode 100644 index 0000000000..72957a4c13 Binary files /dev/null and b/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1-linux.tbz differ diff --git a/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1.tbz b/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1.tbz new file mode 100644 index 0000000000..c7c57aee5f Binary files /dev/null and b/Library/Homebrew/test/support/fixtures/tarballs/testball4-0.1.tbz differ diff --git a/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1-linux.tbz b/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1-linux.tbz new file mode 100644 index 0000000000..4d35e20d2b Binary files /dev/null and b/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1-linux.tbz differ diff --git a/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1.tbz b/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1.tbz new file mode 100644 index 0000000000..06def51fbd Binary files /dev/null and b/Library/Homebrew/test/support/fixtures/tarballs/testball5-0.1.tbz differ diff --git a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb index 8c5c1740d2..f8f3b0f60a 100644 --- a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb +++ b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb @@ -134,13 +134,21 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin bottle_block: nil, tab_attributes: nil) case name when /^testball/ - # Use a different tarball for testball2 to avoid lock errors when writing concurrency tests - prefix = (name == "testball2") ? "testball2" : "testball" - tarball = if OS.linux? - TEST_FIXTURE_DIR/"tarballs/#{prefix}-0.1-linux.tbz" + case name + when "testball4", "testball5" + prefix = name + program_name = name + when "testball2" + prefix = name + program_name = "test" else - TEST_FIXTURE_DIR/"tarballs/#{prefix}-0.1.tbz" + prefix = "testball" + program_name = "test" end + + tarball_name = "#{prefix}-0.1#{"-linux" if OS.linux?}.tbz" + tarball = TEST_FIXTURE_DIR / "tarballs/#{tarball_name}" + content = <<~RUBY desc "Some test" homepage "https://brew.sh/#{name}" @@ -150,12 +158,12 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin option "with-foo", "Build with foo" #{bottle_block} def install - (prefix/"foo"/"test").write("test") if build.with? "foo" + (prefix/"foo"/"#{program_name}").write("#{program_name}") if build.with? "foo" prefix.install Dir["*"] - (buildpath/"test.c").write \ - "#include \\nint main(){printf(\\"test\\");return 0;}" + (buildpath/"#{program_name}.c").write \ + "#include \\nint main(){printf(\\"#{program_name}\\");return 0;}" bin.mkpath - system ENV.cc, "test.c", "-o", bin/"test" + system ENV.cc, "#{program_name}.c", "-o", bin/"#{program_name}" end #{content}