Skip to content

Commit cf54c41

Browse files
committed
Add Gem hook plugin support
Support loading Overcommit hooks supplied by Gems in the same fashion as hooks that can be provided in Repo-Specific hooks.
1 parent 192c84b commit cf54c41

11 files changed

+333
-1
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ any Ruby code.
4141
* [PreRebase](#prerebase)
4242
* [Repo-Specific Hooks](#repo-specific-hooks)
4343
* [Adding Existing Git Hooks](#adding-existing-git-hooks)
44+
* [Gem-provided Hooks](#gem-provided-hooks)
4445
* [Security](#security)
4546
* [Contributing](#contributing)
4647
* [Community](#community)
@@ -671,6 +672,41 @@ of hook, see the [git-hooks documentation][GHD].
671672

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

675+
## Gem-provided Hooks
676+
677+
For hooks which should be available from multiple repositories, but which do not
678+
make sense to contribute back to Overcommit, you can package them as a Ruby gem
679+
and `overcommit` can use them as if they were repo-specific hooks. This would be
680+
useful for an organization that has custom hooks they wish to apply to many
681+
repositories, but manage the hooks in a central way.
682+
683+
To enable gem-provided hooks you need to set the configuration option
684+
`gem_plugins_enabled` to `true`.
685+
686+
If you are using the [`gemfile`](#gemfile) configuration option or otherwise
687+
invoking Overcommit via Bundler then you must ensure the gems are listed
688+
in your Bundler configuration and Bundler will ensure that the gems are in
689+
Ruby's `$LOAD_PATH` so that Overcommit can find and load them.
690+
691+
If you are not using Bundler (the gems are installed directly in your Ruby
692+
environment) then you must provide a path that Overcommit can `require` so
693+
the gem will be added to the `$LOAD_PATH`.
694+
695+
```yaml
696+
gem_plugins_require: ['overcommit_site_hooks']
697+
```
698+
699+
The above would result in a `require 'overcommit_site_hooks'`. The file does
700+
not have to implement any functional code, but merely be a placeholder sufficient
701+
to allow the `require` to work.
702+
703+
The hooks themselves are implemented similarly to Repo-Specific Hooks, but need
704+
to be placed in a gem in the normal `overcommit` hook path within the gem. For
705+
example, to add `MyCustomHook` as a pre_commit hook you would put it here:
706+
`./lib/overcommit/hooks/pre_commit/my_custom_hook.rb` within the gem file structure.
707+
708+
See [Repo-Specific Hooks](#repo-specific-hooks) for details.
709+
674710
## Security
675711

676712
While Overcommit can make managing Git hooks easier and more convenient,

config/default.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@
3434
# (Generate lock file by running `bundle install --gemfile=.overcommit_gems.rb`)
3535
gemfile: false
3636

37+
# If enabled, Overcommit will look for plugins provided anywhere in the
38+
# load path and load them as it would with built-in or repo-specific
39+
# hooks, allowing plugins to be provided by gems.
40+
# Note: If you are providing them as system gems (and not using `gemfile`
41+
# or a Bundler context where they are specified) then you must also set the
42+
# `gem_plugins_require` option.
43+
gem_plugins_enabled: true
44+
45+
# If `gem_plugins_enabled` is true and you are using system gems (not via a
46+
# Bundler context), in order to allow Ruby to find the gems, you must list a
47+
# valid `require` path for each gem providing custom hooks here:
48+
# gem_plugins_require: [ 'overcommit_custom_hook' ]
49+
3750
# Where to store hook plugins specific to a repository. These are loaded in
3851
# addition to the default hooks Overcommit comes with. The location is relative
3952
# to the root of the repository.

lib/overcommit.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require 'overcommit/hook_signer'
1818
require 'overcommit/hook_loader/base'
1919
require 'overcommit/hook_loader/built_in_hook_loader'
20+
require 'overcommit/hook_loader/gem_hook_loader'
2021
require 'overcommit/hook_loader/plugin_hook_loader'
2122
require 'overcommit/interrupt_handler'
2223
require 'overcommit/printer'

lib/overcommit/configuration.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ def enabled_builtin_hooks(hook_context)
119119
select { |hook_name| hook_enabled?(hook_context, hook_name) }
120120
end
121121

122+
# Returns the gem-provided hooks that have been enabled for a hook type.
123+
def enabled_gem_hooks(hook_context)
124+
@hash[hook_context.hook_class_name].keys.
125+
reject { |hook_name| hook_name == 'ALL' }.
126+
reject { |hook_name| built_in_hook?(hook_context, hook_name) }.
127+
select { |hook_name| gem_hook?(hook_context, hook_name) }.
128+
select { |hook_name| hook_enabled?(hook_context, hook_name) }
129+
end
130+
122131
# Returns the ad hoc hooks that have been enabled for a hook type.
123132
def enabled_ad_hoc_hooks(hook_context)
124133
@hash[hook_context.hook_class_name].keys.
@@ -259,6 +268,7 @@ def ad_hoc_hook?(hook_context, hook_name)
259268
# Ad hoc hooks are neither built-in nor have a plugin file written but
260269
# still have a `command` specified to be run
261270
!built_in_hook?(hook_context, hook_name) &&
271+
!gem_hook?(hook_context, hook_name) &&
262272
!plugin_hook?(hook_context, hook_name) &&
263273
(ad_hoc_conf['command'] || ad_hoc_conf['required_executable'])
264274
end
@@ -270,8 +280,18 @@ def built_in_hook?(hook_context, hook_name)
270280
hook_context.hook_type_name, "#{hook_name}.rb"))
271281
end
272282

283+
def gem_hook?(hook_context, hook_name)
284+
hook_name = Overcommit::Utils.snake_case(hook_name)
285+
286+
$LOAD_PATH.any? do |path|
287+
File.exist?(File.join(path, 'overcommit', 'hook',
288+
hook_context.hook_type_name, "#{hook_name}.rb"))
289+
end
290+
end
291+
273292
def hook_exists?(hook_context, hook_name)
274293
built_in_hook?(hook_context, hook_name) ||
294+
gem_hook?(hook_context, hook_name) ||
275295
plugin_hook?(hook_context, hook_name) ||
276296
ad_hoc_hook?(hook_context, hook_name)
277297
end

lib/overcommit/configuration_validator.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def validate(config, hash, options)
2323
check_for_missing_enabled_option(hash) unless @options[:default]
2424
check_for_too_many_processors(config, hash)
2525
check_for_verify_plugin_signatures_option(hash)
26+
check_for_gem_plugins(hash)
2627

2728
hash
2829
end
@@ -181,6 +182,43 @@ def check_for_verify_plugin_signatures_option(hash)
181182
@log.newline
182183
end
183184
end
185+
186+
# check for presence of Gems specified in gem_plugins by trying to load them
187+
def check_for_gem_plugins(hash)
188+
return unless @log
189+
return unless hash['gem_plugins_enabled'] == true
190+
return unless hash.key?('gem_plugins_require')
191+
192+
required = hash['gem_plugins_require']
193+
194+
unless required.is_a?(Array)
195+
@log.error 'gem_plugins_require expects a list value to be set'
196+
raise Overcommit::Exceptions::ConfigurationError,
197+
'gem_plugins_require expects a list value'
198+
end
199+
200+
errors = []
201+
202+
required.each do |path|
203+
begin
204+
require path
205+
rescue LoadError
206+
errors << "Unable to require path '#{path}' listed in gem_plugins_require."
207+
end
208+
@log.debug "Successfully loaded gem_plugins_require: #{path}"
209+
end
210+
211+
return unless errors.any?
212+
213+
errors << 'Ensure that the gems providing requested gem_plugins_require are ' \
214+
'installed on the system or specify them via the gemfile ' \
215+
'configuration option'
216+
217+
@log.error errors.join("\n")
218+
@log.newline
219+
raise Overcommit::Exceptions::ConfigurationError,
220+
'One or more gems specified in gem_plugins_require could not be loaded'
221+
end
184222
end
185223
end
186224
# rubocop:enable Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
module Overcommit::HookLoader
4+
# Responsible for loading custom hooks that users install via Gems
5+
class GemHookLoader < Base
6+
def load_hooks
7+
@config.enabled_gem_hooks(@context).map do |hook_name|
8+
underscored_hook_name = Overcommit::Utils.snake_case(hook_name)
9+
require "overcommit/hook/#{@context.hook_type_name}/#{underscored_hook_name}"
10+
create_hook(hook_name)
11+
end
12+
end
13+
end
14+
end

lib/overcommit/hook_runner.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ def load_hooks
200200

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

203+
# Load Gem-based hooks next, if gemfile in use or gem_plugins is explicitly enabled:
204+
if @config['gem_plugins_enabled'] == true
205+
@hooks += HookLoader::GemHookLoader.new(@config, @context, @log).load_hooks
206+
end
207+
203208
# Load plugin hooks after so they can subclass existing hooks
204209
@hooks += HookLoader::PluginHookLoader.new(@config, @context, @log).load_hooks
205210
rescue LoadError => ex

spec/overcommit/configuration_spec.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,62 @@
294294
end
295295
end
296296
end
297+
298+
describe '#enabled_gem_hooks' do
299+
let(:hash) do
300+
{
301+
'PreCommit' => {
302+
'MyCustomHook' => {
303+
'enabled' => true
304+
},
305+
'MyOtherHook' => {
306+
'enabled' => false
307+
},
308+
}
309+
}
310+
end
311+
312+
let(:context) { double('context') }
313+
subject { config.enabled_gem_hooks(context) }
314+
315+
before do
316+
context.stub(hook_class_name: 'PreCommit',
317+
hook_type_name: 'pre_commit')
318+
end
319+
320+
it 'excludes hooks that are not found' do
321+
subject.should_not include 'MyCustomHook'
322+
subject.should_not include 'MyOtherHook'
323+
end
324+
325+
context 'when custom hooks are found' do
326+
before do
327+
$LOAD_PATH.unshift('/my/custom/path/lib')
328+
allow(File).to receive(:exist?).
329+
with('/my/custom/path/lib/overcommit/hook/pre_commit/my_custom_hook.rb').
330+
and_return(true)
331+
allow(File).to receive(:exist?).
332+
with(File.join(Overcommit::HOME, 'lib/overcommit/hook/pre_commit/my_custom_hook.rb')).
333+
and_return(false)
334+
allow(File).to receive(:exist?).
335+
with('/my/custom/path/lib/overcommit/hook/pre_commit/my_other_hook.rb').
336+
and_return(true)
337+
allow(File).to receive(:exist?).
338+
with(File.join(Overcommit::HOME, 'lib/overcommit/hook/pre_commit/my_other_hook.rb')).
339+
and_return(false)
340+
end
341+
342+
after do
343+
$LOAD_PATH.shift
344+
end
345+
346+
it 'includes hooks that are enabled and found' do
347+
subject.should include 'MyCustomHook'
348+
end
349+
350+
it 'excludes hooks that are not enable but found' do
351+
subject.should_not include 'MyOtherHook'
352+
end
353+
end
354+
end
297355
end

spec/overcommit/configuration_validator_spec.rb

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
let(:logger) { Overcommit::Logger.new(output) }
88
let(:options) { { logger: logger } }
99
let(:config) { Overcommit::Configuration.new(config_hash, validate: false) }
10+
let(:instance) { described_class.new }
1011

11-
subject { described_class.new.validate(config, config_hash, options) }
12+
subject { instance.validate(config, config_hash, options) }
1213

1314
context 'when hook has an invalid name' do
1415
let(:config_hash) do
@@ -115,4 +116,63 @@
115116
end
116117
end
117118
end
119+
120+
context 'when gem_plugins_require is set' do
121+
let(:plugins_enabled) { true }
122+
let(:plugins_require) { nil }
123+
124+
let(:config_hash) do
125+
{
126+
'gem_plugins_enabled' => plugins_enabled,
127+
'gem_plugins_require' => plugins_require,
128+
}
129+
end
130+
131+
context 'when plugins_enabled is true' do
132+
let(:plugins_enabled) { true }
133+
134+
context 'and it is not an array' do
135+
let(:plugins_require) { true }
136+
137+
it 'raises an error' do
138+
expect { subject }.to raise_error Overcommit::Exceptions::ConfigurationError
139+
end
140+
end
141+
142+
context 'and one does not load' do
143+
let(:plugins_require) { %w[mygem missinggem] }
144+
145+
before do
146+
allow(instance).to receive(:require).with('mygem').and_return(true)
147+
allow(instance).to receive(:require).with('missinggem').and_raise(LoadError)
148+
end
149+
150+
it 'raises an error' do
151+
expect(logger).to receive(:error).with(/installed on the system/)
152+
153+
expect { subject }.to raise_error Overcommit::Exceptions::ConfigurationError
154+
end
155+
end
156+
157+
context 'and the gems load' do
158+
let(:plugins_require) { ['mygem'] }
159+
160+
it 'is valid' do
161+
expect(instance).to receive(:require).with('mygem').and_return(true)
162+
163+
expect { subject }.not_to raise_error
164+
end
165+
end
166+
end
167+
168+
context 'when plugins_enabled is false' do
169+
let(:plugins_enabled) { false }
170+
let(:plugins_require) { ['one'] }
171+
172+
it 'loads nothing' do
173+
expect(instance).not_to receive(:require)
174+
subject
175+
end
176+
end
177+
end
118178
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Overcommit::HookLoader::GemHookLoader do
6+
let(:hash) { {} }
7+
let(:config) { Overcommit::Configuration.new(hash) }
8+
let(:logger) { double('logger') }
9+
let(:context) { double('context') }
10+
let(:loader) { described_class.new(config, context, logger) }
11+
12+
describe '#load_hooks' do
13+
subject(:load_hooks) { loader.send(:load_hooks) }
14+
15+
before do
16+
context.stub(hook_class_name: 'PreCommit',
17+
hook_type_name: 'pre_commit')
18+
end
19+
20+
it 'loads enabled gem hooks' do
21+
allow(config).to receive(:enabled_gem_hooks).with(context).and_return(['MyCustomHook'])
22+
23+
allow(loader).to receive(:require).
24+
with('overcommit/hook/pre_commit/my_custom_hook').
25+
and_return(true)
26+
allow(loader).to receive(:create_hook).with('MyCustomHook')
27+
expect(loader).to receive(:require).with('overcommit/hook/pre_commit/my_custom_hook')
28+
load_hooks
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)