require 'digest'
require 'json'
module Overcommit
# Stores configuration for Overcommit and the hooks it runs.
class Configuration # rubocop:disable ClassLength
# Creates a configuration from the given hash.
#
# @param hash [Hash] loaded YAML config file as a hash
# @param options [Hash]
# @option default [Boolean] whether this is the default built-in configuration
# @option logger [Overcommit::Logger]
def initialize(hash, options = {})
@options = options.dup
@options[:logger] ||= Overcommit::Logger.silent
@hash = hash # Assign so validator can read original values
unless options[:validate] == false
@hash = Overcommit::ConfigurationValidator.new.validate(self, hash, options)
end
end
def ==(other)
super || @hash == other.hash
end
# Access the configuration as if it were a hash.
#
# @param key [String]
# @return [Array,Hash,Number,String]
def [](key)
@hash[key]
end
# Returns absolute path to the directory that external hook plugins should
# be loaded from.
def plugin_directory
File.join(Overcommit::Utils.repo_root, @hash['plugin_directory'] || '.git-hooks')
end
def concurrency
@concurrency ||=
begin
cores = Overcommit::Utils.processor_count
content = @hash.fetch('concurrency', '%d')
if content.is_a?(String)
concurrency_expr = content % { processors: cores }
a, op, b = concurrency_expr.scan(%r{(\d+)\s*([+\-*\/])\s*(\d+)})[0]
if a
a.to_i.send(op, b.to_i)
else
concurrency_expr.to_i
end
else
content.to_i
end
end
end
# Returns configuration for all hooks in each hook type.
#
# @return [Hash]
def all_hook_configs
smart_merge(all_builtin_hook_configs, all_plugin_hook_configs)
end
# Returns configuration for all built-in hooks in each hook type.
#
# @return [Hash]
def all_builtin_hook_configs
hook_configs = {}
Overcommit::Utils.supported_hook_type_classes.each do |hook_type|
hook_names = @hash[hook_type].keys.reject { |name| name == 'ALL' }
hook_configs[hook_type] = Hash[
hook_names.map do |hook_name|
[hook_name, for_hook(hook_name, hook_type)]
end
]
end
hook_configs
end
# Returns configuration for all plugin hooks in each hook type.
#
# @return [Hash]
def all_plugin_hook_configs
hook_configs = {}
Overcommit::Utils.supported_hook_types.each do |hook_type|
hook_type_class_name = Overcommit::Utils.camel_case(hook_type)
directory = File.join(plugin_directory, hook_type.tr('-', '_'))
plugin_paths = Dir[File.join(directory, '*.rb')].sort
hook_names = plugin_paths.map do |path|
Overcommit::Utils.camel_case(File.basename(path, '.rb'))
end
hook_configs[hook_type_class_name] = Hash[
hook_names.map do |hook_name|
[hook_name, for_hook(hook_name, Overcommit::Utils.camel_case(hook_type))]
end
]
end
hook_configs
end
# Returns the built-in hooks that have been enabled for a hook type.
def enabled_builtin_hooks(hook_context)
@hash[hook_context.hook_class_name].keys.
reject { |hook_name| hook_name == 'ALL' }.
select { |hook_name| built_in_hook?(hook_context, hook_name) }.
select { |hook_name| hook_enabled?(hook_context, hook_name) }
end
# Returns the ad hoc hooks that have been enabled for a hook type.
def enabled_ad_hoc_hooks(hook_context)
@hash[hook_context.hook_class_name].keys.
reject { |hook_name| hook_name == 'ALL' }.
select { |hook_name| ad_hoc_hook?(hook_context, hook_name) }.
select { |hook_name| hook_enabled?(hook_context, hook_name) }
end
# Returns a non-modifiable configuration for a hook.
def for_hook(hook, hook_type = nil)
unless hook_type
components = hook.class.name.split('::')
hook = components.last
hook_type = components[-2]
end
# Merge hook configuration with special 'ALL' config
hook_config = smart_merge(@hash[hook_type]['ALL'], @hash[hook_type][hook] || {})
# Need to specially handle `enabled` option since not setting it does not
# necessarily mean the hook is disabled
hook_config['enabled'] = hook_enabled?(hook_type, hook)
hook_config.freeze
end
# Merges the given configuration with this one, returning a new
# {Configuration}. The provided configuration will either add to or replace
# any options defined in this configuration.
def merge(config)
self.class.new(smart_merge(@hash, config.hash))
end
# Applies additional configuration settings based on the provided
# environment variables.
def apply_environment!(hook_context, env)
skipped_hooks = "#{env['SKIP']} #{env['SKIP_CHECKS']} #{env['SKIP_HOOKS']}".split(/[:, ]/)
only_hooks = env.fetch('ONLY', '').split(/[:, ]/)
hook_type = hook_context.hook_class_name
if only_hooks.any? || skipped_hooks.include?('all') || skipped_hooks.include?('ALL')
@hash[hook_type]['ALL']['skip'] = true
end
only_hooks.select { |hook_name| hook_exists?(hook_context, hook_name) }.
map { |hook_name| Overcommit::Utils.camel_case(hook_name) }.
each do |hook_name|
@hash[hook_type][hook_name] ||= {}
@hash[hook_type][hook_name]['skip'] = false
end
skipped_hooks.select { |hook_name| hook_exists?(hook_context, hook_name) }.
map { |hook_name| Overcommit::Utils.camel_case(hook_name) }.
each do |hook_name|
@hash[hook_type][hook_name] ||= {}
@hash[hook_type][hook_name]['skip'] = true
end
end
def plugin_hook?(hook_context_or_type, hook_name)
hook_type_name =
if hook_context_or_type.is_a?(String)
Overcommit::Utils.snake_case(hook_context_or_type)
else
hook_context_or_type.hook_type_name
end
hook_name = Overcommit::Utils.snake_case(hook_name)
File.exist?(File.join(plugin_directory, hook_type_name, "#{hook_name}.rb"))
end
# Return whether the signature for this configuration has changed since it
# was last calculated.
#
# @return [true,false]
def signature_changed?
signature != stored_signature
end
# Return whether a previous signature has been recorded for this
# configuration.
#
# @return [true,false]
def previous_signature?
!stored_signature.empty?
end
# Returns whether this configuration should verify itself by checking the
# stored configuration for the repo.
#
# @return [true,false]
def verify_signatures?
return false if ENV['OVERCOMMIT_NO_VERIFY']
return true if @hash['verify_signatures'] != false
result = Overcommit::Utils.execute(
%W[git config --local --get #{verify_signature_config_key}]
)
if result.status == 1 # Key doesn't exist
return true
elsif result.status != 0
raise Overcommit::Exceptions::GitConfigError,
"Unable to read from local repo git config: #{result.stderr}"
end
# We don't cast since we want to allow anything to count as "true" except
# a literal zero
result.stdout.strip != '0'
end
# Update the currently stored signature for this hook.
def update_signature!
result = Overcommit::Utils.execute(
%w[git config --local] + [signature_config_key, signature]
)
verify_signature_value = @hash['verify_signatures'] ? 1 : 0
result &&= Overcommit::Utils.execute(
%W[git config --local #{verify_signature_config_key} #{verify_signature_value}]
)
unless result.success?
raise Overcommit::Exceptions::GitConfigError,
"Unable to write to local repo git config: #{result.stderr}"
end
end
protected
attr_reader :hash
private
def ad_hoc_hook?(hook_context, hook_name)
ad_hoc_conf = @hash.fetch(hook_context.hook_class_name, {}).fetch(hook_name, {})
# Ad hoc hooks are neither built-in nor have a plugin file written but
# still have a `command` specified to be run
!built_in_hook?(hook_context, hook_name) &&
!plugin_hook?(hook_context, hook_name) &&
(ad_hoc_conf['command'] || ad_hoc_conf['required_executable'])
end
def built_in_hook?(hook_context, hook_name)
hook_name = Overcommit::Utils.snake_case(hook_name)
File.exist?(File.join(Overcommit::HOME, 'lib', 'overcommit', 'hook',
hook_context.hook_type_name, "#{hook_name}.rb"))
end
def hook_exists?(hook_context, hook_name)
built_in_hook?(hook_context, hook_name) ||
plugin_hook?(hook_context, hook_name) ||
ad_hoc_hook?(hook_context, hook_name)
end
def hook_enabled?(hook_context_or_type, hook_name)
hook_type =
if hook_context_or_type.is_a?(String)
hook_context_or_type
else
hook_context_or_type.hook_class_name
end
individual_enabled = @hash[hook_type].fetch(hook_name, {})['enabled']
return individual_enabled unless individual_enabled.nil?
all_enabled = @hash[hook_type]['ALL']['enabled']
return all_enabled unless all_enabled.nil?
false
end
def smart_merge(parent, child)
# Treat the ALL hook specially so that it overrides any configuration
# specified by the default configuration.
child_all = child['ALL']
unless child_all.nil?
parent = Hash[parent.collect { |k, v| [k, smart_merge(v, child_all)] }]
end
parent.merge(child) do |_key, old, new|
case old
when Hash
smart_merge(old, new)
else
new
end
end
end
# Returns the unique signature of this configuration.
#
# @return [String]
def signature
Digest::SHA256.hexdigest(@hash.to_json)
end
# Returns the stored signature of this repo's Overcommit configuration.
#
# This is intended to be compared against the current signature of this
# configuration object.
#
# @return [String]
def stored_signature
result = Overcommit::Utils.execute(
%w[git config --local --get] + [signature_config_key]
)
if result.status == 1 # Key doesn't exist
return ''
elsif result.status != 0
raise Overcommit::Exceptions::GitConfigError,
"Unable to read from local repo git config: #{result.stderr}"
end
result.stdout.chomp
end
def signature_config_key
'overcommit.configuration.signature'
end
def verify_signature_config_key
'overcommit.configuration.verifysignatures'
end
end
end