 9e47fc9f31
			
		
	
	
		9e47fc9f31
		
			
		
	
	
	
	
		
			
			`Livecheck#preprocess_url` only contains logic for rewriting Git URLs, so it makes more sense for this code to be part of the `Git` strategy instead. Outside of better code organization, this saves us from having to maintain the list of strategies to skip processing (which is sometimes forgotten when a new strategy is added) and makes it easier to do something similar in other strategies as needed. One thing to note is that `Livecheck#preprocess_url` was previously called on the URL before each strategy's `#match?` method was called. To maintain the existing behavior, this calls `Git#preprocess_url` in `Git#match?`. However, we need the processed URL when we use the `Git` strategy, so we have to call `Git#preprocess_url` again. To avoid duplicating effort, I've added a `@processed_urls` hash to the `Git` strategy and have set up `Git#preprocess_url` to cache processed URLs, so we only do the work once. There may be a better way of handling it but this seems to work as expected.
		
			
				
	
	
		
			221 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			221 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # typed: strict
 | |
| # frozen_string_literal: true
 | |
| 
 | |
| require "addressable"
 | |
| require "system_command"
 | |
| 
 | |
| module Homebrew
 | |
|   module Livecheck
 | |
|     module Strategy
 | |
|       # The {Git} strategy identifies versions of software in a Git repository
 | |
|       # by checking the tags using `git ls-remote --tags`.
 | |
|       #
 | |
|       # Livecheck has historically prioritized the {Git} strategy over others
 | |
|       # and this behavior was continued when the priority setup was created.
 | |
|       # This is partly related to Livecheck checking formula URLs in order of
 | |
|       # `head`, `stable` and then `homepage`. The higher priority here may
 | |
|       # be removed (or altered) in the future if we reevaluate this particular
 | |
|       # behavior.
 | |
|       #
 | |
|       # This strategy does not have a default regex. Instead, it simply removes
 | |
|       # any non-digit text from the start of tags and parses the rest as a
 | |
|       # {Version}. This works for some simple situations but even one unusual
 | |
|       # tag can cause a bad result. It's better to provide a regex in a
 | |
|       # `livecheck` block, so `livecheck` only matches what we really want.
 | |
|       #
 | |
|       # @api public
 | |
|       class Git
 | |
|         extend SystemCommand::Mixin
 | |
| 
 | |
|         # Used to cache processed URLs, to avoid duplicating effort.
 | |
|         @processed_urls = T.let({}, T::Hash[String, String])
 | |
| 
 | |
|         # The priority of the strategy on an informal scale of 1 to 10 (from
 | |
|         # lowest to highest).
 | |
|         PRIORITY = 8
 | |
| 
 | |
|         # The default regex used to naively identify versions from tags when a
 | |
|         # regex isn't provided.
 | |
|         DEFAULT_REGEX = /\D*(.+)/
 | |
| 
 | |
|         GITEA_INSTANCES = T.let(%w[
 | |
|           codeberg.org
 | |
|           gitea.com
 | |
|           opendev.org
 | |
|           tildegit.org
 | |
|         ].freeze, T::Array[String])
 | |
|         private_constant :GITEA_INSTANCES
 | |
| 
 | |
|         GOGS_INSTANCES = T.let(%w[
 | |
|           lolg.it
 | |
|         ].freeze, T::Array[String])
 | |
|         private_constant :GOGS_INSTANCES
 | |
| 
 | |
|         # Processes and returns the URL used by livecheck.
 | |
|         sig { params(url: String).returns(String) }
 | |
|         def self.preprocess_url(url)
 | |
|           processed_url = @processed_urls[url]
 | |
|           return processed_url if processed_url
 | |
| 
 | |
|           begin
 | |
|             uri = Addressable::URI.parse url
 | |
|           rescue Addressable::URI::InvalidURIError
 | |
|             return url
 | |
|           end
 | |
| 
 | |
|           host = uri.host
 | |
|           path = uri.path
 | |
|           return url if host.nil? || path.nil?
 | |
| 
 | |
|           host = "github.com" if host == "github.s3.amazonaws.com"
 | |
|           path = path.delete_prefix("/").delete_suffix(".git")
 | |
|           scheme = uri.scheme
 | |
| 
 | |
|           if host == "github.com"
 | |
|             return url if path.match? %r{/releases/latest/?$}
 | |
| 
 | |
|             owner, repo = path.delete_prefix("downloads/").split("/")
 | |
|             processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
 | |
|           elsif GITEA_INSTANCES.include?(host)
 | |
|             return url if path.match? %r{/releases/latest/?$}
 | |
| 
 | |
|             owner, repo = path.split("/")
 | |
|             processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
 | |
|           elsif GOGS_INSTANCES.include?(host)
 | |
|             owner, repo = path.split("/")
 | |
|             processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
 | |
|           # sourcehut
 | |
|           elsif host == "git.sr.ht"
 | |
|             owner, repo = path.split("/")
 | |
|             processed_url = "#{scheme}://#{host}/#{owner}/#{repo}"
 | |
|           # GitLab (gitlab.com or self-hosted)
 | |
|           elsif path.include?("/-/archive/")
 | |
|             processed_url = url.sub(%r{/-/archive/.*$}i, ".git")
 | |
|           end
 | |
| 
 | |
|           if processed_url && (processed_url != url)
 | |
|             @processed_urls[url] = processed_url
 | |
|           else
 | |
|             url
 | |
|           end
 | |
|         end
 | |
| 
 | |
|         # Whether the strategy can be applied to the provided URL.
 | |
|         #
 | |
|         # @param url [String] the URL to match against
 | |
|         # @return [Boolean]
 | |
|         sig { params(url: String).returns(T::Boolean) }
 | |
|         def self.match?(url)
 | |
|           url = preprocess_url(url)
 | |
|           (DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true
 | |
|         end
 | |
| 
 | |
|         # Fetches a remote Git repository's tags using `git ls-remote --tags`
 | |
|         # and parses the command's output. If a regex is provided, it will be
 | |
|         # used to filter out any tags that don't match it.
 | |
|         #
 | |
|         # @param url [String] the URL of the Git repository to check
 | |
|         # @param regex [Regexp] the regex to use for filtering tags
 | |
|         # @return [Hash]
 | |
|         sig { params(url: String, regex: T.nilable(Regexp)).returns(T::Hash[Symbol, T.untyped]) }
 | |
|         def self.tag_info(url, regex = nil)
 | |
|           stdout, stderr, _status = system_command(
 | |
|             "git",
 | |
|             args:         ["ls-remote", "--tags", url],
 | |
|             env:          { "GIT_TERMINAL_PROMPT" => "0" },
 | |
|             print_stdout: false,
 | |
|             print_stderr: false,
 | |
|             debug:        false,
 | |
|             verbose:      false,
 | |
|           )
 | |
| 
 | |
|           tags_data = { tags: [] }
 | |
|           tags_data[:messages] = stderr.split("\n") if stderr.present?
 | |
|           return tags_data if stdout.blank?
 | |
| 
 | |
|           # Isolate tag strings and filter by regex
 | |
|           tags = stdout.gsub(%r{^.*\trefs/tags/|\^{}$}, "").split("\n").uniq.sort
 | |
|           tags.select! { |t| regex.match?(t) } if regex
 | |
|           tags_data[:tags] = tags
 | |
| 
 | |
|           tags_data
 | |
|         end
 | |
| 
 | |
|         # Identify versions from tag strings using a provided regex or the
 | |
|         # `DEFAULT_REGEX`. The regex is expected to use a capture group around
 | |
|         # the version text.
 | |
|         #
 | |
|         # @param tags [Array] the tags to identify versions from
 | |
|         # @param regex [Regexp, nil] a regex to identify versions
 | |
|         # @return [Array]
 | |
|         sig {
 | |
|           params(
 | |
|             tags:  T::Array[String],
 | |
|             regex: T.nilable(Regexp),
 | |
|             block: T.nilable(Proc),
 | |
|           ).returns(T::Array[String])
 | |
|         }
 | |
|         def self.versions_from_tags(tags, regex = nil, &block)
 | |
|           if block
 | |
|             block_return_value = if regex.present?
 | |
|               yield(tags, regex)
 | |
|             elsif block.arity == 2
 | |
|               yield(tags, DEFAULT_REGEX)
 | |
|             else
 | |
|               yield(tags)
 | |
|             end
 | |
|             return Strategy.handle_block_return(block_return_value)
 | |
|           end
 | |
| 
 | |
|           tags.filter_map do |tag|
 | |
|             if regex
 | |
|               # Use the first capture group (the version)
 | |
|               # This code is not typesafe unless the regex includes a capture group
 | |
|               T.unsafe(tag.scan(regex).first)&.first
 | |
|             else
 | |
|               # Remove non-digits from the start of the tag and use that as the
 | |
|               # version text
 | |
|               tag[DEFAULT_REGEX, 1]
 | |
|             end
 | |
|           end.uniq
 | |
|         end
 | |
| 
 | |
|         # Checks the Git tags for new versions. When a regex isn't provided,
 | |
|         # this strategy simply removes non-digits from the start of tag
 | |
|         # strings and parses the remaining text as a {Version}.
 | |
|         #
 | |
|         # @param url [String] the URL of the Git repository to check
 | |
|         # @param regex [Regexp, nil] a regex used for matching versions
 | |
|         # @return [Hash]
 | |
|         sig {
 | |
|           params(
 | |
|             url:     String,
 | |
|             regex:   T.nilable(Regexp),
 | |
|             _unused: T.untyped,
 | |
|             block:   T.nilable(Proc),
 | |
|           ).returns(T::Hash[Symbol, T.untyped])
 | |
|         }
 | |
|         def self.find_versions(url:, regex: nil, **_unused, &block)
 | |
|           match_data = { matches: {}, regex:, url: }
 | |
| 
 | |
|           tags_data = tag_info(url, regex)
 | |
|           tags = tags_data[:tags]
 | |
| 
 | |
|           if tags_data.key?(:messages)
 | |
|             match_data[:messages] = tags_data[:messages]
 | |
|             return match_data if tags.blank?
 | |
|           end
 | |
| 
 | |
|           versions_from_tags(tags, regex, &block).each do |version_text|
 | |
|             match_data[:matches][version_text] = Version.new(version_text)
 | |
|           rescue TypeError
 | |
|             next
 | |
|           end
 | |
| 
 | |
|           match_data
 | |
|         end
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 |