CocoaPods/CocoaPods

View on GitHub
lib/cocoapods/command/repo/push.rb

Summary

Maintainability
A
3 hrs
Test Coverage
A
93%
require 'tempfile'
require 'fileutils'
require 'active_support/core_ext/string/inflections'

module Pod
  class Command
    class Repo < Command
      class Push < Repo
        self.summary = 'Push new specifications to a spec-repo'

        self.description = <<-DESC
        Validates `NAME.podspec` or `*.podspec` in the current working dir,
        creates a directory and version folder for the pod in the local copy of
        `REPO` (#{Config.instance.repos_dir}/[REPO]), copies the podspec file into the
        version directory, and finally it pushes `REPO` to its remote.
        DESC

        self.arguments = [
          CLAide::Argument.new('REPO', true),
          CLAide::Argument.new('NAME.podspec', false),
        ]

        def self.options
          [
            ['--allow-warnings', 'Allows pushing even if there are warnings'],
            ['--use-libraries', 'Linter uses static libraries to install the spec'],
            ['--use-modular-headers', 'Lint uses modular headers during installation'],
            ["--sources=#{Pod::TrunkSource::TRUNK_REPO_URL}", 'The sources from which to pull dependent pods ' \
             '(defaults to all available repos). Multiple sources must be comma-delimited'],
            ['--local-only', 'Does not perform the step of pushing REPO to its remote'],
            ['--no-private', 'Lint includes checks that apply only to public repos'],
            ['--skip-import-validation', 'Lint skips validating that the pod can be imported'],
            ['--skip-tests', 'Lint skips building and running tests during validation'],
            ['--commit-message="Fix bug in pod"', 'Add custom commit message. Opens default editor if no commit ' \
              'message is specified'],
            ['--use-json', 'Convert the podspec to JSON before pushing it to the repo'],
            ['--swift-version=VERSION', 'The `SWIFT_VERSION` that should be used when linting the spec. ' \
             'This takes precedence over the Swift versions specified by the spec or a `.swift-version` file'],
            ['--no-overwrite', 'Disallow pushing that would overwrite an existing spec'],
            ['--update-sources', 'Make sure sources are up-to-date before a push'],
            ['--validation-dir', 'The directory to use for validation. If none is specified a temporary directory will be used.'],
          ].concat(super)
        end

        def initialize(argv)
          @allow_warnings = argv.flag?('allow-warnings')
          @local_only = argv.flag?('local-only')
          @repo = argv.shift_argument
          @source = source_for_repo
          @source_urls = argv.option('sources', config.sources_manager.all.map(&:url).append(Pod::TrunkSource::TRUNK_REPO_URL).uniq.join(',')).split(',')
          @update_sources = argv.flag?('update-sources')
          @podspec = argv.shift_argument
          @use_frameworks = !argv.flag?('use-libraries')
          @use_modular_headers = argv.flag?('use-modular-headers', false)
          @private = argv.flag?('private', true)
          @message = argv.option('commit-message')
          @commit_message = argv.flag?('commit-message', false)
          @use_json = argv.flag?('use-json')
          @swift_version = argv.option('swift-version', nil)
          @skip_import_validation = argv.flag?('skip-import-validation', false)
          @skip_tests = argv.flag?('skip-tests', false)
          @allow_overwrite = argv.flag?('overwrite', true)
          @validation_dir = argv.option('validation-dir', nil)
          super
        end

        def validate!
          super
          help! 'A spec-repo name or url is required.' unless @repo
          unless @source && @source.repo.directory?
            raise Informative,
                  "Unable to find the `#{@repo}` repo. " \
                  'If it has not yet been cloned, add it via `pod repo add`.'
          end
        end

        def run
          open_editor if @commit_message && @message.nil?
          check_if_push_allowed
          update_sources if @update_sources
          validate_podspec_files
          check_repo_status
          update_repo
          add_specs_to_repo
          push_repo unless @local_only
        end

        #---------------------------------------------------------------------#

        private

        # @!group Push sub-steps

        extend Executable
        executable :git

        # Open default editor to allow users to enter commit message
        #
        def open_editor
          return if ENV['EDITOR'].nil?

          file = Tempfile.new('cocoapods')
          File.chmod(0777, file.path)
          file.close

          system("#{ENV['EDITOR']} #{file.path}")
          @message = File.read file.path
        end

        # Temporary check to ensure that users do not push accidentally private
        # specs to the master repo.
        #
        def check_if_push_allowed
          if @source.is_a?(CDNSource)
            raise Informative, 'Cannot push to a CDN source, as it is read-only.'
          end

          remotes, = Executable.capture_command('git', %w(remote --verbose), :capture => :merge, :chdir => repo_dir)
          master_repo_urls = [
            'git@github.com:CocoaPods/Specs.git',
            'https://github.com/CocoaPods/Specs.git',
          ]
          is_master_repo = master_repo_urls.any? do |url|
            remotes.include?(url)
          end

          if is_master_repo
            raise Informative, 'To push to the CocoaPods master repo use ' \
              "the `pod trunk push` command.\n\nIf you are using a fork of " \
              'the master repo for private purposes we recommend to migrate ' \
              'to a clean private repo. To disable this check remove the ' \
              'remote pointing to the CocoaPods master repo.'
          end
        end

        # Performs a full lint against the podspecs.
        #
        def validate_podspec_files
          UI.puts "\nValidating #{'spec'.pluralize(count)}".yellow
          podspec_files.each do |podspec|
            validator = Validator.new(podspec, @source_urls)
            validator.allow_warnings = @allow_warnings
            validator.use_frameworks = @use_frameworks
            validator.use_modular_headers = @use_modular_headers
            validator.ignore_public_only_results = @private
            validator.swift_version = @swift_version
            validator.skip_import_validation = @skip_import_validation
            validator.skip_tests = @skip_tests
            validator.validation_dir = @validation_dir
            begin
              validator.validate
            rescue => e
              raise Informative, "The `#{podspec}` specification does not validate." \
                                 "\n\n#{e.message}"
            end
            raise Informative, "The `#{podspec}` specification does not validate." unless validator.validated?
          end
        end

        # Checks that the repo is clean.
        #
        # @raise  If the repo is not clean.
        #
        # @todo   Add specs for staged and unstaged files.
        #
        # @todo   Gracefully handle the case where source is not under git
        #         source control.
        #
        # @return [void]
        #
        def check_repo_status
          porcelain_status, = Executable.capture_command('git', %w(status --porcelain), :capture => :merge, :chdir => repo_dir)
          clean = porcelain_status == ''
          raise Informative, "The repo `#{@repo}` at #{UI.path repo_dir} is not clean" unless clean
        end

        # Updates the git repo against the remote.
        #
        # @return [void]
        #
        def update_repo
          UI.puts "Updating the `#{@repo}' repo\n".yellow
          git!(%W(-C #{repo_dir} pull))
        end

        # Update sources if present
        #
        # @return [void]
        #
        def update_sources
          return if @source_urls.nil?
          @source_urls.each do |source_url|
            source = config.sources_manager.source_with_name_or_url(source_url)
            dir = source.specs_dir
            UI.puts "Updating a source at #{dir} for #{source}"
            git!(%W(-C #{dir} pull))
          end
        end

        # Commits the podspecs to the source, which should be a git repo.
        #
        # @note   The pre commit hook of the repo is skipped as the podspecs have
        #         already been linted.
        #
        # @return [void]
        #
        def add_specs_to_repo
          UI.puts "\nAdding the #{'spec'.pluralize(count)} to the `#{@repo}' repo\n".yellow
          podspec_files.each do |spec_file|
            spec = Pod::Specification.from_file(spec_file)
            output_path = @source.pod_path(spec.name) + spec.version.to_s
            message = if @message && !@message.empty?
                        @message
                      elsif output_path.exist?
                        "[Fix] #{spec}"
                      elsif output_path.dirname.directory?
                        "[Update] #{spec}"
                      else
                        "[Add] #{spec}"
                      end

            if output_path.exist? && !@allow_overwrite
              raise Informative, "#{spec} already exists and overwriting has been disabled."
            end

            FileUtils.mkdir_p(output_path)

            if @use_json
              json_file_name = "#{spec.name}.podspec.json"
              json_file = File.join(output_path, json_file_name)
              File.open(json_file, 'w') { |file| file.write(spec.to_pretty_json) }
            else
              FileUtils.cp(spec_file, output_path)
            end

            # only commit if modified
            if repo_git('status', '--porcelain').include?(spec.name)
              UI.puts " - #{message}"
              repo_git('add', spec.name)
              repo_git('commit', '--no-verify', '-m', message)
            else
              UI.puts " - [No change] #{spec}"
            end
          end
        end

        # Pushes the git repo against the remote.
        #
        # @return [void]
        #
        def push_repo
          UI.puts "\nPushing the `#{@repo}' repo\n".yellow
          repo_git('push', 'origin', 'HEAD')
        end

        #---------------------------------------------------------------------#

        private

        # @!group Private helpers

        # @return result of calling the git! with args in repo_dir
        #
        def repo_git(*args)
          git!(['-C', repo_dir] + args)
        end

        # @return [Pathname] The directory of the repository.
        #
        def repo_dir
          @source.specs_dir
        end

        # @return [Array<Pathname>] The path of the specifications to push.
        #
        def podspec_files
          if @podspec
            path = Pathname(@podspec)
            raise Informative, "Couldn't find #{@podspec}" unless path.exist?
            [path]
          else
            files = Pathname.glob('*.podspec{,.json}')
            raise Informative, "Couldn't find any podspec files in current directory" if files.empty?
            files
          end
        end

        # @return [Integer] The number of the podspec files to push.
        #
        def count
          podspec_files.count
        end

        # Returns source for @repo
        #
        # @note If URL is invalid or repo doesn't exist, validate! will throw the error
        #
        # @return [Source]
        #
        def source_for_repo
          config.sources_manager.source_with_name_or_url(@repo) unless @repo.nil?
        rescue
          nil
        end

        #---------------------------------------------------------------------#
      end
    end
  end
end