module Octopolo
  # Abstraction around local Git commands
  class Git
    NO_BRANCH = "(no branch)"
    DEFAULT_DIRTY_MESSAGE = "Your Git index is not clean. Commit, stash, or otherwise clean up the index before continuing."
    # we use date-based tags, so look for anything starting with a 4-digit year
    RELEASE_TAG_FILTER = /^\d{4}.*/
    RECENT_TAG_LIMIT = 9
    # branch prefixes
    DEPLOYABLE_PREFIX = "deployable"
    STAGING_PREFIX = "staging"
    QAREADY_PREFIX = "qaready"

    include CLIWrapper
    extend CLIWrapper # add class-level .cli and .cli= methods

    # Public: Perform the given Git subcommand
    #
    # subcommand - String containing the subcommand and its parameters
    #
    # Example:
    #
    #   > Git.perform "status"
    #   # => output of `git status`
    def self.perform(subcommand)
      cli.perform "git #{subcommand}"
    end

    # Public: Perform the given Git subcommand without displaying the output
    #
    # subcommand - String containing the subcommand and its parameters
    #
    # Example:
    #
    #   > Git.perform_quietly "status"
    #   # => no output
    def self.perform_quietly(subcommand)
      cli.perform_quietly "git #{subcommand}"
    end

    # Public: The name of the currently check-out branch
    #
    # Returns a String of the branch name
    def self.current_branch
      # cut trims the first three characters (whitespace or "*  " for current branch)
      # the chomp removes the newline from the command output
      name = cli.perform_quietly("git branch | grep '^* ' | cut -c 3-").chomp
      if name == NO_BRANCH
        raise NotOnBranch, "Not currently checked out to a particular branch"
      else
        name
      end
    end

    # Public: Determine if current_branch is reserved
    #
    # Returnsa boolean value
    def self.reserved_branch?
      !(current_branch =~ /^(?:#{Git::STAGING_PREFIX}|#{Git::DEPLOYABLE_PREFIX}|#{Git::QAREADY_PREFIX})/).nil?
    end

    # Public: Check out the given branch name
    #
    # branch_name - The name of the branch to check out
    def self.check_out branch_name
      fetch
      perform "checkout #{branch_name}"
      pull
      unless current_branch == branch_name
        raise CheckoutFailed, "Failed to check out '#{branch_name}'"
      end
    end

    # Public: Create a new branch from the given source
    #
    # new_branch_name - The name of the branch to create
    # source_branch_name - The name of the branch to branch from
    #
    # Example:
    #
    #   Git.new_branch("bug-123-fix-thing", "master")
    def self.new_branch(new_branch_name, source_branch_name)
      fetch
      perform("branch --no-track #{new_branch_name} origin/#{source_branch_name}")
      check_out new_branch_name
      perform("push --set-upstream origin #{new_branch_name}")
    end

    # Public: Whether the Git index is clean (has no uncommited changes)
    #
    # Returns a Boolean
    def self.clean?
      # git status --short returns one line for any uncommited changes, if any
      # e.g.,
      # ?? untracked.txt
      # D  deleted.txt
      # M  modified.txt
      cli.perform_quietly("git status --short").empty?
    end

    # Public: Perform the block if the Git index is clean
    def self.if_clean(message=DEFAULT_DIRTY_MESSAGE)
      if clean?
        yield
      else
        alert_dirty_index message
      end
    end

    # Public: Display the message and show the git status
    def self.alert_dirty_index(message)
      cli.say " "
      cli.say message
      cli.say " "
      perform "status"
    end

    # Public: Merge the given remote branch into the current branch
    def self.merge(branch_name)
      Git.if_clean do
        Git.fetch
        perform "merge --no-ff origin/#{branch_name}"
        raise MergeFailed unless Git.clean?
        Git.push
      end
    end

    # Public: Fetch the latest changes from GitHub
    def self.fetch
      perform_quietly "fetch --prune"
    end

    # Public: Push the current branch to GitHub
    def self.push
      if_clean do
        perform "push origin #{current_branch}"
      end
    end

    # Public: Pull the latest changes for the checked-out branch
    def self.pull
      if_clean do
        perform "pull"
      end
    end

    # Public: The list of branches on GitHub
    #
    # Returns an Array of Strings containing the branch names
    def self.remote_branches
      Git.fetch
      raw = Git.perform_quietly "branch --remote"
      all_branches = raw.split("\n").map do |raw_name|
        # will come in as "  origin/foo", we want just "foo"
        raw_name.split("/").last
      end

      all_branches.uniq.sort
    end

    # Public: List of branches starting with the given string
    #
    # prefix - String to match branch names against
    #
    # Returns an Array of Strings containing the branch names
    def self.branches_for(prefix)
      remote_branches.select do |branch_name|
        branch_name =~ /^#{prefix}/
      end
    end

    def self.latest_branch_for(branch_prefix)
      branches_for(branch_prefix).last || raise(NoBranchOfType, "No #{branch_prefix} branch")
    end

    # Public: The name of the current deployable branch
    def self.deployable_branch
      latest_branch_for(DEPLOYABLE_PREFIX)
    end

    # Public: The name of the current staging branch
    def self.staging_branch
      latest_branch_for(STAGING_PREFIX)
    end

    # Public: The name of the current QA-ready branch
    def self.qaready_branch
      latest_branch_for(QAREADY_PREFIX)
    end

    # Public: The list of releases which have been tagged
    #
    # Returns an Array of Strings containing the tag names
    def self.release_tags
      Git.perform_quietly("tag").split("\n").select do |tag|
        tag =~ RELEASE_TAG_FILTER
      end
    end

    # Public: Only the most recent release tags
    #
    # Returns an Array of Strings containing the tag names
    def self.recent_release_tags
      release_tags.last(RECENT_TAG_LIMIT)
    end

    # Public: Create a new tag with the given name
    #
    # tag_name - The name of the tag to create
    def self.new_tag(tag_name)
      perform "tag #{tag_name}"
      push
      perform "push --tag"
    end

    # Public: Delete the given branch
    #
    # branch_name - The name of the branch to delete
    def self.delete_branch(branch_name)
      perform "push origin :#{branch_name}"
      perform "branch -D #{branch_name}"
    end

    # Public: Branches which have been merged into the given branch
    #
    # source_branch_name - The name of the branch to check against
    # branches_to_ignore - An Array of branches to exclude from results
    #
    # Returns an Array of Strings
    def self.stale_branches(source_branch_name="master", branches_to_ignore=[])
      Git.fetch
      command = "branch --remote --merged #{recent_sha(source_branch_name)} | grep -E -v '(#{stale_branches_to_ignore(branches_to_ignore).join("|")})'"
      raw_result = Git.perform_quietly command
      raw_result.split.map { |full_name| full_name.gsub("origin/", "") }
    end

    # Private: The SHA from 1 day ago for the given branch
    #
    # branch_name - The name of the branch to check
    #
    # Returns a String
    def self.recent_sha(branch_name)
      raw = perform_quietly "rev-list `git rev-parse remotes/origin/#{branch_name} --before=1.day.ago` --max-count=1"
      raw.chomp
    end
    private_class_method :recent_sha

    # Private: Branches to ignore when looking for stale branches
    #
    # Returns an Array of Strings
    def self.stale_branches_to_ignore(additional_branches=[])
      %w(HEAD master staging deployable) + Array(additional_branches)
    end
    private_class_method :stale_branches_to_ignore

    # Exceptions
    NotOnBranch = Class.new(StandardError)
    CheckoutFailed = Class.new(StandardError)
    MergeFailed = Class.new(StandardError)
    NoBranchOfType = Class.new(StandardError)
  end
end