Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Gem hook plugin support #748

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ any Ruby code.
* [PreRebase](#prerebase)
* [Repo-Specific Hooks](#repo-specific-hooks)
* [Adding Existing Git Hooks](#adding-existing-git-hooks)
* [Gem-provided Hooks](#gem-provided-hooks)
* [Security](#security)
* [Contributing](#contributing)
* [Community](#community)
Expand Down Expand Up @@ -671,6 +672,41 @@ of hook, see the [git-hooks documentation][GHD].

[GHD]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks

## Gem-provided Hooks

For hooks which should be available from multiple repositories, but which do not
make sense to contribute back to Overcommit, you can package them as a Ruby gem
and `overcommit` can use them as if they were repo-specific hooks. This would be
useful for an organization that has custom hooks they wish to apply to many
repositories, but manage the hooks in a central way.

To enable gem-provided hooks you need to set the configuration option
`gem_plugins_enabled` to `true`.

If you are using the [`gemfile`](#gemfile) configuration option or otherwise
invoking Overcommit via Bundler then you must ensure the gems are listed
in your Bundler configuration and Bundler will ensure that the gems are in
Ruby's `$LOAD_PATH` so that Overcommit can find and load them.

If you are not using Bundler (the gems are installed directly in your Ruby
environment) then you must provide a path that Overcommit can `require` so
the gem will be added to the `$LOAD_PATH`.

```yaml
gem_plugins_require: ['overcommit_site_hooks']
```

The above would result in a `require 'overcommit_site_hooks'`. The file does
not have to implement any functional code, but merely be a placeholder sufficient
to allow the `require` to work.

The hooks themselves are implemented similarly to Repo-Specific Hooks, but need
to be placed in a gem in the normal `overcommit` hook path within the gem. For
example, to add `MyCustomHook` as a pre_commit hook you would put it here:
`./lib/overcommit/hooks/pre_commit/my_custom_hook.rb` within the gem file structure.

See [Repo-Specific Hooks](#repo-specific-hooks) for details.

## Security

While Overcommit can make managing Git hooks easier and more convenient,
Expand Down
13 changes: 13 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@
# (Generate lock file by running `bundle install --gemfile=.overcommit_gems.rb`)
gemfile: false

# If enabled, Overcommit will look for plugins provided anywhere in the
# load path and load them as it would with built-in or repo-specific
# hooks, allowing plugins to be provided by gems.
# Note: If you are providing them as system gems (and not using `gemfile`
# or a Bundler context where they are specified) then you must also set the
# `gem_plugins_require` option.
gem_plugins_enabled: true

# If `gem_plugins_enabled` is true and you are using system gems (not via a
# Bundler context), in order to allow Ruby to find the gems, you must list a
# valid `require` path for each gem providing custom hooks here:
# gem_plugins_require: [ 'overcommit_custom_hook' ]

# Where to store hook plugins specific to a repository. These are loaded in
# addition to the default hooks Overcommit comes with. The location is relative
# to the root of the repository.
Expand Down
1 change: 1 addition & 0 deletions lib/overcommit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require 'overcommit/hook_signer'
require 'overcommit/hook_loader/base'
require 'overcommit/hook_loader/built_in_hook_loader'
require 'overcommit/hook_loader/gem_hook_loader'
require 'overcommit/hook_loader/plugin_hook_loader'
require 'overcommit/interrupt_handler'
require 'overcommit/printer'
Expand Down
20 changes: 20 additions & 0 deletions lib/overcommit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ def enabled_builtin_hooks(hook_context)
select { |hook_name| hook_enabled?(hook_context, hook_name) }
end

# Returns the gem-provided hooks that have been enabled for a hook type.
def enabled_gem_hooks(hook_context)
@hash[hook_context.hook_class_name].keys.
reject { |hook_name| hook_name == 'ALL' }.
reject { |hook_name| built_in_hook?(hook_context, hook_name) }.
select { |hook_name| gem_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.
Expand Down Expand Up @@ -259,6 +268,7 @@ def ad_hoc_hook?(hook_context, 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) &&
!gem_hook?(hook_context, hook_name) &&
!plugin_hook?(hook_context, hook_name) &&
(ad_hoc_conf['command'] || ad_hoc_conf['required_executable'])
end
Expand All @@ -270,8 +280,18 @@ def built_in_hook?(hook_context, hook_name)
hook_context.hook_type_name, "#{hook_name}.rb"))
end

def gem_hook?(hook_context, hook_name)
hook_name = Overcommit::Utils.snake_case(hook_name)

$LOAD_PATH.any? do |path|
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be misunderstanding, but since Overcommit hooks are themselves in the load path, won't this still return true even for built-in hooks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your thorough and thoughtful response.

If we can avoid the creation of a new concept + loading mechanism, we might be able to simplify what is needed to support this (but it is understandable why you implemented in this way).

My initial implementation was built within the core hooks loader, but there were drawbacks. It didn't provide any way for a user to indicate they didn't want to load the hooks, and then I later realized/remembered that when invoked outside of a bundler context the $LOAD_PATH doesn't have the gem path on it until it is required. Once I got done looking at trying to fix both of those problems it seemed to make more sense to put them in a separate loader. But I agree that folding them into an existing one might be useful.

* Loading the gems in the `ConfigurationValidator` allows you execute arbitrary code, which would break expectations users have about Overcommit today. If you look at how the current plugin hook loader is implemented, [it checks to see if signatures have changed](https://github.com/sds/overcommit/blob/192c84bad54c6d4ef2c3ab887968bb2ccd26cc0b/lib/overcommit/hook_loader/plugin_hook_loader.rb#L10) _before_ attempting to load the plugins. This needs to be fixed before we move forward.

I decided that was okay because my thinking was that unlike hooks which are delivered from a repository, hooks from a Ruby gem have to be installed by the system/user. Either via gem install at the system level or by the user via a bundle install. Even using the gemfile Overcommit configuration option won't just go to the Internet and fetch uninstalled gems - it tells you to use bundle install. So, in both scenarios the user (or authorized system administrator) has had to be involved in intentionally putting the gems on the system. I didn't see the code exposure path here being any different than any other gem in the system.

That said, if you strongly believe that gem-based hooks also need to be covered by the signature bit that probably wouldn't be hard to implement.

* It's not clear what would happen if a gem defined a hook that eventually became a built-in hook that shipped with Overcommit. Which one would we expect to run?

That's a great point. I debated quite a bit about putting the hooks in the same load path as the built-in Overcommit hooks. After reading your feedback I think that probably the best thing is to expect the hooks to be in some other load path and provide some way for an Overcommit hook add-on gem to provide that path to Overcommit. In terms of load precedence, I think the safest is that the built-in hooks always win.

Maybe supporting a configuration file somewhat like this makes sense:

gem_plugins:
  Custom: 'overcommit-custom'

PreCommit:
  Custom::MyCustomPreCommitHook:
    enabled: true

It would be clear which were external/custom hooks, which gem provided them (if there were multiple) and there would be no chance of namespace conflicts.

Then in the custom hook Overcommit expects to be able to require overcommit-custom/hooks (the path provided in the config, plus hooks) and in there lives the normal hooks structure.

This raises some questions about perhaps fundamentally changing how Overcommit loads hooks. Based on #745, it sounds like there is also appetite to share configuration as well, so perhaps we can take some cues from RuboCop to support sharing both configuration and actual code.

I could see the above syntax being extendable to something like:

inherit_gem:
  Custom: './default.yml`

Where the path is resolved based on the gem path. I'm not sure I want to tackle inheritable configuration as part of this, just noting that I think it could be added.

Let me know what you think about the syntax above. I don't know if I'll have any more time to work on it in the near future, but I may.

File.exist?(File.join(path, 'overcommit', 'hook',
hook_context.hook_type_name, "#{hook_name}.rb"))
end
end

def hook_exists?(hook_context, hook_name)
built_in_hook?(hook_context, hook_name) ||
gem_hook?(hook_context, hook_name) ||
plugin_hook?(hook_context, hook_name) ||
ad_hoc_hook?(hook_context, hook_name)
end
Expand Down
38 changes: 38 additions & 0 deletions lib/overcommit/configuration_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def validate(config, hash, options)
check_for_missing_enabled_option(hash) unless @options[:default]
check_for_too_many_processors(config, hash)
check_for_verify_plugin_signatures_option(hash)
check_for_gem_plugins(hash)

hash
end
Expand Down Expand Up @@ -181,6 +182,43 @@ def check_for_verify_plugin_signatures_option(hash)
@log.newline
end
end

# check for presence of Gems specified in gem_plugins by trying to load them
def check_for_gem_plugins(hash)
return unless @log
return unless hash['gem_plugins_enabled'] == true
return unless hash.key?('gem_plugins_require')

required = hash['gem_plugins_require']

unless required.is_a?(Array)
@log.error 'gem_plugins_require expects a list value to be set'
raise Overcommit::Exceptions::ConfigurationError,
'gem_plugins_require expects a list value'
end

errors = []

required.each do |path|
begin
require path
rescue LoadError
errors << "Unable to require path '#{path}' listed in gem_plugins_require."
end
@log.debug "Successfully loaded gem_plugins_require: #{path}"
end

return unless errors.any?

errors << 'Ensure that the gems providing requested gem_plugins_require are ' \
'installed on the system or specify them via the gemfile ' \
'configuration option'

@log.error errors.join("\n")
@log.newline
raise Overcommit::Exceptions::ConfigurationError,
'One or more gems specified in gem_plugins_require could not be loaded'
end
end
end
# rubocop:enable Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
14 changes: 14 additions & 0 deletions lib/overcommit/hook_loader/gem_hook_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Overcommit::HookLoader
# Responsible for loading custom hooks that users install via Gems
class GemHookLoader < Base
def load_hooks
@config.enabled_gem_hooks(@context).map do |hook_name|
underscored_hook_name = Overcommit::Utils.snake_case(hook_name)
require "overcommit/hook/#{@context.hook_type_name}/#{underscored_hook_name}"
create_hook(hook_name)
end
end
end
end
5 changes: 5 additions & 0 deletions lib/overcommit/hook_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ def load_hooks

@hooks += HookLoader::BuiltInHookLoader.new(@config, @context, @log).load_hooks

# Load Gem-based hooks next, if gemfile in use or gem_plugins is explicitly enabled:
if @config['gem_plugins_enabled'] == true
@hooks += HookLoader::GemHookLoader.new(@config, @context, @log).load_hooks
end

# Load plugin hooks after so they can subclass existing hooks
@hooks += HookLoader::PluginHookLoader.new(@config, @context, @log).load_hooks
rescue LoadError => ex
Expand Down
58 changes: 58 additions & 0 deletions spec/overcommit/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,62 @@
end
end
end

describe '#enabled_gem_hooks' do
let(:hash) do
{
'PreCommit' => {
'MyCustomHook' => {
'enabled' => true
},
'MyOtherHook' => {
'enabled' => false
},
}
}
end

let(:context) { double('context') }
subject { config.enabled_gem_hooks(context) }

before do
context.stub(hook_class_name: 'PreCommit',
hook_type_name: 'pre_commit')
end

it 'excludes hooks that are not found' do
subject.should_not include 'MyCustomHook'
subject.should_not include 'MyOtherHook'
end

context 'when custom hooks are found' do
before do
$LOAD_PATH.unshift('/my/custom/path/lib')
allow(File).to receive(:exist?).
with('/my/custom/path/lib/overcommit/hook/pre_commit/my_custom_hook.rb').
and_return(true)
allow(File).to receive(:exist?).
with(File.join(Overcommit::HOME, 'lib/overcommit/hook/pre_commit/my_custom_hook.rb')).
and_return(false)
allow(File).to receive(:exist?).
with('/my/custom/path/lib/overcommit/hook/pre_commit/my_other_hook.rb').
and_return(true)
allow(File).to receive(:exist?).
with(File.join(Overcommit::HOME, 'lib/overcommit/hook/pre_commit/my_other_hook.rb')).
and_return(false)
end

after do
$LOAD_PATH.shift
end

it 'includes hooks that are enabled and found' do
subject.should include 'MyCustomHook'
end

it 'excludes hooks that are not enable but found' do
subject.should_not include 'MyOtherHook'
end
end
end
end
62 changes: 61 additions & 1 deletion spec/overcommit/configuration_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
let(:logger) { Overcommit::Logger.new(output) }
let(:options) { { logger: logger } }
let(:config) { Overcommit::Configuration.new(config_hash, validate: false) }
let(:instance) { described_class.new }

subject { described_class.new.validate(config, config_hash, options) }
subject { instance.validate(config, config_hash, options) }

context 'when hook has an invalid name' do
let(:config_hash) do
Expand Down Expand Up @@ -115,4 +116,63 @@
end
end
end

context 'when gem_plugins_require is set' do
let(:plugins_enabled) { true }
let(:plugins_require) { nil }

let(:config_hash) do
{
'gem_plugins_enabled' => plugins_enabled,
'gem_plugins_require' => plugins_require,
}
end

context 'when plugins_enabled is true' do
let(:plugins_enabled) { true }

context 'and it is not an array' do
let(:plugins_require) { true }

it 'raises an error' do
expect { subject }.to raise_error Overcommit::Exceptions::ConfigurationError
end
end

context 'and one does not load' do
let(:plugins_require) { %w[mygem missinggem] }

before do
allow(instance).to receive(:require).with('mygem').and_return(true)
allow(instance).to receive(:require).with('missinggem').and_raise(LoadError)
end

it 'raises an error' do
expect(logger).to receive(:error).with(/installed on the system/)

expect { subject }.to raise_error Overcommit::Exceptions::ConfigurationError
end
end

context 'and the gems load' do
let(:plugins_require) { ['mygem'] }

it 'is valid' do
expect(instance).to receive(:require).with('mygem').and_return(true)

expect { subject }.not_to raise_error
end
end
end

context 'when plugins_enabled is false' do
let(:plugins_enabled) { false }
let(:plugins_require) { ['one'] }

it 'loads nothing' do
expect(instance).not_to receive(:require)
subject
end
end
end
end
31 changes: 31 additions & 0 deletions spec/overcommit/hook_loader/gem_hook_loader_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

require 'spec_helper'

describe Overcommit::HookLoader::GemHookLoader do
let(:hash) { {} }
let(:config) { Overcommit::Configuration.new(hash) }
let(:logger) { double('logger') }
let(:context) { double('context') }
let(:loader) { described_class.new(config, context, logger) }

describe '#load_hooks' do
subject(:load_hooks) { loader.send(:load_hooks) }

before do
context.stub(hook_class_name: 'PreCommit',
hook_type_name: 'pre_commit')
end

it 'loads enabled gem hooks' do
allow(config).to receive(:enabled_gem_hooks).with(context).and_return(['MyCustomHook'])

allow(loader).to receive(:require).
with('overcommit/hook/pre_commit/my_custom_hook').
and_return(true)
allow(loader).to receive(:create_hook).with('MyCustomHook')
expect(loader).to receive(:require).with('overcommit/hook/pre_commit/my_custom_hook')
load_hooks
end
end
end
Loading