From 2f461b1b954a25648d38035c32782c0ac8f106a5 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 21 Mar 2024 08:24:37 -0700 Subject: [PATCH] Port Homebrew::DevCmd::Extract --- Library/Homebrew/dev-cmd/extract.rb | 379 +++++++++--------- Library/Homebrew/test/dev-cmd/extract_spec.rb | 5 +- 2 files changed, 195 insertions(+), 189 deletions(-) diff --git a/Library/Homebrew/dev-cmd/extract.rb b/Library/Homebrew/dev-cmd/extract.rb index 1f8ed18374..f77505a323 100644 --- a/Library/Homebrew/dev-cmd/extract.rb +++ b/Library/Homebrew/dev-cmd/extract.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "cli/parser" require "utils/git" require "formulary" @@ -8,216 +9,220 @@ require "software_spec" require "tap" module Homebrew - BOTTLE_BLOCK_REGEX = / bottle (?:do.+?end|:[a-z]+)\n\n/m + module DevCmd + class Extract < AbstractCommand + BOTTLE_BLOCK_REGEX = / bottle (?:do.+?end|:[a-z]+)\n\n/m - sig { returns(CLI::Parser) } - def self.extract_args - Homebrew::CLI::Parser.new do - usage_banner "`extract` [`--version=`] [`--force`] " - description <<~EOS - Look through repository history to find the most recent version of and - create a copy in . Specifically, the command will create the new - formula file at `/Formula/``@``.rb`. If the tap is not - installed yet, attempt to install/clone the tap before continuing. To extract - a formula from a tap that is not `homebrew/core` use its fully-qualified form of - `/``/`. - EOS - flag "--version=", - description: "Extract the specified of instead of the most recent." - switch "-f", "--force", - description: "Overwrite the destination formula if it already exists." + cmd_args do + usage_banner "`extract` [`--version=`] [`--force`] " + description <<~EOS + Look through repository history to find the most recent version of and + create a copy in . Specifically, the command will create the new + formula file at `/Formula/``@``.rb`. If the tap is not + installed yet, attempt to install/clone the tap before continuing. To extract + a formula from a tap that is not `homebrew/core` use its fully-qualified form of + `/``/`. + EOS + flag "--version=", + description: "Extract the specified of instead of the most recent." + switch "-f", "--force", + description: "Overwrite the destination formula if it already exists." - named_args [:formula, :tap], number: 2, without_api: true - end - end + named_args [:formula, :tap], number: 2, without_api: true + end - def self.extract - args = extract_args.parse + sig { override.void } + def run + if (tap_with_name = args.named.first&.then { Tap.with_formula_name(_1) }) + source_tap, name = tap_with_name + else + name = args.named.first.downcase + source_tap = CoreTap.instance + end + raise TapFormulaUnavailableError.new(source_tap, name) unless source_tap.installed? - if (tap_with_name = args.named.first&.then { Tap.with_formula_name(_1) }) - source_tap, name = tap_with_name - else - name = args.named.first.downcase - source_tap = CoreTap.instance - end - raise TapFormulaUnavailableError.new(source_tap, name) unless source_tap.installed? + destination_tap = Tap.fetch(args.named.second) + unless Homebrew::EnvConfig.developer? + odie "Cannot extract formula to homebrew/core!" if destination_tap.core_tap? + odie "Cannot extract formula to homebrew/cask!" if destination_tap.core_cask_tap? + odie "Cannot extract formula to the same tap!" if destination_tap == source_tap + end + destination_tap.install unless destination_tap.installed? - destination_tap = Tap.fetch(args.named.second) - unless Homebrew::EnvConfig.developer? - odie "Cannot extract formula to homebrew/core!" if destination_tap.core_tap? - odie "Cannot extract formula to homebrew/cask!" if destination_tap.core_cask_tap? - odie "Cannot extract formula to the same tap!" if destination_tap == source_tap - end - destination_tap.install unless destination_tap.installed? - - repo = source_tap.path - pattern = if source_tap.core_tap? - [source_tap.new_formula_path(name), repo/"Formula/#{name}.rb"].uniq - else - # A formula can technically live in the root directory of a tap or in any of its subdirectories - [repo/"#{name}.rb", repo/"**/#{name}.rb"] - end - - if args.version - ohai "Searching repository history" - version = args.version - version_segments = Gem::Version.new(version).segments if Gem::Version.correct?(version) - rev = T.let(nil, T.nilable(String)) - test_formula = T.let(nil, T.nilable(Formula)) - result = "" - loop do - rev = rev.nil? ? "HEAD" : "#{rev}~1" - rev, (path,) = Utils::Git.last_revision_commit_of_files(repo, pattern, before_commit: rev) - if rev.nil? && source_tap.shallow? - odie <<~EOS - Could not find #{name} but #{source_tap} is a shallow clone! - Try again after running: - git -C "#{source_tap.path}" fetch --unshallow - EOS - elsif rev.nil? - odie "Could not find #{name}! The formula or version may not have existed." + repo = source_tap.path + pattern = if source_tap.core_tap? + [source_tap.new_formula_path(name), repo/"Formula/#{name}.rb"].uniq + else + # A formula can technically live in the root directory of a tap or in any of its subdirectories + [repo/"#{name}.rb", repo/"**/#{name}.rb"] end - file = repo/path - result = Utils::Git.last_revision_of_file(repo, file, before_commit: rev) - if result.empty? - odebug "Skipping revision #{rev} - file is empty at this revision" - next - end + if args.version + ohai "Searching repository history" + version = args.version + version_segments = Gem::Version.new(version).segments if Gem::Version.correct?(version) + rev = T.let(nil, T.nilable(String)) + test_formula = T.let(nil, T.nilable(Formula)) + result = "" + loop do + rev = rev.nil? ? "HEAD" : "#{rev}~1" + rev, (path,) = Utils::Git.last_revision_commit_of_files(repo, pattern, before_commit: rev) + if rev.nil? && source_tap.shallow? + odie <<~EOS + Could not find #{name} but #{source_tap} is a shallow clone! + Try again after running: + git -C "#{source_tap.path}" fetch --unshallow + EOS + elsif rev.nil? + odie "Could not find #{name}! The formula or version may not have existed." + end - test_formula = formula_at_revision(repo, name, file, rev) - break if test_formula.nil? || test_formula.version == version + file = repo/path + result = Utils::Git.last_revision_of_file(repo, file, before_commit: rev) + if result.empty? + odebug "Skipping revision #{rev} - file is empty at this revision" + next + end - if version_segments && Gem::Version.correct?(test_formula.version) - test_formula_version_segments = Gem::Version.new(test_formula.version).segments - if version_segments.length < test_formula_version_segments.length - odebug "Apply semantic versioning with #{test_formula_version_segments}" - break if version_segments == test_formula_version_segments.first(version_segments.length) + test_formula = formula_at_revision(repo, name, file, rev) + break if test_formula.nil? || test_formula.version == version + + if version_segments && Gem::Version.correct?(test_formula.version) + test_formula_version_segments = Gem::Version.new(test_formula.version).segments + if version_segments.length < test_formula_version_segments.length + odebug "Apply semantic versioning with #{test_formula_version_segments}" + break if version_segments == test_formula_version_segments.first(version_segments.length) + end + end + + odebug "Trying #{test_formula.version} from revision #{rev} against desired #{version}" + end + odie "Could not find #{name}! The formula or version may not have existed." if test_formula.nil? + else + # Search in the root directory of as well as recursively in all of its subdirectories + files = Dir[repo/"{,**/}"].filter_map do |dir| + Pathname.glob("#{dir}/#{name}.rb").find(&:file?) + end + + if files.empty? + ohai "Searching repository history" + rev, (path,) = Utils::Git.last_revision_commit_of_files(repo, pattern) + odie "Could not find #{name}! The formula or version may not have existed." if rev.nil? + file = repo/path + version = T.must(formula_at_revision(repo, name, file, rev)).version + result = Utils::Git.last_revision_of_file(repo, file) + else + file = files.fetch(0).realpath + rev = T.let("HEAD", T.nilable(String)) + version = Formulary.factory(file).version + result = File.read(file) end end - odebug "Trying #{test_formula.version} from revision #{rev} against desired #{version}" - end - odie "Could not find #{name}! The formula or version may not have existed." if test_formula.nil? - else - # Search in the root directory of as well as recursively in all of its subdirectories - files = Dir[repo/"{,**/}"].filter_map do |dir| - Pathname.glob("#{dir}/#{name}.rb").find(&:file?) + # The class name has to be renamed to match the new filename, + # e.g. Foo version 1.2.3 becomes FooAT123 and resides in Foo@1.2.3.rb. + class_name = Formulary.class_s(name) + + # Remove any existing version suffixes, as a new one will be added later + name.sub!(/\b@(.*)\z\b/i, "") + versioned_name = Formulary.class_s("#{name}@#{version}") + result.sub!("class #{class_name} < Formula", "class #{versioned_name} < Formula") + + # Remove bottle blocks, they won't work. + result.sub!(BOTTLE_BLOCK_REGEX, "") + + path = destination_tap.path/"Formula/#{name}@#{version.to_s.downcase}.rb" + if path.exist? + unless args.force? + odie <<~EOS + Destination formula already exists: #{path} + To overwrite it and continue anyways, run: + brew extract --force --version=#{version} #{name} #{destination_tap.name} + EOS + end + odebug "Overwriting existing formula at #{path}" + path.delete + end + ohai "Writing formula for #{name} from revision #{rev} to:", path + path.dirname.mkpath + path.write result end - if files.empty? - ohai "Searching repository history" - rev, (path,) = Utils::Git.last_revision_commit_of_files(repo, pattern) - odie "Could not find #{name}! The formula or version may not have existed." if rev.nil? - file = repo/path - version = T.must(formula_at_revision(repo, name, file, rev)).version - result = Utils::Git.last_revision_of_file(repo, file) - else - file = files.fetch(0).realpath - rev = T.let("HEAD", T.nilable(String)) - version = Formulary.factory(file).version - result = File.read(file) + private + + sig { params(repo: Pathname, name: String, file: Pathname, rev: String).returns(T.nilable(Formula)) } + def formula_at_revision(repo, name, file, rev) + return if rev.empty? + + contents = Utils::Git.last_revision_of_file(repo, file, before_commit: rev) + contents.gsub!("@url=", "url ") + contents.gsub!("require 'brewkit'", "require 'formula'") + contents.sub!(BOTTLE_BLOCK_REGEX, "") + with_monkey_patch { Formulary.from_contents(name, file, contents, ignore_errors: true) } end - end - # The class name has to be renamed to match the new filename, - # e.g. Foo version 1.2.3 becomes FooAT123 and resides in Foo@1.2.3.rb. - class_name = Formulary.class_s(name) + def with_monkey_patch + # Since `method_defined?` is not a supported type guard, the use of `alias_method` below is not typesafe: + BottleSpecification.class_eval do + T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) + define_method(:method_missing) do |*| + # do nothing + end + end - # Remove any existing version suffixes, as a new one will be added later - name.sub!(/\b@(.*)\z\b/i, "") - versioned_name = Formulary.class_s("#{name}@#{version}") - result.sub!("class #{class_name} < Formula", "class #{versioned_name} < Formula") + Module.class_eval do + T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) + define_method(:method_missing) do |*| + # do nothing + end + end - # Remove bottle blocks, they won't work. - result.sub!(BOTTLE_BLOCK_REGEX, "") + Resource.class_eval do + T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) + define_method(:method_missing) do |*| + # do nothing + end + end - path = destination_tap.path/"Formula/#{name}@#{version.to_s.downcase}.rb" - if path.exist? - unless args.force? - odie <<~EOS - Destination formula already exists: #{path} - To overwrite it and continue anyways, run: - brew extract --force --version=#{version} #{name} #{destination_tap.name} - EOS - end - odebug "Overwriting existing formula at #{path}" - path.delete - end - ohai "Writing formula for #{name} from revision #{rev} to:", path - path.dirname.mkpath - path.write result - end + DependencyCollector.class_eval do + if method_defined?(:parse_symbol_spec) + T.unsafe(self).alias_method :old_parse_symbol_spec, + :parse_symbol_spec + end + define_method(:parse_symbol_spec) do |*| + # do nothing + end + end - # @private - sig { params(repo: Pathname, name: String, file: Pathname, rev: String).returns(T.nilable(Formula)) } - def self.formula_at_revision(repo, name, file, rev) - return if rev.empty? + yield + ensure + BottleSpecification.class_eval do + if method_defined?(:old_method_missing) + T.unsafe(self).alias_method :method_missing, :old_method_missing + undef :old_method_missing + end + end - contents = Utils::Git.last_revision_of_file(repo, file, before_commit: rev) - contents.gsub!("@url=", "url ") - contents.gsub!("require 'brewkit'", "require 'formula'") - contents.sub!(BOTTLE_BLOCK_REGEX, "") - with_monkey_patch { Formulary.from_contents(name, file, contents, ignore_errors: true) } - end + Module.class_eval do + if method_defined?(:old_method_missing) + T.unsafe(self).alias_method :method_missing, :old_method_missing + undef :old_method_missing + end + end - private_class_method def self.with_monkey_patch - # Since `method_defined?` is not a supported type guard, the use of `alias_method` below is not typesafe: - BottleSpecification.class_eval do - T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) - define_method(:method_missing) do |*| - # do nothing - end - end + Resource.class_eval do + if method_defined?(:old_method_missing) + T.unsafe(self).alias_method :method_missing, :old_method_missing + undef :old_method_missing + end + end - Module.class_eval do - T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) - define_method(:method_missing) do |*| - # do nothing - end - end - - Resource.class_eval do - T.unsafe(self).alias_method :old_method_missing, :method_missing if method_defined?(:method_missing) - define_method(:method_missing) do |*| - # do nothing - end - end - - DependencyCollector.class_eval do - T.unsafe(self).alias_method :old_parse_symbol_spec, :parse_symbol_spec if method_defined?(:parse_symbol_spec) - define_method(:parse_symbol_spec) do |*| - # do nothing - end - end - - yield - ensure - BottleSpecification.class_eval do - if method_defined?(:old_method_missing) - T.unsafe(self).alias_method :method_missing, :old_method_missing - undef :old_method_missing - end - end - - Module.class_eval do - if method_defined?(:old_method_missing) - T.unsafe(self).alias_method :method_missing, :old_method_missing - undef :old_method_missing - end - end - - Resource.class_eval do - if method_defined?(:old_method_missing) - T.unsafe(self).alias_method :method_missing, :old_method_missing - undef :old_method_missing - end - end - - DependencyCollector.class_eval do - if method_defined?(:old_parse_symbol_spec) - T.unsafe(self).alias_method :parse_symbol_spec, :old_parse_symbol_spec - undef :old_parse_symbol_spec + DependencyCollector.class_eval do + if method_defined?(:old_parse_symbol_spec) + T.unsafe(self).alias_method :parse_symbol_spec, :old_parse_symbol_spec + undef :old_parse_symbol_spec + end + end end end end diff --git a/Library/Homebrew/test/dev-cmd/extract_spec.rb b/Library/Homebrew/test/dev-cmd/extract_spec.rb index 9f1ec85e49..9643529ddc 100644 --- a/Library/Homebrew/test/dev-cmd/extract_spec.rb +++ b/Library/Homebrew/test/dev-cmd/extract_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/extract" -RSpec.describe "brew extract" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::Extract do + it_behaves_like "parseable arguments", argv: ["foo", "bar"] context "when extracting a formula" do let!(:target) do