oliverguenther/gitolite-rugged

View on GitHub
lib/gitolite/gitolite_admin.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'pathname'
module Gitolite
  class GitoliteAdmin

    attr_accessor :repo

    # Default settings
    DEFAULTS = {
      # clone/push url settings
      git_user: 'git',
      hostname: 'localhost',

      # Commit settings
      author_name: 'gitolite-rugged gem',
      author_email: 'gitolite-rugged@localhost',
      commit_msg: 'Commited by the gitolite-rugged gem',

      # Gitolite-Admin settings
      config_dir: "conf",
      key_dir: "keydir",
      key_subdir: "",
      config_file: "gitolite.conf",
      lock_file_path: '.lock',

      # Repo settings
      update_on_init: true,
      reset_before_update: true
    }

    class << self

      # Checks if the given path is a gitolite-admin repository
      # A valid repository contains a conf folder, keydir folder,
      # and a configuration file within the conf folder
      def is_gitolite_admin_repo?(dir)
        # First check if it is a git repository
        begin
          repo = Rugged::Repository.new(dir)
          return false if repo.empty?
        rescue Rugged::RepositoryError, Rugged::OSError
          return false
        end

        # Check if config file, key directory exist
        [ File.join(dir, DEFAULTS[:config_dir]), File.join(dir, DEFAULTS[:key_dir]),
          File.join(dir, DEFAULTS[:config_dir], DEFAULTS[:config_file])
        ].each { |f| return false unless File.exists?(f) }

        true
      end

      def admin_url(settings)
        ['ssh://', settings[:git_user], '@', settings[:host], '/gitolite-admin.git'].join
      end
    end

    # Intialize with the path to
    # the gitolite-admin repository
    #
    # Settings:
    # [Connection]
    # :git_user: The git user to SSH to (:git_user@localhost:gitolite-admin.git), defaults to 'git'
    # :private_key: The key file containing the private SSH key for :git_user
    # :public_key: The key file containing the public SSH key for :git_user
    # :host: Hostname for clone url. Defaults to 'localhost'
    #
    # [Gitolite-Admin]
    # :config_dir: Config directory within gitolite repository (defaults to 'conf')
    # :key_dir: Public key directory within gitolite repository (defaults to 'keydir')
    # :config_file: Config file to parse (default: 'gitolite.conf')
    # **use only when you use the 'include' directive of gitolite)**
    # :key_subdir: Where to store gitolite-rugged known keys, defaults to '' (i.e., directly in keydir)
    # :lock_file_path: location of the transaction lockfile, defaults to <gitolite-admin.git>/.lock
    #
    # The settings hash is forwarded to +GitoliteAdmin.new+ as options.
    def initialize(path, settings = {})
      @path = path
      @settings = DEFAULTS.merge(settings)

      # Ensure SSH key settings exist
      @settings.fetch(:public_key)
      @settings.fetch(:private_key)

      # setup credentials
      @credentials = Rugged::Credentials::SshKey.new(
        username: @settings[:git_user],
        publickey: settings[:public_key],
        privatekey: settings[:private_key]
      )

      @config_dir_path    = File.join(@path, @settings[:config_dir])
      @config_file_path = File.join(@config_dir_path, @settings[:config_file])
      @key_dir_path     = File.join(@path, relative_key_dir)

      @commit_author = { email: @settings[:author_email], name: @settings[:author_name] }

      if self.class.is_gitolite_admin_repo?(path)
        @repo = Rugged::Repository.new(path, credentials: @credentials )
        # Update repository
        if @settings[:update_on_init]
          update
        end
      else
        @repo = clone
      end

      reload!
    end


    #
    # Returns the relative directory to the gitolite config file location.
    # I.e., settings[config_dir]/settings[config_file]
    # Defaults to 'conf/gitolite.conf'
    def relative_config_file
      File.join(@settings[:config_dir], @settings[:config_file])
    end

    #
    # Returns the relative directory to the public key location.
    # I.e., settings[key_dir]/settings[key_subdir]
    # Defaults to 'keydir/'
    def relative_key_dir
      File.join(@settings[:key_dir], @settings[:key_subdir])
    end

    def config
      @config ||= load_config
    end


    def config=(config)
      @config = config
    end


    def ssh_keys
      @ssh_keys ||= load_keys
    end


    def add_key(key)
      unless key.instance_of? Gitolite::SSHKey
        raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
      end

      ssh_keys[key.owner] << key
    end


    def rm_key(key)
      unless key.instance_of? Gitolite::SSHKey
        raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
      end

      ssh_keys[key.owner].delete key
    end


    # This method will destroy all local tracked changes, resetting the local gitolite
    # git repo to HEAD
    def reset!
      @repo.reset('origin/master', :hard)
    end


    # This method will destroy the in-memory data structures and reload everything
    # from the file system
    def reload!
      @ssh_keys = load_keys
      @config = load_config
    end


    # Writes all changed aspects out to the file system
    # will also stage all changes then commit
    def save(commit_msg = nil)

      # Add all changes to index (staging area)
      index = @repo.index

      #Process config file (if loaded, i.e. may be modified)
      if @config
        new_conf = @config.to_file(path=@config_dir_path)
        index.add(relative_config_file)
      end

      #Process ssh keys (if loaded, i.e. may be modified)
      if @ssh_keys
        files = list_keys.map{|f| relative_key_path(f) }
        keys  = @ssh_keys.values.map{|f| f.map {|t| t.relative_path}}.flatten

        to_remove = (files - keys).each do |key|
          SSHKey.remove(key, @key_dir_path)
          index.remove File.join(relative_key_dir, key)
        end

        @ssh_keys.each_value do |key|
          # Write only keys from sets that has been modified
          next if key.respond_to?(:dirty?) && !key.dirty?
          key.each do |k|
            new_key = k.to_file(@key_dir_path)
            index.add File.join(relative_key_dir, k.relative_path)
          end
        end
      end

      # Write index to git and resync fs
      commit_tree = index.write_tree @repo
      index.write

      commit_author = @commit_author.merge(time: Time.now)

      Rugged::Commit.create(@repo,
        author: commit_author,
        committer: commit_author,
        message: commit_msg || @settings[:commit_msg],
        parents: [repo.head.target],
        tree: commit_tree,
        update_ref: 'HEAD'
      )
    end


    # Push back to origin
    def apply
      @repo.push('origin', ['refs/heads/master'], credentials: @credentials)
    end


    # Commits all staged changes and pushes back to origin
    def save_and_apply()
      save
      apply
    end


    # Lock the gitolite-admin directory and yield.
    # After the block is completed, calls +apply+ only.
    # You have to commit your changes within the transaction block
    def transaction
      get_lock do
        yield

        # Push all changes
        apply
      end
    end


    # Updates the repo with changes from remote master
    # Warning: This resets the repo before pulling in the changes.
    def update()

      # Reset --hard repo before update
      if @settings[:reset_before_update]
        reset!
      end

      # Fetch changes from origin
      @repo.fetch('origin', credentials: @credentials )

      # Currently, only merging from origin/master into master is supported.
      master = @repo.references["refs/heads/master"].target
      origin_master = @repo.references["refs/remotes/origin/master"].target

      # Create the merged index in memory
      merge_index = repo.merge_commits(master, origin_master)

      # Complete the merge by comitting it
      merge_commit = Rugged::Commit.create(@repo,
        parents: [ master, origin_master ],
        tree: merge_index.write_tree(@repo),
        message: '[gitolite-rugged] Merged `origin/master` into `master`',
        author: @commit_author,
        committer: @commit_author,
        update_ref: 'refs/heads/master'
      )

      reload!
    end


    private


    # Clone the gitolite-admin repo
    # to the given path.
    #
    # The repo is cloned from the url
    # +(:git_user)@(:hostname)/gitolite-admin.git+
    #
    # The hostname may use an optional :port to allow for custom SSH ports.
    # E.g., +git@localhost:2222/gitolite-admin.git+
    #
    def clone
      Rugged::Repository.clone_at(GitoliteAdmin.admin_url(@settings), File.expand_path(@path), credentials: @credentials )
    end


    def load_config
      Config.new(@config_file_path)
    end


    def list_keys
      Dir.glob(@key_dir_path + '/**/*.pub')
    end

    # Returns the relative key path
    # <owner>/<location>/<owner> given an absolute path
    # below the keydir.
    def relative_key_path(key_path)
      Pathname.new(key_path).relative_path_from(Pathname.new(@key_dir_path)).to_s
    end


    # Loads all .pub files in the gitolite-admin
    # keydir directory
    def load_keys
      keys = Hash.new {|k,v| k[v] = DirtyProxy.new([])}

      list_keys.each do |key|
        new_key = SSHKey.from_file(key)
        owner = new_key.owner

        keys[owner] << new_key
      end

      # Mark key sets as unmodified (for dirty checking)
      keys.values.each{|set| set.clean_up!}

      keys
    end

    def lock_file_path
      File.expand_path(@settings[:lock_file_path], @path)
    end


    # Aquire LOCK_EX on the gitolite-admin.git directory .
    # Use +GitoliteAdmin.transaction+ to modify with flock.
    def get_lock
      File.open(lock_file_path, File::RDWR|File::CREAT, 0644) do |file|
        file.sync = true
        file.flock(File::LOCK_EX)

        yield

        file.flock(File::LOCK_UN)
      end
    end
  end
end