plugins/repositories/rugged_repository.rb

Summary

Maintainability
D
2 days
Test Coverage
description 'Git repository backend (Using rugged library)'
require 'rugged'
require 'fileutils'

class RuggedRepository < Repository
  CONTENT_EXT  = '.content'
  ATTRIBUTE_EXT = '.attributes'

  class Blob
    def initialize(git, content)
      @git = git
      @content = content
    end

    def type
      :blob
    end

    def filemode
      0100644
    end

    def save
      @git.write(@content, :blob)
    end
  end

  class Reference
    attr_reader :filemode, :type

    def initialize(git, entry)
      @git = git
      @oid = entry[:oid]
      @filemode = entry[:filemode]
      @type = entry[:type]
    end

    def save
      @oid
    end

    def lookup
      if type == :tree
        Tree.new(@git, @oid)
      else
        self
      end
    end
  end

  class Tree
    def initialize(git, oid = nil)
      @git = git
      @entries = {}
      @oid = oid
      if oid
        tree = @git.lookup(oid)
        raise 'Not a tree' unless Rugged::Tree === tree
        tree.each {|entry| @entries[entry[:name].force_encoding(Encoding.default_external)] = Reference.new(@git, entry) }
      end
    end

    def empty?
      @entries.empty?
    end

    def type
      :tree
    end

    def filemode
      0040000
    end

    def get(name)
      child = @entries[name]
      Reference === child ? @entries[name] = child.lookup : child
    end

    def [](path)
      return self if path.blank?
      name, path = path.split('/', 2)
      child = get(name)
      if path && child
        raise 'Find child in blob' unless child.type == :tree
        child[path]
      else
        child
      end
    end

    def []=(path, object)
      raise 'Blank path' if path.blank?
      @oid = nil
      name, path = path.split('/', 2)
      child = get(name)
      if path
        child = @entries[name] = Tree.new(@git) unless child
        if child.type == :tree
          child[path] = object
        else
          raise 'Parent not found'
        end
      else
        @entries[name] = object
      end
    end

    def move(path, destination)
      self[destination] = delete(path)
    end

    def delete(path)
      raise 'Blank path' if path.blank?
      @oid = nil
      name, path = path.split('/', 2)
      child = get(name)
      if path
        if child.type == :tree
          child.delete(path)
        else
          raise 'Object not found'
        end
      else
        entry = @entries.delete(name)
        raise 'Object not found' unless entry
        entry
      end
    end

    def save
      return @oid if @oid
      builder = Rugged::Tree::Builder.new
      @entries.each do |name, entry|
        builder << { type: entry.type, filemode: entry.filemode, oid: entry.save, name: name }
      end
      builder.write(@git)
    end
  end

  class Transaction
    attr_reader :tree

    def initialize(git)
      @git = git
      @head = current_head
      @tree = Tree.new(@git, @head && @git.lookup(@head).tree_oid)
    end

    def commit(comment)
      raise 'Concurrent transactions' if @head != current_head

      user = User.current
      author = {email: user.email, name: user.name, time: Time.now }
      Rugged::Commit.create(@git,
                            author: author,
                            message: comment,
                            committer: author,
                            parents: [@head],
                            tree: @tree.save,
                            update_ref: 'HEAD')
    end

    private

    def current_head
      @git.head.target rescue nil
    end
  end

  def initialize(config)
    @git = Rugged::Repository.new(config[:path])
    Olelo.logger.info "Opening git repository: #{config[:path]}"
  rescue IOError
    Olelo.logger.info "Creating git repository: #{config[:path]}"
    FileUtils.mkpath(config[:path])
    @git = Rugged::Repository.init_at(config[:path], config[:bare])
  end

  def transaction
    raise 'Transaction already running' if Thread.current[:olelo_rugged_tx]
    Thread.current[:olelo_rugged_tx] = Transaction.new(@git)
    yield
  ensure
    Thread.current[:olelo_rugged_tx] = nil
  end

  def set_content(path, content)
    check_path(path)
    expand_tree(path)
    object = work_tree[path]
    if object && object.type == :tree
      if content.blank?
        work_tree.delete(path + CONTENT_EXT)
      else
        work_tree[path + CONTENT_EXT] = Blob.new(@git, content)
      end
      collapse_empty_tree(path)
    else
      work_tree[path] = Blob.new(@git, content)
    end
  end

  def set_attributes(path, attributes)
    check_path(path)
    attributes = attributes.blank? ? nil : yaml_dump(attributes).sub(/\A\-\-\-\s*\n/s, '')
    expand_tree(path)
    if attributes
      work_tree[path + ATTRIBUTE_EXT] = Blob.new(@git, attributes)
    else
      work_tree.delete(path + ATTRIBUTE_EXT) if work_tree[path + ATTRIBUTE_EXT]
    end
  end

  def move(path, destination)
    check_path(destination)
    work_tree.move(path, destination)
    work_tree.move(path + CONTENT_EXT, destination + CONTENT_EXT) if work_tree[path + CONTENT_EXT]
    work_tree.move(path + ATTRIBUTE_EXT, destination + ATTRIBUTE_EXT) if work_tree[path + ATTRIBUTE_EXT]
    collapse_empty_tree(path/'..')
  end

  def delete(path)
    check_path(path)
    work_tree.delete(path)
    work_tree.delete(path + CONTENT_EXT) if work_tree[path + CONTENT_EXT]
    work_tree.delete(path + ATTRIBUTE_EXT) if work_tree[path + ATTRIBUTE_EXT]
    collapse_empty_tree(path/'..')
  end

  def commit(comment)
    current_transaction.commit(comment)
    commit_to_version(@git.last_commit)
  end

  def path_etag(path, version)
    check_path(path)
    commit = @git.lookup(version.to_s)
    raise 'Not a commit' unless Rugged::Commit === commit
    if oid = oid_by_path(commit, path)
      [oid,
       oid_by_path(commit, path + CONTENT_EXT),
       oid_by_path(commit, path + ATTRIBUTE_EXT)].join('-')
    end
  end

  def get_version(version = nil)
    if version
      commit = @git.rev_parse(version.to_s) rescue nil
      commit_to_version(commit)
    else
      commit_to_version(@git.last_commit) rescue nil
    end
  end

  def get_history(path, skip, limit)
    check_path(path)

    commits = []
    walker = Rugged::Walker.new(@git)
    walker.sorting(Rugged::SORT_TOPO)
    walker.push(@git.head.target)
    walker.each do |c|
      if path_changed?(c, path)
        if skip > 0
          skip -= 1
        else
          commits << c
          break if commits.size >= limit
        end
      end
    end
    commits.map {|c| commit_to_version(c) }
  end

  def get_path_version(path, version)
    check_path(path)

    version ||= @git.head.target
    version = version.to_s

    commits = []
    walker = Rugged::Walker.new(@git)
    walker.sorting(Rugged::SORT_TOPO)
    walker.push(version)
    walker.each do |c|
      if path_changed?(c, path)
        commits << c
        break if commits.size == 2
      end
    end

    succ = nil
    if version != @git.head.target
      newer = nil
      walker.reset
      walker.sorting(Rugged::SORT_TOPO)
      walker.push(@git.head.target)
      walker.each do |c|
        if path_changed?(c, path)
          if c == commits[0]
            succ = newer
            break
          end
          newer = c
        end
      end
    end

    [commit_to_version(commits[1]), # previous version
     commit_to_version(commits[0]), # current version
     commit_to_version(succ)] # next version
  end

  def get_children(path, version)
    check_path(path)
    commit = @git.lookup(version.to_s)
    raise 'Not a commit' unless Rugged::Commit === commit
    object = object_by_path(commit, path)
    Rugged::Tree === object ? object.map do |e|
      e[:name].force_encoding(Encoding.default_external)
    end.reject {|name| reserved_name?(name) } : []
  end

  def get_content(path, version)
    check_path(path)
    commit = @git.lookup(version.to_s)
    raise 'Not a commit' unless Rugged::Commit === commit
    object = object_by_path(commit, path)
    object = object_by_path(commit, path + CONTENT_EXT) if Rugged::Tree === object
    Rugged::Blob === object ? object.content.try_encoding(Encoding.default_external) : ''
  end

  def get_attributes(path, version)
    check_path(path)
    commit = @git.lookup(version.to_s)
    raise 'Not a commit' unless Rugged::Commit === commit
    path += ATTRIBUTE_EXT
    object = object_by_path(commit, path)
    object ? yaml_load(object.content) : {}
  end

  def diff(path, from, to)
    check_path(path)
    commit_from = from && @git.rev_parse(from.to_s)
    commit_to = @git.rev_parse(to.to_s)
    raise 'Not a commit' unless (!commit_from || Rugged::Commit === commit_from) && Rugged::Commit === commit_to
    diff = git_diff_tree('--root', '--full-index', '-u', '-M', commit_from ? commit_from.oid : nil, commit_to.oid, '--', path, path + CONTENT_EXT, path + ATTRIBUTE_EXT)
    Diff.new(commit_to_version(commit_from), commit_to_version(commit_to), diff)
  end

  def short_version(version)
    version[0..4]
  end

  def method_missing(name, *args)
    cmd = name.to_s
    if cmd =~ /\Agit_/
      cmd = $'.tr('_', '-')
      args = args.flatten.compact.map(&:to_s)

      out = IO.popen('-', 'rb') do |io|
        if io
          # Read in binary mode (ascii-8bit) and convert afterwards
          block_given? ? yield(io) : io.read.try_encoding(Encoding.default_external)
        else
          # child's stderr goes to stdout
          STDERR.reopen(STDOUT)
          ENV['GIT_DIR'] = @git.path
          exec(self.class.git_path, cmd, *args)
        end
      end

      if $?.exitstatus > 0
        return '' if $?.exitstatus == 1 && out == ''
        raise "git #{cmd} #{args.inspect} #{out}"
      end

      out
    else
      super
    end
  end

  def reserved_name?(name)
    name.ends_with?(ATTRIBUTE_EXT) || name.ends_with?(CONTENT_EXT)
  end

  private

  def self.git_path
    @git_path ||= begin
                    path = `which git`.chomp
                    raise 'git not found' if $?.exitstatus != 0
                    path
                  end
  end

  def check_path(path)
    raise :reserved_path.t if path.split('/').any? {|name| reserved_name?(name) }
  end

  def path_changed?(c, path)
    return true if path.blank?
    ref1, ref2, ref3 = nil, nil, nil
    (c.parents.empty? && (ref1 ||= oid_by_path(c, path))) || c.parents.any? do |parent|
      (ref1 ||= oid_by_path(c, path)) != (oid_by_path(parent, path)) ||
        (ref2 ||= oid_by_path(c, path + ATTRIBUTE_EXT)) != (oid_by_path(parent, path + ATTRIBUTE_EXT)) ||
        (ref3 ||= oid_by_path(c, path + CONTENT_EXT)) != (oid_by_path(parent, path + CONTENT_EXT))
    end
  end

  def oid_by_path(commit, path)
    return commit.tree_oid if path.blank?
    commit.tree.path(path)[:oid]
  rescue Rugged::Error
    nil
  end

  def object_by_path(commit, path)
    return commit.tree if path.blank?
    @git.lookup(commit.tree.path(path)[:oid])
  rescue Rugged::Error
    nil
  end

  def commit_to_version(commit)
    commit && Version.new(commit.oid, User.new(commit.author[:name], commit.author[:email]),
                          Time.at(commit.time), commit.message, commit.parents.map(&:oid), commit.oid == @git.head.target)
  end

  def current_transaction
    Thread.current[:olelo_rugged_tx] || raise('No transaction running')
  end

  def work_tree
    current_transaction.tree
  end

  # Convert blob parents to trees
  # to allow children
  def expand_tree(path)
    names = path.split('/')
    names.pop
    parent = work_tree
    names.each do |name|
      object = parent[name]
      break if !object
      if object.type == :blob
        parent.move(name, name + CONTENT_EXT)
        break
      end
      parent = object
    end
  end

  # If a tree consists only of tree/, tree.content and tree.attributes without
  # children, tree.content can be moved to tree ("collapsing").
  def collapse_empty_tree(path)
    if !path.blank? && work_tree[path].empty? && work_tree[path + CONTENT_EXT]
      work_tree.move(path + CONTENT_EXT, path)
    end
  end
end

Repository.register :git, RuggedRepository
Repository.register :rugged, RuggedRepository