#
# Copyright 2012-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class GitFetcher < Fetcher
    #
    # A fetch is required if the git repository is not cloned or if the local
    # revision does not match the desired revision.
    #
    # @return [true, false]
    #
    def fetch_required?
      !(cloned? && contains_revision?(resolved_version))
    end

    #
    # The version identifier for this git location. This is computed using the
    # current revision on disk.
    #
    # @return [String]
    #
    def version_guid
      # If current_revision cannot be determined (e.g., before checkout),
      # fall back to the resolved_version (locked version) so callers get a
      # usable GUID in tests and code paths that query the GUID before
      # cloning.
      "git:#{current_revision || resolved_version}"
    end

    #
    # Clean the project directory by resetting the current working tree to
    # the required revision.
    #
    # @return [true, false]
    #   true if the project directory was cleaned, false otherwise.
    #   In our case, we always return true because we always call
    #   git checkout/clean.
    #
    def clean
      log.info(log_key) { "Cleaning existing clone" }
      git_checkout
      git("clean -fdx")
      true
    end

    #
    # Fetch (clone) or update (fetch) the remote git repository.
    #
    # @return [void]
    #
    def fetch
      log.info(log_key) { "Fetching from `#{source_url}'" }
      create_required_directories

      if cloned?
        git_fetch
      else
        force_recreate_project_dir! unless dir_empty?(project_dir)
        git_clone
      end
    end

    #
    # The version for this item in the cache.
    #
    # This method is called *before* clean but *after* fetch. Do not ever
    # use the contents of the project_dir here.
    #
    # We aren't including the source/repo path here as there could be
    # multiple branches/tags that all point to the same commit. We're
    # assuming that we won't realistically ever get two git commits
    # that are unique but share sha1s.
    #
    # TODO: Does this work with submodules?
    #
    # @return [String]
    #
    def version_for_cache
      "revision:#{resolved_version}"
    end

    private

    #
    # The URL where the git source should be downloaded from.
    #
    # @return [String]
    #
    def source_url
      source[:git]
    end

    #
    # Determine if submodules should be cloned.
    #
    # @return [true, false]
    #
    def clone_submodules?
      source[:submodules] || false
    end

    #
    # Determine if a directory is empty
    #
    # @return [true, false]
    #
    def dir_empty?(dir)
      Dir.entries(dir).reject { |d| [".", ".."].include?(d) }.empty?
    end

    #
    # Forcibly remove and recreate the project directory
    #
    def force_recreate_project_dir!
      log.warn(log_key) { "Removing existing directory #{project_dir} before cloning" }
      FileUtils.rm_rf(project_dir)
      Dir.mkdir(project_dir)
    end

    #
    # Determine if the clone exists.
    #
    # @return [true, false]
    #
    def cloned?
      File.exist?("#{project_dir}/.git")
    end

    #
    # Clone the +source_url+ into the +project_dir+.
    #
    # @return [void]
    #
    def git_clone
      retry_block("git clone", [CommandTimeout, CommandFailed]) do
        git("clone#{" --recursive" if clone_submodules?} #{source_url} .")
      end
    end

    #
    # Checkout the +resolved_version+.
    #
    # @return [void]
    #
    def git_checkout
      # We are hoping to perform a checkout with detached HEAD (that's the
      # default when a sha1 is provided).  git older than 1.7.5 doesn't
      # support the --detach flag.
      git("checkout #{resolved_version} -f -q")
      if clone_submodules?
        retry_block("git submodule update", [CommandTimeout, CommandFailed]) do
          git("submodule update --recursive")
        end
      end
    end

    #
    # Fetch the remote tags and refs, and reset to +resolved_version+.
    #
    # @return [void]
    #
    def git_fetch
      fetch_cmd = "fetch #{source_url} #{described_version}"
      fetch_cmd << " --recurse-submodules=on-demand" if clone_submodules?
      retry_block("git fetch", [CommandTimeout, CommandFailed]) do
        git(fetch_cmd)
      end
    end

    #
    # The current revision for the cloned checkout.
    #
    # @return [String]
    #
    def current_revision
      cmd = git("rev-parse HEAD")
      cmd.stdout.strip
    rescue CommandFailed
      log.debug(log_key) { "unable to determine current revision" }
      nil
    end

    #
    # Check if the current clone has the requested commit id.
    #
    # @return [true, false]
    #
    def contains_revision?(rev)
      cmd = git("cat-file -t #{rev}")
      cmd.stdout.strip == "commit"
    rescue CommandFailed
      log.debug(log_key) { "unable to determine presence of commit #{rev}" }
      false
    end

    #
    # Execute the given git command, inside the +project_dir+.
    #
    # autcrlf is a hack to help support windows and posix clients using the
    # same repository but canonicalizing files as they are committed to the
    # repo but converting line endings when they are actually checked out
    # into a working tree. We do not want to change the on-disk representation
    # of our sources regardless of the platform we are building on unless
    # explicitly asked for. Hence, we disable autocrlf.
    #
    # @see Util#shellout!
    #
    # @return [Mixlib::ShellOut]
    #   the shellout object
    #
    def git(command)
      shellout!("git -c core.autocrlf=false #{command}", cwd: project_dir)
    end

    # Class methods

    # Return the SHA1 corresponding to a ref as determined by the remote source.
    #
    # @return [String]
    #
    def self.resolve_version(ref, source)
      if sha_hash?(ref)
        # A git server negotiates in terms of refs during the info-refs phase
        # of a fetch. During upload-pack, the client is not allowed to specify
        # any sha1s in the "wants" unless the server has publicized them during
        # info-refs. Hence, the server is allowed to drop requests to fetch
        # particular sha1s, even if it is an otherwise reachable commit object.
        # Only when the service is specifically configured with
        # uploadpack.allowReachableSHA1InWant is there any guarantee that it
        # considers "naked" wants.
        log.warn(log_key) { "git fetch on a sha1 is not guaranteed to work" }
        log.warn(log_key) { "Specify a ref name instead of #{ref} on #{source}" }
        ref
      else
        revision_from_remote_reference(ref, source)
      end
    end

    #
    # Determine if the given revision is a SHA
    #
    # @return [true, false]
    #
    def self.sha_hash?(rev)
      rev =~ /^[0-9a-f]{4,40}$/i
    end

    #
    # Return the SHA corresponding to ref.
    #
    # If ref is an annotated tag, return the SHA that was tagged not the SHA of
    # the tag itself.
    #
    # @return [String]
    #
    def self.revision_from_remote_reference(ref, source)
      # execute `git ls-remote` the trailing '*' does globbing. This
      # allows us to return the SHA of the tagged commit for annotated
      # tags. We take care to only return exact matches in
      # process_remote_list.
      remote_list = retry_block("git ls-remote", [CommandTimeout, CommandFailed]) do
        shellout!("git ls-remote \"#{source[:git]}\" \"#{ref}*\"").stdout
      end

      commit_ref = dereference_annotated_tag(remote_list, ref)

      unless commit_ref
        raise UnresolvableGitReference.new(ref)
      end

      commit_ref
    end

    #
    # Dereference annotated tags.
    #
    # The +remote_list+ parameter is assumed to look like this:
    #
    #   a2ed66c01f42514bcab77fd628149eccb4ecee28        refs/tags/rel-0.11.0
    #   f915286abdbc1907878376cce9222ac0b08b12b8        refs/tags/rel-0.11.0^{}
    #
    # The SHA with ^{} is the commit pointed to by an annotated
    # tag. If ref isn't an annotated tag, there will not be a line
    # with trailing ^{}.
    #
    # @param [String] remote_list
    #   output from `git ls-remote origin` command
    # @param [String] ref
    #   the target git ref
    #
    # @return [String]
    #
    def self.dereference_annotated_tag(remote_list, ref)
      # We'll return the SHA corresponding to the ^{} which is the
      # commit pointed to by an annotated tag. If no such commit
      # exists (not an annotated tag) then we return the SHA of the
      # ref.  If nothing matches, return "".
      lines = remote_list.split("\n")
      matches = lines.map { |line| line.split("\t") }
      # First try for ^{} indicating the commit pointed to by an
      # annotated tag.
      tagged_commit = matches.find { |m| m[1].end_with?("#{ref}^{}") }
      if tagged_commit
        tagged_commit.first
      else
        found = matches.find { |m| m[1].end_with?("#{ref}") }
        if found
          found.first
        else
          nil
        end
      end
    end
  end
end
