ruby-git/ruby-git

View on GitHub
lib/git/status.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module Git
  # The status class gets the status of a git repository
  #
  # This identifies which files have been modified, added, or deleted from the
  # worktree. Untracked files are also identified.
  #
  # The Status object is an Enumerable that contains StatusFile objects.
  #
  # @api public
  #
  class Status
    include Enumerable

    def initialize(base)
      @base = base
      construct_status
    end

    #
    # Returns an Enumerable containing files that have changed from the
    # git base directory
    #
    # @return [Enumerable]
    def changed
      @_changed ||= @files.select { |_k, f| f.type == 'M' }
    end

    #
    # Determines whether the given file has been changed.
    # File path starts at git base directory
    #
    # @param file [String] The name of the file.
    # @example Check if lib/git.rb has changed.
    #     changed?('lib/git.rb')
    # @return [Boolean]
    def changed?(file)
      case_aware_include?(:changed, :lc_changed, file)
    end

    # Returns an Enumerable containing files that have been added.
    # File path starts at git base directory
    #
    # @return [Enumerable]
    def added
      @_added ||= @files.select { |_k, f| f.type == 'A' }
    end

    # Determines whether the given file has been added to the repository
    #
    # File path starts at git base directory
    #
    # @param file [String] The name of the file.
    # @example Check if lib/git.rb is added.
    #     added?('lib/git.rb')
    # @return [Boolean]
    def added?(file)
      case_aware_include?(:added, :lc_added, file)
    end

    #
    # Returns an Enumerable containing files that have been deleted.
    # File path starts at git base directory
    #
    # @return [Enumerable]
    def deleted
      @_deleted ||= @files.select { |_k, f| f.type == 'D' }
    end

    #
    # Determines whether the given file has been deleted from the repository
    # File path starts at git base directory
    #
    # @param file [String] The name of the file.
    # @example Check if lib/git.rb is deleted.
    #     deleted?('lib/git.rb')
    # @return [Boolean]
    def deleted?(file)
      case_aware_include?(:deleted, :lc_deleted, file)
    end

    #
    # Returns an Enumerable containing files that are not tracked in git.
    # File path starts at git base directory
    #
    # @return [Enumerable]
    def untracked
      @_untracked ||= @files.select { |_k, f| f.untracked }
    end

    #
    # Determines whether the given file has is tracked by git.
    # File path starts at git base directory
    #
    # @param file [String] The name of the file.
    # @example Check if lib/git.rb is an untracked file.
    #     untracked?('lib/git.rb')
    # @return [Boolean]
    def untracked?(file)
      case_aware_include?(:untracked, :lc_untracked, file)
    end

    def pretty
      out = ''
      each do |file|
        out << pretty_file(file)
      end
      out << "\n"
      out
    end

    def pretty_file(file)
      <<~FILE
        #{file.path}
        \tsha(r) #{file.sha_repo} #{file.mode_repo}
        \tsha(i) #{file.sha_index} #{file.mode_index}
        \ttype   #{file.type}
        \tstage  #{file.stage}
        \tuntrac #{file.untracked}
      FILE
    end

    # enumerable method

    def [](file)
      @files[file]
    end

    def each(&block)
      @files.values.each(&block)
    end

    # subclass that does heavy lifting
    class StatusFile
      # @!attribute [r] path
      #   The path of the file relative to the project root directory
      #   @return [String]
      attr_accessor :path

      # @!attribute [r] type
      #   The type of change
      #
      #   * 'M': modified
      #   * 'A': added
      #   * 'D': deleted
      #   * nil: ???
      #
      #   @return [String]
      attr_accessor :type

      # @!attribute [r] mode_index
      #   The mode of the file in the index
      #   @return [String]
      #   @example 100644
      #
      attr_accessor :mode_index

      # @!attribute [r] mode_repo
      #   The mode of the file in the repo
      #   @return [String]
      #   @example 100644
      #
      attr_accessor :mode_repo

      # @!attribute [r] sha_index
      #   The sha of the file in the index
      #   @return [String]
      #   @example 123456
      #
      attr_accessor :sha_index

      # @!attribute [r] sha_repo
      #   The sha of the file in the repo
      #   @return [String]
      #   @example 123456
      attr_accessor :sha_repo

      # @!attribute [r] untracked
      #   Whether the file is untracked
      #   @return [Boolean]
      attr_accessor :untracked

      # @!attribute [r] stage
      #   The stage of the file
      #
      #   * '0': the unmerged state
      #   * '1': the common ancestor (or original) version
      #   * '2': "our version" from the current branch head
      #   * '3': "their version" from the other branch head
      #   @return [String]
      attr_accessor :stage

      def initialize(base, hash)
        @base = base
        @path = hash[:path]
        @type = hash[:type]
        @stage = hash[:stage]
        @mode_index = hash[:mode_index]
        @mode_repo = hash[:mode_repo]
        @sha_index = hash[:sha_index]
        @sha_repo = hash[:sha_repo]
        @untracked = hash[:untracked]
      end

      def blob(type = :index)
        if type == :repo
          @base.object(@sha_repo)
        else
          begin
            @base.object(@sha_index)
          rescue
            @base.object(@sha_repo)
          end
        end
      end
    end

    private

    def construct_status
      # Lists all files in the index and the worktree
      # git ls-files --stage
      # { file => { path: file, mode_index: '100644', sha_index: 'dd4fc23', stage: '0' } }
      @files = @base.lib.ls_files

      # Lists files in the worktree that are not in the index
      # Add untracked files to @files
      fetch_untracked

      # Lists files that are different between the index vs. the worktree
      fetch_modified

      # Lists files that are different between the repo HEAD vs. the worktree
      fetch_added

      @files.each do |k, file_hash|
        @files[k] = StatusFile.new(@base, file_hash)
      end
    end

    def fetch_untracked
      # git ls-files --others --exclude-standard, chdir: @git_work_dir)
      # { file => { path: file, untracked: true } }
      @base.lib.untracked_files.each do |file|
        @files[file] = { path: file, untracked: true }
      end
    end

    def fetch_modified
      # Files changed between the index vs. the worktree
      # git diff-files
      # { file => { path: file, type: 'M', mode_index: '100644', mode_repo: '100644', sha_index: '0000000', :sha_repo: '52c6c4e' } }
      @base.lib.diff_files.each do |path, data|
        @files[path] ? @files[path].merge!(data) : @files[path] = data
      end
    end

    def fetch_added
      unless @base.lib.empty?
      # Files changed between the repo HEAD vs. the worktree
      # git diff-index HEAD
      # { file => { path: file, type: 'M', mode_index: '100644', mode_repo: '100644', sha_index: '0000000', :sha_repo: '52c6c4e' } }
      @base.lib.diff_index('HEAD').each do |path, data|
          @files[path] ? @files[path].merge!(data) : @files[path] = data
        end
      end
    end

    # It's worth noting that (like git itself) this gem will not behave well if
    # ignoreCase is set inconsistently with the file-system itself. For details:
    # https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreignoreCase
    def ignore_case?
      return @_ignore_case if defined?(@_ignore_case)
      @_ignore_case = @base.config('core.ignoreCase') == 'true'
    rescue Git::FailedError
      @_ignore_case = false
    end

    def downcase_keys(hash)
      hash.map { |k, v| [k.downcase, v] }.to_h
    end

    def lc_changed
      @_lc_changed ||= changed.transform_keys(&:downcase)
    end

    def lc_added
      @_lc_added ||= added.transform_keys(&:downcase)
    end

    def lc_deleted
      @_lc_deleted ||= deleted.transform_keys(&:downcase)
    end

    def lc_untracked
      @_lc_untracked ||= untracked.transform_keys(&:downcase)
    end

    def case_aware_include?(cased_hash, downcased_hash, file)
      if ignore_case?
        send(downcased_hash).include?(file.downcase)
      else
        send(cased_hash).include?(file)
      end
    end
  end
end