diff --git a/.travis.yml b/.travis.yml index 5eab3380..801c1733 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,11 @@ addons: notifications: email: false +addons: + apt: + packages: + - git-svn + before_script: - RAILS_ENV=test bin/rails db:recreate diff --git a/Gemfile b/Gemfile index f2d2ef9d..6634e7f7 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,9 @@ gem 'rack-cors' gem 'ontohub-models', github: 'ontohub/ontohub-models', branch: 'master' +gem 'gitlab_git', github: 'ontohub/gitlab_git', + branch: 'update_to_9.0.5' + gem 'active_model_serializers', '~> 0.10.4' gem 'config', '~> 1.4.0' diff --git a/Gemfile.lock b/Gemfile.lock index ac36e775..76d34c2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,17 @@ +GIT + remote: git://github.com/ontohub/gitlab_git.git + revision: f242d68f674400c5475980a6919b4e2cc7f75f49 + branch: update_to_9.0.5 + specs: + gitlab_git (10.7.0) + activesupport (>= 4.0) + charlock_holmes (~> 0.7.3) + github-linguist (~> 4.7.0) + rugged (~> 0.24.0) + GIT remote: git://github.com/ontohub/ontohub-models.git - revision: 409babc7879413b3f3eb191123af4788e6e13f5f + revision: 7263c41b474abf7df297b602649ef6fb3b5886e7 branch: master specs: ontohub-models (0.1.0) @@ -70,6 +81,7 @@ GEM byebug (9.0.6) case_transform (0.2) activesupport + charlock_holmes (0.7.3) chewy (0.9.0) activesupport (>= 3.2) elasticsearch (>= 1.0.0) @@ -104,6 +116,7 @@ GEM faraday multi_json erubis (2.7.0) + escape_utils (1.1.1) factory_girl (4.8.0) activesupport (>= 3.0.0) factory_girl_rails (4.8.0) @@ -114,11 +127,16 @@ GEM faraday (0.12.0.1) multipart-post (>= 1.2, < 3) ffi (1.9.18) - globalid (0.3.7) - activesupport (>= 4.1.0) + github-linguist (4.7.6) + charlock_holmes (~> 0.7.3) + escape_utils (~> 1.1.0) + mime-types (>= 1.19) + rugged (>= 0.23.0b) + globalid (0.4.0) + activesupport (>= 4.2.0) i18n (0.8.1) interception (0.5) - json (2.0.3) + json (2.0.4) json-schema (2.7.0) addressable (>= 2.4) jsonapi-renderer (0.1.2) @@ -220,6 +238,7 @@ GEM rspec-support (~> 3.5.0) rspec-support (3.5.0) ruby_dep (1.5.0) + rugged (0.24.6.1) sequel (4.42.1) sequel-devise (0.0.9) devise @@ -274,6 +293,7 @@ DEPENDENCIES database_cleaner (~> 1.5.3) factory_girl_rails (~> 4.8.0) faker (~> 1.7.2) + gitlab_git! json-schema (~> 2.7.0) jwt (~> 1.5.6) listen (~> 3.1.5) diff --git a/app/controllers/v2/repositories_controller.rb b/app/controllers/v2/repositories_controller.rb index d536749d..fed79863 100644 --- a/app/controllers/v2/repositories_controller.rb +++ b/app/controllers/v2/repositories_controller.rb @@ -4,6 +4,7 @@ module V2 # Handles all requests for repository CRUD operations class RepositoriesController < ResourcesWithURLController PERMITTED_PARAMS = %i(description content_type public_access).freeze + resource_class RepositoryCompound find_param :slug actions :all permitted_params PERMITTED_PARAMS, diff --git a/app/controllers/v2/resources_controller/dsl.rb b/app/controllers/v2/resources_controller/dsl.rb index 3810ad91..104aa5f7 100644 --- a/app/controllers/v2/resources_controller/dsl.rb +++ b/app/controllers/v2/resources_controller/dsl.rb @@ -9,7 +9,12 @@ module InstanceMethods def collection return @collection if @collection klass = self.class.instance_variable_get(:@resource_class) - @collection = klass.where(parent_params).all + @collection = klass.where(parent_params) + if @collection.respond_to?(:all) + @collection = @collection.all + else + @collection + end end def resource diff --git a/app/controllers/v2/trees_controller.rb b/app/controllers/v2/trees_controller.rb new file mode 100644 index 00000000..80c70dcb --- /dev/null +++ b/app/controllers/v2/trees_controller.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module V2 + # Handles all requests for file CRUD operations. Although this inherits from + # ResourcesController, we only use a few methods from it and overwrite most of + # it. + class TreesController < ResourcesController + resource_class Blob + permitted_params %i(path content encoding commit_message previous_head_sha) + + def show + if resource + render_resource + elsif resource_tree + render status: :ok, + json: resource_tree, + serializer: V2::TreeSerializer, + include: [] + else # nil + render status: :not_found + end + end + + def create + build_resource + resource.create + render_resource(:created) + rescue Blob::ValidationFailed + render_error(:unprocessable_entity) + end + + def update + if resource + begin + update_resource + render_resource + rescue Blob::ValidationFailed + render_error(:unprocessable_entity) + end + else + render status: :not_found + end + end + + def destroy + super + rescue Blob::ValidationFailed + render_error(:unprocessable_entity) + end + + protected + + def repository + RepositoryCompound.find(slug: params[:repository_slug]) + end + + def git + repository.git + end + + def ref + params[:ref] || git.default_branch + end + + def resource_tree + @resource_tree ||= Tree.find(repository_id: repository.to_param, + branch: ref, + path: params[:path]) + end + + def resource + @resource ||= Blob.find(repository_id: repository.to_param, + branch: ref, + path: params[:path]) + # Use @resource&.user = current_user as soon as + # https://github.com/rubinius/rubinius/issues/3739 + # is fixed. + # rubocop:disable Style/SafeNavigation + @resource.user = current_user unless @resource.nil? + # rubocop:enable Style/SafeNavigation + @resource + end + + def build_resource + @resource = + Blob.new(resource_params.merge(branch: ref, + repository: repository, + user: current_user)) + end + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def update_resource + attributes = {commit_message: resource_params[:commit_message], + branch: ref, + repository: repository, + user: current_user} + # Only for moving the file + attributes[:path] = resource_params[:path] if resource_params[:path] + # Only if changing the content + if resource_params[:content] + attributes[:content] = resource_params[:content] + attributes[:encoding] = resource_params[:encoding] + end + attributes[:previous_path] = params[:path] + resource.update(attributes) + resource.save + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + end +end diff --git a/app/models/blob.rb b/app/models/blob.rb new file mode 100644 index 00000000..4384fff3 --- /dev/null +++ b/app/models/blob.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +require 'base64' + +# Represents a file in a git repository +class Blob < ActiveModelSerializers::Model + include ActiveModel::Dirty + + class Error < ::StandardError; end + class ValidationFailed < Error; end + + ENCODINGS = %w(base64 plain).freeze + + # Only used for +save+ + attr_accessor :branch, :commit_message, :previous_head_sha, :previous_path, + :user + + # Only used for reading + attr_accessor :commit_id, :id + + # Generally used + attr_accessor :content, :encoding, :path, :repository + + # Record dirty state for these attributes + define_attribute_methods :content, :path + + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + def self.find(args) + repo = args[:repository] || Repository.find(slug: args[:repository_id]) + repository = RepositoryCompound.wrap(repo) + + blob = repository&.git&.blob(args[:branch], args[:path]) + unless blob.nil? + new(content: blob.binary ? Base64.encode64(blob.data) : blob.data, + encoding: blob.binary ? 'base64' : 'plain', + commit_id: blob.commit_id, + branch: args[:branch], + id: blob.id, + path: blob.path, + repository: repository) + end + rescue Rugged::ReferenceError + nil + end + + def initialize(params) + super(params) + return unless commit_id && git&.branch_exists?(commit_id) + @commit_id = git&.branch_sha(commit_id) + end + + def update(params) + self.previous_path ||= path if params.keys.map(&:to_sym).include?(:path) + params.each do |field, value| + send("#{field}_will_change!") if %w(content path).include?(field.to_s) + attributes[field] = value + send("#{field}=", value) + end + reset_validation_state + valid?(:update) + true + end + + def create + reset_validation_state + valid?(:create) + save(mode: :create) + end + + def destroy + self.commit_message = attributes[:commit_message] = "Delete #{path}." + git.remove_file(commit_info, previous_head_sha) + rescue Gitlab::Git::Repository::InvalidRef + @errors.add(:branch, "branch does not exist: #{branch}") + raise ValidationFailed + end + + def save(mode: nil) + raise ValidationFailed, @errors.messages.to_json unless valid? + commit_sha = + begin + if rename_file? + git.rename_file(commit_info, previous_head_sha) + elsif mode == :create + git.create_file(commit_info, previous_head_sha) + else + git.update_file(commit_info, previous_head_sha) + end + rescue Git::Committing::HeadChangedError + @errors.add(:branch, + 'Could not save the file in the git repository '\ + 'because it has changed in the meantime. '\ + 'Please try again after checking out the current revision.') + raise ValidationFailed + end + self.commit_id = commit_sha + create_file_version(commit_sha) + + changes_applied + + true + end + + def git + @git ||= repository&.git + end + + def url(prefix) + "#{prefix.sub(%r{/$}, '')}#{url_path}" + end + + def url_path + ['', # this empty string adds the leading slash + repository.to_param, + 'ref', commit_id, + 'tree', path].join('/') + end + + protected + + def rename_file? + previous_path.present? && previous_path != path + end + + # rubocop:disable Style/IfUnlessModifier + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def valid?(mode = :save) + return @errors.blank? if @validated + case mode + when :create + if !git&.empty? && git&.branch_exists?(branch) && + git&.path_exists?(branch, path) + @errors.add(:path, "path already exists: #{path}") + end + when :update + if !content_changed? && !path_changed? + @errors.add(:content, 'either content or path must be changed') + end + end + unless repository.is_a?(RepositoryCompound) + @errors.add(:repository, 'repository must be set') + end + if user.blank? + @errors.add(:user, 'No user set') + end + if !git&.empty? && !git&.branch_exists?(branch) + @errors.add(:branch, "branch does not exist: #{branch}") + end + unless ENCODINGS.include?(encoding) + @errors.add(:encoding, "encoding not supported: #{encoding}. "\ + "Must be one of #{ENCODINGS.join(',')}") + end + if content.nil? + @errors.add(:content, 'content must exist') + end + unless content.is_a?(String) + @errors.add(:content, 'content must be a string') + end + unless commit_message.present? + @errors.add(:commit_message, 'commit_message is not present') + end + @validated = true + @errors.blank? + end + # rubocop:enable Style/IfUnlessModifier + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + + def reset_validation_state + @errors.clear + @validated = false + end + + def decoded_content + @decoded_content ||= + case encoding + when 'base64' + Base64.decode64(content) + when 'plain' + content + end + end + + def create_file_version(commit_sha) + file_version = + FileVersion.new(path: path, + commit_sha: commit_sha, + repository_id: repository.pk, + url_path_method: ->(_file_version) { url_path }) + file_version.save + file_version + end + + def commit_info + now = Time.now + @commit_info ||= {file: {content: decoded_content, path: path}, + author: user_info(now), + committer: user_info(now), + commit: {message: commit_message, branch: branch}} + @commit_info[:file][:previous_path] = previous_path if rename_file? + @commit_info + end + + def user_info(time = nil) + {email: user.email, + name: user.real_name || user.name, + time: time || Time.now} + end +end diff --git a/app/models/repository_compound.rb b/app/models/repository_compound.rb new file mode 100644 index 00000000..99e49aba --- /dev/null +++ b/app/models/repository_compound.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# This class combines the Repository model and the Git library. +# It forwards model methods directly to the Repository object. +class RepositoryCompound < ActiveModelSerializers::Model + GIT_DIRECTORY = Settings.data_directory.join('git').freeze + + class << self + def find(*args) + repository = Repository.find(*args) + wrap(repository) if repository + end + + def where(*args) + Repository.where(*args).map { |repository| wrap(repository) } + end + + def wrap(repository) + return repository if repository.is_a?(RepositoryCompound) + # :nocov: + # We only need this check for development as an assertion. + unless repository.is_a?(Repository) + raise "Object given to ##{__method__} is not a repository" + end + # :nocov: + object = new + object.instance_variable_set(:@repository, repository) + object + end + end + + attr_reader :repository + + delegate :to_param, *(Repository.instance_methods - Object.instance_methods), + to: :repository + + def initialize(*repository_params) + @repository = Repository.new(*repository_params) + end + + def save + Sequel::Model.db.transaction do + repository.save + @git = Git.create(git_path) if repository.exists? + true + end + end + + def destroy + Sequel::Model.db.transaction do + repository.destroy + git.path.rmtree if git.path.exist? + true + end + end + + def git + @git ||= Git.new(git_path) unless repository.nil? + end + + def url(prefix) + "#{prefix.sub(%r{/$}, '')}#{url_path}" + end + + def url_path + "/#{repository.to_param}" + end + + protected + + def git_path + GIT_DIRECTORY.join("#{repository.to_param}.git") + end +end diff --git a/app/models/tree.rb b/app/models/tree.rb new file mode 100644 index 00000000..40e2d53a --- /dev/null +++ b/app/models/tree.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Represents a directory in a git repository +class Tree < ActiveModelSerializers::Model + # Only used for reading + attr_accessor :commit_id, :entries, :id, :path, :repository + + # rubocop:disable Metrics/AbcSize + def self.find(args) + repo = args[:repository] || Repository.find(slug: args[:repository_id]) + repository = RepositoryCompound.wrap(repo) + + opts = {repository: repository, commit_id: args[:branch], path: args[:path]} + if repository&.git&.path_exists?(args[:branch], args[:path]) + new(**opts, entries: repository&.git&.tree(args[:branch], args[:path])) + elsif args[:path].sub(%r{\A/+}, '').empty? && + repository&.git&.branch_exists?(args[:branch]) + new(**opts, entries: []) + end + rescue Rugged::ReferenceError + nil + end + # rubocop:enable Metrics/AbcSize + + def initialize(params) + super(params) + branch_exists = git.branch_exists?(commit_id) + return unless commit_id && (branch_exists || !git.commit(commit_id).nil?) + if branch_exists + attributes[:commit_id] = @commit_id = git.branch_sha(commit_id) + end + transform_entries + end + + def git + @git ||= repository.git + end + + def url(prefix) + "#{prefix.sub(%r{/$}, '')}#{url_path}" + end + + def url_path + ['', # this empty string adds the leading slash + repository.to_param, + 'ref', commit_id, + 'tree', path].join('/') + end + + protected + + def transform_entries + entries.map! do |entry| + TreeEntry.new(@commit_id, + entry, + repository, + name: entry.name, + path: entry.path, + type: entry.file? ? :blobs : :trees) + end + attributes[:entries] = entries + end +end diff --git a/app/models/tree_entry.rb b/app/models/tree_entry.rb new file mode 100644 index 00000000..248425a9 --- /dev/null +++ b/app/models/tree_entry.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Represents an entry of the git tree. Does not load the underlying blob/tree. +class TreeEntry < ActiveModelSerializers::Model + attr_accessor :name, :path, :type + def initialize(commit_id, gitlab_tree, repository, params) + super(params) + @commit_id = commit_id + @repository = repository + @gitlab_tree = gitlab_tree + end +end diff --git a/app/serializers/v2/blob_serializer.rb b/app/serializers/v2/blob_serializer.rb new file mode 100644 index 00000000..7dd703be --- /dev/null +++ b/app/serializers/v2/blob_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module V2 + # The serializer for Repositories, API version 2 + class BlobSerializer < ApplicationSerializer + type :blobs + attribute :content + attribute :encoding + attribute :path + + link :self do + object.url(Settings.server_url) + end + + def id + object.url_path + end + end +end diff --git a/app/serializers/v2/organization_serializer.rb b/app/serializers/v2/organization_serializer.rb index 335bc0d0..79b3f1f7 100644 --- a/app/serializers/v2/organization_serializer.rb +++ b/app/serializers/v2/organization_serializer.rb @@ -25,7 +25,7 @@ def id has_many(:members, serializer: V2::UserSerializer::Relationship) has_many :repositories, - serializer: V2::RepositorySerializer::Relationship do + serializer: V2::RepositoryCompoundSerializer::Relationship do include_data false link :related do path = url_for(controller: 'v2/repositories', action: 'index', diff --git a/app/serializers/v2/repository_serializer.rb b/app/serializers/v2/repository_compound_serializer.rb similarity index 85% rename from app/serializers/v2/repository_serializer.rb rename to app/serializers/v2/repository_compound_serializer.rb index 4c44d348..c0d62efd 100644 --- a/app/serializers/v2/repository_serializer.rb +++ b/app/serializers/v2/repository_compound_serializer.rb @@ -2,14 +2,16 @@ module V2 # The serializer for Repositories, API version 2 - class RepositorySerializer < ApplicationSerializer + class RepositoryCompoundSerializer < ApplicationSerializer # The serializer for the relationship object class Relationship < ApplicationSerializer + type :repositories def id object.to_param end end + type :repositories attribute :name attribute :description attribute :content_type diff --git a/app/serializers/v2/search_result_serializer.rb b/app/serializers/v2/search_result_serializer.rb index 8ce8a3e3..36353418 100644 --- a/app/serializers/v2/search_result_serializer.rb +++ b/app/serializers/v2/search_result_serializer.rb @@ -9,7 +9,8 @@ class SearchResultSerializer < ApplicationSerializer delegate :id, :results_count, :repositories_count, :organizational_units_count, to: :object - has_many(:repositories, serializer: V2::RepositorySerializer::Relationship) + has_many(:repositories, + serializer: V2::RepositoryCompoundSerializer::Relationship) has_many(:organizational_units, serializer: V2::UserSerializer::Relationship) end diff --git a/app/serializers/v2/tree_serializer.rb b/app/serializers/v2/tree_serializer.rb new file mode 100644 index 00000000..d3c4d65c --- /dev/null +++ b/app/serializers/v2/tree_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V2 + # The serializer for Repositories, API version 2 + class TreeSerializer < ApplicationSerializer + type :trees + attribute :entries + attribute :path + + link :self do + object.url(Settings.server_url) + end + + def id + object.url_path + end + end +end diff --git a/app/serializers/v2/user_serializer.rb b/app/serializers/v2/user_serializer.rb index 3fc035de..aa224fc3 100644 --- a/app/serializers/v2/user_serializer.rb +++ b/app/serializers/v2/user_serializer.rb @@ -26,7 +26,7 @@ def id serializer: V2::OrganizationSerializer::Relationship) has_many :repositories, - serializer: V2::RepositorySerializer::Relationship do + serializer: V2::RepositoryCompoundSerializer::Relationship do include_data false link :related do path = url_for(controller: 'v2/repositories', action: 'index', diff --git a/config/application.rb b/config/application.rb index 6c0968ac..23b08434 100644 --- a/config/application.rb +++ b/config/application.rb @@ -41,5 +41,14 @@ class Application < Rails::Application resource '*', headers: :any, methods: :any end end + + config.autoload_paths << Rails.root.join('lib') + + config.after_initialize do + SettingsPresenceValidator.new(Settings).call + SettingsNormalizer.new(Settings).call + SettingsInitializer.new(Settings).call + SettingsValidator.new(Settings).call + end end end diff --git a/config/initializers/core_extensions/action_dispatch/journey/router/utils.rb b/config/initializers/core_extensions/action_dispatch/journey/router/utils.rb new file mode 100644 index 00000000..b24a8772 --- /dev/null +++ b/config/initializers/core_extensions/action_dispatch/journey/router/utils.rb @@ -0,0 +1,45 @@ +# rubocop:disable Style/FrozenStringLiteralComment +# rubocop:disable Style/PerlBackrefs +# Rails is not yet ready for frozen string literals. + +module ActionDispatch + module Journey # :nodoc: + class Router # :nodoc: + class Utils # :nodoc: + original_definition_of_normalize_path = method(:normalize_path) + + define_singleton_method(:normalize_path) do |path| + # rubocop:disable Style/GlobalVars + if $do_not_merge_multiple_slashes_in_request_urls + # This is basically the same as the original method, but the + # multiple slashes are only merged if they occur at the beginning + # of the +path+. This must only be done *after* the routes have been + # loaded. It is called on every request to normalize the requested + # URL. + # :nocov: + path = "/#{path}" + # This is the only changed line wrt. the original code: + path = path.sub(%r{\A/+}, '/') + path.sub!(%r{/+\Z}, ''.freeze) + path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase } + path = '/' if path == ''.freeze + path + # :nocov: + else + # Normalizes URI path. + # + # Strips off trailing slash and ensures there is a leading slash. + # Also converts downcase url encoded string to uppercase. + # + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + # normalize_path("/%ab") # => "/%AB" + original_definition_of_normalize_path.call(path) + end + end + end + end + end +end diff --git a/config/initializers/core_extensions/sequel/model/orm_adapter/sequel.rb b/config/initializers/core_extensions/sequel/model/orm_adapter/sequel.rb new file mode 100644 index 00000000..b1aba424 --- /dev/null +++ b/config/initializers/core_extensions/sequel/model/orm_adapter/sequel.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# rubocop:disable Style/ClassAndModuleChildren +class Sequel::Model + # This is monkey-patching the get method to allow for class table inheritance. + # orm_adapter-sequel's development is stale. + class OrmAdapter < ::OrmAdapter::Base + def get(id) + column = "#{klass.table_name}__#{klass.primary_key}" + klass.find(wrap_key(column.to_sym => id)) + end + end +end diff --git a/config/initializers/settings_initializer.rb b/config/initializers/settings_initializer.rb new file mode 100644 index 00000000..39991d9a --- /dev/null +++ b/config/initializers/settings_initializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Initializes the settings, e.g. creates directories. +class SettingsInitializer + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + def call + create_directories + end + + protected + + def create_directories + [@settings.data_directory].each do |dir| + FileUtils.mkdir_p(dir) unless File.exists?(dir) + end + end +end diff --git a/config/initializers/settings_normalizer.rb b/config/initializers/settings_normalizer.rb new file mode 100644 index 00000000..970c25fd --- /dev/null +++ b/config/initializers/settings_normalizer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Normalizes the settings values. +class SettingsNormalizer + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + def call + normalize_paths + end + + protected + + def normalize_paths + @settings.data_directory = normalize_path(@settings.data_directory) + end + + def normalize_path(path) + return unless path + + path = path.to_s + + # Replace multiple slashes by only one. + path = path.gsub(%r{/+}, '/') + + # Remove trailing slash + path = path.gsub(%r{/\z}, '') + + path = Pathname.new(path) + + # Expand relative paths (e.g. foo/../bar to bar) + path.relative_path_from(Pathname.new('')) if path.relative? + + Rails.root.join(path) + end +end diff --git a/config/initializers/settings_validator.rb b/config/initializers/settings_validator.rb new file mode 100644 index 00000000..a9c327f3 --- /dev/null +++ b/config/initializers/settings_validator.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Because of the order in which the initializers are loaded, this file needs to +# contain two classes: SettingsValidator and SettingsPresenceValidator. + +# Validates the settings and exits if they are invalid. +class SettingsValidator + ERROR_MESSAGE_HEADER = < @settings.server_url, + 'jwt' => @settings.jwt, + 'jwt.expiration_hours' => @settings.jwt.expiration_hours, + 'data_directory' => @settings.data_directory + }.each do |key, value| + validate_presence(key, value) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 3f1f533d..001fb5b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true + # rubocop:disable Metrics/BlockLength +CONTAINING_ONE_SLASH = %r{[^/]+/[^/]+} unless defined?(CONTAINING_ONE_SLASH) +unless defined?(UNTIL_DOUBLE_SLASHES) + UNTIL_DOUBLE_SLASHES = %r{([^/]+)(/[^/]+)*} +end + # :nocov: def rake_task?(tasks = []) defined?(Rake) && tasks.any? do |task| @@ -10,13 +16,45 @@ def rake_task?(tasks = []) # :nocov: Rails.application.routes.draw do + # The router normalizes the paths of all routes and of all requests. This + # also removes doubles slashes. That removal needs to be suppressed for the + # requests because our URLs use double and even triple slashes. The following + # flag activates a monkey patch after the routes have been loaded. It must + # not be active during route loading because the path normalization must + # happen then. Here, we enable normalization. At the end of this block, we + # disable it again. + # rubocop:disable Style/GlobalVars + $do_not_merge_multiple_slashes_in_request_urls = false + # rubocop:enable Style/GlobalVars + + # We need these routes multiple times: + routes_under_repository = lambda do + scope '/tree' do + post '/', controller: 'v2/trees', action: 'create' + get '/', controller: 'v2/trees', action: 'show', defaults: {path: ''} + patch '/:path', + controller: 'v2/trees', + action: 'update', + constraints: {path: UNTIL_DOUBLE_SLASHES} + delete '/:path', + controller: 'v2/trees', + action: 'destroy', + constraints: {path: UNTIL_DOUBLE_SLASHES} + get '/:path', + controller: 'v2/trees', + action: 'show', + constraints: {path: UNTIL_DOUBLE_SLASHES} + end + end + scope format: false, defaults: {format: :json} do root to: 'v2/search#search' # We exclude the devise routes from these rake tasks because they load the # models. When loading the models, the database needs to exist, or else it # throws an error. - unless rake_task?(%w(db:create db:migrate db:drop)) + unless rake_task?(%w(db:create db:migrate db:drop + db:recreate db:recreate:seed)) devise_for :users, controllers: {sessions: 'v2/users/sessions'} end resources :organizations, @@ -25,7 +63,11 @@ def rake_task?(tasks = []) param: :slug do resources :repositories, controller: 'v2/repositories', - only: :index + only: :index do + resources :files, + controller: 'v2/files', + only: %i(create show) + end end resources :users, @@ -43,7 +85,7 @@ def rake_task?(tasks = []) controller: 'v2/repositories', except: %i(index new edit), param: :slug, - constraints: {slug: %r{[^/]+/[^/]+}} + constraints: {slug: CONTAINING_ONE_SLASH} get 'search', controller: 'v2/search', action: 'search' get 'version', controller: 'v2/version', action: 'show' @@ -54,9 +96,33 @@ def rake_task?(tasks = []) action: :show end + # Repositories + resources '/', + param: :slug, + controller: 'v2/repositories', + except: %i(index new create edit), + constraints: {slug: CONTAINING_ONE_SLASH} + + # Below Repositories + scope '/:repository_slug', + constraints: {repository_slug: CONTAINING_ONE_SLASH} do + routes_under_repository.call + # by reference + scope '/ref/:ref' do + routes_under_repository.call + end + end + + # Easy access to Organizational Units get '/:slug', controller: 'v2/organizational_units', action: :show, as: :organizational_unit end + + # The router normalizes the paths of all routes and of all requests. Disable + # normalizing it. See the comment at the beginning of the current block. + # rubocop:disable Style/GlobalVars + $do_not_merge_multiple_slashes_in_request_urls = true + # rubocop:enable Style/GlobalVars end diff --git a/config/settings.yml b/config/settings.yml index 0ffa71ef..2e9792d8 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -3,3 +3,8 @@ server_url: 'http://localhost:3000' jwt: expiration_hours: 24 + +# The data directory contains all the git repositories. A relative path is +# interpreted relative to the application root directory. +# This directory will be created if it does not exist. +data_directory: 'data' diff --git a/config/settings/test.yml b/config/settings/test.yml index d63cc300..83843502 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -1 +1,2 @@ server_url: 'http://example.test' +data_directory: 'tmp/data' diff --git a/db/seeds.rb b/db/seeds.rb index 0e72fa1e..784dee91 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,7 +16,8 @@ url_path_method = lambda do |resource| V2::OrganizationsController.resource_url_path(resource) end -Organization.new(name: 'All Users', url_path_method: url_path_method).save +Organization.new(name: 'Seed User Organization', + url_path_method: url_path_method).save organization = Organization.first User.all do |user| organization.add_member(user) @@ -28,11 +29,24 @@ end owner_count = OrganizationalUnit.count content_types = %w(ontology model specification mathematical) -(0..(2 * owner_count - 1)).each do |index| - Repository.new(owner: OrganizationalUnit.find(id: index % owner_count + 1), - name: "repo#{index}", - content_type: content_types[index % content_types.size], - public_access: true, - description: 'This is a dummy repository.', - url_path_method: url_path_method).save +(0..(2 * owner_count - 1)).each do |repo_index| + owner = OrganizationalUnit.find(id: repo_index % owner_count + 1) + repository = + RepositoryCompound. + new(owner: owner, + name: "repo#{repo_index}", + content_type: content_types[repo_index % content_types.size], + public_access: true, + description: 'This is a dummy repository.', + url_path_method: url_path_method) + repository.save + + user = owner.is_a?(Organization) ? owner.members.first : owner + (1..5).each do |file_index| + path = "#{file_index}_test.txt" + path = "subdir_#{file_index}/#{path}" if file_index <= 2 + Blob.new(repository: repository, user: user, branch: 'master', path: path, + content: "test file ##{file_index}", encoding: 'plain', + commit_message: "Add #{path}.").create + end end diff --git a/lib/git.rb b/lib/git.rb new file mode 100644 index 00000000..a687ee37 --- /dev/null +++ b/lib/git.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# This class encapsulates all git related functionality. +class Git + extend Git::Cloning::ClassMethods + include Git::Committing + include Git::Pulling + + class Error < ::StandardError; end + + attr_reader :gitlab + delegate :bare?, :branches, :branch_count, :branch_exists?, :branch_names, + :commit_count, :find_commits, :empty?, :log, :ls_files, :rugged, + to: :gitlab + + def self.create(path) + raise Error, "Path #{path} already exists." if Pathname.new(path).exist? + FileUtils.mkdir_p(File.dirname(path)) + Rugged::Repository.init_at(path.to_s, :bare) + new(path) + end + + def self.destroy(path) + new(path.to_s).gitlab.repo_exists? && FileUtils.rm_rf(path) + end + + def initialize(path) + @gitlab = Gitlab::Git::Repository.new(path.to_s) + end + + def repo_exists? + gitlab.repo_exists? + rescue Gitlab::Git::Repository::NoRepository + false + end + + def path + Pathname.new(gitlab. + instance_variable_get(:@attributes). + instance_variable_get(:@path)) + end + + # Query for a blob + def blob(ref, path) + Gitlab::Git::Blob.find(gitlab, ref, path) + end + + # Query for a tree + def tree(ref, path) + Gitlab::Git::Tree.where(gitlab, ref, path) + end + + def commit(ref) + Gitlab::Git::Commit.find(gitlab, ref) + end + + # Query for a tree + def path_exists?(ref, path) + !blob(ref, path).nil? || tree(ref, path).any? + end + + def branch_sha(name) + gitlab.find_branch(name)&.dereferenced_target&.sha + end + + def default_branch + gitlab.discover_default_branch + end + + def default_branch=(name) + ref = "refs/heads/#{name}" unless name.start_with?('refs/heads/') + rugged.head = ref + end + + # Create a branch with name +name+ at the reference +ref+. + def create_branch(name, ref) + gitlab.create_branch(name, ref) + end +end diff --git a/lib/git/cloning.rb b/lib/git/cloning.rb new file mode 100644 index 00000000..cd4fde7d --- /dev/null +++ b/lib/git/cloning.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Git + # Methods for committing + module Cloning + class Error < StandardError; end + class InvalidRemoteError < Error; end + + # ... that are invoked from the class + module ClassMethods + def clone(path, remote) + if remote_git?(remote) + clone_git(path, remote) + # rubocop:disable Lint/AssignmentInCondition + elsif layout = remote_svn_layout(remote) # remote_svn? + clone_svn(path, remote, layout) + else + raise InvalidRemoteError + end + new(path) + end + + protected + + def clone_git(path, remote) + Popen.popen(%W(git clone --mirror #{remote} #{path})) + end + + def clone_svn(path, remote, layout) + Dir.mktmpdir do |tmpdir| + tmppath = clone_svn_to_temppath(remote, layout, tmpdir) + convert_to_bare_and_move_to_path(path, tmppath) + end + end + + def clone_svn_to_temppath(remote, layout, tmpdir) + tmppath = File.join(tmpdir, 'clone.git-svn') + if layout == :standard + Popen.popen(%W(git svn clone --stdlayout #{remote} #{tmppath})) + else + Popen.popen(%W(git svn clone #{remote} #{tmppath})) + end + tmppath + end + + def convert_to_bare_and_move_to_path(path, tmppath) + FileUtils.mkdir_p(File.dirname(path)) + FileUtils.mv(File.join(tmppath, '.git'), path) + Popen.popen(%w(git config --bool core.bare true), path) + end + + def remote_git?(remote) + # GIT_ASKPASS is set to the 'true' executable. It simply returns + # successfully. This way, no credentials are supplied. + _out, status = Popen.popen(%w(git ls-remote -h) + [remote], + nil, + 'GIT_ASKPASS' => 'true') + status.zero? + end + + def remote_svn_layout(remote) + out, status = Popen.popen(%w(svn ls) + [remote]) + if status.zero? + if out.split("\n") == %w(branches/ tags/ trunk/) + :standard + else + :non_standard + end + else + false + end + end + end + end +end diff --git a/lib/git/committing.rb b/lib/git/committing.rb new file mode 100644 index 00000000..53bd4ff9 --- /dev/null +++ b/lib/git/committing.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +class Git + # Methods for committing + module Committing + class Error < StandardError; end + class InvalidPathError < Error; end + + # This error is thrown when attempting to commit on a branch whose HEAD has + # changed. + class HeadChangedError < Error + attr_reader :options + def initialize(message, options) + super(message) + @options = options + end + end + + # Create a file in repository and return commit sha + # + # options should contain next structure: + # file: { + # content: 'Lorem ipsum...', + # path: 'documents/story.txt' + # }, + # author: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # committer: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # commit: { + # message: 'Wow such commit', + # branch: 'master', # optional - default: 'master' + # update_ref: false # optional - default: true + # } + def create_file(options, previous_head_sha = nil) + commit_with(options, previous_head_sha) do |index_options| + Gitlab::Git::Index.new(gitlab).create(index_options) + end + end + + # Commit (add or update) file in repository and return commit sha + # + # options should contain next structure: + # file: { + # content: 'Lorem ipsum...', + # path: 'documents/story.txt' + # }, + # author: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # committer: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # commit: { + # message: 'Wow such commit', + # branch: 'master', # optional - default: 'master' + # update_ref: false # optional - default: true + # } + def update_file(options, previous_head_sha = nil) + previous_path = options[:file].delete(:previous_path) + action = previous_path && previous_path != path ? :move : :update + + commit_with(options, previous_head_sha) do |index_options| + Gitlab::Git::Index.new(gitlab).send(action, index_options) + end + end + + # Remove file from repository and return commit sha + # + # options should contain next structure: + # file: { + # path: 'documents/story.txt' + # }, + # author: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # committer: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # commit: { + # message: 'Remove FILENAME', + # branch: 'master' # optional - default: 'master' + # } + def remove_file(options, previous_head_sha = nil) + commit_with(options, previous_head_sha) do |index_options| + Gitlab::Git::Index.new(gitlab).delete(index_options) + end + end + + # Rename file from repository and return commit sha + # + # options should contain next structure: + # file: { + # previous_path: 'documents/old_story.txt' + # path: 'documents/story.txt' + # content: 'Lorem ipsum...', + # update: true/false + # }, + # author: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # committer: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # commit: { + # message: 'Rename FILENAME', + # branch: 'master' # optional - default: 'master' + # } + # + def rename_file(options, previous_head_sha = nil) + commit_with(options, previous_head_sha) do |index_options| + Gitlab::Git::Index.new(gitlab).move(index_options) + end + end + + # Create a new directory with a .gitkeep file. Creates + # all required nested directories (i.e. mkdir -p behavior) + # + # options should contain next structure: + # author: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # committer: { + # email: 'user@example.com', + # name: 'Test User', + # time: Time.now # optional - default: Time.now + # }, + # commit: { + # message: 'Wow such commit', + # branch: 'master', # optional - default: 'master' + # update_ref: false # optional - default: true + # } + def mkdir(path, options, previous_head_sha = nil) + options[:file][:path] = path + insert_defaults(options) + commit_with(options, previous_head_sha) do |index_options| + Gitlab::Git::Index.new(gitlab).create_dir(index_options) + end + end + + protected + + # TODO: Instead of comparing the HEAD with the previous commit_sha, actually + # try merging and only raise if there is a conflict. Add the merge conflict + # to the Error. + # See issue #97. + def prevent_overwriting_previous_changes(options, previous_head_sha) + return unless conflict?(options, previous_head_sha) + raise HeadChangedError.new('The branch has changed since editing.', + options) + end + + def conflict?(options, previous_head_sha) + !previous_head_sha.nil? && + branch_sha(options[:commit][:branch]) != previous_head_sha + end + + def insert_defaults(options) + options[:author][:time] ||= Time.now + options[:committer][:time] ||= Time.now + options[:commit][:branch] ||= 'master' + options[:commit][:update_ref] = true if options[:commit][:update_ref].nil? + normalize_ref(options) + end + + def normalize_ref(options) + return if options[:commit][:branch].start_with?('refs/') + options[:commit][:branch] = 'refs/heads/' + options[:commit][:branch] + end + + # TODO: This needs to be mutexed accross all backend processes/threads and + # Git-SSH. + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/MethodLength + def commit_with(options, previous_head_sha) + insert_defaults(options) + prevent_overwriting_previous_changes(options, previous_head_sha) + + commit = options[:commit] + ref = commit[:branch] + ref = 'refs/heads/' + ref unless ref.start_with?('refs/') + update_ref = commit[:update_ref].nil? ? true : commit[:update_ref] + + index = Gitlab::Git::Index.new(gitlab) + + parents = [] + unless empty? + rugged_ref = rugged.references[ref] + unless rugged_ref + raise Gitlab::Git::Repository::InvalidRef, 'Invalid branch name' + end + last_commit = rugged_ref.target + index.read_tree(last_commit.tree) + parents = [last_commit] + end + + file = options[:file] + index_options = {} + index_options[:file_path] = file[:path] if file[:path] + index_options[:content] = file[:content] if file[:content] + index_options[:encoding] = file[:encoding] if file[:encoding] + if file[:previous_path] + index_options[:previous_path] = file[:previous_path] + end + yield(index_options) + + opts = {} + opts[:tree] = index.write_tree + opts[:author] = options[:author] + opts[:committer] = options[:committer] + opts[:message] = commit[:message] + opts[:parents] = parents + opts[:update_ref] = ref if update_ref + + Rugged::Commit.create(rugged, opts) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/MethodLength + end +end diff --git a/lib/git/pulling.rb b/lib/git/pulling.rb new file mode 100644 index 00000000..df7fa6ae --- /dev/null +++ b/lib/git/pulling.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Git + # Methods for pulling + module Pulling + class Error < StandardError; end + + def pull + if svn? + pull_svn + else + pull_git + end + end + + protected + + def svn? + path.join('svn').directory? + end + + def pull_svn + _out_fetch, status_fetch = Popen.popen(%w(git svn fetch), path.to_s) + + ref = svn_has_trunk? ? 'trunk' : 'git-svn' + cmd = %W(git update-ref refs/heads/master refs/remotes/#{ref}) + _out_update, status_update = Popen.popen(cmd, path.to_s) + + [status_fetch, status_update].all?(&:zero?) + end + + def pull_git + _out, status = Popen.popen(%w(git fetch --all), path.to_s) + status.zero? + end + + def svn_has_trunk? + out, _status = Popen.popen(%w(git config svn-remote.svn.fetch), path.to_s) + out.start_with?('trunk:') + end + end +end diff --git a/lib/popen.rb b/lib/popen.rb new file mode 100644 index 00000000..7525f070 --- /dev/null +++ b/lib/popen.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'open3' + +# Methods for opening the commandline client +module Popen + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def self.popen(cmd, path = nil, vars = {}) + unless cmd.is_a?(Array) + raise 'System commands must be given as an array of strings' + end + + path ||= Dir.pwd + vars = vars.dup + vars['PWD'] = path + options = {chdir: path} + + FileUtils.mkdir_p(path) unless File.directory?(path) + + cmd_output = '' + cmd_status = 0 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| + yield(stdin) if block_given? + stdin.close + + cmd_output += stdout.read + cmd_output += stderr.read + cmd_status = wait_thr.value.exitstatus + end + + [cmd_output, cmd_status] + end +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 2fe8a53d..1a3e031d 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -1,6 +1,10 @@ # frozen_string_literal: true namespace :db do + task recreate: 'repo:clean' do + Rake::Task['db:recreate'].invoke + end + namespace :recreate do desc 'Recreate the database (drop, create, migrate) and seed' task seed: 'db:recreate' do diff --git a/lib/tasks/repo.rake b/lib/tasks/repo.rake new file mode 100644 index 00000000..4e7abafe --- /dev/null +++ b/lib/tasks/repo.rake @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +namespace :repo do + desc 'Remove the git repository directory.' + task clean: :environment do + git_dir = Settings.data_directory.join('git').freeze + git_dir.rmtree if git_dir.exist? + end +end diff --git a/spec/controllers/v2/repositories_controller_spec.rb b/spec/controllers/v2/repositories_controller_spec.rb index 84ea0903..c78fdf0e 100644 --- a/spec/controllers/v2/repositories_controller_spec.rb +++ b/spec/controllers/v2/repositories_controller_spec.rb @@ -64,6 +64,12 @@ it 'creates the repository' do expect(Repository.find(name: name)).not_to be(nil) end + it 'creates the git repository' do + repository = Repository.find(name: name) + git_path = Settings.data_directory.join('git', + "#{repository.to_param}.git") + expect(Git.new(git_path).repo_exists?).to be(true) + end it 'sets the correct url' do found_repository = Repository.find(name: name) expect(found_repository.url_path). @@ -170,6 +176,11 @@ it 'deletes the repository' do expect(Repository.find(slug: repository.slug)).to be(nil) end + it 'deletes the git repository' do + git_path = Settings.data_directory.join('git', + "#{repository.to_param}.git") + expect(Git.new(git_path).repo_exists?).to be(false) + end end context 'failing' do diff --git a/spec/controllers/v2/trees_controller_spec.rb b/spec/controllers/v2/trees_controller_spec.rb new file mode 100644 index 00000000..0b5e47af --- /dev/null +++ b/spec/controllers/v2/trees_controller_spec.rb @@ -0,0 +1,424 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.shared_examples 'a TreesController on GET show' do + it { expect(response).to have_http_status(:ok) } + it { |example| expect([example, response]).to comply_with_api } +end + +RSpec.shared_examples 'a TreesController on POST create' do + it { expect(response).to have_http_status(:created) } + it { |example| expect([example, response]).to comply_with_api } + + it 'creates a file with the correct content at the path' do + expect(git.blob(branch, path).data).to eq(content) + end +end + +RSpec.shared_examples 'a failing TreesController on POST create' do + it { expect(response).to have_http_status(:unprocessable_entity) } + it do |example| + expect([example, response]).to comply_with_api('validation_error') + end + + it 'does not create a file' do + expect(git.blob(branch, path)).to be(nil) + end +end + +RSpec.shared_examples 'a failing TreesController on PATCH update' do + it { expect(response).to have_http_status(:unprocessable_entity) } + it do |example| + expect([example, response]).to comply_with_api('validation_error') + end + + it 'does not change the original file' do + expect(git.blob(branch, path).data).to eq(content) + end + + it 'does not move the file' do + expect(git.blob(branch, updated_path)).to be(nil) + end +end + +RSpec.describe V2::TreesController do + create_user_and_sign_in + let!(:repository) { create(:repository_compound) } + let!(:git) { repository.git } + let!(:branch) { git.default_branch } + + context 'without a ref' do + describe 'GET show tree' do + before do + get :show, params: {repository_slug: repository.to_param, path: '/'} + end + + it_behaves_like 'a TreesController on GET show' + end + + describe 'GET show blob' do + before do + subpath = git.tree(branch, '/').first.path + path = git.tree(branch, subpath).first.path + get :show, params: {repository_slug: repository.to_param, + path: path} + end + + it_behaves_like 'a TreesController on GET show' + end + + describe 'GET show to non-existent path' do + before do + get :show, params: {repository_slug: repository.to_param, + path: '/this/path/does-not-exist'} + end + + it { expect(response).to have_http_status(:not_found) } + end + + describe 'POST create' do + let(:path) { generate(:filepath) } + let(:commit_message) { generate(:commit_message) } + let(:content) { 'some content' } + let(:content_encoded) { Base64.strict_encode64(content) } + + context 'successful' do + before do + post :create, + params: {repository_slug: repository.to_param, + data: {attributes: {content: content_encoded, + encoding: 'base64', + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a TreesController on POST create' + end + + context 'failing' do + context 'because no content is given' do + before do + post :create, + params: {repository_slug: repository.to_param, + data: {attributes: {encoding: 'base64', + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a failing TreesController on POST create' + + it 'shows the content error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/content'}) + end + end + + context 'because no encoding is given' do + before do + post :create, + params: {repository_slug: repository.to_param, + data: {attributes: {content: content_encoded, + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a failing TreesController on POST create' + + it 'shows the content error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/encoding'}) + end + end + + context 'because a resource at that path already exists' do + before do + existing_path = git.tree(branch, '/').first.path + post :create, + params: {repository_slug: repository.to_param, + data: {attributes: {content: content_encoded, + encoding: 'base64', + commit_message: commit_message, + path: existing_path}}} + end + it_behaves_like 'a failing TreesController on POST create' + + it 'shows the path error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/path'}) + end + end + end + end + + describe 'PATCH update' do + let(:path) do + git.tree(branch, git.tree(branch, '/').first.path).first.path + end + let(:updated_path) { generate(:filepath) } + let(:commit_message) { generate(:commit_message) } + let!(:content) { git.blob(branch, path).data } + let(:content_encoded) { Base64.strict_encode64(content) } + let(:updated_content) { 'some updated content' } + + context 'successful' do + before do + patch :update, + params: {repository_slug: repository.to_param, + path: path, + data: {attributes: {content: updated_content, + encoding: 'plain', + commit_message: commit_message, + path: updated_path}}} + end + + it { expect(response).to have_http_status(:ok) } + it { |example| expect([example, response]).to comply_with_api } + + it 'moves the file and ' do + expect(git.blob(branch, updated_path).data).to eq(updated_content) + end + end + + context 'failing' do + context 'because the path does not exist' do + before do + patch :update, + params: {repository_slug: repository.to_param, + path: 'a-path-that-does-not-exist', + data: {attributes: {content: updated_content, + encoding: 'plain', + commit_message: commit_message, + path: updated_path}}} + end + + it { expect(response).to have_http_status(:not_found) } + it { expect(response.body.strip).to be_empty } + end + + context 'because no path and no content is given' do + before do + patch :update, + params: {repository_slug: repository.to_param, + path: path, + data: {attributes: {encoding: 'plain', + commit_message: commit_message}}} + end + it_behaves_like 'a failing TreesController on PATCH update' + + it 'shows the content error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/content'}) + end + end + + context 'because no encoding is given' do + before do + patch :update, + params: {repository_slug: repository.to_param, + path: path, + data: {attributes: {content: updated_content, + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a failing TreesController on PATCH update' + + it 'shows the content error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/encoding'}) + end + end + end + end + + describe 'DELETE destroy' do + let(:path) do + git.tree(branch, git.tree(branch, '/').first.path).first.path + end + + context 'successful' do + before do + delete :destroy, + params: {repository_slug: repository.to_param, path: path} + end + + it { expect(response).to have_http_status(:no_content) } + it { expect(response.body.strip).to be_empty } + it 'removes the file from the git' do + expect(git.blob(branch, path)).to be(nil) + end + end + end + end + + context 'with a ref' do + let(:branch_sha) { git.branch_sha(git.default_branch) } + + describe 'GET show tree' do + before do + get :show, params: {repository_slug: repository.to_param, + ref: branch, + path: '/'} + end + + it_behaves_like 'a TreesController on GET show' + end + + describe 'GET show blob' do + before do + subpath = git.tree(branch, '/').first.path + path = git.tree(branch, subpath).first.path + get :show, params: {repository_slug: repository.to_param, + ref: branch, + path: path} + end + + it_behaves_like 'a TreesController on GET show' + end + + describe 'GET show to non-existent path' do + before do + get :show, params: {repository_slug: repository.to_param, + ref: branch, + path: '/this/path/does-not-exist'} + end + + it { expect(response).to have_http_status(:not_found) } + end + + describe 'POST create' do + let(:path) { generate(:filepath) } + let(:commit_message) { generate(:commit_message) } + let(:content) { 'some content' } + let(:content_encoded) { Base64.strict_encode64(content) } + + context 'successful' do + before do + post :create, + params: {repository_slug: repository.to_param, + ref: branch, + data: {attributes: {content: content_encoded, + encoding: 'base64', + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a TreesController on POST create' + end + + context 'failing' do + context 'because a commit id is given instead of a branch name' do + before do + post :create, + params: {repository_slug: repository.to_param, + ref: branch_sha, + data: {attributes: {content: content_encoded, + encoding: 'base64', + commit_message: commit_message, + path: path}}} + end + it_behaves_like 'a failing TreesController on POST create' + + it 'shows the branch error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/branch'}) + end + end + end + end + + describe 'PATCH update' do + let(:path) do + git.tree(branch, git.tree(branch, '/').first.path).first.path + end + let(:updated_path) { generate(:filepath) } + let(:commit_message) { generate(:commit_message) } + let!(:content) { git.blob(branch, path).data } + let(:content_encoded) { Base64.strict_encode64(content) } + let(:updated_content) { 'some updated content' } + + context 'successful' do + before do + patch :update, + params: {repository_slug: repository.to_param, + ref: branch, + path: path, + data: {attributes: {content: updated_content, + encoding: 'plain', + commit_message: commit_message, + path: updated_path}}} + end + + it { expect(response).to have_http_status(:ok) } + it { |example| expect([example, response]).to comply_with_api } + + it 'moves the file and changes the content' do + expect(git.blob(branch, updated_path).data).to eq(updated_content) + end + end + + context 'failing' do + context 'because a commit id is given instead of a branch name' do + before do + post :create, + params: {repository_slug: repository.to_param, + ref: branch_sha, + path: path, + data: {attributes: {content: updated_content, + encoding: 'plain', + commit_message: commit_message, + path: updated_path}}} + end + it_behaves_like 'a failing TreesController on PATCH update' + + it 'shows the branch error' do + expect(response_hash['errors'].first). + to include('source' => {'pointer' => '/data/attributes/branch'}) + end + end + end + end + + describe 'DELETE destroy' do + let(:path) do + git.tree(branch, git.tree(branch, '/').first.path).first.path + end + + context 'successful' do + before do + delete :destroy, + params: {repository_slug: repository.to_param, + ref: branch, + path: path} + end + + it { expect(response).to have_http_status(:no_content) } + it { expect(response.body.strip).to be_empty } + it 'removes the file from the git' do + expect(git.blob(branch, path)).to be(nil) + end + end + + context 'failing' do + context 'because a commit id is given instead of a branch name' do + before do + delete :destroy, + params: {repository_slug: repository.to_param, + ref: branch_sha, + path: path} + end + + it { expect(response).to have_http_status(:unprocessable_entity) } + it do |example| + expect([example, response]).to comply_with_api('validation_error') + end + + it 'shows the branch error' do + expect(response_hash['errors'].last). + to include('source' => {'pointer' => '/data/attributes/branch'}) + end + + it 'does not delete the file from the git' do + expect(git.blob(branch, path)).not_to be(nil) + end + end + end + end + end +end diff --git a/spec/factories/blob_factory.rb b/spec/factories/blob_factory.rb new file mode 100644 index 00000000..02fbd554 --- /dev/null +++ b/spec/factories/blob_factory.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +FactoryGirl.define do + factory :blob do + transient do + repository { create(:repository_compound) } + path { generate(:filepath) } + branch { 'master' } + content { 'content' } + encoding { 'plain' } + commit_message { generate(:commit_message) } + user { create(:user) } + end + skip_create + initialize_with do + Blob.new(repository: repository, + branch: branch, + path: path, + content: content, + encoding: encoding, + commit_message: commit_message, + user: user) + end + end +end diff --git a/spec/factories/git_factory.rb b/spec/factories/git_factory.rb new file mode 100644 index 00000000..3432abb1 --- /dev/null +++ b/spec/factories/git_factory.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +FactoryGirl.define do + # Sha to mock a commit id + sequence(:commit_sha) do + Faker::Crypto.sha1 + end + + sequence(:git_repository_path) do |n| + File.join(Dir.pwd, 'git_repositories', n.to_s).to_s + end + + sequence(:git_user) do |n| + {email: "git-user-#{n}@example.com", + name: "git-user#-#{n}", + time: Time.now} + end + + sequence(:commit_message) do |n| + "#{n}: #{Faker::Lorem.sentence}" + end + + sequence(:content) do |n| + "#{n}: #{Faker::Lorem.sentence}\n" + end + + factory :git_commit_hash, class: Hash do + transient do + branch 'master' + end + + skip_create + initialize_with { {} } + after(:create) do |git_commit_hash, evaluator| + git_commit_hash.merge!(message: generate(:commit_message), + branch: evaluator.branch, + update_ref: true) + end + end + + factory :git_commit_info, class: Hash do + transient do + branch 'master' + content { generate(:content) } + filepath { generate(:filepath) } + end + + skip_create + initialize_with { {} } + + after(:create) do |git_commit_info, evaluator| + git_user = generate(:git_user) + git_commit_info.merge!(file: {content: evaluator.content, + path: evaluator.filepath}, + author: git_user, + committer: git_user, + commit: create(:git_commit_hash, + branch: evaluator.branch)) + end + end + + factory :git, class: Git do + transient do + path { generate(:git_repository_path) } + end + skip_create + initialize_with { Git.create(path) } + end + + trait :with_commits do + transient do + commit_count 1 + end + + after(:create) do |git, evaluator| + commit_files = (1..evaluator.commit_count).map { generate(:filepath) } + commit_files.each do |filepath| + git.create_file(create(:git_commit_info, filepath: filepath)) + end + end + end +end diff --git a/spec/factories/repository_compound_factory.rb b/spec/factories/repository_compound_factory.rb new file mode 100644 index 00000000..528282df --- /dev/null +++ b/spec/factories/repository_compound_factory.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryGirl.define do + factory :repository_compound do + transient do + repository { create(:repository) } + git do + create(:git, :with_commits, + path: RepositoryCompound::GIT_DIRECTORY. + join("#{repository.to_param}.git")) + end + end + skip_create + initialize_with do + # create the git repository + git + # create the repository and wrap it + RepositoryCompound.wrap(repository) + end + end +end diff --git a/spec/factories/settings_factory.rb b/spec/factories/settings_factory.rb new file mode 100644 index 00000000..2f947f2d --- /dev/null +++ b/spec/factories/settings_factory.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryGirl.define do + factory :settings, class: OpenStruct do + skip_create + initialize_with { OpenStruct.new } + after(:create) do |settings| + settings.server_url = 'http://example.com' + + settings.jwt= OpenStruct.new + settings.jwt.expiration_hours = 24 + + settings.data_directory = 'tmp/data' + end + end +end diff --git a/spec/factories/svn_factory.rb b/spec/factories/svn_factory.rb new file mode 100644 index 00000000..3da767c7 --- /dev/null +++ b/spec/factories/svn_factory.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +def exec_silently(cmd, working_directory = nil) + out, status = Popen.popen(['bash', '-c', cmd], working_directory) + return if status.zero? + # :nocov: + raise "Command failed!\n#{cmd}\nExit status: #{status}\nOutput:\n#{out}\n" + # :nocov: +end + +FactoryGirl.define do + sequence(:svn_repository_path_bare) do |n| + File.join(Dir.pwd, 'svn_repositories_bare', n.to_s).to_s + end + + sequence(:svn_repository_path_work) do |n| + File.join(Dir.pwd, 'svn_repositories_work', n.to_s).to_s + end + + sequence(:svn_branch_name) do |n| + "my-branch-#{n}" + end + + factory :svn_repository, class: Array do + skip_create + initialize_with do + path_bare = generate(:svn_repository_path_bare) + path_work = generate(:svn_repository_path_work) + bare_dirname = File.dirname(path_bare) + FileUtils.mkdir_p(bare_dirname) + Dir.chdir(bare_dirname) do + exec_silently("svnadmin create #{File.basename(path_bare)}") + exec_silently("svn co file://#{path_bare} #{path_work}") + end + + [path_bare, path_work] + end + end + + trait :with_svn_standard_layout do + after(:create) do |(_svn_bare_path, svn_work_path)| + %w(branches tags trunk).each do |dir| + FileUtils.mkdir_p(File.join(svn_work_path, dir)) + exec_silently("svn add #{dir}", svn_work_path) + end + exec_silently("svn commit -m 'Setup standard layout'", + svn_work_path) + end + end + + trait :with_svn_branches do + transient do + branch_count 1 + end + + after(:create) do |(_svn_bare_path, svn_work_path), evaluator| + branches = (1..evaluator.branch_count).map do + generate(:svn_branch_name) + end + + unless File.directory?(File.join(svn_work_path, 'trunk')) + # :nocov: + raise 'Only can create a branch in an svn stanard layout.' + # :nocov: + end + + branches.each do |branch| + full_filepath = File.join(svn_work_path, 'branches', branch) + FileUtils.mkdir_p(full_filepath) + exec_silently("svn add #{full_filepath}", svn_work_path) + end + exec_silently("svn commit -m 'Add branches.'", svn_work_path) + end + end + + trait :with_svn_commits do + transient do + commit_count 1 + end + + after(:create) do |(_svn_bare_path, svn_work_path), evaluator| + commit_files = (1..evaluator.commit_count).map { generate(:filepath) } + commit_files.each do |filepath| + full_filepath = + if File.directory?(File.join(svn_work_path, 'trunk')) + File.join(svn_work_path, 'trunk', filepath) + else + File.join(svn_work_path, filepath) + end + FileUtils.mkdir_p(File.dirname(full_filepath)) + File.write(full_filepath, "#{Faker::Lorem.sentence}\n") + exec_silently("svn add '#{File.dirname(full_filepath)}'", svn_work_path) + exec_silently("svn commit -m '#{generate(:commit_message)}'", + svn_work_path) + end + end + end +end diff --git a/spec/factories/tree_entry_factory.rb b/spec/factories/tree_entry_factory.rb new file mode 100644 index 00000000..7b9f794b --- /dev/null +++ b/spec/factories/tree_entry_factory.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryGirl.define do + factory :tree_entry do + transient do + gitlab_tree { create(:gitlab_tree) } + repository { create(:repository) } + commit_id { generate(:commit_sha) } + end + skip_create + initialize_with do + params = {path: generate(:filepath), + type: :blobs} + params[:name] = File.basename(params[:path]) + TreeEntry.new(nil, nil, nil, params) + end + end +end diff --git a/spec/factories/tree_factory.rb b/spec/factories/tree_factory.rb new file mode 100644 index 00000000..c0259db5 --- /dev/null +++ b/spec/factories/tree_factory.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryGirl.define do + factory :tree do + transient do + repository { create(:repository_compound) } + commit_id { generate(:commit_sha) } + path { generate(:filepath) } + entries { [] } + end + skip_create + initialize_with do + Tree.new(commit_id: commit_id, + entries: entries, + path: path, + repository: repository) + end + end +end diff --git a/spec/initializers/settings_initializer_spec.rb b/spec/initializers/settings_initializer_spec.rb new file mode 100644 index 00000000..911b062f --- /dev/null +++ b/spec/initializers/settings_initializer_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe(SettingsInitializer) do + let(:settings) { create :settings } + subject { SettingsInitializer.new(settings) } + + it 'creates the data directory' do + settings.data_directory = File.join(Dir.pwd, 'data') + expect { subject.call }. + to change { File.directory?(settings.data_directory) }. + from(false).to(true) + end +end diff --git a/spec/initializers/settings_normalizer_spec.rb b/spec/initializers/settings_normalizer_spec.rb new file mode 100644 index 00000000..dee80360 --- /dev/null +++ b/spec/initializers/settings_normalizer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe(SettingsNormalizer) do + let(:settings) { create :settings } + subject { SettingsNormalizer.new(settings) } + + context 'normalize_paths' do + context 'on data_directory' do + let(:relative_path) { 'relative/path' } + + it 'makes it a Pathname' do + subject.call + expect(settings.data_directory).to be_a(Pathname) + end + + it 'makes a relative path absolute' do + settings.data_directory = relative_path + subject.call + expect(settings.data_directory.absolute?).to be(true) + end + + it 'prepends Rails.root to a relative path' do + settings.data_directory = relative_path + subject.call + expect(settings.data_directory).to eq(Rails.root.join(relative_path)) + end + + it 'does not change an absolute path' do + dir = File.join(Dir.pwd, relative_path) + settings.data_directory = dir + subject.call + expect(settings.data_directory.to_s).to eq(dir) + end + end + end +end diff --git a/spec/initializers/settings_presence_validator_spec.rb b/spec/initializers/settings_presence_validator_spec.rb new file mode 100644 index 00000000..f9c2d770 --- /dev/null +++ b/spec/initializers/settings_presence_validator_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe(SettingsPresenceValidator) do + let(:settings) { create :settings } + subject { SettingsPresenceValidator.new(settings) } + + # Setup stubs + before do + # Don't print errors. + allow(subject).to receive(:print_errors) + + # Don't shut down the system. + allow(subject).to receive(:shutdown) + + # Don't complain about missing directories + allow(File).to receive(:directory?).and_call_original + [settings.data_directory].each do |dir| + allow(File).to receive(:directory?).with(dir).and_return(true) + end + end + + it 'passes' do + subject.call + expect(subject.errors).to be_empty + end + + context 'fails if the' do + before { expect(subject).to receive(:shutdown) } + + it 'server_url is nil' do + settings.server_url = nil + subject.call + expect(subject.errors).to include('server_url') + end + + context 'jwt' do + it 'expiration_hours is nil' do + settings.jwt.expiration_hours = nil + subject.call + expect(subject.errors).to include('jwt.expiration_hours') + end + end + + it 'data_directory is nil' do + settings.data_directory = nil + subject.call + expect(subject.errors).to include('data_directory') + end + end +end diff --git a/spec/initializers/settings_validator_spec.rb b/spec/initializers/settings_validator_spec.rb new file mode 100644 index 00000000..5e97028f --- /dev/null +++ b/spec/initializers/settings_validator_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +RSpec.describe(SettingsValidator) do + let(:settings) { create :settings } + subject { SettingsValidator.new(settings) } + + # Setup stubs + before do + # Don't print errors. + allow(subject).to receive(:print_errors) + + # Don't shut down the system. + allow(subject).to receive(:shutdown) + + # Don't complain about missing directories + allow(File).to receive(:directory?).and_call_original + [settings.data_directory].each do |dir| + allow(File).to receive(:directory?).with(dir).and_return(true) + end + end + + it 'passes' do + subject.call + expect(subject.errors).to be_empty + end + + context 'fails if the' do + before { expect(subject).to receive(:shutdown) } + + context 'server_url' do + it 'is not a string' do + settings.server_url = 0 + subject.call + expect(subject.errors).to include('server_url') + end + + it 'has a bad schema' do + settings.server_url = 'gopher://example.com' + subject.call + expect(subject.errors).to include('server_url') + end + + it 'has a path' do + settings.server_url = 'http://example.com/some_path' + subject.call + expect(subject.errors).to include('server_url') + end + + it 'has a query string' do + settings.server_url = 'http://example.com?query_string' + subject.call + expect(subject.errors).to include('server_url') + end + + it 'has a fragment' do + settings.server_url = 'http://example.com#fragment' + subject.call + expect(subject.errors).to include('server_url') + end + + it 'contains user info' do + settings.server_url = 'http://user:pass@example.com' + subject.call + expect(subject.errors).to include('server_url') + end + end + + context 'jwt' do + context 'expiration_hours' do + it 'is not a Numeric type' do + settings.jwt.expiration_hours = 'bad' + subject.call + expect(subject.errors).to include('jwt.expiration_hours') + end + end + end + + context 'data_directory' do + before do + allow(File). + to receive(:directory?).with(settings.data_directory).and_return(false) + end + + it 'does not exist' do + subject.call + expect(subject.errors).to include('data_directory') + end + end + end +end diff --git a/spec/lib/git/cloning_spec.rb b/spec/lib/git/cloning_spec.rb new file mode 100644 index 00000000..253e8186 --- /dev/null +++ b/spec/lib/git/cloning_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a valid clone' do + it 'yields an existing git repository' do + expect(subject.repo_exists?).to be(true) + end + + it 'yields a bare repository' do + expect(subject.bare?).to be(true) + end +end + +RSpec.describe(Git::Cloning) do + before(:all) do + @path = 'clone.git' + @commit_count = 3 + @branch_count = 3 + end + let(:path) { @path } + let(:commit_count) { @commit_count } + let(:branch_count) { @branch_count } + + context 'invalid remote' do + it 'raises an error' do + expect { Git.clone(@path, "file://#{tempdir}") }. + to raise_error(Git::Cloning::InvalidRemoteError) + end + end + + context 'git clone' do + context 'on an empty remote', :git_repository do + git_subject do + remote = create(:git) + Git.clone(@path, "file://#{remote.path}") + end + + it_behaves_like 'a valid clone' + + it 'yields an empty repository' do + expect(subject).to be_empty + end + end + + context 'on a remote with commits and branches', :git_repository do + git_subject do + @remote = create(:git, :with_commits, commit_count: @commit_count) + @remote.create_branch('branch1', 'master~1') + @remote.create_branch('branch2', 'master~2') + Git.clone(@path, "file://#{@remote.path}") + end + + let(:remote) { @remote } + + it_behaves_like 'a valid clone' + + it 'yields a repository with the correct branch names' do + expect(subject.branch_names).to match_array(%w(master branch1 branch2)) + end + + it 'yields a repository with the correct branches' do + expect(subject.branches).to match_branches(remote.branches) + end + + it 'has the correct number of commits' do + expect(subject.commit_count('master')).to eq(commit_count) + end + end + end + + context 'git svn clone', :svn do + context 'on an empty remote', :git_repository do + git_subject do + remote_paths = create(:svn_repository) + Git.clone(@path, "file://#{remote_paths.first}") + end + + it_behaves_like 'a valid clone' + + it 'yields an empty repository' do + expect(subject).to be_empty + end + end + + context 'with an svn-standard layout' do + context 'on a remote without branches and no additional commits', + :git_repository do + git_subject do + remote_paths = create(:svn_repository, :with_svn_standard_layout) + Git.clone(@path, "file://#{remote_paths.first}") + end + + it_behaves_like 'a valid clone' + + it 'yields a repository with the correct branch names' do + expect(subject.branch_names).to match_array(%w(master origin/trunk)) + end + + it 'has the correct number of commits' do + # One commit for setting up the standard layout. Setting up the + # branches does not count as a commit in terms of git-svn. + expect(subject.commit_count('master')).to eq(1) + end + end + + context 'on a remote with branches, but no additional commits', + :git_repository do + git_subject do + remote_paths = + create(:svn_repository, :with_svn_standard_layout, + :with_svn_branches, branch_count: @branch_count) + Git.clone(@path, "file://#{remote_paths.first}") + end + + it_behaves_like 'a valid clone' + + it 'yields a repository with the correct branch names' do + subject.branch_names.each do |branch| + expect(branch). + to match(%r{master|origin/trunk|origin/my-branch-\d+}) + end + end + + it 'yields a repository with the correct number of branches' do + # One additional branch for master and one for origin/trunk + expect(subject.branch_count).to eq(2 + branch_count) + end + + it 'has the correct number of commits' do + # One commit for setting up the standard layout. Setting up the + # branches does not count as a commit in terms of git-svn. + expect(subject.commit_count('master')).to eq(1) + end + end + + context 'on a remote with commits and branches', :git_repository do + git_subject do + @dir = Dir.mktmpdir + Dir.chdir(@dir) do + remote_paths = + create(:svn_repository, :with_svn_standard_layout, + :with_svn_branches, + :with_svn_commits, + branch_count: @branch_count, + commit_count: @commit_count) + Git.clone(@path, "file://#{remote_paths.first}") + end + end + + it_behaves_like 'a valid clone' + + it 'yields a repository with the correct branch names' do + subject.branch_names.each do |branch| + expect(branch). + to match(%r{master|origin/trunk|origin/my-branch-\d+}) + end + end + + it 'yields a repository with the correct number of branches' do + # One additional branch for master and one for origin/trunk + expect(subject.branch_count).to eq(2 + branch_count) + end + + it 'has the correct number of commits' do + # One commit for setting up the standard layout. Setting up the + # branches does not count as a commit in terms of git-svn. + expect(subject.commit_count('master')).to eq(1 + commit_count) + end + end + end + + context 'without an svn-standard layout but with commits', + :git_repository do + git_subject do + remote_paths = + create(:svn_repository, :with_svn_commits, + commit_count: @commit_count) + Git.clone(@path, "file://#{remote_paths.first}") + end + + it_behaves_like 'a valid clone' + + it 'yields a repository with the correct branch names' do + expect(subject.branch_names).to match_array(%w(master git-svn)) + end + + it 'has the correct number of commits' do + expect(subject.commit_count('master')).to eq(commit_count) + end + end + end +end diff --git a/spec/lib/git/committing_spec.rb b/spec/lib/git/committing_spec.rb new file mode 100644 index 00000000..8b591613 --- /dev/null +++ b/spec/lib/git/committing_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +RSpec.describe(Git::Committing) do + context 'without errors' do + subject { create(:git) } + + %w(master some_feature).each do |branch| + it "has not yet created the #{branch} branch" do + expect(subject.branch_exists?('master')).to be(false) + end + + context "working on branch '#{branch}'" do + if branch == 'master' + let!(:additional_commit) { nil } + else + let!(:additional_commit) do + subject.create_file(create(:git_commit_info, + filepath: 'first_file')) + end + before { subject.create_branch(branch, 'master') } + end + let(:prior_commits) { additional_commit.nil? ? 0 : 1 } + + context 'adding a file' do + let(:filepath) { generate(:filepath) } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + + it 'creates a branch' do + expect(subject.branch_exists?(branch)).to be(true) + end + + it 'creates a new commit on the branch' do + expect(subject.find_commits(ref: branch).size). + to be(1 + prior_commits) + end + + it 'sets the HEAD of the branch to the latest commit' do + expect(subject.branch_sha(branch)).to eq(sha) + end + + it 'creates the correct number of commits on that file' do + expect(subject.log(ref: branch, path: filepath).map(&:oid)). + to eq([sha]) + end + + it 'creates the file' do + expect(subject.blob(branch, filepath)).not_to be_nil + end + end + + context 'updating a file' do + let(:filepath) { generate(:filepath) } + let!(:sha1) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + let!(:content1) { subject.blob(branch, filepath).data } + let!(:sha2) do + subject.update_file(create(:git_commit_info, + filepath: filepath, + branch: branch), + sha1) + end + let!(:content2) { subject.blob(branch, filepath).data } + + it 'sets the HEAD of the branch to the latest commit' do + expect(subject.branch_sha(branch)).to eq(sha2) + end + + it 'creates the correct number of commits on that file' do + expect(subject.log(ref: branch, path: filepath).map(&:oid)). + to eq([sha2, sha1]) + end + + it 'changes the content' do + expect(content2).not_to eq(content1) + end + end + + context 'renaming a file' do + let(:filepath1) { generate(:filepath) } + let(:filepath2) { generate(:filepath) } + let!(:sha1) do + subject.create_file(create(:git_commit_info, + filepath: filepath1, + branch: branch)) + end + let!(:content1) { subject.blob(branch, filepath1).data } + let!(:sha2) do + commit_info = create(:git_commit_info, + filepath: filepath2, + branch: branch) + commit_info[:file].merge!(previous_path: filepath1, + content: content1) + subject.rename_file(commit_info, sha1) + end + let!(:content2) { subject.blob(branch, filepath2).data } + + it 'sets the HEAD of the branch to the latest commit' do + expect(subject.branch_sha(branch)).to eq(sha2) + end + + it 'creates the correct number of commits on that file' do + expect(subject.log(ref: branch, path: filepath2).map(&:oid)). + to eq([sha2]) + end + + it 'does not change the content' do + expect(content2).to eq(content1) + end + end + + context 'deleting a file' do + let(:filepath) { generate(:filepath) } + let!(:sha1) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + let!(:sha2) do + commit_info = create(:git_commit_info, + filepath: filepath, + branch: branch) + commit_info[:file].delete(:content) + subject.remove_file(commit_info, sha1) + end + + it 'sets the HEAD of the branch to the latest commit' do + expect(subject.branch_sha(branch)).to eq(sha2) + end + + it 'creates the correct number of commits on that file' do + expect(subject.log(ref: branch, path: filepath).map(&:oid)). + to eq([sha2, sha1]) + end + + it 'removes the file' do + expect(subject.blob(branch, filepath)).to be_nil + end + end + + context 'creating a directory' do + let!(:path) { 'dir/with/subdir' } + let!(:sha) do + subject.mkdir(path, create(:git_commit_info, branch: branch)) + end + + it 'creates a tree at the path' do + expect(subject.tree(branch, path)).not_to be_nil + end + + it 'creates a .gitkeep file in the directory' do + expect(subject.blob(branch, File.join(path, '.gitkeep'))). + not_to be_nil + end + end + end + end + end + + context 'when the branch changed' do + subject { create(:git) } + let(:branch) { 'master' } + let(:invalid_sha) { '0' * 40 } + + context 'adding a file' do + before do + subject.create_file(create(:git_commit_info, + filepath: 'first_file', + branch: branch)) + end + + let(:filepath) { generate(:filepath) } + + it 'raises an error' do + expect do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch), + invalid_sha) + end.to raise_error(Git::HeadChangedError) + end + end + + context 'updating a file' do + let(:filepath) { generate(:filepath) } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + + it 'raises an error' do + expect do + subject.update_file(create(:git_commit_info, + filepath: filepath, + branch: branch), + invalid_sha) + end.to raise_error(Git::HeadChangedError) + end + end + + context 'renaming a file' do + let(:filepath1) { generate(:filepath) } + let(:filepath2) { generate(:filepath) } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath1, + branch: branch)) + end + let!(:content1) { subject.blob(branch, filepath1).data } + + it 'raises an error' do + expect do + commit_info = create(:git_commit_info, + filepath: filepath2, + branch: branch) + commit_info[:file].merge!(previous_path: filepath1, + content: content1) + subject.rename_file(commit_info, invalid_sha) + end.to raise_error(Git::HeadChangedError) + end + end + + context 'deleting a file' do + let(:filepath) { generate(:filepath) } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + it 'raises an error' do + expect do + commit_info = create(:git_commit_info, + filepath: filepath, + branch: branch) + commit_info[:file].delete(:content) + subject.remove_file(commit_info, invalid_sha) + end.to raise_error(Git::HeadChangedError) + end + end + + context 'creating a directory' do + before do + subject.create_file(create(:git_commit_info, + filepath: 'first_file', + branch: branch)) + end + + let!(:path) { 'dir/with/subdir' } + + it 'raises an error' do + expect do + subject.mkdir(path, create(:git_commit_info, branch: branch), + invalid_sha) + end.to raise_error(Git::HeadChangedError) + end + end + end + + context 'when a file exists' do + subject { create(:git) } + let(:branch) { 'master' } + + context 'creating a directory' do + let!(:path) { 'dir/with/subdir' } + before do + subject.create_file(create(:git_commit_info, + filepath: path, + branch: branch)) + end + + it 'raises an error' do + expect do + subject.mkdir(path, create(:git_commit_info, branch: branch)) + end.to raise_error(Gitlab::Git::Repository::InvalidBlobName, + /Directory already exists as a file/) + end + end + end + + context 'when a directory exists' do + subject { create(:git) } + let(:branch) { 'master' } + + context 'creating a directory' do + let!(:path) { 'dir/with/subdir' } + before do + subject.create_file(create(:git_commit_info, + filepath: File.join(path, 'some_file'), + branch: branch)) + end + + it 'raises an error' do + expect do + subject.mkdir(path, create(:git_commit_info, branch: branch)) + end.to raise_error(Gitlab::Git::Repository::InvalidBlobName, + /Directory already exists/) + end + end + end +end diff --git a/spec/lib/git/pulling_spec.rb b/spec/lib/git/pulling_spec.rb new file mode 100644 index 00000000..76741f69 --- /dev/null +++ b/spec/lib/git/pulling_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +RSpec.describe(Git::Pulling) do + before(:all) do + @path = 'clone.git' + @commit_count = 3 + @branch_count = 3 + end + let(:path) { @path } + let(:commit_count) { @commit_count } + let(:branch_count) { @branch_count } + + context 'from a remote git repository', :git_repository do + git_subject do + @remote = create(:git) + + # clone the repository + @subject = Git.clone(@path, "file://#{@remote.path}") + + # create commits + @commit_count.times { @remote.create_file(create(:git_commit_info)) } + + # create branches + @remote.create_branch('branch1', 'master~1') + @remote.create_branch('branch2', 'master~2') + + # pull + @subject.pull + @subject + end + let(:remote) { @remote } + + it 'yields a repository with the correct branch names' do + expect(subject.branch_names).to match_array(%w(master branch1 branch2)) + end + + it 'yields a repository with the correct branches' do + %i(name target dereferenced_target).each do |attribute| + expect(subject.branches.map(&attribute)). + to match_array(remote.branches.map(&attribute)) + end + end + + it 'has the correct number of commits' do + expect(subject.commit_count('master')).to eq(commit_count) + end + end + + context 'from a remote svn repository', :svn do + context 'with an svn standard layout', :git_repository do + git_subject do + remote_paths = create(:svn_repository, :with_svn_standard_layout) + @svn_work_path = remote_paths.last + + # clone the repository + @subject = Git.clone(@path, "file://#{remote_paths.first}") + + # create branches + @branch_count.times do + branch = generate(:svn_branch_name) + full_filepath = File.join(@svn_work_path, 'branches', branch) + FileUtils.mkdir_p(full_filepath) + exec_silently("svn add #{full_filepath}", @svn_work_path) + end + exec_silently("svn commit -m 'Add branches.'", @svn_work_path) + + # create commits + @commit_count.times do + full_filepath = + File.join(@svn_work_path, 'trunk', generate(:filepath)) + FileUtils.mkdir_p(File.dirname(full_filepath)) + File.write(full_filepath, "#{Faker::Lorem.sentence}\n") + exec_silently("svn add '#{File.dirname(full_filepath)}'", + @svn_work_path) + exec_silently("svn commit -m '#{generate(:commit_message)}'", + @svn_work_path) + end + + # pull + @subject.pull + @subject + end + + it 'yields a repository with the correct branch names' do + subject.branch_names.each do |branch| + expect(branch). + to match(%r{master|origin/trunk|origin/my-branch-\d+}) + end + end + + it 'yields a repository with the correct number of branches' do + # One additional branch for master and one for origin/trunk + expect(subject.branch_count).to eq(2 + branch_count) + end + + it 'has the correct number of commits' do + # One commit for setting up the standard layout. Setting up the + # branches does not count as a commit in terms of git-svn. + expect(subject.commit_count('master')).to eq(1) + end + end + + context 'without an svn standard layout', :git_repository do + git_subject do + remote_paths = create(:svn_repository) + @svn_work_path = remote_paths.last + + # clone the repository + @subject = Git.clone(@path, "file://#{remote_paths.first}") + + # create commits + @commit_count.times do + full_filepath = File.join(@svn_work_path, generate(:filepath)) + FileUtils.mkdir_p(File.dirname(full_filepath)) + File.write(full_filepath, "#{Faker::Lorem.sentence}\n") + exec_silently("svn add '#{File.dirname(full_filepath)}'", + @svn_work_path) + exec_silently("svn commit -m '#{generate(:commit_message)}'", + @svn_work_path) + end + + # pull + @subject.pull + @subject + end + + it 'yields a repository with the correct branch names' do + expect(subject.branch_names).to match_array(%w(master git-svn)) + end + + it 'has the correct number of commits' do + expect(subject.commit_count('master')).to eq(commit_count) + end + end + end +end diff --git a/spec/lib/git_spec.rb b/spec/lib/git_spec.rb new file mode 100644 index 00000000..605e50fe --- /dev/null +++ b/spec/lib/git_spec.rb @@ -0,0 +1,436 @@ +# frozen_string_literal: true + +RSpec.describe(Git) do + subject { create(:git) } + let(:branch) { 'master' } + let(:invalid_sha) { '0' * 40 } + + context 'create' do + it 'fails if the path already exists' do + path = tempdir.join('repo') + path.mkpath + expect { Git.create(path) }.to raise_error(Git::Error, /already exists/) + end + + let!(:git) { Git.create('my_repo') } + + it 'is a Git' do + expect(git).to be_a(Git) + end + + it 'creates an existing git repository' do + expect(git.repo_exists?).to be(true) + end + + it 'creates a bare repository' do + expect(git.bare?).to be(true) + end + + it 'creates an empty repository' do + expect(git.empty?).to be(true) + end + + it 'creates a repository with no branches' do + expect(git.branch_count).to eq(0) + end + + it 'creates a directory at its path' do + expect(File.directory?(git.path)).to be(true) + end + end + + context 'destroy' do + before do + # create the subject + subject + # and destroy it + Git.destroy(subject.path) + end + + it 'removes the directory of the git repository' do + expect(File.exist?(subject.path)).to be(false) + end + end + + context 'path' do + it 'is a Pathname' do + expect(subject.path).to be_a(Pathname) + end + + it 'is an absolute path' do + expect(subject.path.absolute?).to be(true) + end + end + + context 'repo_exists?' do + context 'when the repository exists' do + let!(:git) { Git.create('my_repo') } + + it 'is true' do + expect(git.repo_exists?).to be(true) + end + end + + context 'when the repository does not exist' do + let!(:git) { Git.new('my_repo') } + + it 'is true' do + expect(git.repo_exists?).to be(false) + end + end + end + + context 'blob' do + let(:filepath) { generate(:filepath) } + let(:content) { 'some content' } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + content: content, + branch: branch)) + end + + it 'returns the blob by the branch' do + expect(subject.blob(branch, filepath)).not_to be(nil) + end + + it 'returns the same blob by the sha/branch' do + expect(subject.blob(sha, filepath)). + to match_blob(subject.blob(branch, filepath)) + end + + it 'returns nil if the path does not exist' do + expect(subject.blob(sha, "#{filepath}.bad")).to be(nil) + end + + it 'raises an error if the reference does not exist' do + expect { subject.blob(invalid_sha, filepath) }. + to raise_error(Rugged::ReferenceError) + end + + it 'contains the content' do + expect(subject.blob(sha, filepath).data).to eq(content) + end + + it "contains the content's size" do + expect(subject.blob(sha, filepath).size).to eq(content.size) + end + + it 'contains the filepath' do + expect(subject.blob(sha, filepath).path).to eq(filepath) + end + + it 'contains the filename' do + expect(subject.blob(sha, filepath).name).to eq(File.basename(filepath)) + end + end + + context 'tree' do + let(:filepath1) { generate(:filepath) } + let(:filepath2) { generate(:filepath) } + let(:content) { 'some content' } + let!(:sha1) do + subject.create_file(create(:git_commit_info, + filepath: filepath1, + content: content, + branch: branch)) + end + let!(:sha2) do + subject.create_file(create(:git_commit_info, + filepath: filepath2, + content: content, + branch: branch)) + end + + it 'returns the tree by the branch and nil' do + expect(subject.tree(branch, nil)).not_to be(nil) + end + + it 'returns the same tree by the branch and nil/root' do + expect(subject.tree(branch, nil)).to match_tree(subject.tree(branch, '/')) + end + + it 'returns the same tree by the sha/branch' do + expect(subject.tree(sha2, nil)).to match_tree(subject.tree(branch, nil)) + end + + it 'returns an empty Array if no tree exists at the path' do + expect(subject.tree(branch, filepath1)).to be_empty + end + + it 'raises an error if the reference does not exist' do + expect { subject.tree(invalid_sha, nil) }. + to raise_error(Rugged::ReferenceError) + end + + it 'lists the correct entries in the root @ HEAD' do + expect(subject.tree(branch, nil).map(&:path)). + to match_array([filepath1, filepath2].map { |f| File.dirname(f) }) + end + + it "lists the correct entries's paths in one directory @ HEAD" do + expect(subject.tree(branch, File.dirname(filepath1)).map(&:path)). + to match_array([filepath1]) + end + + it "lists the correct entries's names in one directory @ HEAD" do + expect(subject.tree(branch, File.dirname(filepath1)).map(&:name)). + to match_array([File.basename(filepath1)]) + end + + it 'lists the correct entries in the root @ HEAD~1' do + expect(subject.tree(sha1, nil).map(&:path)). + to match_array([File.dirname(filepath1)]) + end + + it 'lists the correct entries in one directory @ HEAD~1' do + expect(subject.tree(sha1, File.dirname(filepath1)).map(&:path)). + to match_array([filepath1]) + end + end + + context 'commit' do + let(:filepath) { generate(:filepath) } + let(:content) { 'some content' } + let(:author) { generate(:git_user) } + let(:committer) { generate(:git_user) } + let(:message) { generate(:commit_message) } + let(:commit_info) do + commit_info = create(:git_commit_info, + filepath: filepath, + content: content, + branch: branch) + commit_info[:author] = author + commit_info[:committer] = committer + commit_info[:commit][:message] = message + commit_info + end + let!(:sha) { subject.create_file(commit_info) } + + it 'finds a commit by branch' do + expect(subject.commit(branch)).to be_a(Gitlab::Git::Commit) + end + + it 'finds the same commit by sha/branch' do + expect(subject.commit(sha)).to match_commit(subject.commit(branch)) + end + + it 'returns nil if the reference does not exist' do + expect(subject.commit(invalid_sha)).to be(nil) + end + + it 'contains author name' do + expect(subject.commit(branch).author_name).to eq(author[:name]) + end + + it 'contains author email' do + expect(subject.commit(branch).author_email).to eq(author[:email]) + end + + it 'contains authored date' do + expect(subject.commit(branch).authored_date). + to match_git_date(author[:time]) + end + + it 'contains committer name' do + expect(subject.commit(branch).committer_name).to eq(committer[:name]) + end + + it 'contains committer email' do + expect(subject.commit(branch).committer_email).to eq(committer[:email]) + end + + it 'contains committed date' do + expect(subject.commit(branch).committed_date). + to match_git_date(committer[:time]) + end + + it 'contains the id' do + expect(subject.commit(branch).id).to eq(sha) + end + + it 'contains the message' do + expect(subject.commit(branch).message).to eq(message) + end + end + + context 'path_exists?' do + let(:filepath) { generate(:filepath) } + let!(:sha) do + subject.create_file(create(:git_commit_info, + filepath: filepath, + branch: branch)) + end + + it 'is true if the path points to a blob' do + expect(subject.path_exists?(branch, filepath)).to be(true) + end + + it 'is true if the path points to a blob @ sha' do + expect(subject.path_exists?(sha, filepath)).to be(true) + end + + it 'is true if the path points to a tree' do + expect(subject.path_exists?(branch, File.dirname(filepath))).to be(true) + end + + it 'is false if the path points to nothing' do + expect(subject.path_exists?(branch, "#{filepath}.bad")).to be(false) + end + end + + context 'branch_sha' do + let!(:sha) do + subject.create_file(create(:git_commit_info, branch: branch)) + end + + it 'is the correct sha if the branch exists' do + expect(subject.branch_sha(branch)).to eq(sha) + end + + it 'is nil if the branch does not exist' do + expect(subject.branch_sha("#{branch}-bad")).to be(nil) + end + end + + context 'default_branch' do + context 'without branches' do + it 'is nil' do + expect(subject.default_branch).to be(nil) + end + end + + context 'with only one branch' do + let(:default_branch) { 'main' } + before do + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = default_branch + subject.create_file(commit_info) + end + + it 'is that branch' do + expect(subject.default_branch).to eq(default_branch) + end + end + + context 'with many branches' do + let(:default_branch) { 'main' } + let(:other_branch) { 'other' } + + before do + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = default_branch + subject.create_file(commit_info) + + subject.create_branch(other_branch, default_branch) + + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = other_branch + subject.create_file(commit_info) + end + + it 'is the first created branch' do + expect(subject.default_branch).to eq(default_branch) + end + + context 'setting the default branch' do + before do + subject.default_branch = other_branch + end + + it 'sets the branch to the other one' do + expect(subject.default_branch).to eq(other_branch) + end + end + end + + context 'with many branches including master' do + let(:default_branch) { 'main' } + let(:other_branch) { 'other' } + before do + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = default_branch + subject.create_file(commit_info) + + subject.create_branch(other_branch, default_branch) + + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = other_branch + subject.create_file(commit_info) + + master_branch = 'master' + subject.create_branch(master_branch, default_branch) + + commit_info = create(:git_commit_info) + commit_info[:commit][:branch] = master_branch + subject.create_file(commit_info) + end + + it 'is the master' do + expect(subject.default_branch).to eq('master') + end + + context 'setting the default branch' do + before do + subject.default_branch = other_branch + end + + it 'sets the branch to the other one' do + expect(subject.default_branch).to eq(other_branch) + end + end + end + end + + context 'create_branch' do + let!(:sha1) do + subject.create_file(create(:git_commit_info, branch: branch)) + end + + let!(:sha2) do + subject.create_file(create(:git_commit_info, branch: branch)) + end + + let(:new_branch) { 'new_branch' } + + RSpec.shared_examples 'a valid branch' do + it 'points to the correct sha' do + expect(subject.branch_sha(new_branch)).to eq(sha) + end + end + + context 'by sha' do + before { subject.create_branch(new_branch, sha1) } + it_behaves_like 'a valid branch' do + let(:sha) { sha1 } + end + end + + context 'by branch' do + before { subject.create_branch(new_branch, branch) } + it_behaves_like 'a valid branch' do + let(:sha) { subject.branch_sha(branch) } + end + end + + context 'by branch-backtrace' do + before { subject.create_branch(new_branch, "#{branch}~1") } + it_behaves_like 'a valid branch' do + let(:sha) { sha1 } + end + end + end + + context 'ls_files' do + let(:filepaths) { (1..5).map { generate(:filepath) } } + before do + filepaths.each do |filepath| + subject.create_file(create(:git_commit_info, filepath: filepath)) + end + end + + it 'returns the filepaths' do + expect(subject.ls_files(branch)).to match_array(filepaths) + end + end +end diff --git a/spec/lib/popen_spec.rb b/spec/lib/popen_spec.rb new file mode 100644 index 00000000..7cbebba6 --- /dev/null +++ b/spec/lib/popen_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe(Popen) do + it 'returns the output' do + out, _status = Popen.popen(%w(echo message)) + expect(out).to eq("message\n") + end + + it 'returns the correct exit code on success' do + _out, status = Popen.popen(%w(bash -c) + ['test -z ""']) + expect(status).to be_zero + end + + it 'returns the correct exit code on failure' do + _out, status = Popen.popen(%w(bash -c) + ['test -n ""']) + expect(status).to eq(1) + end + + it 'raises an error on bad parameters' do + expect { Popen.popen(%(/usr/bin/false)) }.to raise_error(/array of strings/) + end + + it 'changes the working directory' do + dir = Dir.mktmpdir + out, _status = Popen.popen(%w(pwd), dir) + expect(out).to eq("#{dir}\n") + Dir.rmdir(dir) + end + + it 'sets environment variables' do + vars = {'VARIABLE1' => 'value1', 'VARIABLE2' => 'value2'} + vars.each do |variable, value| + out, _status = Popen.popen(%w(bash -c) + ["echo $#{variable}"], nil, vars) + expect(out).to eq("#{value}\n") + end + end +end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb new file mode 100644 index 00000000..14eb2aff --- /dev/null +++ b/spec/models/blob_spec.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Blob do + # Attributes to check for equality + attributes = %i(content path) + + subject { build :blob } + + context 'attributes' do + %i(branch commit_message previous_head_sha previous_path user + commit_id id + content encoding path repository).each do |attribute| + it "contain a getter for #{attribute}" do + expect(subject).to respond_to(attribute) + end + + it "contain a setter for #{attribute}" do + expect(subject).to respond_to("#{attribute}=") + end + end + end + + context '.find after' do + context '#create' do + context 'plain text with plain text encoding' do + before { subject.create } + + context 'can be found again' do + let(:found_blob) do + Blob.find(branch: subject.branch, + repository_id: subject.repository.to_param, + path: subject.path) + end + + attributes.each do |attribute| + it "and has the correct #{attribute}" do + expect(found_blob.send(attribute)).to eq(subject.send(attribute)) + end + end + end + + context 'on a non-existent branch' do + it 'returns nil' do + found = Blob.find(branch: 'branch-that-does-not-exist', + repository_id: subject.repository.to_param, + path: subject.path) + expect(found).to be(nil) + end + end + + context 'after deleting and recreating' do + let!(:new_blob) do + build(:blob, branch: subject.branch, + repository: subject.repository, + path: subject.path) + end + before { subject.destroy } + + it 'does not raise an error' do + expect { new_blob.create }.not_to raise_error + end + end + end + + context 'plain text with base64 text encoding' do + let!(:original_content) { subject.content } + before do + subject.content = Base64.strict_encode64(subject.content) + subject.encoding = 'base64' + subject.create + end + + context 'can be found again' do + let(:found_blob) do + Blob.find(branch: subject.branch, + repository_id: subject.repository.to_param, + path: subject.path) + end + + it 'and has the correct content' do + expect(found_blob.content).to eq(original_content) + end + + it 'and has the correct encoding' do + expect(found_blob.encoding).to eq('plain') + end + end + end + + context 'binary data with base64 encoding' do + let!(:bitmap) do + # rubocop:disable Metrics/LineLength + "Qk18AAAAAAAAAHYAAAAoAAAAAQAAAAEAAAABAAQAAAAAAAYAAAAsLgAALC4A\nAAAAAAAAAAAAAAAAABEREQAiIiIAMzMzAERERABVVVUAZmZmAHd3dwCIiIgA\nmZmZAKqqqgC7u7sAzMzMAN3d3QDu7u4A////APAAAAAAAA==\n" + # rubocop:enable Metrics/LineLength + end + before do + subject.path = "#{subject.path}.1by1pixel_white.bmp" + subject.content = bitmap + subject.encoding = 'base64' + subject.create + end + + context 'can be found again' do + let(:found_blob) do + Blob.find(branch: subject.branch, + repository_id: subject.repository.to_param, + path: subject.path) + end + + it 'and has the correct content' do + expect(found_blob.content).to eq(subject.content) + end + + it 'and has the correct encoding' do + expect(found_blob.encoding).to eq('base64') + end + end + end + + context 'if the path already exists' do + let(:old_blob) do + build(:blob, + repository: subject.repository, + branch: subject.branch, + path: subject.path) + end + before { old_blob.create } + + it 'raises an error' do + expect { subject.create }.to raise_error(Blob::ValidationFailed) + end + end + + context 'if no user is specified' do + before do + subject.update(user: nil) + end + + it 'raises an error' do + expect { subject.create }.to raise_error(Blob::ValidationFailed) + end + end + + context 'if no commit_message is specified' do + before do + subject.update(commit_message: nil) + end + + it 'raises an error' do + expect { subject.create }.to raise_error(Blob::ValidationFailed) + end + end + + context 'if no repository is specified' do + before do + subject.update(repository: nil) + end + + it 'raises an error' do + expect { subject.create }.to raise_error(Blob::ValidationFailed) + end + end + end + + context '#update' do + let(:old_blob) do + build(:blob, + repository: subject.repository, + branch: subject.branch, + path: subject.path, + content: "previous #{subject.content}") + end + before { old_blob.create } + let(:new_blob) do + blob = Blob.find(branch: old_blob.branch, + repository_id: old_blob.repository.to_param, + path: old_blob.path) + blob.commit_message = 'update' + blob.user = old_blob.user + blob + end + + context 'to change content' do + before do + new_blob. + update(branch: old_blob.branch, + content: new_blob.content.sub('previous ', '')) + new_blob.save + end + + context 'can be found again' do + let(:found_blob) do + Blob.find(branch: new_blob.branch, + repository_id: new_blob.repository.to_param, + path: new_blob.path) + end + + attributes.each do |attribute| + it "and has the correct #{attribute}" do + expect(found_blob.send(attribute)). + to eq(new_blob.send(attribute)) + end + end + end + end + + context 'to rename the file' do + before do + new_blob.update(path: "#{new_blob.path}.new") + new_blob.save + end + + context 'can be found again' do + let(:found_blob) do + Blob.find(branch: new_blob.branch, + repository_id: new_blob.repository.to_param, + path: new_blob.path) + end + + attributes.each do |attribute| + it "and has the correct #{attribute}" do + expect(found_blob.send(attribute)). + to eq(new_blob.send(attribute)) + end + end + end + + context 'the previous file can be found again in the previous ref' do + let(:found_blob) do + Blob.find(branch: "#{subject.branch}~1", + repository_id: old_blob.repository.to_param, + path: old_blob.path) + end + + attributes.each do |attribute| + it "and has the correct #{attribute}" do + expect(found_blob.send(attribute)).to eq(old_blob.send(attribute)) + end + end + end + end + end + + context '#destroy' do + before do + subject.create + subject.destroy + end + + it 'can not be found again' do + found_blob = + Blob.find(branch: subject.branch, + repository_id: subject.repository.to_param, + path: subject.path) + expect(found_blob).to be(nil) + end + + context 'the previous file can be found again in the previous ref' do + let(:found_blob) do + Blob.find(branch: "#{subject.branch}~1", + repository_id: subject.repository.to_param, + path: subject.path) + end + + attributes.each do |attribute| + it "and has the correct #{attribute}" do + expect(found_blob.send(attribute)).to eq(subject.send(attribute)) + end + end + end + end + end + + context 'supplying a previous_head_sha' do + let(:old_blob) do + build(:blob, + repository: subject.repository, + branch: subject.branch) + end + before { old_blob.create } + let(:new_blob) do + blob = Blob.find(repository: old_blob.repository, + branch: old_blob.branch, + path: old_blob.path) + blob.commit_message = 'update' + blob.user = old_blob.user + blob.update(content: "new #{old_blob.content}", encoding: 'plain') + blob + end + + context 'that matches the real commit_id of the HEAD' do + let(:previous_head_sha) { old_blob.commit_id } + before { new_blob.previous_head_sha = previous_head_sha } + + it 'passes' do + expect { new_blob.save }.not_to raise_error + end + end + + context 'that does not match the real commit_id of the HEAD' do + let(:previous_head_sha) { '0' * 39 } + before { new_blob.previous_head_sha = previous_head_sha } + + it 'raises a ValidationError' do + expect { new_blob.save }.to raise_error(Blob::ValidationFailed) + end + + it 'raises a ValidationError' do + begin + new_blob.save + rescue Blob::ValidationFailed + expect(new_blob.errors[:branch]).not_to be_empty + end + end + end + end + + context '#url' do + before do + subject.create + subject.destroy + end + + it 'is correct' do + expect(subject.url(Settings.server_url)). + to eq("#{Settings.server_url}/#{subject.repository.to_param}"\ + "/ref/#{subject.commit_id}/tree/#{subject.path}") + end + end + + context '#url_path' do + before do + subject.create + subject.destroy + end + + it 'is correct' do + expect(subject.url_path). + to eq("/#{subject.repository.to_param}"\ + "/ref/#{subject.commit_id}/tree/#{subject.path}") + end + end + + context 'FileVersion' do + it 'creates a FileVersion on creation' do + # rubocop:disable Style/MultilineBlockLayout + # rubocop:disable Style/BlockEndNewline + expect { subject.create }. + to change { FileVersion.find(commit_sha: subject.commit_id, + path: subject.path).nil? }. + from(true).to(false) + # rubocop:enable Style/MultilineBlockLayout + # rubocop:enable Style/BlockEndNewline + end + + it 'does not delete the previous FileVersion on deletion' do + subject.create + + git = subject.repository.git + commit_sha_after_creation = git.branch_sha(git.default_branch) + + # rubocop:disable Style/MultilineBlockLayout + # rubocop:disable Style/BlockEndNewline + # rubocop:disable Lint/AmbiguousBlockAssociation + expect { subject.destroy }. + not_to change { FileVersion.find(commit_sha: commit_sha_after_creation, + path: subject.path).nil? } + # rubocop:enable Style/MultilineBlockLayout + # rubocop:enable Style/BlockEndNewline + # rubocop:enable Lint/AmbiguousBlockAssociation + end + end +end diff --git a/spec/models/tree_entry_spec.rb b/spec/models/tree_entry_spec.rb new file mode 100644 index 00000000..317e038b --- /dev/null +++ b/spec/models/tree_entry_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TreeEntry do + subject { create :tree_entry } + + context 'attributes' do + %i(name path type).each do |attribute| + it "contain a getter for #{attribute}" do + expect(subject).to respond_to(attribute) + end + + it "contain a setter for #{attribute}" do + expect(subject).to respond_to("#{attribute}=") + end + end + end +end diff --git a/spec/models/tree_spec.rb b/spec/models/tree_spec.rb new file mode 100644 index 00000000..4aa9f10f --- /dev/null +++ b/spec/models/tree_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tree do + context 'attributes' do + subject { create :tree } + + %i(commit_id entries id path repository).each do |attribute| + it "contain a getter for #{attribute}" do + expect(subject).to respond_to(attribute) + end + + it "contain a setter for #{attribute}" do + expect(subject).to respond_to("#{attribute}=") + end + end + end + + context 'with a real git repository' do + let(:repository) { create(:repository_compound) } + subject do + create(:tree, repository: repository, commit_id: 'master', path: '/', + entries: repository.git.tree('master', '/')) + end + + context 'that has no files on a branch' do + before do + repository.git.ls_files('master').each do |filepath| + repository.git.remove_file(file: {path: filepath}, + author: generate(:git_user), + committer: generate(:git_user), + commit: {message: 'delete', + branch: 'master'}) + end + end + + subject do + Tree.find(repository_id: repository.to_param, + branch: 'master', path: '/') + end + + it 'is a Tree' do + expect(subject).to be_a(Tree) + end + + it 'has no entries' do + expect(subject.entries).to be_empty + end + end + + context '.new' do + it 'has the correct names' do + expect(subject.entries.map(&:name)). + to match_array(repository.git.tree('master', '/').map(&:name)) + end + + it 'has the correct paths' do + expect(subject.entries.map(&:path)). + to match_array(repository.git.tree('master', '/').map(&:path)) + end + + it 'has the correct types' do + expect(subject.entries.map(&:type)). + to match_array(repository.git.tree('master', '/'). + map { |entry| entry.type.to_s.pluralize.to_sym }) + end + end + + context '.find' do + subject do + Tree.find(repository_id: repository.to_param, + branch: 'master', path: '/') + end + + it "returns nil if the branch doesn't exist" do + expect(Tree.find(repository_id: repository.to_param, + branch: 'inexistent-branch', path: '/')). + to be(nil) + end + + it "returns nil if the path doesn't exist" do + expect(Tree.find(repository_id: repository.to_param, + branch: 'master', path: 'inexistent-path')). + to be(nil) + end + + it 'has the correct names' do + expect(subject.entries.map(&:name)). + to match_array(repository.git.tree('master', '/').map(&:name)) + end + + it 'has the correct paths' do + expect(subject.entries.map(&:path)). + to match_array(repository.git.tree('master', '/').map(&:path)) + end + + it 'has the correct types' do + expect(subject.entries.map(&:type)). + to match_array(repository.git.tree('master', '/'). + map { |entry| entry.type.to_s.pluralize.to_sym }) + end + end + + context '.find on subpath' do + let(:subpath) { repository.git.tree('master', '/').first.path } + subject do + Tree.find(repository_id: repository.to_param, + branch: 'master', path: subpath) + end + + it 'has the correct names' do + expect(subject.entries.map(&:name)). + to match_array(repository.git.tree('master', subpath).map(&:name)) + end + + it 'has the correct paths' do + expect(subject.entries.map(&:path)). + to match_array(repository.git.tree('master', subpath).map(&:path)) + end + + it 'has the correct types' do + expect(subject.entries.map(&:type)). + to match_array(repository.git.tree('master', subpath). + map { |entry| entry.type.to_s.pluralize.to_sym }) + end + + it 'builds the correct url' do + prefix = Settings.server_url + expect(subject.url(prefix)).to eq([Settings.server_url, + repository.to_param, + 'ref', + subject.git.branch_sha('master'), + 'tree', + subpath].join('/')) + end + + it 'builds the correct url_path' do + expect(subject.url_path).to eq(['', + repository.to_param, + 'ref', + subject.git.branch_sha('master'), + 'tree', + subpath].join('/')) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 7d14097b..169b1cc1 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -57,4 +57,5 @@ # Use Devise in tests config.include Devise::Test::ControllerHelpers, type: :controller config.include ControllerHelpers, type: :controller + config.extend ControllerLoginHelpers, type: :controller end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 242ac141..5816eb8c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -64,12 +64,14 @@ config.before(:suite) do DatabaseCleaner.strategy = :transaction DatabaseCleaner.clean_with(:truncation) + Settings.data_directory.rmtree if Settings.data_directory.exist? end config.around(:each) do |example| DatabaseCleaner.cleaning do example.run end + Settings.data_directory.rmtree if Settings.data_directory.exist? end # Allow to find all factories diff --git a/spec/support/api/schemas/controllers/v2/trees/get_show.json b/spec/support/api/schemas/controllers/v2/trees/get_show.json new file mode 100644 index 00000000..e4a52a22 --- /dev/null +++ b/spec/support/api/schemas/controllers/v2/trees/get_show.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "V2 Trees Controller: GET/show", + "description": "V2 Trees Controller: GET/show", + + "type": "object", + "required": ["data", "jsonapi"], + "properties": { + "data": { + "oneOf": [ + {"$ref": "../../../models/blob_model.json"}, + {"$ref": "../../../models/tree_model.json"} + ] + }, + "jsonapi": { "$ref": "../../../controllers/v2/jsonapi_toplevel_object.json" } + } +} diff --git a/spec/support/api/schemas/controllers/v2/trees/get_show_blob.json b/spec/support/api/schemas/controllers/v2/trees/get_show_blob.json new file mode 100644 index 00000000..32e58496 --- /dev/null +++ b/spec/support/api/schemas/controllers/v2/trees/get_show_blob.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "V2 Trees Controller: GET/show", + "description": "V2 Trees Controller: GET/show", + + "type": "object", + "required": ["data", "jsonapi"], + "properties": { + "data": { + "$ref": "../../../models/blob_model.json" + }, + "jsonapi": { "$ref": "../../../controllers/v2/jsonapi_toplevel_object.json" } + } +} diff --git a/spec/support/api/schemas/controllers/v2/trees/get_show_tree.json b/spec/support/api/schemas/controllers/v2/trees/get_show_tree.json new file mode 100644 index 00000000..c7db0dc0 --- /dev/null +++ b/spec/support/api/schemas/controllers/v2/trees/get_show_tree.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "V2 Trees Controller: GET/show", + "description": "V2 Trees Controller: GET/show", + + "type": "object", + "required": ["data", "jsonapi"], + "properties": { + "data": { + "$ref": "../../../models/tree_model.json" + }, + "jsonapi": { "$ref": "../../../controllers/v2/jsonapi_toplevel_object.json" } + } +} diff --git a/spec/support/api/schemas/controllers/v2/trees/patch_update.json b/spec/support/api/schemas/controllers/v2/trees/patch_update.json new file mode 100644 index 00000000..f2a09b6d --- /dev/null +++ b/spec/support/api/schemas/controllers/v2/trees/patch_update.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "V2 Trees Controller: POST/create", + "description": "V2 Trees Controller: PATCH/update", + + "type": "object", + "required": ["data", "jsonapi"], + "properties": { + "data": { + "$ref": "../../../models/blob_model.json" + }, + "jsonapi": { "$ref": "../../../controllers/v2/jsonapi_toplevel_object.json" } + } +} diff --git a/spec/support/api/schemas/controllers/v2/trees/post_create.json b/spec/support/api/schemas/controllers/v2/trees/post_create.json new file mode 100644 index 00000000..b06047f9 --- /dev/null +++ b/spec/support/api/schemas/controllers/v2/trees/post_create.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "V2 Trees Controller: POST/create", + "description": "V2 Trees Controller: POST/create", + + "type": "object", + "required": ["data", "jsonapi"], + "properties": { + "data": { + "$ref": "../../../models/blob_model.json" + }, + "jsonapi": { "$ref": "../../../controllers/v2/jsonapi_toplevel_object.json" } + } +} diff --git a/spec/support/api/schemas/models/blob_model.json b/spec/support/api/schemas/models/blob_model.json new file mode 100644 index 00000000..3739d7bd --- /dev/null +++ b/spec/support/api/schemas/models/blob_model.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Blob Model", + "description": "Blob Model", + + "type": "object", + "required": ["id", "type", "attributes", "links"], + "properties": { + "id": {"type": "string"}, + "type": { + "type": "string", + "format": "^blobs$" + }, + "attributes": { + "type": "object", + "required": ["content", "encoding", "path"], + "properties": { + "content": {"type": "string"}, + "encoding": { + "type": "string", + "enum": ["plain", "base64"] + }, + "path": {"type": "string"} + } + }, + "links": { + "type": "object", + "required": ["self"], + "properties": { + "self": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/spec/support/api/schemas/models/tree_model.json b/spec/support/api/schemas/models/tree_model.json new file mode 100644 index 00000000..892615b3 --- /dev/null +++ b/spec/support/api/schemas/models/tree_model.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Tree Model", + "description": "Tree Model", + + "type": "object", + "required": ["id", "type", "attributes", "links"], + "properties": { + "id": {"type": "string"}, + "type": { + "type": "string", + "format": "^trees$" + }, + "attributes": { + "type": "object", + "required": ["entries", "path"], + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "path", "type"], + "properties": { + "name": {"type": "string"}, + "path": {"type": "string"}, + "type": { + "type": "string", + "format": "^(blobs|trees)$" + } + } + } + }, + "path": {"type": "string"} + } + }, + "links": { + "type": "object", + "required": ["self"], + "properties": { + "self": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/spec/support/controller_login_helpers.rb b/spec/support/controller_login_helpers.rb new file mode 100644 index 00000000..76bed166 --- /dev/null +++ b/spec/support/controller_login_helpers.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Helper methods for user login handling in controller specs +module ControllerLoginHelpers + def create_user_and_sign_in + before(:each) do + @request.env['devise.mapping'] = Devise.mappings[:user] + user = FactoryGirl.create(:user) + # Confirm the user. Alternatively, set a confirmed_at inside the factory. + # Only necessary if you are using the "confirmable" module: + # user.confirm! + sign_in(user) + end + end +end diff --git a/spec/support/example_groups/git_repository_example_group.rb b/spec/support/example_groups/git_repository_example_group.rb new file mode 100644 index 00000000..58d81b17 --- /dev/null +++ b/spec/support/example_groups/git_repository_example_group.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module GitRepositoryExampleGroup + module Hooks + extend ActiveSupport::Concern + + included do + git_subject_proc = -> { @git_subject_block } + before(:all) do + @dir = Dir.mktmpdir + Dir.chdir(@dir) do + git_subject_block = git_subject_proc.call + git = instance_eval(&git_subject_block) + raise 'Block did not return a Git instance!' unless git.is_a?(Git) + @git_path = git.path + end + end + after(:all) { Pathname.new(@dir).rmtree } + subject { Git.new(@git_path) } + end + end + + module ExampleGroupMethods + # This method sets the subject to a new instance of Git that is created with + # the block. The given block must return an instance of Git. + def git_subject(&block) + @git_subject_block = block + end + end + + RSpec.configure do |config| + config.include self::Hooks, git_repository: true + config.extend self::ExampleGroupMethods, git_repository: true + end +end diff --git a/spec/support/git_matcher.rb b/spec/support/git_matcher.rb new file mode 100644 index 00000000..4d7ecf70 --- /dev/null +++ b/spec/support/git_matcher.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :match_blob do |expected| + match do |actual| + %i(id name path size).all? do |attribute| + expected.send(attribute) == actual.send(attribute) + end + end +end + +RSpec::Matchers.define :match_branches do |expected| + match do |actual| + %i(name target dereferenced_target).each do |attribute| + expect(expected.map(&attribute)).to match_array(actual.map(&attribute)) + end + end +end + +RSpec::Matchers.define :match_commit do |expected| + match do |actual| + %i(author_email author_name authored_date + committer_email committer_name committed_date + id message).all? do |attribute| + expected.send(attribute) == actual.send(attribute) + end + end +end + +RSpec::Matchers.define :match_git_date do |expected| + match do |actual| + (expected - actual).to_f.abs < 1.seconds + end +end + +RSpec::Matchers.define :match_tree do |expected| + match do |actual| + %i(name path root_id type).each do |attribute| + expect(expected.map(&attribute)).to match_array(actual.map(&attribute)) + end + end +end diff --git a/spec/support/json_schema_matcher.rb b/spec/support/json_schema_matcher.rb index 3a88b33c..ad756660 100644 --- a/spec/support/json_schema_matcher.rb +++ b/spec/support/json_schema_matcher.rb @@ -36,7 +36,7 @@ def validate_special_schema(response, schema_root, controller, schema) example, response = data controller = normalized_controller(example) context = normalized_context(example) - schema_root = "#{Dir.pwd}/spec/support/api/schemas" + schema_root = "#{Rails.root}/spec/support/api/schemas" errors = if schema diff --git a/spec/support/tempdir.rb b/spec/support/tempdir.rb new file mode 100644 index 00000000..1ef7c1f0 --- /dev/null +++ b/spec/support/tempdir.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Tempdir + def self.with_tempdir + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + instance_variable_set(:@tempdir, Pathname.new(dir)) + yield + end + end + end + + def self.path + instance_variable_get(:@tempdir) + end + + def tempdir + Tempdir.path + end + + RSpec.configure do |config| + config.include self + config.around(:each) do |example| + Tempdir.with_tempdir do + example.run + end + end + end +end