ManageIQ/manageiq

View on GitHub
lib/git_worktree.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require_relative 'git_worktree_exception'

class GitWorktree
  attr_accessor :name, :email, :base_name
  ENTRY_KEYS = [:path, :dev, :ino, :mode, :gid, :uid, :ctime, :mtime]
  DEFAULT_FILE_MODE = 0100644
  LOCK_REFERENCE = 'refs/locks'

  def initialize(options = {})
    require 'rugged'

    raise ArgumentError, "Must specify path" unless options.key?(:path)
    @path          = options[:path]
    @email         = options[:email]
    @username      = options[:username] || ""
    @bare          = options[:bare]
    @commit_sha    = options[:commit_sha]
    @password      = options[:password] || ""
    @fast_forward_merge = options[:ff] || true
    @remote_name   = 'origin'
    @cred          = Rugged::Credentials::UserPassword.new(:username => @username,
                                                           :password => @password)
    @credentials_set = false
    @base_name = File.basename(@path)
    @certificate_check_cb = options[:certificate_check]
    process_repo(options)
  end

  def delete_repo
    return false unless @repo
    @repo.close
    FileUtils.rm_rf(@path)
    true
  end

  def branches(where = nil)
    where.nil? ? @repo.branches.each_name.sort : @repo.branches.each_name(where).sort
  end

  def branch=(name)
    branch = @repo.branches.each.detect { |b| b.name.casecmp(name) == 0 }
    raise GitWorktreeException::BranchMissing, name unless branch
    @commit_sha = branch.target.oid
  end

  def branch_info(name)
    branch = @repo.branches.each.detect { |b| b.name.casecmp(name) == 0 }
    raise GitWorktreeException::BranchMissing, name unless branch
    {:time => branch.target.time, :message => branch.target.message, :commit_sha => branch.target.oid}
  end

  def tags
    @repo.tags.each.collect(&:name)
  end

  def tag=(name)
    tag = @repo.tags.each.detect { |t| t.name.casecmp(name) == 0 }
    raise GitWorktreeException::TagMissing, name unless tag
    @commit_sha = tag.target.oid
  end

  def tag_info(name)
    tag = @repo.tags.each.detect { |t| t.name.casecmp(name) == 0 }
    raise GitWorktreeException::TagMissing, name unless tag
    {:time => tag.target.time, :message => tag.target.message, :commit_sha => tag.target.oid}
  end

  def add(path, data, default_entry_keys = {})
    entry = {}
    entry[:path] = path
    ENTRY_KEYS.each { |key| entry[key] = default_entry_keys[key] if default_entry_keys.key?(key) }
    entry[:oid]  = @repo.write(data, :blob)
    entry[:mode] ||= DEFAULT_FILE_MODE
    entry[:mtime] ||= Time.now
    current_index.add(entry)
  end

  def remove(path)
    current_index.remove(path)
  end

  def remove_dir(path)
    current_index.remove_dir(path)
  end

  def file_exists?(path)
    !!find_entry(path)
  end

  def directory_exists?(path)
    entry = find_entry(path)
    entry && entry[:type] == :tree
  end

  def read_file(path)
    read_entry(fetch_entry(path))
  end

  def read_entry(entry)
    @repo.lookup(entry[:oid]).content
  end

  def entries(path)
    tree = get_tree(path)
    tree.find_all.collect { |e| e[:name] }
  end

  def nodes(path)
    tree = path.empty? ? lookup_commit_tree : get_tree(path)
    entries = tree.find_all
    entries.each do |entry|
      entry[:full_name] = File.join(@base_name, path, entry[:name])
      entry[:rel_path] = File.join(path, entry[:name])
    end
  end

  def save_changes(message, owner = :local)
    cid = commit(message)
    if owner == :local
      lock { merge(cid) }
    else
      merge_and_push(cid)
    end
    true
  end

  def file_attributes(fname)
    walker = Rugged::Walker.new(@repo)
    walker.sorting(Rugged::SORT_DATE)
    walker.push(@repo.ref(local_ref).target)
    commit = walker.find { |c| c.diff(:paths => [fname]).size > 0 }
    return {} unless commit
    {:updated_on => commit.time.gmtime, :updated_by => commit.author[:name]}
  end

  def file_list
    tree = lookup_commit_tree
    return [] unless tree
    tree.walk(:preorder).collect { |root, entry| "#{root}#{entry[:name]}" }
  end

  def find_entry(path)
    get_tree_entry(path)
  end

  def mv_file_with_new_contents(old_file, new_path, new_data, default_entry_keys = {})
    add(new_path, new_data, default_entry_keys)
    remove(old_file)
  end

  def mv_file(old_file, new_file)
    entry = current_index[old_file]
    return unless entry
    entry[:path] = new_file
    current_index.add(entry)
    remove(old_file)
  end

  def mv_dir(old_dir, new_dir)
    raise GitWorktreeException::DirectoryAlreadyExists, new_dir if find_entry(new_dir)
    old_dir = fix_path_mv(old_dir)
    new_dir = fix_path_mv(new_dir)
    updates = current_index.entries.select { |entry| entry[:path].start_with?(old_dir) }
    updates.each do |entry|
      entry[:path] = entry[:path].sub(old_dir, new_dir)
      current_index.add(entry)
    end
    current_index.remove_dir(old_dir)
  end

  def credentials_cb(url, _username, _types)
    if @credentials_set
      raise GitWorktreeException::InvalidCredentials, "Please provide username and password for URL #{url}" if @username.blank? || @password.blank?
      raise GitWorktreeException::InvalidCredentials, "Invalid credentials for URL #{url}"
    end
    @credentials_set = true
    @cred
  end

  private

  def current_branch
    @repo.head_unborn? ? 'master' : @repo.head.name.sub(/^refs\/heads\//, '')
  end

  def upstream_ref
    "refs/remotes/#{@remote_name}/#{current_branch}"
  end

  def local_ref
    "refs/heads/#{current_branch}"
  end

  def fetch_and_merge
    fetch
    commit = @repo.ref(upstream_ref).target
    merge(commit)
  end

  def fetch
    @credentials_set = false
    options = {:credentials => method(:credentials_cb), :certificate_check => @certificate_check_cb}
    @repo.fetch(@remote_name, options)
  end

  def pull
    lock { fetch_and_merge }
  end

  def merge_and_push(commit)
    rebase = false
    push_lock do
      @saved_cid = @repo.ref(local_ref).target.oid
      merge(commit, rebase)
      rebase = true
      @credentials_set = false
      @repo.push(@remote_name, [local_ref], :credentials => method(:credentials_cb))
    end
  end

  def merge(commit, rebase = false)
    current_branch = @repo.ref(local_ref)
    merge_index = current_branch ? @repo.merge_commits(current_branch.target, commit) : nil
    if merge_index && merge_index.conflicts?
      result = differences_with_current(commit)
      raise GitWorktreeException::GitConflicts, result
    end
    commit = rebase(commit, merge_index, current_branch.try(:target)) if rebase
    @repo.reset(commit, :soft)
  end

  def rebase(commit, merge_index, parent)
    commit_obj = commit if commit.class == Rugged::Commit
    commit_obj ||= @repo.lookup(commit)
    Rugged::Commit.create(@repo, :author    => commit_obj.author,
                                 :committer => commit_obj.author,
                                 :message   => commit_obj.message,
                                 :parents   => parent ? [parent] : [],
                                 :tree      => merge_index.write_tree(@repo))
  end

  def commit(message)
    tree = @current_index.write_tree(@repo)
    parents = @repo.empty? ? [] : [@repo.ref(local_ref).target].compact
    create_commit(message, tree, parents)
  end

  def process_repo(options)
    if options[:url]
      clone(options[:url])
    elsif options[:new]
      create_repo
    else
      open_repo
    end
  end

  def create_repo
    @repo = @bare ? Rugged::Repository.init_at(@path, :bare) : Rugged::Repository.init_at(@path)
    @repo.config['user.name']  = @username  if @username
    @repo.config['user.email'] = @email if @email
    @repo.config['merge.ff']   = 'only' if @fast_forward_merge
  end

  def open_repo
    @repo = Rugged::Repository.new(@path)
  end

  def clone(url)
    @credentials_set = false
    options = {:credentials => method(:credentials_cb), :bare => true, :remote => @remote_name, :certificate_check => @certificate_check_cb}
    @repo = Rugged::Repository.clone_at(url, @path, options)
  end

  def fetch_entry(path)
    find_entry(path).tap do |entry|
      raise GitWorktreeException::GitEntryMissing, path unless entry
    end
  end

  def fix_path_mv(dir_name)
    dir_name = dir_name[1..-1] if dir_name[0] == '/'
    dir_name += '/'            if dir_name[-1] != '/'
    dir_name
  end

  def get_tree(path)
    return lookup_commit_tree if path.empty?
    entry = get_tree_entry(path)
    raise GitWorktreeException::GitEntryMissing, path unless entry
    raise GitWorktreeException::GitEntryNotADirectory, path  unless entry[:type] == :tree
    @repo.lookup(entry[:oid])
  end

  def lookup_commit_tree
    return nil if !@commit_sha && !@repo.branches['master']
    ct = @commit_sha ? @repo.lookup(@commit_sha) : @repo.branches['master'].target
    ct.tree if ct
  end

  def get_tree_entry(path)
    path = path[1..-1] if path[0] == '/'
    tree = lookup_commit_tree
    begin
      entry             = tree.path(path)
      entry[:full_name] = File.join(@base_name, path)
      entry[:rel_path]  = path
    rescue
      return nil
    end
    entry
  end

  def current_index
    @current_index ||= Rugged::Index.new.tap do |index|
      unless @repo.empty?
        tree = lookup_commit_tree
        raise ArgumentError, "Cannot locate commit tree" unless tree
        @current_tree_oid = tree.oid
        index.read_tree(tree)
      end
    end
  end

  def create_commit(message, tree, parents)
    author = {:email => @email, :name => @username, :time => Time.now}
    # Create the actual commit but dont update the reference
    Rugged::Commit.create(@repo, :author  => author,  :committer  => author,
                                 :message => message, :parents    => parents,
                                 :tree    => tree)
  end

  def lock
    @repo.references.create(LOCK_REFERENCE, local_ref)
    yield
  rescue Rugged::ReferenceError
    sleep 0.1
    retry
  ensure
    @repo.references.delete(LOCK_REFERENCE)
  end

  def push_lock
    @repo.references.create(LOCK_REFERENCE, local_ref)
    begin
      yield
    rescue Rugged::ReferenceError => err
      sleep 0.1
      @repo.reset(@saved_cid, :soft)
      fetch_and_merge
      retry
    rescue GitWorktreeException::GitConflicts => err
      @repo.reset(@saved_cid, :soft)
      raise GitWorktreeException::GitConflicts, err.conflicts
    ensure
      @repo.references.delete(LOCK_REFERENCE)
    end
  end

  def differences_with_current(commit)
    differences = {}
    diffs = @repo.diff(commit, @repo.ref(local_ref).target)
    diffs.deltas.each do |delta|
      result = []
      delta.diff.each_line do |line|
        next unless line.addition? || line.deletion?
        result << "+ #{line.content.to_str}"  if line.addition?
        result << "- #{line.content.to_str}"  if line.deletion?
      end
      differences[delta.old_file[:path]] = {:status => delta.status, :diffs => result}
    end
    differences
  end
end